Flesh-out model on the backend
This commit is contained in:
@@ -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>() => t.Transform(t.String())
|
||||
.Decode(Function.unsafeCoerce<string, T>)
|
||||
.Encode(Function.unsafeCoerce<T, string>);
|
||||
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>(),
|
||||
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>(),
|
||||
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>(),
|
||||
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>(),
|
||||
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>(),
|
||||
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<PieceId>(),
|
||||
attachmentId: tbranded<AttachmentId>(),
|
||||
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<PieceId>(),
|
||||
attachmentId: tbranded<AttachmentId>(),
|
||||
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<PieceId>(),
|
||||
attachmentId: tbranded<AttachmentId>(),
|
||||
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>(),
|
||||
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>(),
|
||||
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<PieceId>(),
|
||||
})),
|
||||
entries: t.Array(Model.PieceId),
|
||||
}),
|
||||
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 }) => {
|
||||
|
||||
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>(),
|
||||
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;
|
||||
|
||||
@@ -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<PieceId, PieceId, never>;
|
||||
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, Sha256, never>;
|
||||
sha256: ColumnType<Sha256_Bin, Sha256_Bin, never>;
|
||||
data: ColumnType<Uint8Array, Uint8Array, never>;
|
||||
}
|
||||
|
||||
@@ -69,9 +69,12 @@ export interface RepertoireTable extends SystemInformation {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RepertoireEntryTable {
|
||||
repertoireId: ColumnType<RepertoireId, RepertoireId, never>;
|
||||
interface RepertoireEntryData {
|
||||
pieceId: ColumnType<PieceId, PieceId, never>;
|
||||
}
|
||||
|
||||
export interface RepertoireEntryTable extends RepertoireEntryData {
|
||||
repertoireId: ColumnType<RepertoireId, RepertoireId, never>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
|
||||
109
packages/backend/src/model.ts
Normal file
109
packages/backend/src/model.ts
Normal 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;
|
||||
@@ -3,8 +3,11 @@ import * as Brand from "./Brand";
|
||||
export type UUID = Brand.Branded<string, "UUID">;
|
||||
export const UUID = Brand.nominal<UUID>();
|
||||
|
||||
export type Sha256 = Brand.Branded<Uint8Array, "Sha256">;
|
||||
export const Sha256 = Brand.nominal<Sha256>();
|
||||
export type Sha256_Bin = Brand.Branded<Uint8Array, "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 const AttachmentId = Brand.nominal<AttachmentId>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -116,7 +116,7 @@ function RepertoireRow(props: RepertoireRow.Props) {
|
||||
piecesParts.push(<em>Brak</em>);
|
||||
} 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 += ", …";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user