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 { 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;

View File

@@ -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;
}

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 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>();

View File

@@ -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,

View File

@@ -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 += ", …";
}