From ac091b1dc973574159995ae659e53c4611e897f1 Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Tue, 13 Aug 2024 23:17:48 +0200 Subject: [PATCH] Implement DB interface for Piece CRUD --- packages/backend/src/app.ts | 12 +++- packages/backend/src/services/database.ts | 49 ++++++++++++++- packages/common/src/api.ts | 43 +++++++++++++- packages/frontend/src/routes/Home.css.ts | 72 ++++++++++++++++++++++- packages/frontend/src/routes/Home.tsx | 6 +- 5 files changed, 171 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index a7eb4db..ffacbc3 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,6 +1,6 @@ import { Schema as S } from "@effect/schema"; import { SessionId } from "common"; -import { Login, Logout, Me } from "common/api"; +import { CreatePiece, GetPieces, UpdatePiece, DeletePiece, Login, Logout, Me } from "common/api"; import { AccessLog, SessionData } from "common/db"; import { DateTime, Duration, Either as E, Effect, Match, Option as O, pipe } from "effect"; import { constant } from "effect/Function"; @@ -111,7 +111,15 @@ export const app = pipe( yield* db.createAccessLog(accessLog); console.log(`${DateTime.toDateUtc(accessLog.timestamp).toISOString()} ${accessLog.requestId} ${accessLog.method} ${accessLog.pathname}?${new URLSearchParams(accessLog.query).toString()} ${accessLog.ip}`); - if (yield* match(Login)) { + if (yield* match(CreatePiece)) { + return new Response(null, { status: 501 }); + } else if (yield* match(GetPieces)) { + return new Response(null, { status: 501 }); + } else if (yield* match(UpdatePiece)) { + return new Response(null, { status: 501 }); + } else if (yield* match(DeletePiece)) { + return new Response(null, { status: 501 }); + } else if (yield* match(Login)) { const body = yield* requestJson(Login.props.request.schema); diff --git a/packages/backend/src/services/database.ts b/packages/backend/src/services/database.ts index 84a4913..b23a944 100644 --- a/packages/backend/src/services/database.ts +++ b/packages/backend/src/services/database.ts @@ -1,8 +1,8 @@ import { Schema as S } from "@effect/schema"; import { Database as SqliteDatabase } from "bun:sqlite"; -import { SessionId, UserId } from "common"; -import { AccessLog, SessionData, User } from "common/db"; -import { Context, Effect, Layer, pipe } from "effect"; +import { PieceId, SessionId, UserId } from "common"; +import { AccessLog, Piece, SessionData, User } from "common/db"; +import { Context, Effect, Layer, pipe, Option as O } from "effect"; import { NoSuchElementException } from "effect/Cause"; import { ulid } from "ulid"; @@ -16,6 +16,11 @@ export function generateSessionId(byteLength: number = 12): SessionId { export interface DatabaseInterface { readonly createAccessLog: (accessLog: AccessLog) => Effect.Effect; + readonly createPiece: (name: string, composer: O.Option, lyricist: O.Option, arranger: O.Option, user: UserId) => Effect.Effect; + readonly getPieces: Effect.Effect; + readonly updatePiece: (pieceId: PieceId, name: string, composer: O.Option, lyricist: O.Option, arranger: O.Option, user: UserId) => Effect.Effect; + readonly deletePiece: (pieceId: PieceId) => Effect.Effect; + readonly getUserByUsername: (username: string) => Effect.Effect; readonly getUserById: (userId: UserId) => Effect.Effect; @@ -96,6 +101,21 @@ export const DatabaseLive = (filePath: string = "db.sqlite3") => Layer.effect(Da [timestamp: string, requestId: string, method: string, pathname: string, query: string, ip: string | null] >("INSERT INTO AccessLog (timestamp, requestId, method, pathname, query, ip) VALUES (?, ?, ?, ?, ?, ?)"); + const createPiece = db.prepare< + never, + [pieceId: PieceId, name: string, composer: string | null, lyricist: string | null, arranger: string | null, user: UserId] + >("INSERT INTO Piece (pieceId, name, composer, lyricist, arranger, createdBy, createdAt, modifiedBy, modifiedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime(), ?6, datetime())"); + const getPieces = db.prepare< + typeof Piece.Encoded, + [] + >("SELECT pieceId, name, composer, lyricist, arranger, createdBy, createdAt, modifiedBy, modifiedAt FROM Piece ORDER BY name, composer, arranger"); + const updatePiece = db.prepare< + never, + [pieceId: PieceId, name: string, composer: string | null, lyricist: string | null, arranger: string | null, user: UserId] + >("UPDATE Piece SET name = ?2, composer = ?3, lyricist = ?4, arranger = ?5, modifiedBy = ?6, modifiedAt = datetime() WHERE pieceId = ?1"); + const pieceExists = db.prepare("SELECT 1 FROM Piece WHERE PieceId = ?"); + const deletePiece = db.prepare("DELETE FROM Piece WHERE pieceId = ?"); + const getUserByUsername = db.prepare< { userId: string, username: string, password: string, admin: number }, [username: string] @@ -119,6 +139,29 @@ export const DatabaseLive = (filePath: string = "db.sqlite3") => Layer.effect(Da createAccessLog.run(timestamp, requestId, method, pathname, query, ip); }), + createPiece: (name: string, composer: O.Option, lyricist: O.Option, arranger: O.Option, user: UserId) => Effect.sync(() => { + const pieceId = PieceId.make(ulid()); + createPiece.run(pieceId, name, O.getOrNull(composer), O.getOrNull(lyricist), O.getOrNull(arranger), user); + return pieceId; + }), + + getPieces: Effect.sync(() => { + const res = getPieces.all(); + const decoder = S.decodeSync(Piece); + return Object.freeze(res.map((encoded) => decoder(encoded))); + }), + + updatePiece: (pieceId: PieceId, name: string, composer: O.Option, lyricist: O.Option, arranger: O.Option, user: UserId) => Effect.suspend(() => { + updatePiece.run(pieceId, name, O.getOrNull(composer), O.getOrNull(lyricist), O.getOrNull(arranger), user); + const matched = pieceExists.get(pieceId) !== null; + return matched ? Effect.void : Effect.fail(new NoSuchElementException()); + }), + + deletePiece: (pieceId: PieceId) => Effect.suspend(() => { + const matched = deletePiece.run(pieceId).changes > 0; + return matched ? Effect.void : Effect.fail(new NoSuchElementException()); + }), + getUserByUsername: (username) => Effect.suspend(() => { const res = getUserByUsername.get(username); if (res === null) return Effect.fail(new NoSuchElementException()); diff --git a/packages/common/src/api.ts b/packages/common/src/api.ts index 3404090..7e5a8a0 100644 --- a/packages/common/src/api.ts +++ b/packages/common/src/api.ts @@ -1,7 +1,48 @@ import { Api } from "make-api"; import { Schema as S } from "@effect/schema"; -import { UserId } from "common"; +import { PieceId, UserId } from "common"; import { pipe } from "effect"; +import { Piece } from "./db"; + +// --- PIECES ------------------------------------------------------------------ + +export const CreatePiece = pipe( + Api.make("POST", "piece"), + Api.requestBodyJson(S.Struct({ + name: S.NonEmptyString, + composer: S.Union(S.NonEmptyString, S.Null), + lyricist: S.Union(S.NonEmptyString, S.Null), + arranger: S.Union(S.NonEmptyString, S.Null), + })), + Api.responseBodyJson(201, Piece), + Api.responseBodyText(400, S.String), +); + +export const GetPieces = pipe( + Api.make("GET", "piece"), + Api.responseBodyJson(200, S.Array(Piece)), +); + +export const UpdatePiece = pipe( + Api.make("PUT", "piece", ["pieceId", PieceId]), + Api.requestBodyJson(S.Struct({ + name: S.NonEmptyString, + composer: S.Union(S.NonEmptyString, S.Null), + lyricist: S.Union(S.NonEmptyString, S.Null), + arranger: S.Union(S.NonEmptyString, S.Null), + })), + Api.responseBodyJson(200, Piece), + Api.responseBodyText(400, S.String), +); + +export const DeletePiece = pipe( + Api.make("DELETE", "piece", ["pieceId", PieceId]), + Api.responseBodyNone(200), + Api.responseBodyText(400, S.String), + Api.responseBodyText(404, S.String), +); + +// --- AUTHENTICATION ---------------------------------------------------------- export const Me = pipe( Api.make("GET", "me"), diff --git a/packages/frontend/src/routes/Home.css.ts b/packages/frontend/src/routes/Home.css.ts index fb1f5b0..61aaf67 100644 --- a/packages/frontend/src/routes/Home.css.ts +++ b/packages/frontend/src/routes/Home.css.ts @@ -1,10 +1,14 @@ import { style } from "@vanilla-extract/css"; export const container = style({ - width: 800, + width: 1000, maxWidth: "100%", margin: "0 auto", + + display: "flex", + flexDirection: "column", + alignItems: "stretch", }); export const loading = style({ @@ -13,6 +17,70 @@ export const loading = style({ textAlign: "center", }); -export const content = style({ +export const header = style({ padding: 8, + + display: "flex", + justifyContent: "space-between", + alignItems: "baseline", +}); + +export const logout = style({ + padding: 8, + + backgroundColor: "#C0C0C0", + + borderWidth: 2, + borderStyle: "solid", + borderTopColor: "#E0E0E0", + borderLeftColor: "#E0E0E0", + borderRightColor: "#404040", + borderBottomColor: "#404040", + + borderRadius: 4, + + cursor: "pointer", + + "selectors": { + "&:focus": { + outlineWidth: 2, + outlineStyle: "solid", + outlineColor: "#8080FF", + + "@media": { + "(prefers-color-scheme: dark)": { + outlineColor: "#C0C0FF", + }, + }, + }, + + "&:active": { + borderTopColor: "#404040", + borderLeftColor: "#404040", + borderRightColor: "#E0E0E0", + borderBottomColor: "#E0E0E0", + + "@media": { + "(prefers-color-scheme: dark)": { + outlineColor: "#C0C0FF", + + borderTopColor: "#202020", + borderLeftColor: "#202020", + borderRightColor: "#606060", + borderBottomColor: "#606060", + }, + }, + }, + }, + + "@media": { + "(prefers-color-scheme: dark)": { + backgroundColor: "#404040", + + borderTopColor: "#606060", + borderLeftColor: "#606060", + borderRightColor: "#202020", + borderBottomColor: "#202020", + }, + }, }); diff --git a/packages/frontend/src/routes/Home.tsx b/packages/frontend/src/routes/Home.tsx index 5d2b896..aba72cf 100644 --- a/packages/frontend/src/routes/Home.tsx +++ b/packages/frontend/src/routes/Home.tsx @@ -71,9 +71,9 @@ export const Home = () => { return (
-
- Użytkownik: {user.username} - +
+
Użytkownik: {user.username}
+
);