diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 1105d47..48c1eff 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,11 +1,11 @@ import cors from "@elysiajs/cors"; import { staticPlugin } from "@elysiajs/static"; import { swagger } from "@elysiajs/swagger"; -import { AttachmentId, PieceId, RequestId, SessionId, Sha256, UserId } from "common"; +import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common"; import * as Function from "common/Function"; import { Elysia, error, t } from "elysia"; import { sql } from "kysely"; -import { generateSessionId, initDatabase } from "./database"; +import { generateSessionId, initDatabase, RepertoireEntry } from "./database"; const tbranded = () => t.Transform(t.String()) .Decode(Function.unsafeCoerce) @@ -510,6 +510,178 @@ const app = new Elysia() pieceId: tbranded(), attachmentId: tbranded(), }), + }) + + // --- MARK: REPERTOIRE CRUD ------------------------------------------- + + .post("/repertoire", async ({ db, body: { name, entries }, user }) => { + + if (user === null) { + return error("Unauthorized"); + } + + const repertoireId = RepertoireId(Bun.randomUUIDv7()); + + const repertoire = await db + .insertInto("Repertoire") + .values({ repertoireId, name, createdBy: user.userId, createdAt: sql`datetime()` }) + .returningAll() + .executeTakeFirstOrThrow(); + + const dbEntries: RepertoireEntry[] = []; + for (let i = 0; i < entries.length; ++i) { + const entry = entries[i]; + const dbEntry = await db + .insertInto("RepertoireEntry") + .values({ ...entry, repertoireId, order: i }) + .returningAll() + .executeTakeFirstOrThrow(); + dbEntries.push(dbEntry); + } + + return { + ...repertoire, + entries: dbEntries, + }; + }, { + body: t.Object({ + name: t.String({ minLength: 1 }), + entries: t.Array(t.Object({ + pieceId: tbranded(), + })), + }), + }) + + .get("/repertoire", async ({ db, query, user }) => { + + if (user === null) { + return error("Unauthorized"); + } + + let q = db + .selectFrom("Repertoire") + .select("repertoireId") + .orderBy(["name"]) + .offset(query.offset ?? 0) + .limit(query.limit ?? 100); + + if (query.name !== undefined) { + q = q.where("name", "like", "%" + query.name + "%"); + } + + const res = await q.execute(); + return res.map(({ repertoireId }) => repertoireId); + }, { + query: t.Object({ + name: t.Optional(t.String()), + offset: t.Optional(t.Integer({ minimum: 0 })), + limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), + }), + }) + + .get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, user }) => { + + if (user === null) { + return error("Unauthorized"); + } + + const repertoire = await db + .selectFrom("Repertoire") + .selectAll() + .where("repertoireId", "=", repertoireId) + .executeTakeFirst(); + + if (repertoire === undefined) { + return error("Not Found"); + } + + const entries = await db + .selectFrom("RepertoireEntry") + .selectAll() + .where("repertoireId", "=", repertoireId) + .orderBy("order") + .execute(); + + return { + ...repertoire, + entries, + }; + }, { + params: t.Object({ + repertoireId: tbranded(), + }), + }) + + .put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, user }) => { + + if (user === null) { + return error("Unauthorized"); + } + + const res = await db + .updateTable("Repertoire") + .set({ name, modifiedBy: user.userId, modifiedAt: sql`datetime()` }) + .where("repertoireId", "=", repertoireId) + .returningAll() + .execute(); + + if (res.length === 0) { + return error("Not Found"); + } + + await db + .deleteFrom("RepertoireEntry") + .where("repertoireId", "=", repertoireId) + .execute(); + + const dbEntries: RepertoireEntry[] = []; + for (let i = 0; i < entries.length; ++i) { + const entry = entries[i]; + const dbEntry = await db + .insertInto("RepertoireEntry") + .values({ ...entry, repertoireId, order: i }) + .returningAll() + .executeTakeFirstOrThrow(); + dbEntries.push(dbEntry); + } + + return { + ...res, + entries: dbEntries, + }; + }, { + body: t.Object({ + name: t.String({ minLength: 1 }), + entries: t.Array(t.Object({ + pieceId: tbranded(), + })), + }), + params: t.Object({ + repertoireId: tbranded(), + }), + }) + + .delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, user }) => { + + if (user === null) { + return error("Unauthorized"); + } + + const res = await db + .deleteFrom("Repertoire") + .where("repertoireId", "=", repertoireId) + .returningAll() + .execute(); + + if (res.length === 0) { + return error("Not Found"); + } + + set.status = "No Content"; + }, { + params: t.Object({ + repertoireId: tbranded(), + }), // eslint-disable-next-line @stylistic/comma-dangle -- a comma would confuse the TS compiler here }) ) diff --git a/packages/backend/src/database.ts b/packages/backend/src/database.ts index 151984c..d10faf5 100644 --- a/packages/backend/src/database.ts +++ b/packages/backend/src/database.ts @@ -1,5 +1,5 @@ import { Database as BunSqliteDatabase } from "bun:sqlite"; -import { AttachmentId, PieceId, RequestId, SessionId, Sha256, UserId } from "common"; +import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common"; import { ColumnType, CompiledQuery, CreateTableBuilder, Kysely, Selectable } from "kysely"; import { BunSqliteDialect } from "kysely-bun-sqlite"; @@ -15,6 +15,8 @@ export interface Database { Attachment: AttachmentTable; File: FileTable; Piece: PieceTable; + Repertoire: RepertoireTable; + RepertoireEntry: RepertoireEntryTable; Session: SessionTable; User: UserTable; } @@ -62,6 +64,17 @@ export interface PieceTable extends PieceData, SystemInformation { pieceId: ColumnType; } +export interface RepertoireTable extends SystemInformation { + repertoireId: ColumnType; + name: string; +} + +export interface RepertoireEntryTable { + repertoireId: ColumnType; + pieceId: ColumnType; + order: number; +} + export interface SessionData { userId: UserId; } @@ -82,6 +95,8 @@ export type AccessLog = Selectable; export type Attachment = Selectable; export type File = Selectable; export type Piece = Selectable; +export type Repertoire = Selectable; +export type RepertoireEntry = Selectable; export type Session = Selectable; export type User = Selectable; @@ -153,6 +168,30 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise c.notNull().primaryKey()) + .addColumn("name", "text", (c) => c.notNull()) + .$call(systemInformation) + .execute(); + + await db.schema + .createIndex("Repertoire_name") + .ifNotExists() + .on("Repertoire") + .column("name") + .execute(); + + await db.schema + .createTable("RepertoireEntry") + .ifNotExists() + .addColumn("repertoireId", "text", (c) => c.notNull().references("Repertoire.repertoireId").onDelete("cascade").onUpdate("cascade")) + .addColumn("order", "integer", (c) => c.notNull()) + .addColumn("pieceId", "text", (c) => c.notNull().references("Piece.pieceId").onDelete("restrict").onUpdate("restrict")) + .addPrimaryKeyConstraint("pk_RepertoireEntry", ["repertoireId", "order"]) + .execute(); + await db.schema .createTable("Session") .ifNotExists() diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 80a8004..4b9b819 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -12,6 +12,9 @@ export const AttachmentId = Brand.nominal(); export type PieceId = Brand.Branded; export const PieceId = Brand.nominal(); +export type RepertoireId = Brand.Branded; +export const RepertoireId = Brand.nominal(); + export type RequestId = Brand.Branded; export const RequestId = Brand.nominal();