Flesh-out model on the backend

This commit is contained in:
Szymon Nowakowski
2024-12-26 21:40:05 +01:00
parent 8e57a76e60
commit 1d2b19f072
6 changed files with 460 additions and 113 deletions

View File

@@ -1,23 +1,34 @@
import cors from "@elysiajs/cors"; import cors from "@elysiajs/cors";
import { staticPlugin } from "@elysiajs/static"; import { staticPlugin } from "@elysiajs/static";
import { swagger } from "@elysiajs/swagger"; import { swagger } from "@elysiajs/swagger";
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common"; import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex, UserId } from "common";
import * as Function from "common/Function";
import { Elysia, error, t } from "elysia"; import { Elysia, error, t } from "elysia";
import { sql } from "kysely"; import { sql } from "kysely";
import { generateSessionId, initDatabase, RepertoireEntry } from "./database"; import * as Db from "./database";
import * as Model from "./model";
const tbranded = <T>() => t.Transform(t.String())
.Decode(Function.unsafeCoerce<string, T>)
.Encode(Function.unsafeCoerce<T, string>);
const app = new Elysia() 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" })) .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 }) => { .resolve(async ({ db, cookie }) => {
await db await db
@@ -43,7 +54,7 @@ const app = new Elysia()
const user = await db const user = await db
.selectFrom("User") .selectFrom("User")
.selectAll() .select(["userId", "username", "admin"])
.where("userId", "=", res.userId) .where("userId", "=", res.userId)
.executeTakeFirst(); .executeTakeFirst();
@@ -51,7 +62,13 @@ const app = new Elysia()
return { user: null }; return { user: null };
} }
return { user }; return {
user: {
userId: user.userId,
username: user.username,
admin: user.admin !== 0,
},
};
}) })
.onTransform(async ({ db, request, server }) => { .onTransform(async ({ db, request, server }) => {
@@ -86,14 +103,15 @@ const app = new Elysia()
.get("/me", ({ user }) => { .get("/me", ({ user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
return { return user;
userId: user.userId, }, {
username: user.username, response: {
admin: user.admin !== 0, 200: Model.User,
}; 401: t.Literal("Session invalid or expired"),
},
}) })
.post("/login", async ({ db, body: { username, password }, cookie }) => { .post("/login", async ({ db, body: { username, password }, cookie }) => {
@@ -113,7 +131,7 @@ const app = new Elysia()
return error("Unauthorized", "Invalid username or password"); return error("Unauthorized", "Invalid username or password");
} }
const sessionId = generateSessionId(); const sessionId = Db.generateSessionId();
await db await db
.insertInto("Session") .insertInto("Session")
.values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` }) .values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` })
@@ -132,12 +150,17 @@ const app = new Elysia()
userId: user.userId, userId: user.userId,
username: user.username, username: user.username,
admin: user.admin !== 0, admin: user.admin !== 0,
sessionId,
}; };
}, { }, {
body: t.Object({ body: t.Object({
username: t.String({ minLength: 1 }), username: t.String({ minLength: 1 }),
password: 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 }) => { .post("/logout", async ({ db, cookie, set }) => {
@@ -156,6 +179,11 @@ const app = new Elysia()
.deleteFrom("Session") .deleteFrom("Session")
.where("sessionId", "=", SessionId(sessionId)) .where("sessionId", "=", SessionId(sessionId))
.execute(); .execute();
}, {
response: {
204: t.Void(),
},
}) })
.post("/change-password", async ({ db, body: { username, currentPassword, newPassword }, set }) => { .post("/change-password", async ({ db, body: { username, currentPassword, newPassword }, set }) => {
@@ -195,14 +223,82 @@ const app = new Elysia()
currentPassword: t.String({ minLength: 1 }), currentPassword: t.String({ minLength: 1 }),
newPassword: t.String({ minLength: 1 }), newPassword: t.String({ minLength: 1 }),
}), }),
response: {
204: t.Void(),
401: t.Literal("Invalid username or password"),
},
}) })
// --- MARK: USER MANAGEMENT ------------------------------------------- // --- 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 }) => { .get("/user/:userId", async ({ db, params: { userId }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const res = await db const res = await db
@@ -212,7 +308,7 @@ const app = new Elysia()
.executeTakeFirst(); .executeTakeFirst();
if (res === undefined) { if (res === undefined) {
return error("Not Found"); return error("Not Found", undefined);
} }
return { return {
@@ -222,8 +318,89 @@ const app = new Elysia()
}; };
}, { }, {
params: t.Object({ params: t.Object({
userId: tbranded<UserId>(), 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 ------------------------------------------------ // --- MARK: PIECE CRUD ------------------------------------------------
@@ -231,7 +408,7 @@ const app = new Elysia()
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => { .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const pieceId = PieceId(Bun.randomUUIDv7()); const pieceId = PieceId(Bun.randomUUIDv7());
@@ -242,20 +419,22 @@ const app = new Elysia()
.returningAll() .returningAll()
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return res; return {
...res,
attachments: [],
};
}, { }, {
body: t.Object({ body: Model.Piece_Post,
name: t.String({ minLength: 1 }), response: {
composer: t.Nullable(t.String({ minLength: 1 })), 200: Model.Piece,
lyricist: t.Nullable(t.String({ minLength: 1 })), 401: t.Literal("Session invalid or expired"),
arranger: t.Nullable(t.String({ minLength: 1 })), },
}),
}) })
.get("/piece", async ({ db, query, user }) => { .get("/piece", async ({ db, query, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
let q = db let q = db
@@ -280,18 +459,17 @@ const app = new Elysia()
const res = await q.execute(); const res = await q.execute();
return res.map(({ pieceId }) => pieceId); return res.map(({ pieceId }) => pieceId);
}, { }, {
query: t.Object({ query: Model.Piece_Query,
name: t.Optional(t.String()), response: {
author: t.Optional(t.String()), 200: t.Array(Model.PieceId),
offset: t.Optional(t.Integer({ minimum: 0 })), 401: t.Literal("Session invalid or expired"),
limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), },
}),
}) })
.get("/piece/:pieceId", async ({ db, params: { pieceId }, user }) => { .get("/piece/:pieceId", async ({ db, params: { pieceId }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const piece = await db const piece = await db
@@ -301,7 +479,7 @@ const app = new Elysia()
.executeTakeFirst(); .executeTakeFirst();
if (piece === undefined) { if (piece === undefined) {
return error("Not Found"); return error("Not Found", undefined);
} }
const attachments = await db const attachments = await db
@@ -313,20 +491,25 @@ const app = new Elysia()
return { return {
...piece, ...piece,
attachments: attachments.map(({ sha256, ...rest }) => ({ attachments: attachments.map(({ sha256, ...rest }) => ({
sha256: Buffer.from(sha256).toString("hex"), sha256: Sha256_Hex(Buffer.from(sha256).toString("hex")),
...rest, ...rest,
})), })),
}; };
}, { }, {
params: t.Object({ params: t.Object({
pieceId: tbranded<PieceId>(), 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 }) => { .put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const res = await db const res = await db
@@ -337,10 +520,22 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { 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({ body: t.Object({
name: t.String({ minLength: 1 }), name: t.String({ minLength: 1 }),
@@ -349,14 +544,19 @@ const app = new Elysia()
arranger: t.Nullable(t.String({ minLength: 1 })), arranger: t.Nullable(t.String({ minLength: 1 })),
}), }),
params: t.Object({ params: t.Object({
pieceId: tbranded<PieceId>(), 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 }) => { .delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const res = await db const res = await db
@@ -366,14 +566,19 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { if (res.length === 0) {
return error("Not Found"); return error("Not Found", undefined);
} }
set.status = "No Content"; set.status = "No Content";
}, { }, {
params: t.Object({ params: t.Object({
pieceId: tbranded<PieceId>(), pieceId: Model.PieceId,
}), }),
response: {
204: t.Void(),
401: t.Literal("Session invalid or expired"),
404: t.Void(),
},
}) })
// --- MARK: ATTACHMENT CRUD ------------------------------------------- // --- MARK: ATTACHMENT CRUD -------------------------------------------
@@ -381,13 +586,13 @@ const app = new Elysia()
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => { .post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const attachmentId = AttachmentId(Bun.randomUUIDv7()); const attachmentId = AttachmentId(Bun.randomUUIDv7());
const dataArray = new Uint8Array(await data.arrayBuffer()); 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); Bun.SHA256.hash(dataArray, sha256);
await db await db
@@ -404,7 +609,7 @@ const app = new Elysia()
return { return {
...res, ...res,
sha256: Buffer.from(res.sha256).toString("hex"), sha256: Sha256_Hex(Buffer.from(res.sha256).toString("hex")),
}; };
}, { }, {
body: t.Object({ body: t.Object({
@@ -413,8 +618,12 @@ const app = new Elysia()
data: t.File(), data: t.File(),
}), }),
params: t.Object({ params: t.Object({
pieceId: tbranded<PieceId>(), 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 /* 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 }) => { .get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const res = await db const res = await db
@@ -441,7 +650,7 @@ const app = new Elysia()
.executeTakeFirst(); .executeTakeFirst();
if (res === undefined) { if (res === undefined) {
return error("Not Found"); return error("Not Found", undefined);
} }
set.headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(res.filename)}`; 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 }); return new File([res.data], res.filename, { type: res.mediaType });
}, { }, {
params: t.Object({ params: t.Object({
pieceId: tbranded<PieceId>(), pieceId: Model.PieceId,
attachmentId: tbranded<AttachmentId>(), 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 }) => { .put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const res = await db const res = await db
@@ -471,24 +685,32 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { 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({ body: t.Object({
filename: t.String({ minLength: 1 }), filename: t.String({ minLength: 1 }),
}), }),
params: t.Object({ params: t.Object({
pieceId: tbranded<PieceId>(), pieceId: Model.PieceId,
attachmentId: tbranded<AttachmentId>(), 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 }) => { .delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const res = await db const res = await db
@@ -501,15 +723,20 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { if (res.length === 0) {
return error("Not Found"); return error("Not Found", undefined);
} }
set.status = "No Content"; set.status = "No Content";
}, { }, {
params: t.Object({ params: t.Object({
pieceId: tbranded<PieceId>(), pieceId: Model.PieceId,
attachmentId: tbranded<AttachmentId>(), attachmentId: Model.AttachmentId,
}), }),
response: {
204: t.Void(),
401: t.Literal("Session invalid or expired"),
404: t.Void(),
},
}) })
// --- MARK: REPERTOIRE CRUD ------------------------------------------- // --- MARK: REPERTOIRE CRUD -------------------------------------------
@@ -517,7 +744,7 @@ const app = new Elysia()
.post("/repertoire", async ({ db, body: { name, entries }, user }) => { .post("/repertoire", async ({ db, body: { name, entries }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const repertoireId = RepertoireId(Bun.randomUUIDv7()); const repertoireId = RepertoireId(Bun.randomUUIDv7());
@@ -528,7 +755,7 @@ const app = new Elysia()
.returningAll() .returningAll()
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
const dbEntries: RepertoireEntry[] = []; const dbEntries: Db.RepertoireEntry[] = [];
for (let i = 0; i < entries.length; ++i) { for (let i = 0; i < entries.length; ++i) {
const entry = entries[i]; const entry = entries[i];
const dbEntry = await db const dbEntry = await db
@@ -547,15 +774,18 @@ const app = new Elysia()
body: t.Object({ body: t.Object({
name: t.String({ minLength: 1 }), name: t.String({ minLength: 1 }),
entries: t.Array(t.Object({ entries: t.Array(t.Object({
pieceId: tbranded<PieceId>(), pieceId: Model.PieceId,
})), })),
}), }),
response: {
401: t.Literal("Session invalid or expired"),
},
}) })
.get("/repertoire", async ({ db, query, user }) => { .get("/repertoire", async ({ db, query, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
let q = db let q = db
@@ -577,12 +807,16 @@ const app = new Elysia()
offset: t.Optional(t.Integer({ minimum: 0 })), offset: t.Optional(t.Integer({ minimum: 0 })),
limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), 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 }) => { .get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const repertoire = await db const repertoire = await db
@@ -592,30 +826,35 @@ const app = new Elysia()
.executeTakeFirst(); .executeTakeFirst();
if (repertoire === undefined) { if (repertoire === undefined) {
return error("Not Found"); return error("Not Found", undefined);
} }
const entries = await db const entries = await db
.selectFrom("RepertoireEntry") .selectFrom("RepertoireEntry")
.selectAll() .select(["pieceId"])
.where("repertoireId", "=", repertoireId) .where("repertoireId", "=", repertoireId)
.orderBy("order") .orderBy("order")
.execute(); .execute();
return { return {
...repertoire, ...repertoire,
entries, entries: entries.map(({ pieceId }) => pieceId),
}; };
}, { }, {
params: t.Object({ params: t.Object({
repertoireId: tbranded<RepertoireId>(), 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 }) => { .put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const res = await db const res = await db
@@ -626,7 +865,7 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { if (res.length === 0) {
return error("Not Found"); return error("Not Found", undefined);
} }
await db await db
@@ -634,37 +873,38 @@ const app = new Elysia()
.where("repertoireId", "=", repertoireId) .where("repertoireId", "=", repertoireId)
.execute(); .execute();
const dbEntries: RepertoireEntry[] = [];
for (let i = 0; i < entries.length; ++i) { for (let i = 0; i < entries.length; ++i) {
const entry = entries[i]; const entry = entries[i];
const dbEntry = await db await db
.insertInto("RepertoireEntry") .insertInto("RepertoireEntry")
.values({ ...entry, repertoireId, order: i }) .values({ pieceId: entry, repertoireId, order: i })
.returningAll() .returning(["pieceId"])
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
dbEntries.push(dbEntry);
} }
return { return {
...res, ...res[0],
entries: dbEntries, entries,
}; };
}, { }, {
body: t.Object({ body: t.Object({
name: t.String({ minLength: 1 }), name: t.String({ minLength: 1 }),
entries: t.Array(t.Object({ entries: t.Array(Model.PieceId),
pieceId: tbranded<PieceId>(),
})),
}), }),
params: t.Object({ params: t.Object({
repertoireId: tbranded<RepertoireId>(), 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 }) => { .delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized", "Session invalid or expired");
} }
const res = await db const res = await db
@@ -680,15 +920,20 @@ const app = new Elysia()
set.status = "No Content"; set.status = "No Content";
}, { }, {
params: t.Object({ params: t.Object({
repertoireId: tbranded<RepertoireId>(), repertoireId: Model.RepertoireId,
}), }),
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 // eslint-disable-next-line @stylistic/comma-dangle -- a comma would confuse the TS compiler here
}) })
) )
.get("*", () => Bun.file("packages/frontend/build/index.html")); .get("*", () => Bun.file("packages/frontend/build/index.html"));
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
app.listen(process.env.PORT || 3000); app.listen(process.env.PORT || 3000);
export type App = typeof app; export type App = typeof app;

View File

@@ -1,5 +1,5 @@
import { Database as BunSqliteDatabase } from "bun:sqlite"; 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 { ColumnType, CompiledQuery, CreateTableBuilder, Kysely, Selectable } from "kysely";
import { BunSqliteDialect } from "kysely-bun-sqlite"; import { BunSqliteDialect } from "kysely-bun-sqlite";
@@ -39,7 +39,7 @@ export interface AccessLogTable {
export interface AttachmentData { export interface AttachmentData {
pieceId: ColumnType<PieceId, PieceId, never>; pieceId: ColumnType<PieceId, PieceId, never>;
sha256: Sha256; sha256: Sha256_Bin;
filename: string; filename: string;
mediaType: string; mediaType: string;
} }
@@ -49,7 +49,7 @@ export interface AttachmentTable extends AttachmentData, SystemInformation {
} }
export interface FileTable { export interface FileTable {
sha256: ColumnType<Sha256, Sha256, never>; sha256: ColumnType<Sha256_Bin, Sha256_Bin, never>;
data: ColumnType<Uint8Array, Uint8Array, never>; data: ColumnType<Uint8Array, Uint8Array, never>;
} }
@@ -69,9 +69,12 @@ export interface RepertoireTable extends SystemInformation {
name: string; name: string;
} }
export interface RepertoireEntryTable { interface RepertoireEntryData {
repertoireId: ColumnType<RepertoireId, RepertoireId, never>;
pieceId: ColumnType<PieceId, PieceId, never>; pieceId: ColumnType<PieceId, PieceId, never>;
}
export interface RepertoireEntryTable extends RepertoireEntryData {
repertoireId: ColumnType<RepertoireId, RepertoireId, never>;
order: number; order: number;
} }

View File

@@ -0,0 +1,109 @@
import * as Common from "common";
import * as Function from "common/Function";
import { t } from "elysia";
const brandedString = <T>() => t.Transform(t.String())
.Decode(Function.unsafeCoerce<string, T>)
.Encode(Function.unsafeCoerce<T, string>);
export const Sha256_Hex = brandedString<Common.Sha256_Hex>();
export const AttachmentId = brandedString<Common.AttachmentId>();
export const PieceId = brandedString<Common.PieceId>();
export const RepertoireId = brandedString<Common.RepertoireId>();
export const RequestId = brandedString<Common.RequestId>();
export const SessionId = brandedString<Common.SessionId>();
export const UserId = brandedString<Common.UserId>();
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;

View File

@@ -3,8 +3,11 @@ import * as Brand from "./Brand";
export type UUID = Brand.Branded<string, "UUID">; export type UUID = Brand.Branded<string, "UUID">;
export const UUID = Brand.nominal<UUID>(); export const UUID = Brand.nominal<UUID>();
export type Sha256 = Brand.Branded<Uint8Array, "Sha256">; export type Sha256_Bin = Brand.Branded<Uint8Array, "Sha256">;
export const Sha256 = Brand.nominal<Sha256>(); export const Sha256_Bin = Brand.nominal<Sha256_Bin>();
export type Sha256_Hex = Brand.Branded<string, "Sha256">;
export const Sha256_Hex = Brand.nominal<Sha256_Hex>();
export type AttachmentId = Brand.Branded<UUID, "AttachmentId">; export type AttachmentId = Brand.Branded<UUID, "AttachmentId">;
export const AttachmentId = Brand.nominal<AttachmentId>(); export const AttachmentId = Brand.nominal<AttachmentId>();

View File

@@ -87,25 +87,12 @@ export const denormalizePiece = ({
Effect.flatMap(denormalizeSystemInformation), 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 = ({ export const denormalizeRepertoire = ({
entries, entries,
...rest ...rest
}: Db.Repertoire & { entries: Db.RepertoireEntry[] }) => pipe( }: Db.Repertoire & { entries: PieceId[] }) => pipe(
Effect.all({ 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" }), }, { concurrency: "unbounded" }),
Effect.map((repertoire) => Object.freeze({ Effect.map((repertoire) => Object.freeze({
...rest, ...rest,

View File

@@ -116,7 +116,7 @@ function RepertoireRow(props: RepertoireRow.Props) {
piecesParts.push(<em>Brak</em>); piecesParts.push(<em>Brak</em>);
} else { } else {
const CUTOFF = 10; 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) { if (repertoire.entries.length > CUTOFF) {
text += ", …"; text += ", …";
} }