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) => 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; 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())); }), });