diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0e53536..82a0eaa4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,12 +118,12 @@ model mfa_factors { created_at DateTime @db.Timestamptz(6) updated_at DateTime @db.Timestamptz(6) secret String? - phone String? @unique + phone String? last_challenged_at DateTime? @unique @db.Timestamptz(6) mfa_challenges mfa_challenges[] users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - @@unique([user_id, phone], map: "unique_verified_phone_factor") + @@unique([user_id, phone], map: "unique_phone_factor_per_user") @@index([user_id, created_at], map: "factor_id_created_at_idx") @@index([user_id]) @@schema("auth") @@ -319,7 +319,8 @@ model users { one_time_tokens one_time_tokens[] sessions sessions[] applications_applications_interviewer_idTousers applications[] @relation("applications_interviewer_idTousers") - applications applications[] + applications_applications_reviewer_idTousers applications[] @relation("applications_reviewer_idTousers") + members members? roles roles? submissions submissions[] @@ -328,14 +329,23 @@ model users { @@schema("auth") } -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model form_status { - label String - form_id BigInt - forms forms @relation(fields: [form_id], references: [id], onDelete: Cascade) +model applications { + id String @id @db.Uuid + status String? + reviewer_id String? @db.Uuid + notes String? + created_at DateTime? @default(now()) @db.Timestamptz(6) + meta Json? + level String? @default("unassigned") + interviewer_id String? @db.Uuid + team_id BigInt? + submissions submissions @relation(fields: [id], references: [id], onDelete: Cascade) + users_applications_interviewer_idTousers users? @relation("applications_interviewer_idTousers", fields: [interviewer_id], references: [id]) + users_applications_reviewer_idTousers users? @relation("applications_reviewer_idTousers", fields: [reviewer_id], references: [id], onUpdate: Restrict) + teams teams? @relation(fields: [team_id], references: [id]) + pending_members pending_members[] - @@id([label, form_id]) @@schema("public") } @@ -350,12 +360,51 @@ model forms { close_at DateTime? @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6) type form_type? @default(survey) - form_status form_status[] submissions submissions[] @@schema("public") } +/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. +model members { + id String @id @db.Uuid + first_name String + last_name String + grad_year Int @db.SmallInt + faculty String? + specialization String? + year_level Int? @db.SmallInt + users users @relation(fields: [id], references: [id]) + team_members team_members[] + + @@schema("public") +} + +/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. +model pending_members { + user_id String @db.Uuid + status String? + meta Json? + created_at DateTime? @default(now()) @db.Timestamptz(6) + team_id BigInt + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + applications applications @relation(fields: [user_id], references: [id], onDelete: Cascade, map: "pending_member_id_fkey") + teams teams @relation(fields: [team_id], references: [id], onDelete: Cascade, map: "pending_member_team_id_fkey") + + @@schema("public") +} + +/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. +model roles { + id String @id @db.Uuid + roles String? + created_at DateTime @default(now()) @db.Timestamptz(6) + display_name String? + users users @relation(fields: [id], references: [id], onDelete: Cascade) + + @@schema("public") +} + /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. model submissions { @@ -375,29 +424,37 @@ model submissions { } /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model applications { - id String @id @db.Uuid - status String? - reviewer_id String? @db.Uuid - notes String? - created_at DateTime? @default(now()) @db.Timestamptz(6) - meta Json? - level String? @default("unassigned") - interviewer_id String? @db.Uuid - submissions submissions @relation(fields: [id], references: [id], onDelete: Cascade) - users_applications_interviewer_idTousers users? @relation("applications_interviewer_idTousers", fields: [interviewer_id], references: [id]) - users users? @relation(fields: [reviewer_id], references: [id], onUpdate: Restrict) +model team_members { + member_id String @db.Uuid + team_id BigInt + joined_on DateTime? @default(now()) @db.Timestamptz(6) + role String? + members members @relation(fields: [member_id], references: [id], onDelete: Cascade) + team_role team_role? @relation(fields: [role], references: [name]) + teams teams @relation(fields: [team_id], references: [id], onDelete: Cascade) + + @@id([member_id, team_id]) + @@schema("public") +} + +/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. +model team_role { + id BigInt @id @default(autoincrement()) + name String @unique + team_members team_members[] @@schema("public") } /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model roles { - id String @id @db.Uuid - roles String? - created_at DateTime @default(now()) @db.Timestamptz(6) - display_name String? - users users @relation(fields: [id], references: [id], onDelete: Cascade) +model teams { + id BigInt @id @default(autoincrement()) + name String @unique + started_at DateTime? @default(now()) @db.Timestamptz(6) + ended_at DateTime? @db.Timestamptz(6) + applications applications[] + pending_members pending_members[] + team_members team_members[] @@schema("public") } diff --git a/src/app/portal/admin/forms/[id]/submissions/AnalyticsPage.tsx b/src/app/portal/admin/forms/[id]/submissions/AnalyticsPage.tsx index da0f734b..53361903 100644 --- a/src/app/portal/admin/forms/[id]/submissions/AnalyticsPage.tsx +++ b/src/app/portal/admin/forms/[id]/submissions/AnalyticsPage.tsx @@ -14,6 +14,7 @@ export default function AnalyticsPage({ }) { const columns = [ "status", + "team_id", "level", "role", "reviewer_id", diff --git a/src/app/portal/admin/forms/[id]/submissions/columns.tsx b/src/app/portal/admin/forms/[id]/submissions/columns.tsx index 195d8fee..fe43f2a0 100644 --- a/src/app/portal/admin/forms/[id]/submissions/columns.tsx +++ b/src/app/portal/admin/forms/[id]/submissions/columns.tsx @@ -87,6 +87,7 @@ export function createColumns( return { meta: { field: field, id: key }, accessorKey: key as keyof TData, + size: 250, header: (body: any) => { if (!body) { return {field.label}; @@ -245,22 +246,35 @@ export function createColumns( return [ { accessorKey: "popover", - header: "", + header: "Actions", enableColumnFilter: false, - size: 100, - maxSize: 100, + size: 300, + maxSize: 300, cell: ({ row }) => { return ( - +
+ + +
); }, }, @@ -360,21 +374,23 @@ export function SelectField({ }; return ( - updateField(e[0])} - allowMultiple={false} - emptyText={nullLabel?.toString() || "None"} - value={Array.isArray(selected) ? selected : [selected]} - options={ - selectOptions?.map((op) => ({ - label: op.label, - value: op.id, - })) || [] - } - > + <> + updateField(e[0])} + allowMultiple={false} + emptyText={nullLabel?.toString() || "None"} + value={Array.isArray(selected) ? selected : [selected]} + options={ + selectOptions?.map((op) => ({ + label: op.label, + value: op.id, + })) || [] + } + > + ); } diff --git a/src/app/portal/admin/forms/[id]/submissions/data-table.tsx b/src/app/portal/admin/forms/[id]/submissions/data-table.tsx index 9881942b..0da4a2f1 100644 --- a/src/app/portal/admin/forms/[id]/submissions/data-table.tsx +++ b/src/app/portal/admin/forms/[id]/submissions/data-table.tsx @@ -49,9 +49,9 @@ const getCommonPinningStyles = (column: Column): CSSProperties => { right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, opacity: isPinned ? 0.95 : 1, position: isPinned ? "sticky" : "relative", - width: column.getSize() ? column.getSize() : "300px", + width: column.getSize() ? column.getSize() : "500px", // zIndex: isPinned ? 1 : 0, - maxWidth: column.getSize() ? column.getSize() : "300px", + maxWidth: column.getSize() ? column.getSize() : "500px", }; }; @@ -88,6 +88,7 @@ export function DataTable({ columnOrder: [ "popover", "status", + "team_id", "level", "reviewer_id", "interviewer_id", diff --git a/src/app/portal/api/v1/offers/[id]/route.ts b/src/app/portal/api/v1/offers/[id]/route.ts new file mode 100644 index 00000000..f93fa61a --- /dev/null +++ b/src/app/portal/api/v1/offers/[id]/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/db"; + +const newOfferSchema = z.object({ + userId: z.string().uuid(), + teamId: z.number(), + appId: z.string().uuid(), + expiringAt: z.date().optional(), +}); + +export async function POST(request: NextRequest) { + const offerDetails = newOfferSchema.safeParse(request.body); + if (!offerDetails.success) { + return NextResponse.json( + { message: "Invalid offer details" }, + { status: 400 }, + ); + } + + const { userId, teamId, appId } = offerDetails.data; + + const application = await db.applications.findUnique({ + where: { + id: appId, + }, + include: { + submissions: true, + }, + }); + + if (!application) { + return NextResponse.json("Application not found", { status: 404 }); + } + + await db.pending_members.create({ + data: { + user_id: userId, + team_id: teamId, + status: "pending", + meta: { + application: application, + expiringAt: offerDetails.data.expiringAt, + }, + }, + }); + + const body = { + message: `Offer to join has been created`, + }; + + return NextResponse.json(JSON.stringify(body), { status: 201 }); +} + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } }, +) { + const pendingOffer = await db.pending_members.findUnique({ + where: { + id: params.id, + }, + }); + + if (!pendingOffer) { + return NextResponse.json("Offer not found", { status: 404 }); + } + + return NextResponse.json(JSON.stringify(pendingOffer), { status: 200 }); +} + +const updateOfferSchema = z.object({ + status: z.enum(["accepted", "declined", "expired"]), +}); + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } }, +) { + const pendingOffer = await db.pending_members.findUnique({ + where: { + id: params.id, + }, + }); + + if (!pendingOffer) { + return NextResponse.json("Offer not found", { status: 404 }); + } + + const updateDetails = updateOfferSchema.safeParse(request.body); + + if (!updateDetails.success) { + return NextResponse.json("Invalid update details", { status: 400 }); + } + + const updatedOffer = await db.pending_members.update({ + where: { + id: params.id, + }, + data: { + ...updateDetails.data, + }, + }); + + const body = { + message: `Offer has been updated - ${updateDetails.data.status}`, + }; + + if (updateDetails.data.status === "accepted") { + // Add user to team and add user to members table + await db.members.create({ + data: { + id: pendingOffer.user_id, + }, + }); + + await db.team_members.create({ + data: { + user_id: pendingOffer.user_id, + team_id: pendingOffer.team_id, + }, + }); + } + + return NextResponse.json(JSON.stringify(body), { status: 200 }); +} diff --git a/src/app/portal/api/v1/offers/route.ts b/src/app/portal/api/v1/offers/route.ts new file mode 100644 index 00000000..14c9a13a --- /dev/null +++ b/src/app/portal/api/v1/offers/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/db"; + +const newOfferSchema = z.object({ + userId: z.string().uuid(), + teamId: z.number(), + appId: z.string().uuid(), + expiringAt: z.date().optional(), +}); + +export async function POST(request: NextRequest) { + const body = await request.json(); + const offerDetails = newOfferSchema.safeParse(body); + if (!offerDetails.success) { + return NextResponse.json( + { message: "Invalid offer details" }, + { status: 400 }, + ); + } + + const { userId, teamId, appId } = offerDetails.data; + const application = await db.applications.findUnique({ + where: { + id: appId, + }, + include: { + submissions: true, + }, + }); + + if (!application) { + return NextResponse.json("Application not found", { status: 404 }); + } + + const pendingOffer = await db.pending_members.create({ + data: { + user_id: userId, + team_id: teamId, + status: "pending", + meta: { + application: application, + expiringAt: offerDetails.data.expiringAt, + }, + }, + }); + + const resbody = { + message: `Offer to join has been created`, + }; + + return NextResponse.json(resbody, { status: 201 }); +} diff --git a/src/app/portal/forms/actions.ts b/src/app/portal/forms/actions.ts index 8b3e0056..94b5cbe7 100644 --- a/src/app/portal/forms/actions.ts +++ b/src/app/portal/forms/actions.ts @@ -2,6 +2,7 @@ import { render } from "@react-email/components"; import { db } from "@/db"; import { FormStep, Obj } from "@/lib/types/questions"; +import { Trigger } from "@/lib/types/forms"; import { createClient } from "@/lib/utils/supabase/server"; import { JSONValidationToZod } from "@/lib/utils/forms/helpers"; import { sendEmail } from "@/lib/utils/forms/email"; @@ -29,7 +30,6 @@ export async function submitApplication({ formId }: { formId: bigint }) { }); const [res, form] = await Promise.all([resPromise, formPromise]); - // console.log(res, form); if (!res || !form) { return null; @@ -40,43 +40,96 @@ export async function submitApplication({ formId }: { formId: bigint }) { formAnswers: res.details as Obj, }); - console.log(isValid, errors); - // if (!isValid) { - // return errors; - // } + const steps = form?.questions as unknown as FormStep[]; + const triggers = steps.flatMap((step) => + step.config?.triggers ? step.config.triggers : [], + ) as unknown as Trigger[]; - const updateSubmission = db.submissions.update({ - where: { - user_id_form_id: { - form_id: formId, - user_id: data.user.id, - }, - }, - data: { - status: "submitted", - }, + triggers.forEach((trigger) => { + let shouldTrigger = false; + const eventValues = trigger.event.values; + const event = trigger.event.id as string; + if ( + res.details && + res.details[event] && + (Array.isArray(res.details[event]) + ? eventValues.includes(res.details[event][0]) + : eventValues.includes(res.details[event])) + ) { + shouldTrigger = true; + } + if (!shouldTrigger) { + return; + } + const promises = trigger.actions.map(async (action) => { + if (action.type === "api") { + const body = replaceTemplateValues(action.body, res.details); + console.log(`${process.env.NEXT_PUBLIC_BASE_URL}${action.url}`); + console.log(body); + const response = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL}${action.url}`, + { + method: "POST", + // headers: action.headers, + body: JSON.stringify(body), + }, + ); + const data = await response.json(); + console.log(data); + // action.responseHandlers.forEach((handler) => { + // if (handler.type === "success") { + // console.log(handler.message, data); + // } else { + // console.error(handler.message, data); + // } + // }); + } + }); + Promise.all(promises); }); - const createApplication = db.applications.create({ - data: { - id: res.id!, - status: null, - reviewer_id: null, - }, - }); + function replaceTemplateValues(obj: Obj, values: Obj) { + const newObj = { ...obj }; + Object.keys(newObj).forEach((key) => { + if (typeof newObj[key] === "string") { + newObj[key] = obj[key].replace(/{{(.*?)}}/g, (_, key) => values[key]); + } + }); + return newObj; + } - await Promise.all([updateSubmission, createApplication]); - const appEmail = res.details ? (res.details as Obj) : {}; - const template = await render(SubmissionTemplate({ formTitle: form.title })); - await sendEmail({ - from: "no-reply@ubclaunchpad.com", - fromName: "No-reply UBC Launch Pad", - to: data.user.email!.toString(), - subject: `${form.title} - Form Submitted`, - html: template, - cc: appEmail?.email as string, - }); - return true; + // const updateSubmission = db.submissions.update({ + // where: { + // user_id_form_id: { + // form_id: formId, + // user_id: data.user.id, + // }, + // }, + // data: { + // status: "submitted", + // }, + // }); + + // const createApplication = db.applications.create({ + // data: { + // id: res.id!, + // status: null, + // reviewer_id: null, + // }, + // }); + + // await Promise.all([updateSubmission, createApplication]); + // const appEmail = res.details ? (res.details as Obj) : {}; + // const template = await render(SubmissionTemplate({ formTitle: form.title })); + // await sendEmail({ + // from: "no-reply@ubclaunchpad.com", + // fromName: "No-reply UBC Launch Pad", + // to: data.user.email!.toString(), + // subject: `${form.title} - Form Submitted`, + // html: template, + // cc: appEmail?.email as string, + // }); + // return true; } export async function updateApplication({ @@ -99,6 +152,7 @@ export async function updateApplication({ }, }, }); + const details = res?.details as unknown as any; await db.submissions.update({ where: { diff --git a/src/components/general/multiSelect.tsx b/src/components/general/multiSelect.tsx index ba6f0731..79cc3937 100644 --- a/src/components/general/multiSelect.tsx +++ b/src/components/general/multiSelect.tsx @@ -30,6 +30,7 @@ export default function MultiSelect({ if (Array.isArray(value)) { return value.includes(option.value); } + console.log(value, option.value); return value === option.value; }); @@ -53,7 +54,6 @@ export default function MultiSelect({ > {/*{JSON.stringify(selectedOptions)}*/} - {/*{JSON.stringify(value)}*/} {selectedOptions !== [null] && selectedOptions.map((option) => (