582 lines
16 KiB
TypeScript
582 lines
16 KiB
TypeScript
import { AttachmentId, PieceId, RepertoireId, Sha256 } from "common";
|
|
import api, { NotFound, Role, Unauthorized } from "common/the_api";
|
|
import { DateTime, Effect, HashSet, Number, Option, pipe } from "effect";
|
|
import { sql } from "kysely";
|
|
import * as crypto from "node:crypto";
|
|
import * as uuid from "uuid";
|
|
import { implement } from "./api.ts";
|
|
import * as Authentication from "./services/Authentication.ts";
|
|
import * as Database from "./services/Database.ts";
|
|
|
|
const READ_ACCESS = HashSet.make(Role.Admin, Role.Editor, Role.Viewer);
|
|
const WRITE_ACCESS = HashSet.make(Role.Admin, Role.Editor);
|
|
const ADMIN_ACCESS = HashSet.make(Role.Admin);
|
|
|
|
const requireAuthenticated = pipe(
|
|
Authentication.Authentication,
|
|
Effect.flatMap(({ me }) => me),
|
|
);
|
|
|
|
const requireOneOf = (roles: HashSet.HashSet<Role>) => pipe(
|
|
Authentication.Authentication,
|
|
Effect.flatMap(({ me }) => me),
|
|
Effect.flatMap((user) => pipe(
|
|
HashSet.intersection(user.roles, roles),
|
|
HashSet.size,
|
|
Number.greaterThan(0),
|
|
)
|
|
? Effect.succeed(user)
|
|
: Effect.fail(Unauthorized.make())
|
|
),
|
|
);
|
|
|
|
export const handle = implement(api, {
|
|
|
|
// --- Authentication ---
|
|
|
|
me: () => pipe(
|
|
Authentication.Authentication,
|
|
Effect.flatMap(({ me }) => me),
|
|
),
|
|
logout: () => pipe(
|
|
Authentication.Authentication,
|
|
Effect.flatMap(({ logout }) => logout),
|
|
),
|
|
|
|
// --- User CRUD ---
|
|
|
|
getUser: (userId) => Effect.gen(function* () {
|
|
yield* requireAuthenticated;
|
|
|
|
const user = yield* pipe(
|
|
Authentication.getUser(userId),
|
|
Effect.catchTag("NoSuchElementException", () => Effect.fail(NotFound.make())),
|
|
);
|
|
|
|
return user;
|
|
}),
|
|
queryUsers: ({ displayName, role, offset, limit }) => Effect.gen(function* () {
|
|
yield* requireAuthenticated;
|
|
const db = yield* Database.Database;
|
|
|
|
let query = db
|
|
.selectFrom("User")
|
|
.leftJoin("UserRole", "User.userId", "UserRole.userId")
|
|
.select("User.userId")
|
|
.groupBy("User.userId")
|
|
.orderBy("displayName")
|
|
.offset(offset)
|
|
.limit(limit);
|
|
|
|
query = Option.match(displayName, {
|
|
onNone: () => query,
|
|
onSome: (displayName) => query.where("User.displayName", "like", "%" + displayName + "%"),
|
|
});
|
|
|
|
query = Option.match(role, {
|
|
onNone: () => query,
|
|
onSome: (role) => query.where("UserRole.role", "=", role),
|
|
});
|
|
|
|
const res = yield* query.$call(Database.execute);
|
|
return res.map(({ userId }) => userId);
|
|
}),
|
|
updateUser: ({ userId, ...user }) => Effect.gen(function* () {
|
|
yield* requireOneOf(ADMIN_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
const res = yield* pipe(
|
|
Authentication.getUser(userId),
|
|
Effect.mapError(() => NotFound.make()),
|
|
);
|
|
|
|
yield* db
|
|
.deleteFrom("UserRole")
|
|
.where("userId", "=", userId)
|
|
.$call(Database.execute);
|
|
|
|
const roles = yield* pipe(
|
|
user.roles,
|
|
Effect.forEach((role) => db
|
|
.insertInto("UserRole")
|
|
.values({ userId, role })
|
|
.returning("role")
|
|
.$call(Database.executeTakeFirstOrDie)
|
|
.pipe(Effect.map(({ role }) => role)),
|
|
),
|
|
);
|
|
|
|
return {
|
|
userId: res.userId,
|
|
displayName: res.displayName,
|
|
avatarUrl: res.avatarUrl,
|
|
roles: HashSet.fromIterable(roles),
|
|
};
|
|
}),
|
|
deleteUser: (userId) => Effect.gen(function* () {
|
|
yield* requireOneOf(ADMIN_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
yield* db
|
|
.deleteFrom("User")
|
|
.where("userId", "=", userId)
|
|
.returning("userId")
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
}),
|
|
|
|
// --- Piece CRUD ---
|
|
|
|
createPiece: (piece) => Effect.gen(function* () {
|
|
const { userId } = yield* requireOneOf(WRITE_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
const res = yield* db
|
|
.insertInto("Piece")
|
|
.values({
|
|
pieceId: PieceId.make(uuid.v7()),
|
|
name: piece.name,
|
|
composer: Option.getOrNull(piece.composer),
|
|
lyricist: Option.getOrNull(piece.lyricist),
|
|
arranger: Option.getOrNull(piece.arranger),
|
|
createdBy: userId,
|
|
createdAt: sql`datetime()`,
|
|
})
|
|
.returningAll()
|
|
.$call(Database.executeTakeFirstOrDie);
|
|
|
|
return {
|
|
pieceId: res.pieceId,
|
|
name: res.name,
|
|
composer: Option.fromNullable(res.composer),
|
|
lyricist: Option.fromNullable(res.lyricist),
|
|
arranger: Option.fromNullable(res.arranger),
|
|
attachments: [],
|
|
createdBy: Option.fromNullable(res.createdBy),
|
|
createdAt: DateTime.unsafeMake(res.createdAt),
|
|
modifiedBy: Option.fromNullable(res.modifiedBy),
|
|
modifiedAt: pipe(
|
|
res.modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
};
|
|
}),
|
|
getPiece: (pieceId) => Effect.gen(function* () {
|
|
yield* requireOneOf(READ_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
const piece = yield* db
|
|
.selectFrom("Piece")
|
|
.selectAll()
|
|
.where("pieceId", "=", pieceId)
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
|
|
const attachments = yield* db
|
|
.selectFrom("Attachment")
|
|
.selectAll()
|
|
.where("pieceId", "=", pieceId)
|
|
.$call(Database.execute);
|
|
|
|
return {
|
|
pieceId: piece.pieceId,
|
|
name: piece.name,
|
|
composer: Option.fromNullable(piece.composer),
|
|
lyricist: Option.fromNullable(piece.lyricist),
|
|
arranger: Option.fromNullable(piece.arranger),
|
|
attachments: attachments.map(({
|
|
createdBy,
|
|
createdAt,
|
|
modifiedBy,
|
|
modifiedAt,
|
|
...rest
|
|
}) => ({
|
|
createdBy: Option.fromNullable(createdBy),
|
|
createdAt: DateTime.unsafeMake(createdAt),
|
|
modifiedBy: Option.fromNullable(modifiedBy),
|
|
modifiedAt: pipe(
|
|
modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
...rest,
|
|
})),
|
|
createdBy: Option.fromNullable(piece.createdBy),
|
|
createdAt: DateTime.unsafeMake(piece.createdAt),
|
|
modifiedBy: Option.fromNullable(piece.modifiedBy),
|
|
modifiedAt: pipe(
|
|
piece.modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
};
|
|
}),
|
|
queryPieces: ({ name, author, offset, limit }) => Effect.gen(function* () {
|
|
yield* requireOneOf(READ_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
let query = db
|
|
.selectFrom("Piece")
|
|
.select("pieceId")
|
|
.orderBy("name")
|
|
.orderBy("composer")
|
|
.orderBy("arranger")
|
|
.offset(offset)
|
|
.limit(limit);
|
|
|
|
query = Option.match(name, {
|
|
onNone: () => query,
|
|
onSome: (name) => query.where("name", "like", "%" + name + "%"),
|
|
});
|
|
|
|
query = Option.match(author, {
|
|
onNone: () => query,
|
|
onSome: (author) => query.where((eb) => eb.or([
|
|
eb("composer", "like", "%" + author + "%"),
|
|
eb("arranger", "like", "%" + author + "%"),
|
|
eb("lyricist", "like", "%" + author + "%"),
|
|
]))
|
|
});
|
|
|
|
const res = yield* query.$call(Database.execute);
|
|
return res.map(({ pieceId }) => pieceId);
|
|
}),
|
|
updatePiece: ({ pieceId, ...piece }) => Effect.gen(function* () {
|
|
const { userId } = yield* requireOneOf(WRITE_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
const res = yield* db
|
|
.updateTable("Piece")
|
|
.set({
|
|
name: piece.name,
|
|
composer: Option.getOrUndefined(piece.composer),
|
|
lyricist: Option.getOrUndefined(piece.lyricist),
|
|
arranger: Option.getOrUndefined(piece.arranger),
|
|
modifiedBy: userId,
|
|
modifiedAt: sql`datetime()`,
|
|
})
|
|
.where("pieceId", "=", pieceId)
|
|
.returningAll()
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
|
|
const attachments = yield* db
|
|
.selectFrom("Attachment")
|
|
.selectAll()
|
|
.where("pieceId", "=", pieceId)
|
|
.$call(Database.execute);
|
|
|
|
return {
|
|
pieceId: res.pieceId,
|
|
name: res.name,
|
|
composer: Option.fromNullable(res.composer),
|
|
lyricist: Option.fromNullable(res.lyricist),
|
|
arranger: Option.fromNullable(res.arranger),
|
|
attachments: attachments.map(({
|
|
createdBy,
|
|
createdAt,
|
|
modifiedBy,
|
|
modifiedAt,
|
|
...rest
|
|
}) => ({
|
|
createdBy: Option.fromNullable(createdBy),
|
|
createdAt: DateTime.unsafeMake(createdAt),
|
|
modifiedBy: Option.fromNullable(modifiedBy),
|
|
modifiedAt: pipe(
|
|
modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
...rest,
|
|
})),
|
|
createdBy: Option.fromNullable(res.createdBy),
|
|
createdAt: DateTime.unsafeMake(res.createdAt),
|
|
modifiedBy: Option.fromNullable(res.modifiedBy),
|
|
modifiedAt: pipe(
|
|
res.modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
};
|
|
}),
|
|
deletePiece: (pieceId) => Effect.gen(function* () {
|
|
yield* requireOneOf(WRITE_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
yield* db
|
|
.deleteFrom("Piece")
|
|
.where("pieceId", "=", pieceId)
|
|
.returning("pieceId")
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
}),
|
|
|
|
// --- Attachment CRUD ---
|
|
|
|
createAttachment: (attachment) => Effect.gen(function* () {
|
|
const { userId } = yield* requireOneOf(WRITE_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
let sha256: Sha256;
|
|
if ("sha256" in attachment) {
|
|
sha256 = attachment.sha256;
|
|
|
|
yield* db
|
|
.selectFrom("File")
|
|
.select("sha256")
|
|
.where("sha256", "=", sha256)
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
} else {
|
|
const buffer = crypto.hash("sha256", attachment.data, { outputEncoding: "buffer" }) as Buffer<ArrayBuffer>;
|
|
sha256 = Sha256.make(buffer);
|
|
|
|
yield* db
|
|
.insertInto("File")
|
|
.values({ sha256, data: attachment.data })
|
|
.onConflict((cb) => cb.column("sha256").doNothing())
|
|
.$call(Database.execute);
|
|
}
|
|
|
|
const res = yield* db
|
|
.insertInto("Attachment")
|
|
.values({
|
|
attachmentId: AttachmentId.make(uuid.v7()),
|
|
pieceId: attachment.pieceId,
|
|
filename: attachment.filename,
|
|
mediaType: attachment.mediaType,
|
|
sha256,
|
|
createdBy: userId,
|
|
createdAt: sql`datetime()`,
|
|
})
|
|
.returningAll()
|
|
.$call(Database.executeTakeFirstOrDie);
|
|
|
|
return {
|
|
attachmentId: res.attachmentId,
|
|
pieceId: res.pieceId,
|
|
filename: res.filename,
|
|
sha256: res.sha256,
|
|
mediaType: res.mediaType,
|
|
createdBy: Option.fromNullable(res.createdBy),
|
|
createdAt: DateTime.unsafeMake(res.createdAt),
|
|
modifiedBy: Option.fromNullable(res.modifiedBy),
|
|
modifiedAt: pipe(
|
|
res.modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
};
|
|
}),
|
|
getAttachment: (attachmentId) => Effect.gen(function* () {
|
|
yield* requireOneOf(READ_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
const res = yield* db
|
|
.selectFrom("File")
|
|
.innerJoin("Attachment", "File.sha256", "Attachment.sha256")
|
|
.select(["Attachment.filename", "Attachment.mediaType", "File.data"])
|
|
.where("Attachment.attachmentId", "=", attachmentId)
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
|
|
return res;
|
|
}),
|
|
updateAttachment: ({ attachmentId, ...attachment }) => Effect.gen(function* () {
|
|
const { userId } = yield* requireOneOf(WRITE_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
const res = yield* db
|
|
.updateTable("Attachment")
|
|
.set({
|
|
...attachment,
|
|
modifiedBy: userId,
|
|
modifiedAt: sql`datetime()`,
|
|
})
|
|
.returningAll()
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
|
|
return {
|
|
attachmentId: res.attachmentId,
|
|
pieceId: res.pieceId,
|
|
filename: res.filename,
|
|
sha256: res.sha256,
|
|
mediaType: res.mediaType,
|
|
createdBy: Option.fromNullable(res.createdBy),
|
|
createdAt: DateTime.unsafeMake(res.createdAt),
|
|
modifiedBy: Option.fromNullable(res.modifiedBy),
|
|
modifiedAt: pipe(
|
|
res.modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
};
|
|
}),
|
|
deleteAttachment: (attachmentId) => Effect.gen(function* () {
|
|
yield* requireOneOf(WRITE_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
yield* db
|
|
.deleteFrom("Attachment")
|
|
.where("attachmentId", "=", attachmentId)
|
|
.returning("attachmentId")
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
}),
|
|
|
|
// --- Repertoire CRUD ---
|
|
|
|
createRepertoire: (repertoire) => Effect.gen(function* () {
|
|
const { userId } = yield* requireOneOf(WRITE_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
const repertoireId = RepertoireId.make(uuid.v7());
|
|
|
|
const res = yield* db
|
|
.insertInto("Repertoire")
|
|
.values({
|
|
repertoireId,
|
|
name: repertoire.name,
|
|
createdBy: userId,
|
|
createdAt: sql`datetime()`,
|
|
})
|
|
.returningAll()
|
|
.$call(Database.executeTakeFirstOrDie);
|
|
|
|
const entries = yield* pipe(
|
|
repertoire.entries,
|
|
Effect.forEach((pieceId, i) => db
|
|
.insertInto("RepertoireEntry")
|
|
.values({ pieceId, repertoireId, order: i })
|
|
.returningAll()
|
|
.$call(Database.executeTakeFirstOrDie)
|
|
.pipe(Effect.map(({ pieceId }) => pieceId)),
|
|
),
|
|
);
|
|
|
|
return {
|
|
repertoireId: res.repertoireId,
|
|
name: res.name,
|
|
entries,
|
|
createdBy: Option.fromNullable(res.createdBy),
|
|
createdAt: DateTime.unsafeMake(res.createdAt),
|
|
modifiedBy: Option.fromNullable(res.modifiedBy),
|
|
modifiedAt: pipe(
|
|
res.modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
};
|
|
}),
|
|
getRepertoire: (repertoireId) => Effect.gen(function* () {
|
|
yield* requireOneOf(READ_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
const repertoire = yield* db
|
|
.selectFrom("Repertoire")
|
|
.selectAll()
|
|
.where("repertoireId", "=", repertoireId)
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
|
|
const entries = yield* db
|
|
.selectFrom("RepertoireEntry")
|
|
.select("pieceId")
|
|
.where("repertoireId", "=", repertoireId)
|
|
.orderBy("order")
|
|
.$call(Database.execute);
|
|
|
|
return {
|
|
repertoireId: repertoire.repertoireId,
|
|
name: repertoire.name,
|
|
entries: entries.map(({ pieceId }) => pieceId),
|
|
createdBy: Option.fromNullable(repertoire.createdBy),
|
|
createdAt: DateTime.unsafeMake(repertoire.createdAt),
|
|
modifiedBy: Option.fromNullable(repertoire.modifiedBy),
|
|
modifiedAt: pipe(
|
|
repertoire.modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
};
|
|
}),
|
|
queryRepertoires: ({ name, offset, limit }) => Effect.gen(function* () {
|
|
yield* requireOneOf(READ_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
let query = db
|
|
.selectFrom("Repertoire")
|
|
.select("repertoireId")
|
|
.orderBy("name")
|
|
.offset(offset)
|
|
.limit(limit);
|
|
|
|
query = Option.match(name, {
|
|
onNone: () => query,
|
|
onSome: (name) => query.where("name", "like", "%" + name + "%"),
|
|
});
|
|
|
|
const res = yield* query.$call(Database.execute);
|
|
return res.map(({ repertoireId }) => repertoireId);
|
|
}),
|
|
updateRepertoire: ({ repertoireId, ...repertoire }) => Effect.gen(function* () {
|
|
const { userId } = yield* requireOneOf(WRITE_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
const res = yield* db
|
|
.updateTable("Repertoire")
|
|
.set({
|
|
name: repertoire.name,
|
|
modifiedBy: userId,
|
|
modifiedAt: sql`datetime()`,
|
|
})
|
|
.where("repertoireId", "=", repertoireId)
|
|
.returningAll()
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
|
|
yield* db
|
|
.deleteFrom("RepertoireEntry")
|
|
.where("repertoireId", "=", repertoireId)
|
|
.$call(Database.execute);
|
|
|
|
const entries = yield* pipe(
|
|
repertoire.entries,
|
|
Effect.forEach((pieceId, i) => db
|
|
.insertInto("RepertoireEntry")
|
|
.values({ pieceId, repertoireId, order: i })
|
|
.returningAll()
|
|
.$call(Database.executeTakeFirstOrDie)
|
|
.pipe(Effect.map(({ pieceId }) => pieceId)),
|
|
),
|
|
);
|
|
|
|
return {
|
|
repertoireId: res.repertoireId,
|
|
name: res.name,
|
|
entries,
|
|
createdBy: Option.fromNullable(res.createdBy),
|
|
createdAt: DateTime.unsafeMake(res.createdAt),
|
|
modifiedBy: Option.fromNullable(res.modifiedBy),
|
|
modifiedAt: pipe(
|
|
res.modifiedAt,
|
|
Option.fromNullable,
|
|
Option.map(DateTime.unsafeMake),
|
|
),
|
|
};
|
|
}),
|
|
deleteRepertoire: (repertoireId) => Effect.gen(function* () {
|
|
yield* requireOneOf(WRITE_ACCESS);
|
|
const db = yield* Database.Database;
|
|
|
|
yield* db
|
|
.deleteFrom("Repertoire")
|
|
.where("repertoireId", "=", repertoireId)
|
|
.returning("repertoireId")
|
|
.$call(Database.executeTakeFirst)
|
|
.pipe(Effect.mapError(() => NotFound.make()));
|
|
}),
|
|
});
|