{
+ await this.sendEmail(
+ contestantDisqualificationAppealTemplate(
+ email,
+ ofmiName,
+ preferredName,
+ appealed,
+ ),
+ );
+ }
}
diff --git a/src/lib/emailer/template.ts b/src/lib/emailer/template.ts
index fcc8e6c..f346538 100644
--- a/src/lib/emailer/template.ts
+++ b/src/lib/emailer/template.ts
@@ -1,5 +1,6 @@
import config from "@/config/default";
import type { MailOptions } from "nodemailer/lib/json-transport";
+import { disqualificationReasons } from "@/types/participation.schema";
import { getSecretOrError } from "../secret";
export const OFMI_EMAIL_SMTP_USER_KEY = "OFMI_EMAIL_SMTP_USER";
@@ -120,3 +121,53 @@ export const successfulPasswordRecoveryTemplate = (
`,
};
};
+
+export const contestantDisqualificationTemplate = (
+ email: string,
+ ofmiName: string,
+ preferredName: string,
+ shortReason: string,
+): MailOptions => {
+ let longReason = shortReason;
+ if (Object.hasOwn(disqualificationReasons, shortReason)) {
+ longReason = disqualificationReasons[shortReason];
+ }
+ return {
+ from: getSecretOrError(OFMI_EMAIL_SMTP_USER_KEY),
+ to: email,
+ subject: `Descalificación de la ${ofmiName}`,
+ text: `Descalificación de la ${ofmiName}`,
+ html: `
+ Hola, ${preferredName}!
+ Te informamos que, lamentablemente, has sido descalificada de la ${ofmiName} por el siguiente motivo:
+ ${longReason}.
+ Si tienes alguna duda, quieres más informacion o te gustaría realizar una apelación, por favor envía un correo a
+ ofmi@omegaup.com
+
+ Equipo organizador de la OFMI
+ `,
+ };
+};
+
+export const contestantDisqualificationAppealTemplate = (
+ email: string,
+ ofmiName: string,
+ preferredName: string,
+ appealed: boolean,
+): MailOptions => {
+ return {
+ from: getSecretOrError(OFMI_EMAIL_SMTP_USER_KEY),
+ to: email,
+ subject: `(Actualización) Descalificación de la ${ofmiName}`,
+ text: `(Actualización) Descalificación de la ${ofmiName}`,
+ html: `
+ Hola, ${preferredName}!
+ Te informamos que la apelación a tu descalificación de la ${ofmiName} ha sido ${appealed ? "aceptada" : "rechazada"}.
+ En otras palabras, hemos ${appealed ? "retractado" : "reafirmado"} nuestra decisión de descalificarte.
+ Si ${appealed ? "tienes alguna duda" : "te gustaría realizar otra apelación"}, por favor envía un correo a
+ ofmi@omegaup.com
+
+ Equipo organizador de la OFMI
+ `,
+ };
+};
diff --git a/src/lib/ofmi.ts b/src/lib/ofmi.ts
index 1a66fc9..3abb0ab 100644
--- a/src/lib/ofmi.ts
+++ b/src/lib/ofmi.ts
@@ -5,6 +5,7 @@ import {
ParticipationRequestInputSchema,
ParticipationOutputSchema,
UserParticipation,
+ UserParticipationSchema,
} from "@/types/participation.schema";
import { Pronoun, PronounsOfString } from "@/types/pronouns";
import { ShirtStyle, ShirtStyleOfString } from "@/types/shirt";
@@ -18,10 +19,19 @@ const caches = {
findMostRecentOfmi: new TTLCache(),
};
-export function friendlyOfmiName(ofmiEdition: number): string {
- return `${ofmiEdition}a-ofmi`;
+export function friendlyOfmiName(
+ ofmiEdition: number,
+ humanReadable = false,
+): string {
+ return `${ofmiEdition}a${humanReadable ? " OFMI" : "-ofmi"}`;
}
+export const findOfmiByEdition = async (
+ edition: number,
+): Promise => {
+ return prisma.ofmi.findFirst({ where: { edition } });
+};
+
export function registrationSpreadsheetsPath(ofmiEdition: number): string {
return path.join(
friendlyOfmiName(ofmiEdition),
@@ -53,7 +63,7 @@ export async function findParticipants(
ofmi: Ofmi,
): Promise> {
const participants = await prisma.participation.findMany({
- where: { ofmiId: ofmi.id },
+ where: { ofmiId: ofmi.id, volunteerParticipationId: null },
include: {
user: {
include: {
@@ -66,12 +76,31 @@ export async function findParticipants(
ContestantParticipation: {
include: {
School: true,
+ Disqualification: {
+ select: {
+ appealed: true,
+ reason: true,
+ id: true,
+ },
+ },
},
},
VolunteerParticipation: true,
},
});
+ const mappedDisqualifications = new Map();
+
+ for (const participant of participants) {
+ let reason = "N/A";
+ const participation = participant.ContestantParticipation!;
+ const disqualification = participation.Disqualification;
+ if (disqualification && !disqualification.appealed) {
+ reason = disqualification.reason;
+ }
+ mappedDisqualifications.set(participant.id, reason);
+ }
+
const res = participants.map((participation) => {
// TODO: Share code with findParticipation
const {
@@ -84,14 +113,16 @@ export async function findParticipants(
const userParticipation: UserParticipation | null =
(role === "CONTESTANT" &&
- contestantParticipation && {
+ contestantParticipation &&
+ Value.Cast(UserParticipationSchema, {
role,
schoolName: contestantParticipation.School.name,
schoolStage: contestantParticipation.School.stage,
schoolGrade: contestantParticipation.schoolGrade,
schoolCountry: contestantParticipation.School.country,
schoolState: contestantParticipation.School.state,
- }) ||
+ disqualificationReason: mappedDisqualifications.get(user.id),
+ })) ||
(role === "VOLUNTEER" &&
volunteerParticipation && {
role,
@@ -138,6 +169,9 @@ export async function findParticipation(
where: { ofmiId: ofmi.id, user: { UserAuth: { email: email } } },
include: {
user: {
+ select: {
+ id: true,
+ },
include: {
MailingAddress: true,
UserAuth: {
diff --git a/src/pages/api/admin/disqualifyParticipant.ts b/src/pages/api/admin/disqualifyParticipant.ts
new file mode 100644
index 0000000..b46c05b
--- /dev/null
+++ b/src/pages/api/admin/disqualifyParticipant.ts
@@ -0,0 +1,190 @@
+import { prisma } from "@/lib/prisma";
+import { Value } from "@sinclair/typebox/value";
+import { parseValueError } from "@/lib/typebox";
+import { NextApiRequest, NextApiResponse } from "next";
+import {
+ DisqualifyParticipantCreateRequestSchema,
+ DisqualifyParticipantUpdateRequestSchema,
+} from "@/types/admin.schema";
+import {
+ findMostRecentOfmi,
+ findOfmiByEdition,
+ friendlyOfmiName,
+} from "@/lib/ofmi";
+import { emailer } from "@/lib/emailer";
+
+type ofmiAndContestantInfo = {
+ ofmiName: string;
+ preferredName: string;
+ disqualificationId: string | null;
+ message: string;
+};
+
+const fetchOfmiAndContestantInfo = async (
+ email: string,
+ ofmiEdition?: number,
+): Promise => {
+ const ofmi = !ofmiEdition
+ ? await findMostRecentOfmi()
+ : await findOfmiByEdition(ofmiEdition);
+ if (!ofmi) {
+ return "No se encontró edición de la OFMI";
+ }
+ const ofmiName = friendlyOfmiName(ofmi.edition, true);
+ const participation = await prisma.participation.findFirst({
+ where: {
+ ofmiId: ofmi.id,
+ user: { UserAuth: { email: email } },
+ role: "CONTESTANT",
+ volunteerParticipationId: null,
+ contestantParticipationId: {
+ not: null,
+ },
+ },
+ include: {
+ user: {
+ select: {
+ firstName: true,
+ lastName: true,
+ preferredName: true,
+ },
+ },
+ ContestantParticipation: {
+ include: {
+ Disqualification: {
+ select: {
+ id: true,
+ },
+ },
+ },
+ },
+ },
+ });
+ if (!participation || !participation.ContestantParticipation) {
+ return `No se encontró participación de ${email} en la ${ofmiName}`;
+ }
+ const contestant = participation.user;
+ const fullName = `${contestant.firstName} ${contestant.lastName}`;
+ return {
+ message: `Descalificación de ${fullName} de la ${ofmiName}`,
+ disqualificationId:
+ participation.ContestantParticipation.DisqualificationId,
+ preferredName: contestant.preferredName,
+ ofmiName: ofmiName,
+ };
+};
+
+async function createParticipantDisqualification(
+ req: NextApiRequest,
+ res: NextApiResponse,
+): Promise {
+ const { body } = req;
+ if (!Value.Check(DisqualifyParticipantCreateRequestSchema, body)) {
+ const firstError = Value.Errors(
+ DisqualifyParticipantCreateRequestSchema,
+ body,
+ ).First();
+ return res.status(400).json({
+ message: `${firstError ? parseValueError(firstError) : "Invalid request body."}`,
+ });
+ }
+ const { email, ofmiEdition, sendEmail, ...others } = Value.Cast(
+ DisqualifyParticipantCreateRequestSchema,
+ body,
+ );
+ try {
+ const sharedInfo = await fetchOfmiAndContestantInfo(email, ofmiEdition);
+ if (typeof sharedInfo === "string") {
+ return res.status(404).json({ message: sharedInfo });
+ }
+ const { message, ofmiName, preferredName, disqualificationId } = sharedInfo;
+ if (disqualificationId) {
+ return res.status(401).json({
+ message: `Esta concursante ya ha sido descalificada de la ${ofmiName}.`,
+ });
+ }
+ await prisma.disqualification.create({
+ data: others,
+ });
+ if (sendEmail) {
+ await emailer.notifyContestantDisqualification(
+ email,
+ ofmiName,
+ preferredName,
+ others.reason,
+ );
+ }
+ return res.status(201).json({
+ message: `${message} creada`,
+ });
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ message: "Internal Server Error" });
+ }
+}
+
+async function updateParticipantDisqualification(
+ req: NextApiRequest,
+ res: NextApiResponse,
+): Promise {
+ const { body } = req;
+ if (!Value.Check(DisqualifyParticipantUpdateRequestSchema, body)) {
+ const firstError = Value.Errors(
+ DisqualifyParticipantUpdateRequestSchema,
+ body,
+ ).First();
+ return res.status(400).json({
+ message: `${firstError ? parseValueError(firstError) : "Invalid request body."}`,
+ });
+ }
+ const { email, ofmiEdition, sendEmail, ...others } = Value.Cast(
+ DisqualifyParticipantUpdateRequestSchema,
+ body,
+ );
+ try {
+ const sharedInfo = await fetchOfmiAndContestantInfo(email, ofmiEdition);
+ if (typeof sharedInfo === "string") {
+ return res.status(404).json({ message: sharedInfo });
+ }
+ const { message, ofmiName, preferredName, disqualificationId } = sharedInfo;
+ if (!disqualificationId) {
+ return res.status(401).json({
+ message: `Esta participante no ha sido descalificada de la ${ofmiName}.`,
+ });
+ }
+ await prisma.disqualification.update({
+ where: {
+ id: disqualificationId,
+ },
+ data: others,
+ });
+ if (sendEmail && "appealed" in others) {
+ await emailer.notifyContestantDisqualificationUpdate(
+ email,
+ ofmiName,
+ preferredName,
+ others["appealed"],
+ );
+ }
+ return res.status(200).json({
+ message: `${message} actualizada`,
+ });
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ message: "Internal Server Error" });
+ }
+}
+
+export default async function handle(
+ req: NextApiRequest,
+ res: NextApiResponse,
+): Promise {
+ const { method } = req;
+ if (method === "POST") {
+ await createParticipantDisqualification(req, res);
+ } else if (method === "PUT") {
+ await updateParticipantDisqualification(req, res);
+ } else {
+ return res.status(405).json({ message: "Method Not Allowed" });
+ }
+}
diff --git a/src/pages/api/ofmi/upsertParticipation.ts b/src/pages/api/ofmi/upsertParticipation.ts
index 4a18da9..8a87178 100644
--- a/src/pages/api/ofmi/upsertParticipation.ts
+++ b/src/pages/api/ofmi/upsertParticipation.ts
@@ -182,7 +182,6 @@ async function upsertParticipationHandler(
const contestantParticipationPayload = contestantParticipationInput
? {
schoolGrade: contestantParticipationInput.schoolGrade,
- disqualified: false,
School: {
connectOrCreate: {
where: {
diff --git a/src/types/admin.schema.ts b/src/types/admin.schema.ts
index 1191f0b..7cc5c37 100644
--- a/src/types/admin.schema.ts
+++ b/src/types/admin.schema.ts
@@ -1,3 +1,4 @@
+import { emailReg } from "@/lib/validators";
import { Static, Type } from "@sinclair/typebox";
export type SendEmailResponse = Static;
@@ -15,3 +16,53 @@ export const SendEmailRequestSchema = Type.Object(
},
{ description: "Envía un correo desde la cuenta de ofmi-no-reply" },
);
+
+export const BaseDisqualifyParticipantRequestSchema = Type.Object({
+ ofmiEdition: Type.Optional(Type.Number({ minimum: 1 })),
+ email: Type.String({ minLength: 6, pattern: emailReg }),
+ sendEmail: Type.Optional(Type.Boolean({ default: true })),
+ reason: Type.String({ minLength: 1 }),
+ appealed: Type.Boolean(),
+});
+
+const excludeCommonFieldsFromDPRS = Type.Omit(
+ BaseDisqualifyParticipantRequestSchema,
+ ["appealed"],
+);
+
+const excludeCommonUpdateFieldsFromDPRS = Type.Omit(
+ excludeCommonFieldsFromDPRS,
+ ["reason"],
+);
+
+export const updateDisqualificationReasonSchema = Type.Composite([
+ excludeCommonUpdateFieldsFromDPRS,
+ Type.Pick(BaseDisqualifyParticipantRequestSchema, ["reason"]),
+]);
+
+export const updateDisqualificationAppealSchema = Type.Composite([
+ excludeCommonUpdateFieldsFromDPRS,
+ Type.Pick(BaseDisqualifyParticipantRequestSchema, ["appealed"]),
+]);
+
+export const DisqualifyParticipantCreateRequestSchema = Type.Composite([
+ excludeCommonFieldsFromDPRS,
+ Type.Object({
+ appealed: Type.Boolean({ default: false }),
+ }),
+]);
+
+export const DisqualifyParticipantUpdateRequestSchema = Type.Union([
+ updateDisqualificationReasonSchema,
+ updateDisqualificationAppealSchema,
+ BaseDisqualifyParticipantRequestSchema,
+]);
+
+export const DisqualifyParticipantRequestSchema = Type.Union([
+ DisqualifyParticipantCreateRequestSchema,
+ DisqualifyParticipantUpdateRequestSchema,
+]);
+
+export type DisqualifyParticipantRequest = Static<
+ typeof DisqualifyParticipantRequestSchema
+>;
diff --git a/src/types/participation.schema.ts b/src/types/participation.schema.ts
index 907527a..b4cea67 100644
--- a/src/types/participation.schema.ts
+++ b/src/types/participation.schema.ts
@@ -79,13 +79,15 @@ const UserInputSchema = Type.Object({
export type ContestantParticipationInput = Static<
typeof ContestantParticipationInputSchema
>;
-const ContestantParticipationInputSchema = Type.Object({
+
+export const ContestantParticipationInputSchema = Type.Object({
role: Type.Literal(ParticipationRole.CONTESTANT),
schoolName: Type.String({ minLength: 1 }),
schoolStage: SchoolStageSchema,
schoolGrade: Type.Integer({ minimum: 1 }),
schoolCountry: Type.String({ pattern: countryReg.toString() }),
schoolState: Type.String({ minLength: 1 }),
+ disqualificationReason: Type.Optional(Type.String({ default: "N/A" })),
});
const VolunteerParticipationInputSchema = Type.Object({
@@ -99,7 +101,7 @@ const VolunteerParticipationInputSchema = Type.Object({
});
export type UserParticipation = Static;
-const UserParticipationSchema = Type.Union([
+export const UserParticipationSchema = Type.Union([
ContestantParticipationInputSchema,
VolunteerParticipationInputSchema,
]);
@@ -146,3 +148,25 @@ export type ParticipationWithUserOauth = Prisma.ParticipationGetPayload<{
export interface UpsertParticipationResponse {
participation: Participation;
}
+
+export const disqualificationReasons: {
+ [key: string]: string;
+} = {
+ NO_ELEGIBLE:
+ "No cumples con todos los criterios de eligibilidad señalados en la convocatoria",
+ IA: "Durante la competencia, usaste herramientas de Inteligencia Artificial para autocompletar/generar código u obtener la solución a un problema",
+ SUMINISTROS_PROHIBIDOS:
+ "Durante la competencia, usaste uno o varios suministros que no están explícitamente mencionados en la sección de Suministros de la convocatoria",
+ MATERIAL_PROHIBIDO:
+ "Durante la competencia, usaste uno o varios materiales que no están explícitamente mencionados en la sección de Material Permitido de la convocatoria",
+ IDENTIDAD_NO_VERIFICADA:
+ "No se pudo verificar tu identidad con las grabaciones que enviaste",
+ MALAS_GRABACIONES:
+ "No se pudo verificar que no hayas hecho trampa con las grabaciones que enviaste",
+ MULTICUENTAS:
+ "Durante la competencia, iniciaste sesión en una cuenta de OmegaUp distinta a la que se te asignó para la competencia",
+ COMUNICACION_PROHIBIDA:
+ "Durante la competencia, te comunicaste con personas que no son parte del Comité Organizador",
+ FALTA_GRABACION: "No subiste tu grabación del día 1 o del día 2",
+ MALA_CONDUCTA: "No cumpliste con el Código de Conducta",
+} as const;