diff --git a/src/routes/assignments/index.ts b/src/routes/assignments/index.ts index 9ad6b31..33ae272 100644 --- a/src/routes/assignments/index.ts +++ b/src/routes/assignments/index.ts @@ -1,6 +1,7 @@ import Elysia from "elysia"; import { createAssignment } from "./create"; +import { listAssignments } from "./list"; -export const assignmentsRouter = new Elysia({ prefix: "/assignments" }).use( - createAssignment, -); +export const assignmentsRouter = new Elysia({ prefix: "/assignments" }) + .use(listAssignments) + .use(createAssignment); diff --git a/src/routes/assignments/list.ts b/src/routes/assignments/list.ts new file mode 100644 index 0000000..8c37396 --- /dev/null +++ b/src/routes/assignments/list.ts @@ -0,0 +1,122 @@ +import e from "@edgedb"; +import { DATABASE_READ_FAILED } from "constants/responses"; +import Elysia, { t } from "elysia"; +import { HttpStatusCode } from "elysia-http-status-code"; +import { client } from "index"; +import { removeDuplicates } from "utils/arrays/duplicates"; +import { filterTruthy } from "utils/arrays/filter"; +import { areSameValue } from "utils/arrays/general"; +import { merge } from "utils/arrays/merge"; +import { normalDateToCustom } from "utils/dates/customAndNormal"; +import { multipleClasses } from "utils/db/classes"; +import { promiseResult } from "utils/errors"; +import { responseBuilder } from "utils/response"; +import { split } from "utils/strings/split"; +import { z } from "zod"; + +const classesSchema = z.array(z.string().min(1)).nonempty(); + +export const listAssignments = new Elysia().use(HttpStatusCode()).get( + "/", + async ({ query, set, httpStatus }) => { + const classesResult = classesSchema.safeParse( + filterTruthy(split(query.classes)), + ); + if (!classesResult.success) { + set.status = httpStatus.HTTP_400_BAD_REQUEST; + return responseBuilder("error", { + error: "Classes must be an array of strings", + }); + } + const classNames = removeDuplicates(classesResult.data).sort(); + + const assignmentsQuery = (limit: number, offset: number) => + e.select(e.Assignment, (a) => { + const classMatches = e.op(a.class.name, "in", e.set(...classNames)); + const schoolMatches = e.op(a.class.school.name, "=", query.school); + + // -1 disables the limit + const internalLimit = limit === -1 ? undefined : limit; + + return { + filter: e.op(classMatches, "and", schoolMatches), + limit: internalLimit, + offset, + + subject: true, + description: true, + dueDate: true, + fromDate: true, + updates: true, + updatedBy: () => ({ username: true }), + }; + }); + + const result = await promiseResult(() => { + return client.transaction(async (tx) => { + const assignments = await assignmentsQuery( + query.limit, + query.offset, + ).run(tx); + const count = await e.count(assignmentsQuery(-1, 0)).run(tx); + const classes = await multipleClasses({ + schoolName: query.school, + classNames: classNames, + }) + .run(tx) + .then((c) => (c ? c.map((cl) => cl.name).sort() : [])); + + return { assignments, count, classes }; + }); + }); + + if (result.isError) { + set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR; + return DATABASE_READ_FAILED; + } + + if (!areSameValue(result.data.classes, classNames)) { + set.status = httpStatus.HTTP_404_NOT_FOUND; + return responseBuilder("error", { + error: "Not all of the specified classes exist", + }); + } + + const formatted = result.data.assignments.map((assignment) => { + const updates = merge( + { + key: "user", + array: assignment.updatedBy.map((u) => u.username), + }, + { + key: "timestamp", + array: assignment.updates.map((d) => d.getTime()), + }, + ); + + return { + subject: assignment.subject, + description: assignment.description, + from: normalDateToCustom(assignment.fromDate), + due: normalDateToCustom(assignment.dueDate), + updates, + }; + }); + + return responseBuilder("success", { + message: "Received data", + data: { + totalCount: result.data.count, + assignments: formatted, + }, + }); + }, + { + query: t.Object({ + school: t.String({ minLength: 1 }), + classes: t.String({ minLength: 1 }), + limit: t.Numeric({ minimum: -1, default: 50 }), + offset: t.Numeric({ minimum: 0, default: 0 }), + }), + }, +); diff --git a/src/utils/arrays/duplicates.ts b/src/utils/arrays/duplicates.ts new file mode 100644 index 0000000..9cdd1c1 --- /dev/null +++ b/src/utils/arrays/duplicates.ts @@ -0,0 +1,8 @@ +/** + * Remove duplicates from an array + * The original array will not be modified + * The order will not be changed + */ +export const removeDuplicates = (arr: T[]) => { + return arr.filter((val, i, self) => self.indexOf(val) === i); +}; diff --git a/src/utils/arrays/filter.ts b/src/utils/arrays/filter.ts new file mode 100644 index 0000000..c91c5c9 --- /dev/null +++ b/src/utils/arrays/filter.ts @@ -0,0 +1,4 @@ +/** + * Filter out all falsy values from an array without modifying it + */ +export const filterTruthy = (array: T[]) => array.filter((i) => i); diff --git a/src/utils/arrays/general.ts b/src/utils/arrays/general.ts new file mode 100644 index 0000000..8ce71e6 --- /dev/null +++ b/src/utils/arrays/general.ts @@ -0,0 +1,11 @@ +export const areSameValue = (first: T[], second: T[]): boolean => { + if (first.length !== second.length) return false; + if (first.length === 0) return true; + + const firstArrFirst = first[0]; + const secondArrFirst = first[0]; + + if (firstArrFirst !== secondArrFirst) return false; + + return areSameValue(first.slice(1), second.slice(1)); +}; diff --git a/src/utils/arrays/merge.ts b/src/utils/arrays/merge.ts new file mode 100644 index 0000000..da93b78 --- /dev/null +++ b/src/utils/arrays/merge.ts @@ -0,0 +1,34 @@ +interface MergeInput { + array: T[]; + key: K; +} + +type MergeResult = { + [K in FK]: F | undefined; +} & { + [K in SK]: S | undefined; +}; + +/** + * Merge two arrays into one + */ +export const merge = ( + first: MergeInput, + second: MergeInput, +) => { + type Rec = MergeResult; + + const mergedArray: Rec[] = []; + const maxLength = Math.max(first.array.length, second.array.length); + + for (let i = 0; i < maxLength; i++) { + const entries = [ + [first.key, first.array[i]], + [second.key, second.array[i]], + ] as const; + + mergedArray.push(Object.fromEntries(entries) as Rec); + } + + return mergedArray; +}; diff --git a/src/utils/db/classes.ts b/src/utils/db/classes.ts index 79d6dd9..5a82cc5 100644 --- a/src/utils/db/classes.ts +++ b/src/utils/db/classes.ts @@ -62,3 +62,18 @@ export async function doesClassExist(props: ClassIdentifier) { return !!result.data; } + +export const multipleClasses = (props: { + schoolName: string; + classNames: string[]; +}) => { + return e.select(e.Class, (c) => { + const nameMatches = e.op(c.name, "in", e.set(...props.classNames)); + const schoolMatches = e.op(c.school.name, "=", props.schoolName); + + return { + filter: e.op(nameMatches, "and", schoolMatches), + name: true, + }; + }); +}; diff --git a/src/utils/strings/split.ts b/src/utils/strings/split.ts new file mode 100644 index 0000000..4db6c00 --- /dev/null +++ b/src/utils/strings/split.ts @@ -0,0 +1,5 @@ +/** + * Split a string at commas but not escaped ones + */ +export const split = (str: string) => + str.split(/(? s.replace(/\\,/g, ",")); diff --git a/tests/array.test.ts b/tests/array.test.ts new file mode 100644 index 0000000..b808d5c --- /dev/null +++ b/tests/array.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { removeDuplicates } from "utils/arrays/duplicates"; +import { areSameValue } from "utils/arrays/general"; +import { merge } from "utils/arrays/merge"; + +describe("merge", () => { + it("same length", () => { + const result = merge( + { key: "a", array: [1, 2, 3] }, + { key: "b", array: ["x", "y", "z"] }, + ); + + expect(result).toEqual([ + { a: 1, b: "x" }, + { a: 2, b: "y" }, + { a: 3, b: "z" }, + ]); + }); + + it("works when the first one is longer", () => { + const result = merge( + { key: "a", array: [1, 2, 3] }, + { key: "b", array: ["a"] }, + ); + + expect(result).toEqual([ + { a: 1, b: "a" }, + { a: 2, b: undefined }, + { a: 3, b: undefined }, + ]); + }); + + it("works when the second one is longer", () => { + const result = merge( + { key: "b", array: ["a"] }, + { key: "a", array: [1, 2, 3] }, + ); + + expect(result).toEqual([ + { b: "a", a: 1 }, + { b: undefined, a: 2 }, + { b: undefined, a: 3 }, + ]); + }); +}); + +describe("duplicates", () => { + it("does nothing when there'no duplicates", () => { + expect(removeDuplicates([1, 2, 3, 4])).toEqual([1, 2, 3, 4]); + expect(removeDuplicates(["a", "b", "c"])).toEqual(["a", "b", "c"]); + }); + + it("removes duplicates", () => { + expect(removeDuplicates([1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1])).toEqual([ + 1, 2, + ]); + expect( + removeDuplicates(["ts", "rust", "linux", "i3", "linux", "js", "rust"]), + ).toEqual(["ts", "rust", "linux", "i3", "js"]); + }); +}); + +describe("same value", () => { + it("works for equal arrays", () => { + const testCases = [[], ["a", "a"], ["a", "b"], [1, 2], [1, 1]]; + + for (const tCase of testCases) { + // @ts-ignore + expect(areSameValue(tCase, tCase)).toBeTrue(); + } + }); +}); diff --git a/tests/strings.test.ts b/tests/strings.test.ts new file mode 100644 index 0000000..e9948eb --- /dev/null +++ b/tests/strings.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "bun:test"; +import { split } from "utils/strings/split"; + +describe("split", () => { + it("splits at ,", () => { + const original = "value,value2,valu5,"; + expect(split(original)).toEqual(["value", "value2", "valu5", ""]); + expect(original).toBe("value,value2,valu5,"); + }); + + it("ignores escaped commas", () => { + const original = "this\\,is\\,;one,and\\,this\\,another"; + expect(split(original)).toEqual(["this,is,;one", "and,this,another"]); + }); +});