865 lines
22 KiB
TypeScript
865 lines
22 KiB
TypeScript
import cors from "@elysiajs/cors";
|
|
import { staticPlugin } from "@elysiajs/static";
|
|
import { swagger } from "@elysiajs/swagger";
|
|
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex } from "common";
|
|
import { Effect, Option, pipe, Redacted } from "effect";
|
|
import { Elysia, error, t } from "elysia";
|
|
import { sql } from "kysely";
|
|
import { EXTERNAL_OAUTH_CONFIGURATION, getUser, INTERNAL_OAUTH_CONFIGURATION, makeAuthorizationUrl, REDIRECT_URI, revalidateTokens } from "./auth";
|
|
import { config } from "./config";
|
|
import * as Db from "./database";
|
|
import * as Model from "./model";
|
|
import { DbFromInstance } from "./services/db";
|
|
import { SessionFromValue } from "./services/session";
|
|
|
|
const app = new Elysia()
|
|
|
|
.use(swagger({
|
|
scalarConfig: {
|
|
authentication: {
|
|
securitySchemes: {
|
|
cookieAuth: {
|
|
type: "apiKey",
|
|
in: "cookie",
|
|
name: "sessionId",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
swaggerOptions: {
|
|
withCredentials: true,
|
|
},
|
|
}))
|
|
|
|
.use(cors({ origin: config.NODE_ENV === "production" ? false : "localhost:5173" }))
|
|
|
|
.decorate("db", await Db.initDatabase(config.DB_PATH))
|
|
|
|
.resolve(async ({ db, cookie }) => {
|
|
await db
|
|
.deleteFrom("Session")
|
|
.where(sql`datetime()`, ">=", "expiresAt")
|
|
.execute();
|
|
|
|
const sessionId = (cookie.sessionId.value as SessionId | undefined) ?? Db.generateSessionId();
|
|
|
|
const expiresAt = new Date().getTime() + 604800000;
|
|
cookie.sessionId.set({
|
|
value: sessionId,
|
|
expires: new Date(expiresAt),
|
|
httpOnly: true,
|
|
sameSite: "none",
|
|
secure: true,
|
|
});
|
|
|
|
const returning = [
|
|
"sessionId",
|
|
"accessToken",
|
|
"codeVerifier",
|
|
"external",
|
|
"idToken",
|
|
"refreshToken",
|
|
"state",
|
|
] as const;
|
|
|
|
let session = await db
|
|
.updateTable("Session")
|
|
.set({ expiresAt: sql`datetime('now', '+7 days') ` })
|
|
.where("sessionId", "=", sessionId)
|
|
.returning(returning)
|
|
.executeTakeFirst();
|
|
|
|
if (session === undefined) {
|
|
session = await db
|
|
.insertInto("Session")
|
|
.values({ sessionId, expiresAt: sql`datetime('now', '+7 days')` })
|
|
.returning(returning)
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
const { accessToken, idToken, refreshToken, roles, userId } = await pipe(
|
|
{
|
|
accessToken: Option.fromNullable(session.accessToken),
|
|
idToken: Option.fromNullable(session.idToken),
|
|
refreshToken: Option.fromNullable(session.refreshToken),
|
|
external: Boolean(session.external),
|
|
},
|
|
revalidateTokens,
|
|
Effect.runPromise,
|
|
);
|
|
|
|
await db
|
|
.updateTable("Session")
|
|
.set({
|
|
accessToken: pipe(
|
|
accessToken,
|
|
Option.map((at) => at.token),
|
|
Option.getOrNull,
|
|
),
|
|
idToken: pipe(
|
|
idToken,
|
|
Option.map((it) => it.token),
|
|
Option.getOrNull,
|
|
),
|
|
refreshToken: Option.getOrNull(refreshToken),
|
|
})
|
|
.execute();
|
|
|
|
return {
|
|
session: {
|
|
sessionId: session.sessionId,
|
|
accessToken,
|
|
idToken,
|
|
refreshToken,
|
|
roles,
|
|
userId,
|
|
codeVerifier: Option.fromNullable(session.codeVerifier),
|
|
external: pipe(
|
|
session.external,
|
|
Option.fromNullable,
|
|
Option.map((e) => e !== 0),
|
|
),
|
|
state: Option.fromNullable(session.state),
|
|
},
|
|
};
|
|
})
|
|
|
|
.onTransform(async ({ db, request, server }) => {
|
|
|
|
const requestId = RequestId(Bun.randomUUIDv7("hex"));
|
|
const timestamp = new Date().toISOString();
|
|
const { method } = request;
|
|
const url = new URL(request.url);
|
|
const { pathname } = url;
|
|
const query = JSON.stringify(Object.fromEntries(url.searchParams.entries()));
|
|
const ip = server?.requestIP(request)?.address ?? null;
|
|
|
|
await db
|
|
.insertInto("AccessLog")
|
|
.values({ requestId, timestamp, method, pathname, query, ip })
|
|
.execute();
|
|
|
|
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
|
|
})
|
|
|
|
.use(staticPlugin({
|
|
assets: "packages/frontend/build/assets",
|
|
prefix: "/assets",
|
|
alwaysStatic: true,
|
|
indexHTML: false,
|
|
}))
|
|
|
|
.group("/api/v1", (app) => app
|
|
|
|
// --- MARK: AUTHENTICATION --------------------------------------------
|
|
|
|
.get("/me", ({ session: { idToken, roles } }) => {
|
|
return Option.match(idToken, {
|
|
onNone: () => error("Unauthorized", "Session invalid or expired"),
|
|
onSome: ({ payload: { oid, name } }) => ({
|
|
userId: oid,
|
|
username: name,
|
|
roles: roles as string[],
|
|
}),
|
|
});
|
|
}, {
|
|
response: {
|
|
200: Model.Me,
|
|
401: t.Literal("Session invalid or expired"),
|
|
},
|
|
})
|
|
|
|
.get("/login", async ({ db, query, redirect, session: { sessionId } }) => {
|
|
|
|
const url = await pipe(
|
|
makeAuthorizationUrl({ external: "external" in query }),
|
|
Effect.provide([
|
|
DbFromInstance(db),
|
|
SessionFromValue(sessionId),
|
|
]),
|
|
Effect.runPromise,
|
|
);
|
|
|
|
return redirect(url, 302) as unknown as void;
|
|
}, {
|
|
response: {
|
|
302: t.Void(),
|
|
},
|
|
})
|
|
|
|
.post("/login", async ({ db, request, redirect, session: { sessionId, external, codeVerifier } }) => {
|
|
const data = await request.formData();
|
|
|
|
const code = data.get("code") as string | null;
|
|
const state = data.get("state") as string | null;
|
|
|
|
if (code !== null && state !== null && Option.isSome(external) && Option.isSome(codeVerifier)) {
|
|
const { tokenEndpoint } = external.value ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION;
|
|
|
|
const res = await fetch(tokenEndpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: new URLSearchParams({
|
|
"client_id": config.CLIENT_ID,
|
|
"code": code,
|
|
"redirect_uri": REDIRECT_URI,
|
|
"grant_type": "authorization_code",
|
|
"code_verifier": codeVerifier.value,
|
|
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
|
}).toString(),
|
|
});
|
|
|
|
const {
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
id_token: idToken,
|
|
} = await res.json() as { access_token: string, refresh_token: string, id_token: string };
|
|
|
|
await db
|
|
.updateTable("Session")
|
|
.set({
|
|
accessToken,
|
|
refreshToken,
|
|
idToken,
|
|
codeVerifier: null,
|
|
state: null,
|
|
})
|
|
.where("sessionId", "=", sessionId)
|
|
.execute();
|
|
}
|
|
|
|
return redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303) as unknown as void;
|
|
}, {
|
|
response: {
|
|
303: t.Void(),
|
|
},
|
|
})
|
|
|
|
.post("/logout", async ({ db, cookie, set }) => {
|
|
|
|
set.status = "No Content";
|
|
|
|
const sessionCookie = cookie.sessionId;
|
|
sessionCookie.remove();
|
|
|
|
const sessionId = sessionCookie.value;
|
|
if (sessionId === undefined) {
|
|
return;
|
|
}
|
|
|
|
await db
|
|
.deleteFrom("Session")
|
|
.where("sessionId", "=", SessionId(sessionId))
|
|
.execute();
|
|
}, {
|
|
response: {
|
|
204: t.Void(),
|
|
},
|
|
})
|
|
|
|
// --- MARK: USER MANAGEMENT -------------------------------------------
|
|
|
|
.get("/user/:userId", async ({ params: { userId }, session: { accessToken } }) => {
|
|
|
|
if (Option.isNone(accessToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
const res = await pipe(
|
|
{ accessToken: accessToken.value.token, userId },
|
|
getUser,
|
|
Effect.runPromise,
|
|
);
|
|
|
|
return Option.match(res, {
|
|
onNone: () => error("Not Found", new Response() as unknown as void),
|
|
onSome: ({ displayName }) => ({ userId, displayName }),
|
|
});
|
|
}, {
|
|
params: t.Object({
|
|
userId: Model.UserId,
|
|
}),
|
|
response: {
|
|
200: Model.User,
|
|
401: t.Literal("Session invalid or expired"),
|
|
404: t.Void(),
|
|
},
|
|
})
|
|
|
|
// --- MARK: PIECE CRUD ------------------------------------------------
|
|
|
|
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, session: { idToken, roles } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
if (!roles.includes("Editor")) {
|
|
return error("Forbidden", "Must be an Editor");
|
|
}
|
|
|
|
const pieceId = PieceId(Bun.randomUUIDv7());
|
|
|
|
const res = await db
|
|
.insertInto("Piece")
|
|
.values({ pieceId, name, composer, lyricist, arranger, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return {
|
|
...res,
|
|
attachments: [],
|
|
};
|
|
}, {
|
|
body: Model.Piece_Post,
|
|
response: {
|
|
200: Model.Piece,
|
|
401: t.Literal("Session invalid or expired"),
|
|
403: t.Literal("Must be an Editor"),
|
|
},
|
|
})
|
|
|
|
.get("/piece", async ({ db, query, session: { idToken } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
let q = db
|
|
.selectFrom("Piece")
|
|
.select("pieceId")
|
|
.orderBy(["name", "composer", "arranger"])
|
|
.offset(query.offset ?? 0)
|
|
.limit(query.limit ?? 100);
|
|
|
|
if (query.name !== undefined) {
|
|
q = q.where("name", "like", "%" + query.name + "%");
|
|
}
|
|
|
|
if (query.author !== undefined) {
|
|
q = q.where((eb) => eb.or([
|
|
eb("composer", "like", "%" + query.author + "%"),
|
|
eb("arranger", "like", "%" + query.author + "%"),
|
|
eb("lyricist", "like", "%" + query.author + "%"),
|
|
]));
|
|
}
|
|
|
|
const res = await q.execute();
|
|
return res.map(({ pieceId }) => pieceId);
|
|
}, {
|
|
query: Model.Piece_Query,
|
|
response: {
|
|
200: t.Array(Model.PieceId),
|
|
401: t.Literal("Session invalid or expired"),
|
|
},
|
|
})
|
|
|
|
.get("/piece/:pieceId", async ({ db, params: { pieceId }, session: { idToken } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
const piece = await db
|
|
.selectFrom("Piece")
|
|
.selectAll()
|
|
.where("pieceId", "=", pieceId)
|
|
.executeTakeFirst();
|
|
|
|
if (piece === undefined) {
|
|
return error("Not Found", new Response() as unknown as void);
|
|
}
|
|
|
|
const attachments = await db
|
|
.selectFrom("Attachment")
|
|
.selectAll()
|
|
.where("pieceId", "=", pieceId)
|
|
.execute();
|
|
|
|
return {
|
|
...piece,
|
|
attachments: attachments.map(({ sha256, ...rest }) => ({
|
|
sha256: Sha256_Hex(Buffer.from(sha256).toString("hex")),
|
|
...rest,
|
|
})),
|
|
};
|
|
}, {
|
|
params: t.Object({
|
|
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 }, session: { idToken, roles } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
if (!roles.includes("Editor")) {
|
|
return error("Forbidden", "Must be an Editor");
|
|
}
|
|
|
|
const res = await db
|
|
.updateTable("Piece")
|
|
.set({ name, composer, lyricist, arranger, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
|
.where("pieceId", "=", pieceId)
|
|
.returningAll()
|
|
.execute();
|
|
|
|
if (res.length === 0) {
|
|
return error("Not Found", new Response() as unknown as void);
|
|
}
|
|
|
|
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 }),
|
|
composer: t.Nullable(t.String({ minLength: 1 })),
|
|
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
|
arranger: t.Nullable(t.String({ minLength: 1 })),
|
|
}),
|
|
params: t.Object({
|
|
pieceId: Model.PieceId,
|
|
}),
|
|
response: {
|
|
200: Model.Piece,
|
|
401: t.Literal("Session invalid or expired"),
|
|
403: t.Literal("Must be an Editor"),
|
|
404: t.Void(),
|
|
},
|
|
})
|
|
|
|
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, session: { idToken, roles } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
if (!roles.includes("Editor")) {
|
|
return error("Forbidden", "Must be an Editor");
|
|
}
|
|
|
|
const res = await db
|
|
.deleteFrom("Piece")
|
|
.where("pieceId", "=", pieceId)
|
|
.returningAll()
|
|
.execute();
|
|
|
|
if (res.length === 0) {
|
|
return error("Not Found", new Response() as unknown as void);
|
|
}
|
|
|
|
set.status = "No Content";
|
|
}, {
|
|
params: t.Object({
|
|
pieceId: Model.PieceId,
|
|
}),
|
|
response: {
|
|
204: t.Void(),
|
|
401: t.Literal("Session invalid or expired"),
|
|
404: t.Void(),
|
|
},
|
|
})
|
|
|
|
// --- MARK: ATTACHMENT CRUD -------------------------------------------
|
|
|
|
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, session: { idToken, roles } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
if (!roles.includes("Editor")) {
|
|
return error("Forbidden", "Must be an Editor");
|
|
}
|
|
|
|
const attachmentId = AttachmentId(Bun.randomUUIDv7());
|
|
const dataArray = new Uint8Array(await data.arrayBuffer());
|
|
|
|
const sha256 = Sha256_Bin(new Uint8Array(Bun.SHA256.byteLength));
|
|
Bun.SHA256.hash(dataArray, sha256);
|
|
|
|
await db
|
|
.insertInto("File")
|
|
.values({ sha256, data: dataArray })
|
|
.onConflict((cb) => cb.column("sha256").doNothing())
|
|
.execute();
|
|
|
|
const res = await db
|
|
.insertInto("Attachment")
|
|
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return {
|
|
...res,
|
|
sha256: Sha256_Hex(Buffer.from(res.sha256).toString("hex")),
|
|
};
|
|
}, {
|
|
body: t.Object({
|
|
filename: t.String({ minLength: 1 }),
|
|
mediaType: t.String({ minLength: 1 }),
|
|
data: t.File(),
|
|
}),
|
|
params: t.Object({
|
|
pieceId: Model.PieceId,
|
|
}),
|
|
response: {
|
|
200: Model.Attachment,
|
|
401: t.Literal("Session invalid or expired"),
|
|
403: t.Literal("Must be an Editor"),
|
|
},
|
|
})
|
|
|
|
/* NOTE The piece ID is reduntant, because attachment IDs are unique for
|
|
* the entire DB, not just per piece. However, we consider a piece to be
|
|
* the sole owner of an attachment, i.e. attachments are not shared
|
|
* (attachments are deduplicated on file storage level by their SHA-256
|
|
* hash). Thus, we reflect the ownership in the URLs.
|
|
*/
|
|
|
|
.get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, session: { idToken }, set }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
const res = await db
|
|
.selectFrom("File")
|
|
.innerJoin("Attachment", "File.sha256", "Attachment.sha256")
|
|
.select(["Attachment.filename", "Attachment.mediaType", "File.data"])
|
|
.where((eb) => eb.and([
|
|
eb("Attachment.pieceId", "=", pieceId),
|
|
eb("Attachment.attachmentId", "=", attachmentId),
|
|
]))
|
|
.executeTakeFirst();
|
|
|
|
if (res === undefined) {
|
|
return error("Not Found", new Response() as unknown as void);
|
|
}
|
|
|
|
set.headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(res.filename)}`;
|
|
set.headers["content-type"] = res.mediaType;
|
|
return new File([res.data], res.filename, { type: res.mediaType });
|
|
}, {
|
|
params: t.Object({
|
|
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 }, session: { idToken, roles } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
if (!roles.includes("Editor")) {
|
|
return error("Forbidden", "Must be an Editor");
|
|
}
|
|
|
|
const res = await db
|
|
.updateTable("Attachment")
|
|
.set({ filename, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
|
.where((eb) => eb.and([
|
|
eb("pieceId", "=", pieceId),
|
|
eb("attachmentId", "=", attachmentId),
|
|
]))
|
|
.returningAll()
|
|
.execute();
|
|
|
|
if (res.length === 0) {
|
|
return error("Not Found", new Response() as unknown as void);
|
|
}
|
|
|
|
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: Model.PieceId,
|
|
attachmentId: Model.AttachmentId,
|
|
}),
|
|
response: {
|
|
200: Model.Attachment,
|
|
401: t.Literal("Session invalid or expired"),
|
|
403: t.Literal("Must be an Editor"),
|
|
404: t.Void(),
|
|
},
|
|
})
|
|
|
|
.delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, session: { idToken, roles } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
if (!roles.includes("Editor")) {
|
|
return error("Forbidden", "Must be an Editor");
|
|
}
|
|
|
|
const res = await db
|
|
.deleteFrom("Attachment")
|
|
.where((eb) => eb.and([
|
|
eb("pieceId", "=", pieceId),
|
|
eb("attachmentId", "=", attachmentId),
|
|
]))
|
|
.returningAll()
|
|
.execute();
|
|
|
|
if (res.length === 0) {
|
|
return error("Not Found", new Response() as unknown as void);
|
|
}
|
|
|
|
set.status = "No Content";
|
|
}, {
|
|
params: t.Object({
|
|
pieceId: Model.PieceId,
|
|
attachmentId: Model.AttachmentId,
|
|
}),
|
|
response: {
|
|
204: t.Void(),
|
|
401: t.Literal("Session invalid or expired"),
|
|
403: t.Literal("Must be an Editor"),
|
|
404: t.Void(),
|
|
},
|
|
})
|
|
|
|
// --- MARK: REPERTOIRE CRUD -------------------------------------------
|
|
|
|
.post("/repertoire", async ({ db, body: { name, entries }, session: { idToken, roles } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
if (!roles.includes("Editor")) {
|
|
return error("Forbidden", "Must be an Editor");
|
|
}
|
|
|
|
const repertoireId = RepertoireId(Bun.randomUUIDv7());
|
|
|
|
const repertoire = await db
|
|
.insertInto("Repertoire")
|
|
.values({ repertoireId, name, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
|
|
const dbEntries: Db.RepertoireEntry[] = [];
|
|
for (let i = 0; i < entries.length; ++i) {
|
|
const entry = entries[i];
|
|
const dbEntry = await db
|
|
.insertInto("RepertoireEntry")
|
|
.values({ ...entry, repertoireId, order: i })
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
dbEntries.push(dbEntry);
|
|
}
|
|
|
|
return {
|
|
...repertoire,
|
|
entries: dbEntries,
|
|
};
|
|
}, {
|
|
body: t.Object({
|
|
name: t.String({ minLength: 1 }),
|
|
entries: t.Array(t.Object({
|
|
pieceId: Model.PieceId,
|
|
})),
|
|
}),
|
|
response: {
|
|
401: t.Literal("Session invalid or expired"),
|
|
403: t.Literal("Must be an Editor"),
|
|
},
|
|
})
|
|
|
|
.get("/repertoire", async ({ db, query, session: { idToken } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
let q = db
|
|
.selectFrom("Repertoire")
|
|
.select("repertoireId")
|
|
.orderBy(["name"])
|
|
.offset(query.offset ?? 0)
|
|
.limit(query.limit ?? 100);
|
|
|
|
if (query.name !== undefined) {
|
|
q = q.where("name", "like", "%" + query.name + "%");
|
|
}
|
|
|
|
const res = await q.execute();
|
|
return res.map(({ repertoireId }) => repertoireId);
|
|
}, {
|
|
query: Model.Repertoire_Query,
|
|
response: {
|
|
200: t.Array(Model.RepertoireId),
|
|
401: t.Literal("Session invalid or expired"),
|
|
},
|
|
})
|
|
|
|
.get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, session: { idToken } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
const repertoire = await db
|
|
.selectFrom("Repertoire")
|
|
.selectAll()
|
|
.where("repertoireId", "=", repertoireId)
|
|
.executeTakeFirst();
|
|
|
|
if (repertoire === undefined) {
|
|
return error("Not Found", new Response() as unknown as void);
|
|
}
|
|
|
|
const entries = await db
|
|
.selectFrom("RepertoireEntry")
|
|
.select(["pieceId"])
|
|
.where("repertoireId", "=", repertoireId)
|
|
.orderBy("order")
|
|
.execute();
|
|
|
|
return {
|
|
...repertoire,
|
|
entries: entries.map(({ pieceId }) => pieceId),
|
|
};
|
|
}, {
|
|
params: t.Object({
|
|
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 }, session: { idToken, roles } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
if (!roles.includes("Editor")) {
|
|
return error("Forbidden", "Must be an Editor");
|
|
}
|
|
|
|
const res = await db
|
|
.updateTable("Repertoire")
|
|
.set({ name, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
|
.where("repertoireId", "=", repertoireId)
|
|
.returningAll()
|
|
.execute();
|
|
|
|
if (res.length === 0) {
|
|
return error("Not Found", new Response() as unknown as void);
|
|
}
|
|
|
|
await db
|
|
.deleteFrom("RepertoireEntry")
|
|
.where("repertoireId", "=", repertoireId)
|
|
.execute();
|
|
|
|
for (let i = 0; i < entries.length; ++i) {
|
|
const entry = entries[i];
|
|
await db
|
|
.insertInto("RepertoireEntry")
|
|
.values({ pieceId: entry, repertoireId, order: i })
|
|
.returning(["pieceId"])
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
return {
|
|
...res[0],
|
|
entries,
|
|
};
|
|
}, {
|
|
body: t.Object({
|
|
name: t.String({ minLength: 1 }),
|
|
entries: t.Array(Model.PieceId),
|
|
}),
|
|
params: t.Object({
|
|
repertoireId: Model.RepertoireId,
|
|
}),
|
|
response: {
|
|
200: Model.Repertoire,
|
|
401: t.Literal("Session invalid or expired"),
|
|
403: t.Literal("Must be an Editor"),
|
|
404: t.Void(),
|
|
},
|
|
})
|
|
|
|
.delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, session: { idToken, roles } }) => {
|
|
|
|
if (Option.isNone(idToken)) {
|
|
return error("Unauthorized", "Session invalid or expired");
|
|
}
|
|
|
|
if (!roles.includes("Editor")) {
|
|
return error("Forbidden", "Must be an Editor");
|
|
}
|
|
|
|
const res = await db
|
|
.deleteFrom("Repertoire")
|
|
.where("repertoireId", "=", repertoireId)
|
|
.returningAll()
|
|
.execute();
|
|
|
|
if (res.length === 0) {
|
|
return error("Not Found");
|
|
}
|
|
|
|
set.status = "No Content";
|
|
}, {
|
|
params: t.Object({
|
|
repertoireId: Model.RepertoireId,
|
|
}),
|
|
response: {
|
|
204: t.Void(),
|
|
401: t.Literal("Session invalid or expired"),
|
|
403: t.Literal("Must be an Editor"),
|
|
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(config.PORT);
|
|
export type App = typeof app;
|