diff --git a/apps/blade/src/app/admin/forms/[slug]/con-viewer.tsx b/apps/blade/src/app/admin/forms/[slug]/con-viewer.tsx index 0765c02e..d0491562 100644 --- a/apps/blade/src/app/admin/forms/[slug]/con-viewer.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/con-viewer.tsx @@ -93,7 +93,9 @@ export function ConnectionViewer({ Form Field
- {conn.formField || "Not Mapped"} + {conn.customValue + ? `Custom: "${conn.customValue}"` + : conn.formField || "Not Mapped"}
diff --git a/apps/blade/src/app/admin/forms/[slug]/linker.tsx b/apps/blade/src/app/admin/forms/[slug]/linker.tsx index f14f0d00..cb6b3c59 100644 --- a/apps/blade/src/app/admin/forms/[slug]/linker.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/linker.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import { useState } from "react"; import { Loader2 } from "lucide-react"; import { z } from "zod"; @@ -26,6 +26,7 @@ const matchingSchema = z.object({ z.object({ procField: z.string(), formField: z.string().optional(), + customValue: z.string().optional(), }), ), }); @@ -44,7 +45,7 @@ export default function ListMatcher({ const [procFields, setProcFields] = useState([]); const [isLoading, setIsLoading] = useState(false); const [connections, setConnections] = useState< - { procField: string; formField: string }[] + { procField: string; formField?: string; customValue?: string }[] >([]); const formFields = form.questions; @@ -67,16 +68,35 @@ export default function ListMatcher({ setProcSelection(value); const newProcFields = procs[value].inputSchema; setProcFields(newProcFields); - setConnections( - newProcFields.map((item) => ({ procField: item, formField: "" })), - ); + setConnections(newProcFields.map((item) => ({ procField: item }))); }; const updateConnection = (index: number, value: string) => { setConnections((prev) => { const updated = [...prev]; if (!updated[index]) return updated; - updated[index] = { ...updated[index], formField: value }; + if (value === "__CUSTOM__") { + updated[index] = { + ...updated[index], + formField: undefined, + customValue: updated[index].customValue || "", + }; + } else { + updated[index] = { + ...updated[index], + formField: value, + customValue: undefined, + }; + } + return updated; + }); + }; + + const updateCustomValue = (index: number, value: string) => { + setConnections((prev) => { + const updated = [...prev]; + if (!updated[index]) return updated; + updated[index] = { ...updated[index], customValue: value }; return updated; }); }; @@ -89,13 +109,35 @@ export default function ListMatcher({ return formFields.filter((item) => !usedItems.includes(item)); }; + const isCustomValue = (index: number) => { + const conn = connections[index]; + return conn && !conn.formField && conn.customValue !== undefined; + }; + const handleSubmit = () => { setIsLoading(true); + const cleanedConnections = connections.map((conn) => { + const cleaned: { + procField: string; + formField?: string; + customValue?: string; + } = { + procField: conn.procField, + }; + if (conn.formField) { + cleaned.formField = conn.formField; + } + if (conn.customValue !== undefined && conn.customValue !== "") { + cleaned.customValue = conn.customValue; + } + return cleaned; + }); + const data = { form: form.id, proc: procSelection, - connections: connections, + connections: cleanedConnections, }; try { @@ -126,7 +168,7 @@ export default function ListMatcher({ - {procFields.length > 0 && formFields.length > 0 && ( + {procFields.length > 0 && (

Connect Items

@@ -145,7 +187,11 @@ export default function ListMatcher({
+ {isCustomValue(index) && ( + updateCustomValue(index, e.target.value)} + className="mt-2" + /> + )}
))} 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 340b679b..aa7b12fa 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 @@ -2,6 +2,7 @@ import { useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { ChevronLeft, ChevronRight, @@ -11,6 +12,7 @@ import { FileSpreadsheet, FileText, Loader2, + X, } from "lucide-react"; import type { FormType } from "@forge/consts/knight-hacks"; @@ -24,6 +26,7 @@ import { api } from "~/trpc/react"; interface PerUserResponsesViewProps { formData: FormType; responses: { + id: string; submittedAt: Date; responseData: Record; member: { @@ -36,6 +39,7 @@ interface PerUserResponsesViewProps { } interface GroupedResponse { + id: string; member: { firstName: string; lastName: string; @@ -58,6 +62,7 @@ export function PerUserResponsesView({ acc[anonymousKey] = []; } acc[anonymousKey].push({ + id: response.id, member: { firstName: "Anonymous", lastName: "", @@ -75,6 +80,7 @@ export function PerUserResponsesView({ acc[userId] = []; } acc[userId].push({ + id: response.id, member: response.member, submittedAt: response.submittedAt, responseData: response.responseData, @@ -191,10 +197,15 @@ export function PerUserResponsesView({ {currentUserResponses.map((response, responseIndex) => ( - Response #{responseIndex + 1} -

- Submitted: {new Date(response.submittedAt).toLocaleString()} -

+
+
+ Response #{responseIndex + 1} +

+ Submitted: {new Date(response.submittedAt).toLocaleString()} +

+
+ +
{formData.questions.map((question, questionIndex) => { @@ -241,6 +252,38 @@ export function PerUserResponsesView({ ); } +function DeleteResponseButton({ responseId }: { responseId: string }) { + const router = useRouter(); + const utils = api.useUtils(); + + const deleteResponse = api.forms.deleteResponse.useMutation({ + async onSuccess() { + toast.success("Response deleted"); + await utils.forms.getResponses.invalidate(); + router.refresh(); + }, + onError() { + toast.error("Failed to delete response"); + }, + }); + + return ( + + ); +} + function FileUploadDisplay({ objectName }: { objectName: string }) { const [isDownloading, setIsDownloading] = useState(false); diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx index a76823e0..862a55eb 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx @@ -65,6 +65,7 @@ export default async function FormResponsesPage({ // type assertion to the correct format const responses = apiResponses as { + id: string; submittedAt: Date; responseData: Record; member: { diff --git a/apps/blade/src/app/forms/[formName]/page.tsx b/apps/blade/src/app/forms/[formName]/page.tsx index 8d7b6022..8c227d50 100644 --- a/apps/blade/src/app/forms/[formName]/page.tsx +++ b/apps/blade/src/app/forms/[formName]/page.tsx @@ -48,10 +48,14 @@ export default async function FormResponderPage({ const data: Record = {}; for (const map of con.connections as { procField: string; - formField: string; + formField?: string; + customValue?: string; }[]) { - if (map.formField in response) + if (map.customValue !== undefined) { + data[map.procField] = map.customValue; + } else if (map.formField && map.formField in response) { data[map.procField] = response[map.formField]; + } } const route = procs[con.proc]?.route.split("."); @@ -71,10 +75,13 @@ export default async function FormResponderPage({ color: "success_green", userId: session.user.discordUserId, }); - } catch { + } catch (error) { + const errorMessage = JSON.stringify(error, null, 2); await log({ title: `Failed to automatically fire procedure`, - message: `**Failed to fire procedure**\n\`${con.proc}\`\n\nTriggered after **${form.name}** submission from **${session.user.name}**\n\n**Data:**\n\`\`\`json\n${stringify(data)}\`\`\``, + message: + `**Failed to fire procedure**\n\`${con.proc}\`\n\nTriggered after **${form.name}** submission from **${session.user.name}**\n\n**Data:**\n\`\`\`json\n${stringify(data)}\`\`\`` + + `\n\n**Error:**\n\`\`\`json\n${errorMessage}\`\`\``, color: "uhoh_red", userId: session.user.discordUserId, }); diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 4b8e3dd0..35b1f091 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -13,6 +13,7 @@ import { hackathonRouter } from "./routers/hackathon"; import { hackerRouter } from "./routers/hacker"; import { judgeRouter } from "./routers/judge"; import { memberRouter } from "./routers/member"; +import { miscRouter } from "./routers/misc"; import { passkitRouter } from "./routers/passkit"; import { qrRouter } from "./routers/qr"; import { resumeRouter } from "./routers/resume"; @@ -21,6 +22,7 @@ import { userRouter } from "./routers/user"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter<{ + misc: typeof miscRouter; auth: typeof authRouter; duesPayment: typeof duesPaymentRouter; member: typeof memberRouter; @@ -42,6 +44,7 @@ export const appRouter = createTRPCRouter<{ forms: typeof formsRouter; roles: typeof rolesRouter; }>({ + misc: miscRouter, auth: authRouter, duesPayment: duesPaymentRouter, member: memberRouter, diff --git a/packages/api/src/routers/forms.ts b/packages/api/src/routers/forms.ts index c10b68e6..a2c41336 100644 --- a/packages/api/src/routers/forms.ts +++ b/packages/api/src/routers/forms.ts @@ -118,6 +118,10 @@ export const formsRouter = { }); } + const existingForm = await db.query.FormsSchemas.findFirst({ + where: (t, { eq }) => eq(t.id, input.id ?? ""), + }); + await db .insert(FormsSchemas) .values({ @@ -125,6 +129,7 @@ export const formsRouter = { name: input.formData.name, slugName: slug_name, formValidatorJson: jsonSchema.schema, + sectionId: existingForm?.sectionId ?? null, }) .onConflictDoUpdate({ //If it already exists upsert it @@ -134,6 +139,7 @@ export const formsRouter = { name: input.formData.name, slugName: slug_name, formValidatorJson: jsonSchema.schema, + sectionId: existingForm?.sectionId ?? null, }, }); }), @@ -244,7 +250,17 @@ export const formsRouter = { }), addConnection: permProcedure - .input(TrpcFormConnectionSchema) + .input( + TrpcFormConnectionSchema.extend({ + connections: z.array( + z.object({ + procField: z.string(), + formField: z.string().optional(), + customValue: z.string().optional(), + }), + ), + }), + ) .mutation(async ({ input, ctx }) => { controlPerms.or(["EDIT_FORMS"], ctx); try { @@ -360,6 +376,7 @@ export const formsRouter = { controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); return await db .select({ + id: FormResponse.id, submittedAt: FormResponse.createdAt, responseData: FormResponse.responseData, member: { @@ -375,6 +392,26 @@ export const formsRouter = { .orderBy(desc(FormResponse.createdAt)); }), + deleteResponse: permProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + controlPerms.or(["EDIT_FORMS"], ctx); + try { + await db.delete(FormResponse).where(eq(FormResponse.id, input.id)); + await log({ + title: `Form response deleted`, + message: `**Response deleted:** ${input.id}`, + color: "uhoh_red", + userId: ctx.session.user.discordUserId, + }); + } catch { + throw new TRPCError({ + message: "Could not delete response", + code: "BAD_REQUEST", + }); + } + }), + getUserResponse: protectedProcedure .input( z.object({ diff --git a/packages/api/src/routers/misc.ts b/packages/api/src/routers/misc.ts new file mode 100644 index 00000000..15b4a8ab --- /dev/null +++ b/packages/api/src/routers/misc.ts @@ -0,0 +1,94 @@ +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 { protectedProcedure } from "../trpc"; +import { discord } from "../utils"; + +// Miscellaneous routes (primarily for form integrations) +export const miscRouter = { + recruitingUpdate: protectedProcedure + .meta({ + id: "recruitingUpdate", + inputSchema: z.object({ + name: z.string().min(1), + email: z.string().email(), + major: z.string().min(1), + gradTerm: z.string().min(1), + gradYear: z.number().min(1), + team: z.string().min(1), + }), + }) + .input( + z.object({ + name: z.string().min(1), + email: z.string().email(), + major: z.string().min(1), + gradTerm: z.string().min(1), + gradYear: z.number().min(1), + team: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const team = TEAM_MAP.find((team) => team.team === input.team); + if (!team) { + throw new Error("Team not found"); + } + + const directorRole = team.director_role; + + // Convert hex color string to integer for Discord API + const colorInt = parseInt(team.color.replace("#", ""), 16); + + await discord.post(Routes.channelMessages(RECRUITING_CHANNEL), { + body: { + content: `<@&${directorRole}> **New Applicant for ${team.team}!**`, + embeds: [ + { + title: `${input.name}'s Application`, + description: `A new applicant is interested in joining the **${team.team}** team.\n\nPlease see details below:`, + color: colorInt, + fields: [ + { + name: "Name", + value: input.name, + inline: true, + }, + { + name: "Email", + value: input.email, + inline: true, + }, + { + name: "Major", + value: input.major, + inline: true, + }, + { + name: "Grad Term", + value: input.gradTerm, + inline: true, + }, + { + name: "Grad Year", + value: input.gradYear.toString(), + inline: true, + }, + { + name: "Team", + value: team.team, + inline: true, + }, + ], + footer: { + text: `Submitted at: ${new Date().toLocaleString()}`, + }, + timestamp: new Date().toISOString(), + }, + ], + }, + }); + }), +} satisfies TRPCRouterRecord; diff --git a/packages/consts/src/knight-hacks.ts b/packages/consts/src/knight-hacks.ts index 8144a87b..e8695386 100644 --- a/packages/consts/src/knight-hacks.ts +++ b/packages/consts/src/knight-hacks.ts @@ -6821,3 +6821,40 @@ export const FORM_QUESTION_TYPES = [ { value: "BOOLEAN", label: "Boolean (Yes/No)" }, { value: "LINK", label: "Link (URL)" }, ] as const; + +export const RECRUITING_CHANNEL = IS_PROD + ? "1461758896950608104" + : DEV_KNIGHTHACKS_LOG_CHANNEL; + +export const TEAM_MAP = [ + { + team: "Outreach", + color: "#88fea1", + director_role: "779845137822908436", + }, + { + team: "Design", + color: "#eaacff", + director_role: "874028482089349172", + }, + { + team: "Development", + color: "#93ceff", + director_role: "1082124530077683772", + }, + { + team: "Sponsorship", + color: "#f5f4af", + director_role: "626815399442513920", + }, + { + team: "Workshops", + color: "#206694", + director_role: "757002949603098837", + }, + { + team: "Projects/Mentorship", + color: "#3498db", + director_role: "1244790444626280550", + }, +];