diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 48c1eff..f0d34ee 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,23 +1,34 @@ import cors from "@elysiajs/cors"; import { staticPlugin } from "@elysiajs/static"; import { swagger } from "@elysiajs/swagger"; -import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common"; -import * as Function from "common/Function"; +import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex, UserId } from "common"; import { Elysia, error, t } from "elysia"; import { sql } from "kysely"; -import { generateSessionId, initDatabase, RepertoireEntry } from "./database"; - -const tbranded = () => t.Transform(t.String()) - .Decode(Function.unsafeCoerce) - .Encode(Function.unsafeCoerce); +import * as Db from "./database"; +import * as Model from "./model"; const app = new Elysia() - .use(swagger()) + .use(swagger({ + scalarConfig: { + authentication: { + securitySchemes: { + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "sessionId", + }, + }, + }, + }, + swaggerOptions: { + withCredentials: true, + }, + })) .use(cors({ origin: process.env.NODE_ENV === "production" ? false : "localhost:5173" })) - .decorate("db", await initDatabase(process.env.DB_PATH)) + .decorate("db", await Db.initDatabase(process.env.DB_PATH)) .resolve(async ({ db, cookie }) => { await db @@ -43,7 +54,7 @@ const app = new Elysia() const user = await db .selectFrom("User") - .selectAll() + .select(["userId", "username", "admin"]) .where("userId", "=", res.userId) .executeTakeFirst(); @@ -51,7 +62,13 @@ const app = new Elysia() return { user: null }; } - return { user }; + return { + user: { + userId: user.userId, + username: user.username, + admin: user.admin !== 0, + }, + }; }) .onTransform(async ({ db, request, server }) => { @@ -86,14 +103,15 @@ const app = new Elysia() .get("/me", ({ user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } - return { - userId: user.userId, - username: user.username, - admin: user.admin !== 0, - }; + return user; + }, { + response: { + 200: Model.User, + 401: t.Literal("Session invalid or expired"), + }, }) .post("/login", async ({ db, body: { username, password }, cookie }) => { @@ -113,7 +131,7 @@ const app = new Elysia() return error("Unauthorized", "Invalid username or password"); } - const sessionId = generateSessionId(); + const sessionId = Db.generateSessionId(); await db .insertInto("Session") .values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` }) @@ -132,12 +150,17 @@ const app = new Elysia() userId: user.userId, username: user.username, admin: user.admin !== 0, + sessionId, }; }, { body: t.Object({ username: t.String({ minLength: 1 }), password: t.String({ minLength: 1 }), }), + response: { + 200: Model.User, + 401: t.Literal("Invalid username or password"), + }, }) .post("/logout", async ({ db, cookie, set }) => { @@ -156,6 +179,11 @@ const app = new Elysia() .deleteFrom("Session") .where("sessionId", "=", SessionId(sessionId)) .execute(); + }, { + + response: { + 204: t.Void(), + }, }) .post("/change-password", async ({ db, body: { username, currentPassword, newPassword }, set }) => { @@ -195,14 +223,82 @@ const app = new Elysia() currentPassword: t.String({ minLength: 1 }), newPassword: t.String({ minLength: 1 }), }), + response: { + 204: t.Void(), + 401: t.Literal("Invalid username or password"), + }, }) // --- MARK: USER MANAGEMENT ------------------------------------------- + .post("/user", async ({ db, body: { username, password, admin }, user }) => { + + if (user === null) { + return error("Unauthorized", "Session invalid or expired"); + } + + if (!user.admin) { + return error("Forbidden", "Operation allowed only for admin users"); + } + + const userId = UserId(Bun.randomUUIDv7()); + + const newUser = await db + .insertInto("User") + .values({ + userId, + username, + password: await Bun.password.hash(password), + admin: admin ? 1 : 0, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return { + userId: newUser.userId, + username: newUser.username, + admin: newUser.admin !== 0, + }; + }, { + body: Model.User_Post, + response: { + 200: Model.User, + 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Operation allowed only for admin users"), + }, + }) + + .get("/user", async ({ db, query, user }) => { + + if (user === null) { + return error("Unauthorized", "Session invalid or expired"); + } + + let q = db + .selectFrom("User") + .select("userId") + .orderBy(["username", "userId"]) + .offset(query.offset ?? 0) + .limit(query.limit ?? 100); + + if (query.username !== undefined) { + q = q.where("username", "like", "%" + query.username + "%"); + } + + const res = await q.execute(); + return res.map(({ userId }) => userId); + }, { + query: Model.User_Query, + response: { + 200: t.Array(Model.UserId), + 401: t.Literal("Session invalid or expired"), + }, + }) + .get("/user/:userId", async ({ db, params: { userId }, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const res = await db @@ -212,7 +308,7 @@ const app = new Elysia() .executeTakeFirst(); if (res === undefined) { - return error("Not Found"); + return error("Not Found", undefined); } return { @@ -222,8 +318,89 @@ const app = new Elysia() }; }, { params: t.Object({ - userId: tbranded(), + userId: Model.UserId, }), + response: { + 200: Model.User, + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, + }) + + .patch("/user/:userId", async ({ db, body: { username, password, admin }, params: { userId }, user }) => { + + if (user === null) { + return error("Unauthorized", "Session invalid or expired"); + } + + if (!user.admin) { + return error("Forbidden", "Operation allowed only for admin users"); + } + + const res = await db + .updateTable("User") + .set({ + ...(username !== undefined ? { username } : undefined), + ...(password !== undefined ? { password: await Bun.password.hash(password) } : undefined), + ...(admin !== undefined ? { admin: admin ? 1 : 0 } : undefined), + }) + .where("userId", "=", userId) + .returning(["userId", "username", "admin"]) + .executeTakeFirst(); + + if (res === undefined) { + return error("Not Found", undefined); + } + + return { + userId: res.userId, + username: res.username, + admin: res.admin !== 0, + }; + }, { + body: Model.User_Patch, + params: t.Object({ + userId: Model.UserId, + }), + response: { + 200: Model.User, + 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Operation allowed only for admin users"), + 404: t.Void(), + }, + }) + + .delete("/user/:userId", async ({ db, params: { userId }, set, user }) => { + + if (user === null) { + return error("Unauthorized", "Session invalid or expired"); + } + + if (!user.admin) { + return error("Forbidden", "Operation allowed only for admin users"); + } + + const res = await db + .deleteFrom("User") + .where("userId", "=", userId) + .returningAll() + .execute(); + + if (res.length === 0) { + return error("Not Found"); + } + + set.status = "No Content"; + }, { + params: t.Object({ + userId: Model.UserId, + }), + response: { + 204: t.Void(), + 401: t.Literal("Session invalid or expired"), + 403: t.Literal("Operation allowed only for admin users"), + 404: t.Void(), + }, }) // --- MARK: PIECE CRUD ------------------------------------------------ @@ -231,7 +408,7 @@ const app = new Elysia() .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const pieceId = PieceId(Bun.randomUUIDv7()); @@ -242,20 +419,22 @@ const app = new Elysia() .returningAll() .executeTakeFirstOrThrow(); - return res; + return { + ...res, + attachments: [], + }; }, { - body: t.Object({ - name: t.String({ minLength: 1 }), - composer: t.Nullable(t.String({ minLength: 1 })), - lyricist: t.Nullable(t.String({ minLength: 1 })), - arranger: t.Nullable(t.String({ minLength: 1 })), - }), + body: Model.Piece_Post, + response: { + 200: Model.Piece, + 401: t.Literal("Session invalid or expired"), + }, }) .get("/piece", async ({ db, query, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } let q = db @@ -280,18 +459,17 @@ const app = new Elysia() const res = await q.execute(); return res.map(({ pieceId }) => pieceId); }, { - query: t.Object({ - name: t.Optional(t.String()), - author: t.Optional(t.String()), - offset: t.Optional(t.Integer({ minimum: 0 })), - limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), - }), + query: Model.Piece_Query, + response: { + 200: t.Array(Model.PieceId), + 401: t.Literal("Session invalid or expired"), + }, }) .get("/piece/:pieceId", async ({ db, params: { pieceId }, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const piece = await db @@ -301,7 +479,7 @@ const app = new Elysia() .executeTakeFirst(); if (piece === undefined) { - return error("Not Found"); + return error("Not Found", undefined); } const attachments = await db @@ -313,20 +491,25 @@ const app = new Elysia() return { ...piece, attachments: attachments.map(({ sha256, ...rest }) => ({ - sha256: Buffer.from(sha256).toString("hex"), + sha256: Sha256_Hex(Buffer.from(sha256).toString("hex")), ...rest, })), }; }, { params: t.Object({ - pieceId: tbranded(), + pieceId: Model.PieceId, }), + response: { + 200: Model.Piece, + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, }) .put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const res = await db @@ -337,10 +520,22 @@ const app = new Elysia() .execute(); if (res.length === 0) { - return error("Not Found"); + return error("Not Found", undefined); } - return res[0]; + const attachments = await db + .selectFrom("Attachment") + .selectAll() + .where("pieceId", "=", pieceId) + .execute(); + + return { + ...res[0], + attachments: attachments.map(({ sha256, ...rest }) => ({ + sha256: Sha256_Hex(Buffer.from(sha256).toString("hex")), + ...rest, + })), + }; }, { body: t.Object({ name: t.String({ minLength: 1 }), @@ -349,14 +544,19 @@ const app = new Elysia() arranger: t.Nullable(t.String({ minLength: 1 })), }), params: t.Object({ - pieceId: tbranded(), + pieceId: Model.PieceId, }), + response: { + 200: Model.Piece, + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, }) .delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const res = await db @@ -366,14 +566,19 @@ const app = new Elysia() .execute(); if (res.length === 0) { - return error("Not Found"); + return error("Not Found", undefined); } set.status = "No Content"; }, { params: t.Object({ - pieceId: tbranded(), + pieceId: Model.PieceId, }), + response: { + 204: t.Void(), + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, }) // --- MARK: ATTACHMENT CRUD ------------------------------------------- @@ -381,13 +586,13 @@ const app = new Elysia() .post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const attachmentId = AttachmentId(Bun.randomUUIDv7()); const dataArray = new Uint8Array(await data.arrayBuffer()); - const sha256 = Sha256(new Uint8Array(Bun.SHA256.byteLength)); + const sha256 = Sha256_Bin(new Uint8Array(Bun.SHA256.byteLength)); Bun.SHA256.hash(dataArray, sha256); await db @@ -404,7 +609,7 @@ const app = new Elysia() return { ...res, - sha256: Buffer.from(res.sha256).toString("hex"), + sha256: Sha256_Hex(Buffer.from(res.sha256).toString("hex")), }; }, { body: t.Object({ @@ -413,8 +618,12 @@ const app = new Elysia() data: t.File(), }), params: t.Object({ - pieceId: tbranded(), + pieceId: Model.PieceId, }), + response: { + 200: Model.Attachment, + 401: t.Literal("Session invalid or expired"), + }, }) /* NOTE The piece ID is reduntant, because attachment IDs are unique for @@ -427,7 +636,7 @@ const app = new Elysia() .get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const res = await db @@ -441,7 +650,7 @@ const app = new Elysia() .executeTakeFirst(); if (res === undefined) { - return error("Not Found"); + return error("Not Found", undefined); } set.headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(res.filename)}`; @@ -449,15 +658,20 @@ const app = new Elysia() return new File([res.data], res.filename, { type: res.mediaType }); }, { params: t.Object({ - pieceId: tbranded(), - attachmentId: tbranded(), + pieceId: Model.PieceId, + attachmentId: Model.AttachmentId, }), + response: { + 200: t.File(), + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, }) .put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const res = await db @@ -471,24 +685,32 @@ const app = new Elysia() .execute(); if (res.length === 0) { - return error("Not Found"); + return error("Not Found", undefined); } - return res[0]; + return { + ...res[0], + sha256: Sha256_Hex(Buffer.from(res[0].sha256).toString("hex")), + }; }, { body: t.Object({ filename: t.String({ minLength: 1 }), }), params: t.Object({ - pieceId: tbranded(), - attachmentId: tbranded(), + pieceId: Model.PieceId, + attachmentId: Model.AttachmentId, }), + response: { + 200: Model.Attachment, + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, }) .delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const res = await db @@ -501,15 +723,20 @@ const app = new Elysia() .execute(); if (res.length === 0) { - return error("Not Found"); + return error("Not Found", undefined); } set.status = "No Content"; }, { params: t.Object({ - pieceId: tbranded(), - attachmentId: tbranded(), + pieceId: Model.PieceId, + attachmentId: Model.AttachmentId, }), + response: { + 204: t.Void(), + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, }) // --- MARK: REPERTOIRE CRUD ------------------------------------------- @@ -517,7 +744,7 @@ const app = new Elysia() .post("/repertoire", async ({ db, body: { name, entries }, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const repertoireId = RepertoireId(Bun.randomUUIDv7()); @@ -528,7 +755,7 @@ const app = new Elysia() .returningAll() .executeTakeFirstOrThrow(); - const dbEntries: RepertoireEntry[] = []; + const dbEntries: Db.RepertoireEntry[] = []; for (let i = 0; i < entries.length; ++i) { const entry = entries[i]; const dbEntry = await db @@ -547,15 +774,18 @@ const app = new Elysia() body: t.Object({ name: t.String({ minLength: 1 }), entries: t.Array(t.Object({ - pieceId: tbranded(), + pieceId: Model.PieceId, })), }), + response: { + 401: t.Literal("Session invalid or expired"), + }, }) .get("/repertoire", async ({ db, query, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } let q = db @@ -577,12 +807,16 @@ const app = new Elysia() offset: t.Optional(t.Integer({ minimum: 0 })), limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), }), + response: { + 200: t.Array(Model.RepertoireId), + 401: t.Literal("Session invalid or expired"), + }, }) .get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const repertoire = await db @@ -592,30 +826,35 @@ const app = new Elysia() .executeTakeFirst(); if (repertoire === undefined) { - return error("Not Found"); + return error("Not Found", undefined); } const entries = await db .selectFrom("RepertoireEntry") - .selectAll() + .select(["pieceId"]) .where("repertoireId", "=", repertoireId) .orderBy("order") .execute(); return { ...repertoire, - entries, + entries: entries.map(({ pieceId }) => pieceId), }; }, { params: t.Object({ - repertoireId: tbranded(), + repertoireId: Model.RepertoireId, }), + response: { + 200: Model.Repertoire, + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, }) .put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const res = await db @@ -626,7 +865,7 @@ const app = new Elysia() .execute(); if (res.length === 0) { - return error("Not Found"); + return error("Not Found", undefined); } await db @@ -634,37 +873,38 @@ const app = new Elysia() .where("repertoireId", "=", repertoireId) .execute(); - const dbEntries: RepertoireEntry[] = []; for (let i = 0; i < entries.length; ++i) { const entry = entries[i]; - const dbEntry = await db + await db .insertInto("RepertoireEntry") - .values({ ...entry, repertoireId, order: i }) - .returningAll() + .values({ pieceId: entry, repertoireId, order: i }) + .returning(["pieceId"]) .executeTakeFirstOrThrow(); - dbEntries.push(dbEntry); } return { - ...res, - entries: dbEntries, + ...res[0], + entries, }; }, { body: t.Object({ name: t.String({ minLength: 1 }), - entries: t.Array(t.Object({ - pieceId: tbranded(), - })), + entries: t.Array(Model.PieceId), }), params: t.Object({ - repertoireId: tbranded(), + repertoireId: Model.RepertoireId, }), + response: { + 200: Model.Repertoire, + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, }) .delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, user }) => { if (user === null) { - return error("Unauthorized"); + return error("Unauthorized", "Session invalid or expired"); } const res = await db @@ -680,15 +920,20 @@ const app = new Elysia() set.status = "No Content"; }, { params: t.Object({ - repertoireId: tbranded(), + repertoireId: Model.RepertoireId, }), - // eslint-disable-next-line @stylistic/comma-dangle -- a comma would confuse the TS compiler here + response: { + 204: t.Void(), + 401: t.Literal("Session invalid or expired"), + 404: t.Void(), + }, + // eslint-disable-next-line @stylistic/comma-dangle -- a comma would confuse the TS compiler here }) ) .get("*", () => Bun.file("packages/frontend/build/index.html")); - // ------------------------------------------------------------------------- +// ------------------------------------------------------------------------- app.listen(process.env.PORT || 3000); export type App = typeof app; diff --git a/packages/backend/src/database.ts b/packages/backend/src/database.ts index d10faf5..e8593b5 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, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common"; +import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, UserId } from "common"; import { ColumnType, CompiledQuery, CreateTableBuilder, Kysely, Selectable } from "kysely"; import { BunSqliteDialect } from "kysely-bun-sqlite"; @@ -39,7 +39,7 @@ export interface AccessLogTable { export interface AttachmentData { pieceId: ColumnType; - sha256: Sha256; + sha256: Sha256_Bin; filename: string; mediaType: string; } @@ -49,7 +49,7 @@ export interface AttachmentTable extends AttachmentData, SystemInformation { } export interface FileTable { - sha256: ColumnType; + sha256: ColumnType; data: ColumnType; } @@ -69,9 +69,12 @@ export interface RepertoireTable extends SystemInformation { name: string; } -export interface RepertoireEntryTable { - repertoireId: ColumnType; +interface RepertoireEntryData { pieceId: ColumnType; +} + +export interface RepertoireEntryTable extends RepertoireEntryData { + repertoireId: ColumnType; order: number; } diff --git a/packages/backend/src/model.ts b/packages/backend/src/model.ts new file mode 100644 index 0000000..46cc05b --- /dev/null +++ b/packages/backend/src/model.ts @@ -0,0 +1,109 @@ +import * as Common from "common"; +import * as Function from "common/Function"; +import { t } from "elysia"; + +const brandedString = () => t.Transform(t.String()) + .Decode(Function.unsafeCoerce) + .Encode(Function.unsafeCoerce); + +export const Sha256_Hex = brandedString(); +export const AttachmentId = brandedString(); +export const PieceId = brandedString(); +export const RepertoireId = brandedString(); +export const RequestId = brandedString(); +export const SessionId = brandedString(); +export const UserId = brandedString(); + +const SystemInformation = Object.freeze({ + createdBy: t.Nullable(UserId), + createdAt: t.String(), + modifiedBy: t.Nullable(UserId), + modifiedAt: t.Nullable(t.String()), +}); + +const Pagination = Object.freeze({ + offset: t.Optional(t.Integer({ minimum: 0 })), + limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), +}); + +export const AccessLog = t.Object({ + requestId: RequestId, + timestamp: t.String(), + method: t.String(), + pathname: t.String(), + query: t.String(), + ip: t.Nullable(t.String()), +}); + +export const Attachment = t.Object({ + attachmentId: AttachmentId, + pieceId: PieceId, + filename: t.String(), + sha256: Sha256_Hex, + mediaType: t.String(), + ...SystemInformation, +}); + +export const Piece = t.Object({ + pieceId: PieceId, + name: t.String({ minLength: 1 }), + composer: t.Nullable(t.String({ minLength: 1 })), + lyricist: t.Nullable(t.String({ minLength: 1 })), + arranger: t.Nullable(t.String({ minLength: 1 })), + attachments: t.Array(Attachment), + ...SystemInformation, +}); + +export const Piece_Post = t.Object({ + name: t.String({ minLength: 1 }), + composer: t.Nullable(t.String({ minLength: 1 })), + lyricist: t.Nullable(t.String({ minLength: 1 })), + arranger: t.Nullable(t.String({ minLength: 1 })), +}); + +export const Piece_Query = t.Object({ + name: t.Optional(t.String()), + author: t.Optional(t.String()), + ...Pagination, +}); + +export const Repertoire = t.Object({ + repertoireId: RepertoireId, + name: t.String(), + entries: t.Array(PieceId), + ...SystemInformation, +}); + +export const User = t.Object({ + userId: UserId, + username: t.String(), + admin: t.Boolean(), +}); + +export const User_Patch = t.Object({ + username: t.Optional(t.String()), + password: t.Optional(t.String({ minLength: 8 })), + admin: t.Optional(t.Boolean()), +}); + +export const User_Post = t.Object({ + username: t.String(), + password: t.String({ minLength: 8 }), + admin: t.Boolean(), +}); + +export const User_Query = t.Object({ + username: t.Optional(t.String()), + ...Pagination, +}); + +export type AccessLog = typeof AccessLog.static; +export type Attachment = typeof Attachment.static; +export type Piece = typeof Piece.static; +export type Piece_Post = typeof Piece_Post.static; +export type Piece_Query = typeof Piece_Query.static; +export type Repertoire = typeof Repertoire.static; +export type User = typeof User.static; +export type User_Patch = typeof User_Patch.static; +export type User_Post = typeof User_Post.static; +export type User_Query = typeof User_Query.static; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4b9b819..8d4908c 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -3,8 +3,11 @@ import * as Brand from "./Brand"; export type UUID = Brand.Branded; export const UUID = Brand.nominal(); -export type Sha256 = Brand.Branded; -export const Sha256 = Brand.nominal(); +export type Sha256_Bin = Brand.Branded; +export const Sha256_Bin = Brand.nominal(); + +export type Sha256_Hex = Brand.Branded; +export const Sha256_Hex = Brand.nominal(); export type AttachmentId = Brand.Branded; export const AttachmentId = Brand.nominal(); diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index 33900d8..0cea104 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -87,25 +87,12 @@ export const denormalizePiece = ({ Effect.flatMap(denormalizeSystemInformation), ); -export const denormalizeRepertoireEntry = ({ - pieceId, - ...rest -}: Db.RepertoireEntry) => pipe( - Effect.all({ - piece: Effect.uninterruptible(pieceCache.get(pieceId)), - }, { concurrency: "unbounded" }), - Effect.map((entry) => Object.freeze({ - ...rest, - ...entry, - })), -); - export const denormalizeRepertoire = ({ entries, ...rest -}: Db.Repertoire & { entries: Db.RepertoireEntry[] }) => pipe( +}: Db.Repertoire & { entries: PieceId[] }) => pipe( Effect.all({ - entries: Effect.all(entries.map(denormalizeRepertoireEntry), { concurrency: "unbounded" }), + entries: Effect.all(entries.map((entry) => Effect.uninterruptible(pieceCache.get(entry))), { concurrency: "unbounded" }), }, { concurrency: "unbounded" }), Effect.map((repertoire) => Object.freeze({ ...rest, diff --git a/packages/frontend/src/routes/Repertoires.tsx b/packages/frontend/src/routes/Repertoires.tsx index d5da820..35461ec 100644 --- a/packages/frontend/src/routes/Repertoires.tsx +++ b/packages/frontend/src/routes/Repertoires.tsx @@ -116,7 +116,7 @@ function RepertoireRow(props: RepertoireRow.Props) { piecesParts.push(Brak); } else { const CUTOFF = 10; - let text = repertoire.entries.slice(0, CUTOFF).map(({ piece: { name } }) => name).join(", "); + let text = repertoire.entries.slice(0, CUTOFF).map(({ name }) => name).join(", "); if (repertoire.entries.length > CUTOFF) { text += ", …"; }