@@ -60,7 +60,7 @@ export function FileUploadResponsesTable({
return (
- {question}
+ {question}
{responses.length} {responses.length === 1 ? "response" : "responses"}
diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx
index aa7b12fa..44890b6e 100644
--- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx
+++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx
@@ -214,7 +214,7 @@ export function PerUserResponsesView({
return (
-
+
{question.question}
{!question.optional && (
*
@@ -234,7 +234,13 @@ export function PerUserResponsesView({
{formatResponseValue(answer)}
) : (
-
+
{formatResponseValue(answer)}
)}
diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseBarChart.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseBarChart.tsx
index 88f277b9..e5cf2da5 100644
--- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseBarChart.tsx
+++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseBarChart.tsx
@@ -73,7 +73,9 @@ export function ResponseBarChart({
{/* question text as card title */}
- {question}
+
+ {question}
+
{/* show total number of responses */}
{responses.length} {responses.length === 1 ? "response" : "responses"}
diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseHorizontalBarChart.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseHorizontalBarChart.tsx
index 4d939a3f..10d7be57 100644
--- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseHorizontalBarChart.tsx
+++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponseHorizontalBarChart.tsx
@@ -88,7 +88,9 @@ export function ResponseHorizontalBarChart({
{/* question text as card title */}
- {question}
+
+ {question}
+
{/* show total number of responses */}
{responses.length} {responses.length === 1 ? "response" : "responses"}
diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsePieChart.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsePieChart.tsx
index aecb6184..bba25b2a 100644
--- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsePieChart.tsx
+++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsePieChart.tsx
@@ -34,12 +34,22 @@ export function ResponsePieChart({
if (answer !== undefined && answer !== null) {
// increment count for this answer
let answerStr: string;
- if (typeof answer === "string") {
- answerStr = answer;
+ if (typeof answer === "boolean") {
+ // convert boolean to "Yes" or "No" for display
+ answerStr = answer ? "Yes" : "No";
+ } else if (typeof answer === "string") {
+ // convert "true"/"false" strings to "Yes"/"No" for boolean questions
+ if (answer === "true") {
+ answerStr = "Yes";
+ } else if (answer === "false") {
+ answerStr = "No";
+ } else {
+ answerStr = answer;
+ }
} else if (typeof answer === "object") {
answerStr = JSON.stringify(answer);
} else {
- // for primitive types (number, boolean, etc.) - safe to stringify
+ // for primitive types (number, etc.) - safe to stringify
// eslint-disable-next-line @typescript-eslint/no-base-to-string
answerStr = String(answer);
}
@@ -77,7 +87,9 @@ export function ResponsePieChart({
{/* question text as card title */}
- {question}
+
+ {question}
+
{/* show total number of responses */}
{responses.length} {responses.length === 1 ? "response" : "responses"}
diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsesTable.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsesTable.tsx
index 49d7342e..9f90636c 100644
--- a/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsesTable.tsx
+++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/ResponsesTable.tsx
@@ -35,7 +35,7 @@ export function ResponsesTable({ question, responses }: ResponsesTableProps) {
return (
- {question}
+ {question}
@@ -49,7 +49,7 @@ export function ResponsesTable({ question, responses }: ResponsesTableProps) {
return (
- {question}
+ {question}
{/* show total response count */}
{responses.length} {responses.length === 1 ? "response" : "responses"}
@@ -94,8 +94,10 @@ export function ResponsesTable({ question, responses }: ResponsesTableProps) {
);
} catch {
- // Not a valid URL, just display as string
- displayValue = answer;
+ // Not a valid URL, just display as string (preserve newlines)
+ displayValue = (
+ {answer}
+ );
}
} else if (typeof answer === "object") {
displayValue = JSON.stringify(answer);
diff --git a/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx b/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx
index fbe9fc9a..631dc891 100644
--- a/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx
+++ b/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx
@@ -7,6 +7,7 @@ import Image from "next/image";
import { FileUp, Loader2, X } from "lucide-react";
import type { QuestionValidator } from "@forge/consts/knight-hacks";
+import { getDropdownOptionsFromConst } from "@forge/consts/knight-hacks";
import { Button } from "@forge/ui/button";
import { Card } from "@forge/ui/card";
import { Checkbox } from "@forge/ui/checkbox";
@@ -14,6 +15,7 @@ import { DatePicker } from "@forge/ui/date-picker";
import { Input } from "@forge/ui/input";
import { Label } from "@forge/ui/label";
import { RadioGroup, RadioGroupItem } from "@forge/ui/radio-group";
+import { ResponsiveComboBox } from "@forge/ui/responsive-combo-box";
import {
Select,
SelectContent,
@@ -22,6 +24,7 @@ import {
SelectValue,
} from "@forge/ui/select";
import { Slider } from "@forge/ui/slider";
+import { Textarea } from "@forge/ui/textarea";
import { TimePicker } from "@forge/ui/time-picker";
import { toast } from "@forge/ui/toast";
@@ -55,7 +58,7 @@ export function QuestionResponseCard({
{/* Header */}
-
+
{question.question}
{isRequired && * }
@@ -106,19 +109,61 @@ function QuestionBody({
formId?: string;
}) {
switch (question.type) {
- case "SHORT_ANSWER":
- case "PARAGRAPH":
+ case "SHORT_ANSWER": {
+ const currentValue = (value as string) || "";
+ const maxLength = 150;
+ const charCount = currentValue.length;
+ const isOverLimit = charCount > maxLength;
+
return (
-
+
onChange(e.target.value)}
disabled={disabled}
className="rounded-none border-x-0 border-b border-t-0 border-gray-300 bg-transparent px-0 shadow-none outline-none focus-visible:border-b-2 focus-visible:border-primary focus-visible:ring-0"
/>
+
+
+ {charCount}/{maxLength}
+
+
+
+ );
+ }
+ case "PARAGRAPH": {
+ const currentValue = (value as string) || "";
+ const maxLength = 750;
+ const charCount = currentValue.length;
+ const isOverLimit = charCount > maxLength;
+
+ return (
+
);
+ }
case "MULTIPLE_CHOICE":
return (
@@ -186,7 +231,7 @@ function QuestionBody({
case "EMAIL":
return (
-
+
+
+
+
void;
disabled: boolean;
}) {
- const options = question.options || [];
+ // If optionsConst is set, load options from constants instead of question.options
+ const options = question.optionsConst
+ ? getDropdownOptionsFromConst(question.optionsConst)
+ : question.options || [];
const questionKey = question.question.replace(/\s+/g, "-").toLowerCase();
+ const [otherText, setOtherText] = useState
("");
+ const OTHER_VALUE = "__OTHER__";
+ const allowOther = Boolean(question.allowOther);
+
+ const isOtherSelected =
+ value &&
+ typeof value === "string" &&
+ !options.includes(value) &&
+ value !== OTHER_VALUE;
+
+ React.useEffect(() => {
+ if (isOtherSelected && typeof value === "string") {
+ setOtherText(value);
+ }
+ }, [value, isOtherSelected]);
+
+ const capitalizeWords = (text: string): string => {
+ return text
+ .split(" ")
+ .map((word) => {
+ if (word.length === 0) return word;
+ return word[0]?.toUpperCase() + word.slice(1).toLowerCase();
+ })
+ .join(" ");
+ };
+
+ const handleOtherTextChange = (text: string) => {
+ const capitalized = capitalizeWords(text);
+ setOtherText(capitalized);
+ onChange(capitalized || null);
+ };
+
+ const handleRadioChange = (newValue: string) => {
+ if (newValue === OTHER_VALUE) {
+ // When "Other" is selected, don't clear the text, just mark as other
+ onChange(otherText || OTHER_VALUE);
+ } else {
+ onChange(newValue || null);
+ }
+ };
return (
- onChange(newValue || null)}
- className="flex flex-col gap-3"
- disabled={disabled}
- >
- {options.map((option, idx) => (
-
-
-
- {option}
-
-
- ))}
-
+
+
+ {options.map((option, idx) => (
+
+
+
+ {option}
+
+
+ ))}
+ {allowOther && (
+
+
+
+ Other:
+
+
+ )}
+
+ {allowOther &&
+ (isOtherSelected ||
+ (typeof value === "string" && value === OTHER_VALUE)) && (
+
+ handleOtherTextChange(e.target.value)}
+ onBlur={(e) => {
+ const capitalized = capitalizeWords(e.target.value);
+ setOtherText(capitalized);
+ onChange(capitalized || null);
+ }}
+ disabled={disabled}
+ className="rounded-none border-x-0 border-b border-t-0 border-gray-300 bg-transparent px-0 shadow-none outline-none focus-visible:border-b-2 focus-visible:border-primary focus-visible:ring-0"
+ />
+
+ )}
+
);
}
@@ -342,9 +463,38 @@ function CheckboxesInput({
onChange: (value: string | string[] | number | Date | null) => void;
disabled?: boolean;
}) {
- const options = question.options || [];
+ // If optionsConst is set, load options from constants instead of question.options
+ const options = question.optionsConst
+ ? getDropdownOptionsFromConst(question.optionsConst)
+ : question.options || [];
const selectedValues = value || [];
const questionKey = question.question.replace(/\s+/g, "-").toLowerCase();
+ const [otherText, setOtherText] = useState("");
+ const OTHER_VALUE = "__OTHER__";
+ const allowOther = Boolean(question.allowOther);
+
+ // Get all "Other" values (values not in predefined options)
+ const otherValues = selectedValues.filter(
+ (v) => !options.includes(v) && v !== OTHER_VALUE,
+ );
+
+ // If there's an other value, use it as the otherText
+ React.useEffect(() => {
+ if (otherValues.length > 0) {
+ setOtherText(otherValues[0] ?? "");
+ }
+ }, [otherValues]);
+
+ // Helper function to capitalize first letter of each word
+ const capitalizeWords = (text: string): string => {
+ return text
+ .split(" ")
+ .map((word) => {
+ if (word.length === 0) return word;
+ return word[0]?.toUpperCase() + word.slice(1).toLowerCase();
+ })
+ .join(" ");
+ };
const handleCheckboxChange = (option: string, checked: boolean) => {
if (checked) {
@@ -354,6 +504,49 @@ function CheckboxesInput({
}
};
+ const isOtherChecked =
+ selectedValues.includes(OTHER_VALUE) || otherValues.length > 0;
+
+ const handleOtherCheckboxChange = (checked: boolean) => {
+ if (checked) {
+ // Add OTHER_VALUE marker and current otherText if it exists
+ const newValues = [...selectedValues.filter((v) => v !== OTHER_VALUE)];
+ if (otherText) {
+ newValues.push(otherText);
+ } else {
+ newValues.push(OTHER_VALUE);
+ }
+ onChange(newValues);
+ } else {
+ // Remove OTHER_VALUE and all other values
+ onChange(
+ selectedValues.filter(
+ (v) => v !== OTHER_VALUE && !otherValues.includes(v),
+ ),
+ );
+ setOtherText("");
+ }
+ };
+
+ const handleOtherTextChange = (text: string) => {
+ const capitalized = capitalizeWords(text);
+ setOtherText(capitalized);
+
+ // Update the selected values: remove old other values and add new one
+ const valuesWithoutOther = selectedValues.filter(
+ (v) => !otherValues.includes(v) && v !== OTHER_VALUE,
+ );
+ if (capitalized) {
+ onChange([...valuesWithoutOther, capitalized]);
+ } else {
+ onChange(
+ isOtherChecked
+ ? [...valuesWithoutOther, OTHER_VALUE]
+ : valuesWithoutOther,
+ );
+ }
+ };
+
return (
{options.map((option, idx) => (
@@ -374,6 +567,40 @@ function CheckboxesInput({
))}
+ {allowOther && (
+
+
+ handleOtherCheckboxChange(checked === true)
+ }
+ disabled={disabled}
+ />
+
+ Other:
+
+
+ )}
+ {allowOther && isOtherChecked && (
+
+ handleOtherTextChange(e.target.value)}
+ onBlur={(e) => {
+ const capitalized = capitalizeWords(e.target.value);
+ setOtherText(capitalized);
+ handleOtherTextChange(capitalized);
+ }}
+ disabled={disabled}
+ className="rounded-none border-x-0 border-b border-t-0 border-gray-300 bg-transparent px-0 shadow-none outline-none focus-visible:border-b-2 focus-visible:border-primary focus-visible:ring-0"
+ />
+
+ )}
);
}
@@ -389,7 +616,28 @@ function DropdownInput({
onChange: (value: string | string[] | number | Date | null) => void;
disabled: boolean;
}) {
- const options = question.options || [];
+ // If optionsConst is set, load options from constants instead of question.options
+ const options = question.optionsConst
+ ? getDropdownOptionsFromConst(question.optionsConst)
+ : question.options || [];
+
+ // Use ResponsiveComboBox for dropdowns with more than 15 options
+ if (options.length > 15) {
+ return (
+
+
{option}
}
+ getItemValue={(option) => option}
+ getItemLabel={(option) => option}
+ onItemSelect={(option) => onChange(option || null)}
+ buttonPlaceholder="Select an option"
+ inputPlaceholder="Search options..."
+ isDisabled={disabled}
+ />
+
+ );
+ }
return (
-
- {value ? "Yes" : "No"}
-
);
}
diff --git a/apps/blade/src/app/forms/[formName]/page.tsx b/apps/blade/src/app/forms/[formName]/page.tsx
index 8c227d50..7c34f2f3 100644
--- a/apps/blade/src/app/forms/[formName]/page.tsx
+++ b/apps/blade/src/app/forms/[formName]/page.tsx
@@ -1,9 +1,11 @@
import { redirect } from "next/navigation";
+import { XCircle } from "lucide-react";
import { stringify } from "superjson";
import { appRouter } from "@forge/api";
import { log } from "@forge/api/utils";
import { auth } from "@forge/auth";
+import { Card } from "@forge/ui/card";
import { SIGN_IN_PATH } from "~/consts";
import { extractProcedures } from "~/lib/utils";
@@ -21,7 +23,14 @@ export default async function FormResponderPage({
}
if (!params.formName) {
- return
Form not found
;
+ return (
+
+
+
+ Form not found
+
+
+ );
}
// handle url encode form names to allow spacing and special characters
@@ -39,6 +48,24 @@ export default async function FormResponderPage({
}
const form = await api.forms.getForm({ slug_name: formName });
+
+ const { canRespond } = await api.forms.checkResponseAccess({
+ formId: form.id,
+ });
+
+ if (!canRespond) {
+ return (
+
+
+
+
+ You do not have permission to respond to this form
+
+
+
+ );
+ }
+
const connections = await api.forms.getConnections({ id: form.id });
const procs = extractProcedures(appRouter);
diff --git a/apps/blade/src/components/admin/forms/instruction-edit-card.tsx b/apps/blade/src/components/admin/forms/instruction-edit-card.tsx
index edbada17..aa8978fa 100644
--- a/apps/blade/src/components/admin/forms/instruction-edit-card.tsx
+++ b/apps/blade/src/components/admin/forms/instruction-edit-card.tsx
@@ -5,6 +5,8 @@ import type { z } from "zod";
import { useRef, useState } from "react";
import Image from "next/image";
import {
+ ArrowDown,
+ ArrowUp,
Copy,
GripHorizontal,
Image as ImageIcon,
@@ -20,6 +22,7 @@ import { Button } from "@forge/ui/button";
import { Card } from "@forge/ui/card";
import { Textarea } from "@forge/ui/textarea";
import { toast } from "@forge/ui/toast";
+import { useMediaQuery } from "@forge/ui/use-media-query";
import { api } from "~/trpc/react";
@@ -31,6 +34,10 @@ interface InstructionEditCardProps {
onUpdate: (updatedInstruction: FormInstruction & { id: string }) => void;
onDelete: (id: string) => void;
onDuplicate: (instruction: FormInstruction & { id: string }) => void;
+ onMoveUp?: () => void;
+ onMoveDown?: () => void;
+ canMoveUp?: boolean;
+ canMoveDown?: boolean;
dragHandleProps?: DraggableSyntheticListeners;
}
@@ -41,7 +48,12 @@ export function InstructionEditCard({
onDelete,
onDuplicate,
dragHandleProps,
+ onMoveUp,
+ onMoveDown,
+ canMoveUp = false,
+ canMoveDown = false,
}: InstructionEditCardProps) {
+ const isMobile = !useMediaQuery("(min-width: 768px)");
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [isUploadingVideo, setIsUploadingVideo] = useState(false);
const imageInputRef = useRef
(null);
@@ -154,7 +166,7 @@ export function InstructionEditCard({
return (
@@ -296,12 +308,41 @@ export function InstructionEditCard({
{/* Footer */}
-
-
-
+ {isMobile ? (
+
+
{
+ e.stopPropagation();
+ onMoveUp?.();
+ }}
+ disabled={!canMoveUp}
+ className="rounded p-1 text-gray-300 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-30"
+ aria-label="Move up"
+ >
+
+
+
{
+ e.stopPropagation();
+ onMoveDown?.();
+ }}
+ disabled={!canMoveDown}
+ className="rounded p-1 text-gray-300 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-30"
+ aria-label="Move down"
+ >
+
+
+
+ ) : (
+
+
+
+ )}
;
type QuestionType = FormQuestion["type"];
@@ -55,6 +62,10 @@ interface QuestionEditCardProps {
onDelete: (id: string) => void;
onDuplicate: (question: FormQuestion & { id: string }) => void;
onForceSave?: () => void;
+ onMoveUp?: () => void;
+ onMoveDown?: () => void;
+ canMoveUp?: boolean;
+ canMoveDown?: boolean;
error?: string;
dragHandleProps?: DraggableSyntheticListeners;
}
@@ -85,7 +96,12 @@ export function QuestionEditCard({
onForceSave,
error,
dragHandleProps,
+ onMoveUp,
+ onMoveDown,
+ canMoveUp = false,
+ canMoveDown = false,
}: QuestionEditCardProps) {
+ const isMobile = !useMediaQuery("(min-width: 768px)");
// -- Handlers --
const handleTitleChange = (e: React.ChangeEvent) => {
@@ -140,7 +156,7 @@ export function QuestionEditCard({
return (
-
-
-
+ {isMobile ? (
+
+
{
+ e.stopPropagation();
+ onMoveUp?.();
+ }}
+ disabled={!canMoveUp}
+ className="rounded p-1 text-gray-300 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-30"
+ aria-label="Move up"
+ >
+
+
+
{
+ e.stopPropagation();
+ onMoveDown?.();
+ }}
+ disabled={!canMoveDown}
+ className="rounded p-1 text-gray-300 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-30"
+ aria-label="Move down"
+ >
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
{
+ onUpdate({ ...question, allowOther: checked });
+ };
+
+ const allowOther = question.allowOther ?? false;
+ const optionsConst = question.optionsConst;
+ const isUsingConst = Boolean(optionsConst);
+
+ const handleConstChange = (constName: string | null) => {
+ if (constName) {
+ onUpdate({ ...question, optionsConst: constName, options: [] });
+ } else {
+ onUpdate({ ...question, optionsConst: undefined });
+ }
+ };
+
+ const showConstSelector =
+ question.type === "DROPDOWN" ||
+ question.type === "MULTIPLE_CHOICE" ||
+ question.type === "CHECKBOXES";
+
+ const isRestrictedType =
+ question.type === "MULTIPLE_CHOICE" || question.type === "CHECKBOXES";
+
+ const availableConstants = Object.entries(AVAILABLE_DROPDOWN_CONSTANTS).map(
+ ([key, label]) => {
+ const constOptions = getDropdownOptionsFromConst(key);
+ const isDisabled = isRestrictedType && constOptions.length >= 15;
+ return { key, label, isDisabled, length: constOptions.length };
+ },
+ );
+
return (
- {options.map((optionValue, idx) => (
-
+ {showConstSelector && (
+
+
Use Preset Options
+
+ handleConstChange(value === "__MANUAL__" ? null : value)
+ }
+ >
+
+
+
+
+ Manual options
+ {availableConstants.map(({ key, label, isDisabled }) => (
+
+ {label}
+ {isDisabled &&
+ " (too long for this question type, max 15 options) Use Dropdown instead"}
+
+ ))}
+
+
+ {isUsingConst && (
+
+ Using constant:{" "}
+ {
+ AVAILABLE_DROPDOWN_CONSTANTS[
+ optionsConst as keyof typeof AVAILABLE_DROPDOWN_CONSTANTS
+ ]
+ }
+
+ )}
+
+ )}
+
+ {!isUsingConst &&
+ options.map((optionValue, idx) => (
+
+ {question.type === "DROPDOWN" ? (
+ {idx + 1}.
+ ) : (
+
+ )}
+
+ handleOptionChange(idx, e.target.value)}
+ onPaste={(e) => handlePaste(e, idx)}
+ className="flex-1 rounded-none border-none px-0 hover:border-b hover:border-gray-200 focus:border-b-2 focus:border-blue-500 focus:ring-0"
+ placeholder={`Option ${idx + 1}`}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ addOption();
+ }
+ if (e.key === "Backspace" && optionValue === "") {
+ e.preventDefault();
+ removeOption(idx);
+ }
+ }}
+ autoFocus={idx === options.length - 1 && options.length > 1}
+ />
+
+ removeOption(idx)}
+ tabIndex={-1}
+ >
+
+
+
+ ))}
+
+ {/* Add Option Button - Only show when not using a constant */}
+ {!isUsingConst && (
+
{question.type === "DROPDOWN" ? (
-
{idx + 1}.
+
+ {options.length + 1}.
+
) : (
-
+
)}
-
handleOptionChange(idx, e.target.value)}
- onPaste={(e) => handlePaste(e, idx)}
- className="flex-1 rounded-none border-none px-0 hover:border-b hover:border-gray-200 focus:border-b-2 focus:border-blue-500 focus:ring-0"
- placeholder={`Option ${idx + 1}`}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- addOption();
- }
- if (e.key === "Backspace" && optionValue === "") {
- e.preventDefault();
- removeOption(idx);
- }
- }}
- autoFocus={idx === options.length - 1 && options.length > 1}
- />
-
-
removeOption(idx)}
- tabIndex={-1}
- >
-
-
+
+
+ Add option
+
+
- ))}
-
- {/* Add Option Button */}
-
- {question.type === "DROPDOWN" ? (
-
{options.length + 1}.
- ) : (
-
- )}
+ )}
-
-
+
+
- Add option
-
+ Allow "Other" option with custom text input
+
-
+ )}
);
}
diff --git a/apps/blade/src/components/forms/section-manager-dialog.tsx b/apps/blade/src/components/forms/section-manager-dialog.tsx
index fd342411..14fba8c5 100644
--- a/apps/blade/src/components/forms/section-manager-dialog.tsx
+++ b/apps/blade/src/components/forms/section-manager-dialog.tsx
@@ -1,7 +1,15 @@
"use client";
-import { useState } from "react";
-import { Pencil, Plus, Trash2, X } from "lucide-react";
+import { useEffect, useState } from "react";
+import {
+ ArrowDown,
+ ArrowUp,
+ Pencil,
+ Plus,
+ Trash2,
+ Users,
+ X,
+} from "lucide-react";
import * as z from "zod";
import { Button } from "@forge/ui/button";
@@ -38,6 +46,10 @@ const createSectionSchema = z.object({
roleIds: z.array(z.string().uuid()).optional().default([]),
});
+const editSectionRolesSchema = z.object({
+ roleIds: z.array(z.string().uuid()).optional().default([]),
+});
+
export function SectionManagerDialog({
trigger,
}: {
@@ -45,8 +57,11 @@ export function SectionManagerDialog({
}) {
const [isOpen, setIsOpen] = useState(false);
const [editingSection, setEditingSection] = useState
(null);
+ const [editingRolesFor, setEditingRolesFor] = useState(null);
const [isCreatingNew, setIsCreatingNew] = useState(false);
- const utils = api.useUtils();
+ const [sectionRolesMap, setSectionRolesMap] = useState<
+ Map
+ >(new Map());
const { data: sections = [] } = api.forms.getSections.useQuery(undefined, {
enabled: isOpen,
@@ -59,10 +74,39 @@ export function SectionManagerDialog({
},
);
- const { data: roles = [] } = api.roles.getAllLinks.useQuery(undefined, {
- enabled: isOpen && isCreatingNew,
+ const { data: allRoles = [] } = api.roles.getAllLinks.useQuery(undefined, {
+ enabled: isOpen,
});
+ const utils = api.useUtils();
+
+ useEffect(() => {
+ if (!isOpen || sections.length === 0) return;
+
+ const fetchSectionRoles = async () => {
+ const newRolesMap = new Map();
+
+ await Promise.all(
+ sections
+ .filter((s) => s !== "General")
+ .map(async (section) => {
+ try {
+ const roles = await utils.forms.getSectionRoles.fetch({
+ sectionName: section,
+ });
+ newRolesMap.set(section, roles);
+ } catch {
+ newRolesMap.set(section, []);
+ }
+ }),
+ );
+
+ setSectionRolesMap(newRolesMap);
+ };
+
+ void fetchSectionRoles();
+ }, [isOpen, sections, utils.forms.getSectionRoles]);
+
const countMap = new Map();
if (Array.isArray(sectionCounts)) {
for (const sc of sectionCounts) {
@@ -87,6 +131,13 @@ export function SectionManagerDialog({
},
});
+ const editRolesForm = useForm({
+ schema: editSectionRolesSchema,
+ defaultValues: {
+ roleIds: [],
+ },
+ });
+
const renameSection = api.forms.renameSection.useMutation({
onSuccess() {
toast.success("Section renamed");
@@ -134,6 +185,48 @@ export function SectionManagerDialog({
},
});
+ const updateSectionRoles = api.forms.updateSectionRoles.useMutation({
+ onSuccess(_, variables) {
+ toast.success("Section roles updated");
+ setEditingRolesFor(null);
+ editRolesForm.reset();
+ void utils.forms.getSectionRoles
+ .fetch({ sectionName: variables.sectionName })
+ .then((roles) => {
+ setSectionRolesMap((prev) => {
+ const newMap = new Map(prev);
+ newMap.set(variables.sectionName, roles);
+ return newMap;
+ });
+ })
+ .catch(() => {
+ setSectionRolesMap((prev) => {
+ const newMap = new Map(prev);
+ newMap.set(variables.sectionName, []);
+ return newMap;
+ });
+ });
+ },
+ onError(error: unknown) {
+ const message =
+ error && typeof error === "object" && "message" in error
+ ? String(error.message)
+ : "Failed to update section roles";
+ toast.error(message);
+ },
+ async onSettled() {
+ await utils.forms.getSectionRoles.invalidate();
+ await utils.forms.getSections.invalidate();
+ },
+ });
+
+ const reorderSection = api.forms.reorderSection.useMutation({
+ async onSettled() {
+ await utils.forms.getSections.invalidate();
+ await utils.forms.getForms.invalidate();
+ },
+ });
+
const handleRename = (oldName: string) => {
const newName = renameForm.getValues().name;
if (!newName || newName === oldName) {
@@ -175,7 +268,6 @@ export function SectionManagerDialog({
- {roles.map((role) => (
+ {allRoles.map((role) => (
))}
- {roles.length === 0 && (
+ {allRoles.length === 0 && (
No roles available. All users will have access.
@@ -279,84 +371,242 @@ export function SectionManagerDialog({
)}
- {sections.map((section: string) => (
-
- {editingSection === section ? (
-
diff --git a/packages/api/src/routers/forms.ts b/packages/api/src/routers/forms.ts
index a2c41336..ccac6f8d 100644
--- a/packages/api/src/routers/forms.ts
+++ b/packages/api/src/routers/forms.ts
@@ -15,6 +15,7 @@ import { db } from "@forge/db/client";
import { Permissions, Roles } from "@forge/db/schemas/auth";
import {
FormResponse,
+ FormResponseRoles,
FormSchemaSchema,
FormSectionRoles,
FormSections,
@@ -102,7 +103,9 @@ export const formsRouter = {
createdAt: true,
formData: true,
formValidatorJson: true,
- }).extend({ formData: FormSchemaValidator }),
+ })
+ .extend({ formData: FormSchemaValidator })
+ .extend({ responseRoleIds: z.array(z.string().uuid()).optional() }),
)
.mutation(async ({ input, ctx }) => {
controlPerms.or(["EDIT_FORMS"], ctx);
@@ -122,6 +125,15 @@ export const formsRouter = {
where: (t, { eq }) => eq(t.id, input.id ?? ""),
});
+ if (!existingForm) {
+ throw new TRPCError({
+ message: "Form not found",
+ code: "NOT_FOUND",
+ });
+ }
+
+ const formId = existingForm.id;
+
await db
.insert(FormsSchemas)
.values({
@@ -129,7 +141,7 @@ export const formsRouter = {
name: input.formData.name,
slugName: slug_name,
formValidatorJson: jsonSchema.schema,
- sectionId: existingForm?.sectionId ?? null,
+ sectionId: existingForm.sectionId,
})
.onConflictDoUpdate({
//If it already exists upsert it
@@ -139,9 +151,25 @@ export const formsRouter = {
name: input.formData.name,
slugName: slug_name,
formValidatorJson: jsonSchema.schema,
- sectionId: existingForm?.sectionId ?? null,
+ sectionId: existingForm.sectionId,
},
});
+
+ const { responseRoleIds } = input;
+ if (responseRoleIds !== undefined) {
+ await db
+ .delete(FormResponseRoles)
+ .where(eq(FormResponseRoles.formId, formId));
+
+ if (responseRoleIds.length > 0) {
+ await db.insert(FormResponseRoles).values(
+ responseRoleIds.map((roleId) => ({
+ formId,
+ roleId,
+ })),
+ );
+ }
+ }
}),
getForm: protectedProcedure
@@ -161,6 +189,11 @@ export const formsRouter = {
const { formValidatorJson: _JSONValidator, ...retForm } = form;
const formData = form.formData as FormType;
+ const responseRoles = await db
+ .select({ roleId: FormResponseRoles.roleId })
+ .from(FormResponseRoles)
+ .where(eq(FormResponseRoles.formId, form.id));
+
// Regenerate presigned URLs for any media that has objectNames
const instructionsWithFreshUrls = await regenerateMediaUrls(
formData.instructions,
@@ -168,6 +201,7 @@ export const formsRouter = {
return {
...retForm,
+ responseRoleIds: responseRoles.map((r) => r.roleId),
formData: {
...formData,
instructions: instructionsWithFreshUrls,
@@ -176,6 +210,35 @@ export const formsRouter = {
};
}),
+ checkResponseAccess: protectedProcedure
+ .input(z.object({ formId: z.string() }))
+ .query(async ({ input, ctx }) => {
+ const userId = ctx.session.user.id;
+
+ const responseRoles = await db
+ .select({ roleId: FormResponseRoles.roleId })
+ .from(FormResponseRoles)
+ .where(eq(FormResponseRoles.formId, input.formId));
+
+ if (responseRoles.length === 0) {
+ return { canRespond: true };
+ }
+
+ const userRoleIds = await db
+ .select({ roleId: Permissions.roleId })
+ .from(Permissions)
+ .where(sql`cast(${Permissions.userId} as text) = ${userId}`);
+
+ const userRoleIdSet = new Set(userRoleIds.map((r) => r.roleId));
+ const formRoleIdSet = new Set(responseRoles.map((r) => r.roleId));
+
+ const hasRequiredRole = Array.from(formRoleIdSet).some((roleId) =>
+ userRoleIdSet.has(roleId),
+ );
+
+ return { canRespond: hasRequiredRole };
+ }),
+
deleteForm: permProcedure
.input(z.object({ slug_name: z.string() }))
.mutation(async ({ input, ctx }) => {
@@ -322,6 +385,32 @@ export const formsRouter = {
});
}
+ const responseRoles = await db
+ .select({ roleId: FormResponseRoles.roleId })
+ .from(FormResponseRoles)
+ .where(eq(FormResponseRoles.formId, input.form));
+
+ if (responseRoles.length > 0) {
+ const userRoleIds = await db
+ .select({ roleId: Permissions.roleId })
+ .from(Permissions)
+ .where(sql`cast(${Permissions.userId} as text) = ${userId}`);
+
+ const userRoleIdSet = new Set(userRoleIds.map((r) => r.roleId));
+ const formRoleIdSet = new Set(responseRoles.map((r) => r.roleId));
+
+ const hasRequiredRole = Array.from(formRoleIdSet).some((roleId) =>
+ userRoleIdSet.has(roleId),
+ );
+
+ if (!hasRequiredRole) {
+ throw new TRPCError({
+ message: "You don't have permission to respond to this form",
+ code: "FORBIDDEN",
+ });
+ }
+ }
+
// check if user already submitted and form doesnt allow resubmission
if (!form.allowResubmission) {
const existing = await db.query.FormResponse.findFirst({
@@ -625,12 +714,19 @@ export const formsRouter = {
}
const allDbSections = await db
- .select({ id: FormSections.id, name: FormSections.name })
- .from(FormSections);
+ .select({
+ id: FormSections.id,
+ name: FormSections.name,
+ order: FormSections.order,
+ })
+ .from(FormSections)
+ .orderBy(FormSections.order);
const sectionIdToName = new Map();
+ const sectionIdToOrder = new Map();
for (const section of allDbSections) {
sectionIdToName.set(section.id, section.name);
+ sectionIdToOrder.set(section.id, section.order);
}
const accessibleSectionIds = new Set();
@@ -684,7 +780,26 @@ export const formsRouter = {
}
}
- return Array.from(allSections).sort();
+ const sortedSections = Array.from(allSections).sort((a, b) => {
+ if (a === "General") return -1;
+ if (b === "General") return 1;
+
+ let aOrder = 999;
+ let bOrder = 999;
+
+ for (const section of allDbSections) {
+ if (section.name === a) {
+ aOrder = section.order;
+ }
+ if (section.name === b) {
+ bOrder = section.order;
+ }
+ }
+
+ return aOrder - bOrder;
+ });
+
+ return sortedSections;
}),
getSectionCounts: permProcedure.query(async ({ ctx }) => {
@@ -834,10 +949,19 @@ export const formsRouter = {
}
}
+ const maxOrderResult = await db
+ .select({
+ maxOrder: sql`COALESCE(MAX(${FormSections.order}), 0)`,
+ })
+ .from(FormSections);
+
+ const maxOrder = maxOrderResult[0]?.maxOrder ?? 0;
+
const [newSection] = await db
.insert(FormSections)
.values({
name: input.name,
+ order: maxOrder + 1,
})
.returning();
@@ -870,6 +994,170 @@ export const formsRouter = {
});
}),
+ getSectionRoles: permProcedure
+ .input(z.object({ sectionName: z.string() }))
+ .query(async ({ input, ctx }) => {
+ controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx);
+
+ const section = await db.query.FormSections.findFirst({
+ where: (t, { eq }) => eq(t.name, input.sectionName),
+ });
+
+ if (!section) {
+ return [];
+ }
+
+ const sectionRoles = await db
+ .select({
+ roleId: FormSectionRoles.roleId,
+ })
+ .from(FormSectionRoles)
+ .where(eq(FormSectionRoles.sectionId, section.id));
+
+ const roleIds = sectionRoles.map((sr) => sr.roleId);
+
+ if (roleIds.length === 0) {
+ return [];
+ }
+
+ const roles = await db
+ .select({ id: Roles.id, name: Roles.name })
+ .from(Roles)
+ .where(inArray(Roles.id, roleIds));
+
+ return roles;
+ }),
+
+ updateSectionRoles: permProcedure
+ .input(
+ z.object({
+ sectionName: z.string(),
+ roleIds: z.array(z.string().uuid()),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS"], ctx);
+
+ const section = await db.query.FormSections.findFirst({
+ where: (t, { eq }) => eq(t.name, input.sectionName),
+ });
+
+ if (!section) {
+ throw new TRPCError({
+ message: "Section not found",
+ code: "NOT_FOUND",
+ });
+ }
+
+ const isOfficer = ctx.session.permissions.IS_OFFICER;
+
+ if (input.roleIds.length && !isOfficer) {
+ const userRoles = await db
+ .select({ roleId: Permissions.roleId })
+ .from(Permissions)
+ .where(
+ sql`cast(${Permissions.userId} as text) = ${ctx.session.user.id}`,
+ );
+
+ const userRoleIds = new Set(userRoles.map((r) => r.roleId));
+
+ const hasAllRoles = input.roleIds.every((roleId) =>
+ userRoleIds.has(roleId),
+ );
+
+ if (!hasAllRoles) {
+ throw new TRPCError({
+ message:
+ "You don't have permission to assign sections to one or more of the selected roles",
+ code: "UNAUTHORIZED",
+ });
+ }
+ }
+
+ await db
+ .delete(FormSectionRoles)
+ .where(eq(FormSectionRoles.sectionId, section.id));
+
+ if (input.roleIds.length > 0) {
+ await db.insert(FormSectionRoles).values(
+ input.roleIds.map((roleId) => ({
+ sectionId: section.id,
+ roleId,
+ })),
+ );
+ }
+
+ const roleNames = await db
+ .select({ name: Roles.name })
+ .from(Roles)
+ .where(inArray(Roles.id, input.roleIds));
+
+ await log({
+ title: `Form section roles updated`,
+ message: `**Form section:** ${input.sectionName}. Roles: ${roleNames.length > 0 ? roleNames.map((r) => r.name).join(", ") : "None (all users)"}`,
+ color: "success_green",
+ userId: ctx.session.user.discordUserId,
+ });
+ }),
+
+ reorderSection: permProcedure
+ .input(
+ z.object({
+ sectionName: z.string(),
+ direction: z.enum(["up", "down"]),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS"], ctx);
+
+ const allSections = await db
+ .select({
+ id: FormSections.id,
+ name: FormSections.name,
+ order: FormSections.order,
+ })
+ .from(FormSections)
+ .orderBy(FormSections.order);
+
+ const currentIndex = allSections.findIndex(
+ (s) => s.name === input.sectionName,
+ );
+
+ if (currentIndex === -1) {
+ throw new TRPCError({
+ message: "Section not found",
+ code: "NOT_FOUND",
+ });
+ }
+
+ const newIndex =
+ input.direction === "up" ? currentIndex - 1 : currentIndex + 1;
+
+ if (newIndex < 0 || newIndex >= allSections.length) {
+ return;
+ }
+
+ const currentSection = allSections[currentIndex];
+ const targetSection = allSections[newIndex];
+
+ await db
+ .update(FormSections)
+ .set({ order: targetSection?.order ?? newIndex })
+ .where(eq(FormSections.id, currentSection?.id ?? ""));
+
+ await db
+ .update(FormSections)
+ .set({ order: currentSection?.order ?? currentIndex })
+ .where(eq(FormSections.id, targetSection?.id ?? ""));
+
+ await log({
+ title: `Form section reordered`,
+ message: `**Form section:** ${input.sectionName} moved ${input.direction}`,
+ color: "success_green",
+ userId: ctx.session.user.discordUserId,
+ });
+ }),
+
checkFormEditAccess: permProcedure
.input(z.object({ slug_name: z.string() }))
.query(async ({ input, ctx }) => {
diff --git a/packages/api/src/routers/misc.ts b/packages/api/src/routers/misc.ts
index 15b4a8ab..13b864ce 100644
--- a/packages/api/src/routers/misc.ts
+++ b/packages/api/src/routers/misc.ts
@@ -2,10 +2,14 @@ import type { TRPCRouterRecord } from "@trpc/server";
import { Routes } from "discord-api-types/v10";
import { z } from "zod";
-import { RECRUITING_CHANNEL, TEAM_MAP } from "@forge/consts/knight-hacks";
+import {
+ generateFundingRequestEmailHtml,
+ RECRUITING_CHANNEL,
+ TEAM_MAP,
+} from "@forge/consts/knight-hacks";
import { protectedProcedure } from "../trpc";
-import { discord } from "../utils";
+import { discord, sendEmail } from "../utils";
// Miscellaneous routes (primarily for form integrations)
export const miscRouter = {
@@ -91,4 +95,45 @@ export const miscRouter = {
},
});
}),
+
+ fundingRequest: protectedProcedure
+ .meta({
+ id: "fundingRequest",
+ inputSchema: z.object({
+ team: z.string().min(1),
+ description: z.string(),
+ amount: z.number(),
+ itemization: z.string(),
+ importance: z.number(),
+ dateNeeded: z.string(),
+ deadlineType: z.string(),
+ }),
+ })
+ .input(
+ z.object({
+ team: z.string().min(1),
+ description: z.string(),
+ amount: z.number(),
+ itemization: z.string(),
+ importance: z.number(),
+ dateNeeded: z.string(),
+ deadlineType: z.string(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const dateObj =
+ typeof input.dateNeeded === "string"
+ ? new Date(input.dateNeeded)
+ : input.dateNeeded;
+ const formattedDate = `${String(dateObj.getMonth() + 1).padStart(2, "0")}/${String(dateObj.getDate()).padStart(2, "0")}`;
+ const htmlContent = generateFundingRequestEmailHtml(input);
+
+ await sendEmail({
+ to: "treasurer@knighthacks.org",
+ cc: "exec@knighthacks.org",
+ subject: `KHFR - $${input.amount.toLocaleString()} | ${formattedDate} | ${input.team}`,
+ html: htmlContent,
+ from: "Funding Requests ",
+ });
+ }),
} satisfies TRPCRouterRecord;
diff --git a/packages/api/src/utils.ts b/packages/api/src/utils.ts
index 0df7b1b4..b30c083e 100644
--- a/packages/api/src/utils.ts
+++ b/packages/api/src/utils.ts
@@ -201,19 +201,40 @@ export const sendEmail = async ({
subject,
html,
from,
+ cc,
+ bcc,
}: {
- to: string;
+ to: string | string[];
subject: string;
html: string;
from?: string;
+ cc?: string | string[];
+ bcc?: string | string[];
}): Promise<{ success: true; messageId: string }> => {
try {
- const { data, error } = await resend.emails.send({
+ const emailPayload: {
+ from: string;
+ to: string | string[];
+ subject: string;
+ html: string;
+ cc?: string | string[];
+ bcc?: string | string[];
+ } = {
from: from ?? env.RESEND_FROM_EMAIL,
to,
subject,
html,
- });
+ };
+
+ if (cc) {
+ emailPayload.cc = cc;
+ }
+
+ if (bcc) {
+ emailPayload.bcc = bcc;
+ }
+
+ const { data, error } = await resend.emails.send(emailPayload);
if (error) {
console.error("Resend error:", error);
@@ -358,6 +379,9 @@ function createJsonSchemaValidator({
case "SHORT_ANSWER":
case "PARAGRAPH":
schema.type = "string";
+ if (max === undefined) {
+ schema.maxLength = type === "SHORT_ANSWER" ? 150 : 750;
+ }
break;
case "EMAIL":
schema.type = "string";
@@ -418,7 +442,10 @@ function createJsonSchemaValidator({
}
if (max !== undefined) {
- if (schema.type === "string") schema.maxLength = max;
+ if (schema.type === "string") {
+ // Explicit max value overrides any defaults
+ schema.maxLength = max;
+ }
if (schema.type === "array") schema.maxItems = max;
if (schema.type === "number") schema.maximum = max;
}
diff --git a/packages/consts/src/knight-hacks.ts b/packages/consts/src/knight-hacks.ts
index e8695386..d0528a68 100644
--- a/packages/consts/src/knight-hacks.ts
+++ b/packages/consts/src/knight-hacks.ts
@@ -6754,7 +6754,7 @@ export const OFFICER_ROLE_ID =
export const DEVPOST_TEAM_MEMBER_EMAIL_OFFSET = 3;
export const QuestionValidator = z.object({
- question: z.string().max(200),
+ question: z.string(),
image: z.string().url().optional(),
type: z.enum([
"SHORT_ANSWER",
@@ -6773,7 +6773,9 @@ export const QuestionValidator = z.object({
"LINK",
]),
options: z.array(z.string()).optional(),
+ optionsConst: z.string().optional(),
optional: z.boolean().optional(),
+ allowOther: z.boolean().optional(),
min: z.number().optional(),
max: z.number().optional(),
order: z.number().optional(),
@@ -6805,6 +6807,201 @@ export type ValidatorOptions = Omit;
export type QuestionsType = z.infer["type"];
+export const AVAILABLE_DROPDOWN_CONSTANTS = {
+ LEVELS_OF_STUDY: "Levels of Study",
+ ALLERGIES: "Allergies",
+ MAJORS: "Majors",
+ GENDERS: "Genders",
+ RACES_OR_ETHNICITIES: "Races or Ethnicities",
+ COUNTRIES: "Countries",
+ SCHOOLS: "Schools",
+ COMPANIES: "Companies",
+ SHIRT_SIZES: "Shirt Sizes",
+ EVENT_FEEDBACK_HEARD: "Event Feedback - How You Heard",
+ SHORT_LEVELS_OF_STUDY: "Short Levels of Study",
+ SHORT_RACES_AND_ETHNICITIES: "Short Races and Ethnicities",
+} as const;
+
+export type DropdownConstantKey = keyof typeof AVAILABLE_DROPDOWN_CONSTANTS;
+
+export function getDropdownOptionsFromConst(
+ constName: string,
+): readonly string[] {
+ switch (constName) {
+ case "LEVELS_OF_STUDY":
+ return LEVELS_OF_STUDY;
+ case "ALLERGIES":
+ return ALLERGIES;
+ case "MAJORS":
+ return MAJORS;
+ case "GENDERS":
+ return GENDERS;
+ case "RACES_OR_ETHNICITIES":
+ return RACES_OR_ETHNICITIES;
+ case "COUNTRIES":
+ return COUNTRIES;
+ case "SCHOOLS":
+ return SCHOOLS;
+ case "COMPANIES":
+ return COMPANIES;
+ case "SHIRT_SIZES":
+ return SHIRT_SIZES;
+ case "EVENT_FEEDBACK_HEARD":
+ return EVENT_FEEDBACK_HEARD;
+ case "SHORT_LEVELS_OF_STUDY":
+ return SHORT_LEVELS_OF_STUDY;
+ case "SHORT_RACES_AND_ETHNICITIES":
+ return SHORT_RACES_AND_ETHNICITIES;
+ default:
+ return [];
+ }
+}
+
+export interface FundingRequestInput {
+ team: string;
+ description: string;
+ amount: number;
+ itemization: string;
+ importance: number;
+ dateNeeded: string;
+ deadlineType: string;
+}
+
+export function generateFundingRequestEmailHtml(
+ input: FundingRequestInput,
+): string {
+ const formatText = (text: string | null | undefined): string => {
+ if (!text) return "N/A";
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+ .replace(/\n/g, " ");
+ };
+
+ // Format date as MM/DD
+ const formatDate = (date: Date | string): string => {
+ const dateObj = typeof date === "string" ? new Date(date) : date;
+ const month = String(dateObj.getMonth() + 1).padStart(2, "0");
+ const day = String(dateObj.getDate()).padStart(2, "0");
+ return `${month}/${day}`;
+ };
+
+ const formattedDate = formatDate(input.dateNeeded);
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Funding Request
+
+
+ A new funding request has been submitted for the ${input.team} team
+
+
+
+
+
+
+
+
+
+
+ Team:
+
+
+ ${input.team}
+
+
+
+
+ Amount:
+
+
+ $${input.amount.toLocaleString()}
+
+
+
+
+ Date Needed:
+
+
+ ${formattedDate}
+
+
+
+
+ Importance:
+
+
+
+ ${input.importance}/10
+
+
+
+
+
+ Deadline Type:
+
+
+ ${input.deadlineType || "N/A"}
+
+
+
+
+
+
Description:
+
+ ${formatText(input.description)}
+
+
+
+
+
Itemization:
+
+ ${formatText(input.itemization)}
+
+
+
+
+
+
+
+
+
+ Submitted at ${new Date().toLocaleString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ })}
+
+
+
+
+
+
+
+
+`;
+}
+
export const FORM_QUESTION_TYPES = [
{ value: "SHORT_ANSWER", label: "Short answer" },
{ value: "PARAGRAPH", label: "Paragraph" },
diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts
index f10cf6fc..e6c46289 100644
--- a/packages/db/src/schemas/knight-hacks.ts
+++ b/packages/db/src/schemas/knight-hacks.ts
@@ -540,6 +540,7 @@ export const InsertOtherCompaniesSchema = createInsertSchema(OtherCompanies);
export const FormSections = createTable("form_sections", (t) => ({
id: t.uuid().notNull().primaryKey().defaultRandom(),
name: t.varchar({ length: 255 }).notNull().unique(),
+ order: t.integer().notNull().default(0),
createdAt: t.timestamp().notNull().defaultNow(),
}));
@@ -580,6 +581,23 @@ export const FormsSchemas = createTable("form_schemas", (t) => ({
//Ts so dumb
export const FormSchemaSchema = createInsertSchema(FormsSchemas);
+export const FormResponseRoles = createTable(
+ "form_response_roles",
+ (t) => ({
+ formId: t
+ .uuid()
+ .notNull()
+ .references(() => FormsSchemas.id, { onDelete: "cascade" }),
+ roleId: t
+ .uuid()
+ .notNull()
+ .references(() => Roles.id, { onDelete: "cascade" }),
+ }),
+ (t) => ({
+ pk: primaryKey({ columns: [t.formId, t.roleId] }),
+ }),
+);
+
export const FormResponse = createTable("form_response", (t) => ({
id: t.uuid().notNull().primaryKey().defaultRandom(),
form: t