diff --git a/README.pdf b/README.pdf new file mode 100644 index 0000000..7b8a2e3 Binary files /dev/null and b/README.pdf differ diff --git a/src/controllers/matchmaking/controller.ts b/src/controllers/matchmaking/controller.ts new file mode 100644 index 0000000..270fd7a --- /dev/null +++ b/src/controllers/matchmaking/controller.ts @@ -0,0 +1,32 @@ +import Elysia, {t} from "elysia"; +import { + createContestTree, + getAllContestTrees, + getContestTreeByContestId, + getContestTreeByRankId, + updateContestTreeByContestId, + updateContestTreeByRankId } from "./handlers"; +import { ContestTreeSchema } from "../../utils/contest-tree/contest-tree-schema"; + +export const matchmaking = new Elysia({prefix: "/matchmaking"}) + .state("user", {}) + .post("", async (context) => { return createContestTree(context)}, { + body: ContestTreeSchema + }) + .get("", async (context) => { return getAllContestTrees(context)}) + .get("rank/:rank_id", async (context) => { + return getContestTreeByRankId(context.params.rank_id); + }) + .get("contest/:contest_id", async (context) => { + return getContestTreeByContestId(parseInt(context.params.contest_id)); + }) + .put("/contest/:contest_id", async (context) => { + return updateContestTreeByContestId(parseInt(context.params.contest_id), context.body); + }, { + body: ContestTreeSchema + }) + .put("/rank/:rank_id", async (context) => { + return updateContestTreeByRankId(context.params.rank_id, context.body); + }, { + body: ContestTreeSchema + }); \ No newline at end of file diff --git a/src/controllers/matchmaking/handlers.ts b/src/controllers/matchmaking/handlers.ts new file mode 100644 index 0000000..715d67c --- /dev/null +++ b/src/controllers/matchmaking/handlers.ts @@ -0,0 +1,81 @@ +import { Context } from "elysia"; +import { IDatabase } from "../../db/database.interface"; +import { MongoAdapter } from "../../db/mongo/mongo.adapter"; +import { ContestTree } from "../../utils/contest-tree/contest-tree-schema"; +import ContestTreesCache from "../../utils/contest-tree/contest-trees-cache"; + +const COLLECTION: string = "matchmaking"; +const db: IDatabase = new MongoAdapter(); +const cache = ContestTreesCache.getInstance(); + +// Crud - Create + +export const createContestTree = async (context: Context) => { + const contestTree = context.body as ContestTree; + const existing = await db.getBy(COLLECTION, { contest_id: contestTree.contest_id }); + if (existing.data){ + console.log("Existing contest tree found for contest", contestTree.contest_id, "with rank", existing.data.rank_id); + return null; + } + + if (existing.error) { + const result = await db.insert(COLLECTION, contestTree); + if (result.error) return null; + return result.data; + } else { + const result = await db.insert(COLLECTION, contestTree); + if (result.error) return null; + return result.data; + } +}; + +// Crud - Read all + +export const getAllContestTrees = async (context: Context) => { + const result = await db.getAll(COLLECTION); + const data = Array.isArray(result) ? result : result.data ?? []; + + return data; +}; + + +// Crud - Read by contest id + +export const getContestTreeByContestId = async (contest_id: number): Promise => { + let cacheTree = cache.getByContest(contest_id); + if (cacheTree) { + return cacheTree; + } + const result = await db.getBy(COLLECTION, { contest_id }); + if (result.error) return null; + cache.count_call(result.data); + return result.data; +} + +// Crud - read by rank id + +export const getContestTreeByRankId = async (rank_id: string) => { + const result = await db.getBy(COLLECTION, { rank_id }); + if (result.error) return null; + cache.count_call(result.data); + return result.data; +} + +// Crud - update by contest id + +export const updateContestTreeByContestId = async (contest_id: number, updatedData: ContestTree) => { + const result = await db.update(COLLECTION, { contest_id }, updatedData); + if (result.error) return null; + cache.updateStoredTree(updatedData); + return result.data; +} + +// Crud - update by rank id + +export const updateContestTreeByRankId = async (rank_id: string, updatedData: ContestTree) => { + const result = await db.update(COLLECTION, { rank_id }, updatedData); + if (result.error) return null; + cache.updateStoredTree(updatedData); + return result.data; +}; + diff --git a/src/controllers/results/handlers.ts b/src/controllers/results/handlers.ts index a9f5e99..ea474f7 100644 --- a/src/controllers/results/handlers.ts +++ b/src/controllers/results/handlers.ts @@ -4,6 +4,9 @@ import { IDatabase } from "../../db/database.interface"; import { SupabaseAdapter } from "../../db/supabase/supabase.adapter"; import { CreateResultSchema, UpdateResultSchema } from "../../utils/entities"; import { ENTITY_FILTER_SCHEMAS, getEntityFilters } from "../../utils/filters"; +import ContestTreesCache from "../../utils/contest-tree/contest-trees-cache"; +import { getContestTreeByContestId } from "../matchmaking/handlers"; +import { ContestTreeNode } from "../../utils/contest-tree/contest-tree-node-class"; const COLLECTION: string = "results"; @@ -53,7 +56,7 @@ export const createResult = async ( body: typeof CreateResultSchema; }, ) => { - const { local_id, visitant_id, winner_id } = context.body; + const { contest_id, local_id, visitant_id, winner_id } = context.body; if (local_id == visitant_id) return BadRequest(context, "Visitant and Local are the same."); @@ -61,10 +64,17 @@ export const createResult = async ( return BadRequest(context, "Neither visitant or local is the winner"); const result = await db.insert(COLLECTION, context.body); + getContestTreeByContestId(contest_id).then((contestTree) => { + if (contestTree) { + let tree = new ContestTreeNode(); + tree.rebuildTree(contestTree.tree); + tree.set_winner(winner_id); + } if (result.error) return BadRequest(context, result.error); return Created(context, result.data); + }); }; export const updateResult = async ( diff --git a/src/db/mongo/mongo.adapter.ts b/src/db/mongo/mongo.adapter.ts index c812ad8..6dfb2f0 100644 --- a/src/db/mongo/mongo.adapter.ts +++ b/src/db/mongo/mongo.adapter.ts @@ -1,71 +1,71 @@ -import { ObjectId } from "mongodb"; -import { IDatabase } from "../database.interface"; -import MongoDB from "./mongo"; + import { ObjectId } from "mongodb"; + import { IDatabase } from "../database.interface"; + import MongoDB from "./mongo"; -export class MongoAdapter implements IDatabase { - private db = MongoDB.getInstance(); + export class MongoAdapter implements IDatabase { + private db = MongoDB.getInstance(); - async insert(collection: string, data: T) { - return this.db.insertDocument(collection, data); - } + async insert(collection: string, data: T) { + return this.db.insertDocument(collection, data); + } - async insertMany(collection: string, data: T[]) { - return this.db.insertManyDocuments(collection, data); - } + async insertMany(collection: string, data: T[]) { + return this.db.insertManyDocuments(collection, data); + } - async getAll( - collection: string, - order?: { - column: string; - asc?: boolean; - }, - suborder?: { - column: string; - asc?: boolean; - }, - limit?: number, - ) { - return this.db.getAllDocuments(collection, order, suborder, limit); - } + async getAll( + collection: string, + order?: { + column: string; + asc?: boolean; + }, + suborder?: { + column: string; + asc?: boolean; + }, + limit?: number, + ) { + return this.db.getAllDocuments(collection, order, suborder, limit); + } - // Por alguna razón este getBy siempre esta limitado a solo 1 documento. - // No se implementa order y limit por la misma razón. + // Por alguna razón este getBy siempre esta limitado a solo 1 documento. + // No se implementa order y limit por la misma razón. - async getBy( - collection: string, - query: Partial, - order?: { - column: string; - asc?: boolean; - }, - suborder?: { - column: string; - asc?: boolean; - }, - limit?: number, - ) { - //@ts-expect-error - if (collection == "members" && query._id) - query._id = parseInt(query._id as string); - //@ts-expect-error - else if (query._id) query._id = new ObjectId(query._id as string); - return await this.db.getOneDocument(collection, query); - } + async getBy( + collection: string, + query: Partial, + order?: { + column: string; + asc?: boolean; + }, + suborder?: { + column: string; + asc?: boolean; + }, + limit?: number, + ) { + //@ts-expect-error + if (collection == "members" && query._id) + query._id = parseInt(query._id as string); + //@ts-expect-error + else if (query._id) query._id = new ObjectId(query._id as string); + return await this.db.getOneDocument(collection, query); + } - async update(collection: string, query: Partial, data: Partial) { - return this.db.updateOneDocument(collection, query, data); - } + async update(collection: string, query: Partial, data: Partial) { + return this.db.updateOneDocument(collection, query, data); + } - async delete(collection: string, query: Partial) { - return this.db.deleteOneDocument(collection, query); - } + async delete(collection: string, query: Partial) { + return this.db.deleteOneDocument(collection, query); + } - async getMultiple( - table: string, - column: string, - options: any[], - ): Promise<{ error: string | null; data: any }> { - // TODO: Hay que implementarlo cuando se necesite, creo que por ahora no hay necesidad. - throw new Error("Method not implemented."); + async getMultiple( + table: string, + column: string, + options: any[], + ): Promise<{ error: string | null; data: any }> { + // TODO: Hay que implementarlo cuando se necesite, creo que por ahora no hay necesidad. + throw new Error("Method not implemented."); + } } -} diff --git a/src/index.ts b/src/index.ts index 0616d1d..5684113 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ import { pictures } from "./controllers/picture/controller"; import { results } from "./controllers/results/controller"; import { students } from "./controllers/student/controller"; import { participation } from "./controllers/participation/controller"; +import { matchmaking } from "./controllers/matchmaking/controller"; +import contestTreesCache from "./utils/contest-tree/contest-trees-cache"; export const app = new Elysia() .use( @@ -31,8 +33,10 @@ export const app = new Elysia() .use(students) .use(pictures) .use(participation) + .use(matchmaking) .get("/ping", () => "Pong! From Xaverian ACM Chapter") - .listen(Number(process.env.PORT)); + .listen(Number(process.env.PORT)) + .state("contest-trees-cache", contestTreesCache); console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`, diff --git a/src/utils/contest-tree/contest-tree-node-class.ts b/src/utils/contest-tree/contest-tree-node-class.ts new file mode 100644 index 0000000..84dbd62 --- /dev/null +++ b/src/utils/contest-tree/contest-tree-node-class.ts @@ -0,0 +1,87 @@ +// contest-tree-node-class.ts +import type { Static } from "elysia"; +import { ContestTreeNodeSchema } from "./contest-tree-node-schema"; + +export type ContestTreeNodeType = Static; + +export class ContestTreeNode implements ContestTreeNodeType { + id_participant?: number; + parent?: ContestTreeNode; + left?: ContestTreeNode; + right?: ContestTreeNode; + + constructor(id_participant?: number) { + this.id_participant = id_participant; + } + + public createTree(ids_participants: number[]): void { + // Create a complete binary tree and assign participant IDs to the leaves + const depth = Math.ceil(Math.log2(ids_participants.length + 1)) - 1; + this.createTreeRecursive(depth); + // Assign participant IDs to the leaves in left-to-right order + const leaves: ContestTreeNode[] = []; + this.collectLeaves(this, leaves); + for (let i = 0; i < ids_participants.length; i++) { + if (i < leaves.length) { + leaves[i].id_participant = ids_participants[i]; + } + } + } + + public rebuildTree(nodeData: any): ContestTreeNode | undefined { + if (!nodeData) return undefined; + this.left = this.rebuildTree(nodeData.left); + this.right = this.rebuildTree(nodeData.right); + + if (this.left) this.left.parent = this; + if (this.right) this.right.parent = this; + + return this; + } + + + collectLeaves(node: ContestTreeNode | undefined, leaves: ContestTreeNode[]): void { + if (!node) return; + if (!node.left && !node.right) { + leaves.push(node); + } else { + this.collectLeaves(node.left, leaves); + this.collectLeaves(node.right, leaves); + } + } + + + createTreeRecursive(depth: number): void { + if (depth <= 0) return; + this.left = new ContestTreeNode(); + this.right = new ContestTreeNode(); + this.left.parent = this; + this.right.parent = this; + this.left.createTreeRecursive(depth - 1); + this.right.createTreeRecursive(depth - 1); + } + + printTree(prefix: string = "", isLeft: boolean = true): void { + console.log(prefix + (isLeft ? "├── " : "└── ") + (this.id_participant !== undefined ? this.id_participant : "null")); + if (this.left) this.left.printTree(prefix + (isLeft ? "│ " : " "), true); + if (this.right) this.right.printTree(prefix + (isLeft ? "│ " : " "), false); + } + + findNode(id_participant: number): ContestTreeNode | null { + if (this.id_participant === id_participant) return this; + let foundNode: ContestTreeNode | null = null; + if (this.left) foundNode = this.left.findNode(id_participant); + if (foundNode) return foundNode; + if (this.right) foundNode = this.right.findNode(id_participant); + return foundNode; + } + + public set_winner(id_participant: number): void { + // Find the node with the given participant ID + const node = this.findNode(id_participant); + if (!node || !node.parent) return; // Node not found or is root + + // Set the parent's participant ID to the winner's ID + node.parent.id_participant = id_participant; + } +} diff --git a/src/utils/contest-tree/contest-tree-node-schema.ts b/src/utils/contest-tree/contest-tree-node-schema.ts new file mode 100644 index 0000000..934dbef --- /dev/null +++ b/src/utils/contest-tree/contest-tree-node-schema.ts @@ -0,0 +1,12 @@ +import { Static, t } from "elysia"; + +export const ContestTreeNodeSchema = t.Recursive((Self) => + t.Object({ + id_participant: t.Optional(t.Number()), + parent: t.Optional(Self), + left: t.Optional(Self), + right: t.Optional(Self), + }) +); + +export type ContestTreeNode = Static; \ No newline at end of file diff --git a/src/utils/contest-tree/contest-tree-schema.ts b/src/utils/contest-tree/contest-tree-schema.ts new file mode 100644 index 0000000..415a0d4 --- /dev/null +++ b/src/utils/contest-tree/contest-tree-schema.ts @@ -0,0 +1,10 @@ +import { t } from "elysia"; +import { ContestTreeNodeSchema } from "./contest-tree-node-schema"; + +export const ContestTreeSchema = t.Object({ + rank_id: t.String(), + contest_id: t.Integer(), + tree: ContestTreeNodeSchema +}); + +export type ContestTree = typeof ContestTreeSchema.static; diff --git a/src/utils/contest-tree/contest-trees-cache.ts b/src/utils/contest-tree/contest-trees-cache.ts new file mode 100644 index 0000000..ac7dd63 --- /dev/null +++ b/src/utils/contest-tree/contest-trees-cache.ts @@ -0,0 +1,66 @@ +import { ContestTree } from "./contest-tree-schema"; +import {t} from "elysia"; + +class ContestTreesCache { + private static instance: ContestTreesCache; + private cache: ContestTree[] = []; + private readonly maxSize = 3; + // Key value (rank_id, call_count) + private call_counter : Record = {}; + // Minimum number of calls to save a tree in cache + private min_calls = 0; + + private constructor() {} + + public static getInstance(): ContestTreesCache { + if (!ContestTreesCache.instance) { + ContestTreesCache.instance = new ContestTreesCache(); + } + return ContestTreesCache.instance; + } + + public add(tree: ContestTree): void { + if (this.cache.length >= this.maxSize) { + this.cache.shift(); + } + this.cache.push(tree); + } + + public getAll(): ContestTree[] { + return [...this.cache]; + } + + public getByContest(contest_id: number): ContestTree | undefined { + return this.cache.find((t) => t.contest_id === contest_id); + } + + public getByRank(rank_id: string): ContestTree | undefined { + return this.cache.find((t) => t.rank_id === rank_id); + } + + public count_call(contestTree: ContestTree): number { + if (!this.call_counter[contestTree.rank_id]) { + this.call_counter[contestTree.rank_id] = 1; + } + else { + this.call_counter[contestTree.rank_id] += 1; + } + if (this.call_counter[contestTree.rank_id] > this.min_calls) { + this.add(contestTree); + } + return this.call_counter[contestTree.rank_id]; + } + + public updateStoredTree(updatedTree: ContestTree): void { + const index = this.cache.findIndex((t) => t.contest_id === updatedTree.contest_id); + if (index !== -1) { + this.cache[index] = updatedTree; + } + } + + public clear(): void { + this.cache = []; + } +} + +export default ContestTreesCache;