Attachment CRUD API, show piece table and add piece form

This commit is contained in:
2024-11-19 23:38:58 +01:00
parent 5f0b8a69d3
commit 080cb1b490
6 changed files with 355 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
import cors from "@elysiajs/cors"; import cors from "@elysiajs/cors";
import { PieceId, RequestId, SessionId } from "common"; import { AttachmentId, PieceId, RequestId, SessionId, Sha256 } from "common";
import * as Function from "common/Function"; import * as Function from "common/Function";
import { Elysia, error, t } from "elysia"; import { Elysia, error, form, t } from "elysia";
import { sql } from "kysely"; import { sql } from "kysely";
import { generateSessionId, initDatabase } from "./database"; import { generateSessionId, initDatabase } from "./database";
@@ -10,8 +10,11 @@ const tbranded = <T>() => t.Transform(t.String())
.Encode(Function.unsafeCoerce<T, string>); .Encode(Function.unsafeCoerce<T, string>);
const app = new Elysia() const app = new Elysia()
.use(cors({ origin: "localhost:5173" })) .use(cors({ origin: "localhost:5173" }))
.decorate("db", await initDatabase()) .decorate("db", await initDatabase())
.resolve(async ({ db, cookie }) => { .resolve(async ({ db, cookie }) => {
await db await db
.deleteFrom("Session") .deleteFrom("Session")
@@ -46,6 +49,7 @@ const app = new Elysia()
return { user }; return { user };
}) })
.onTransform(async ({ db, request, server }) => { .onTransform(async ({ db, request, server }) => {
const requestId = RequestId(Bun.randomUUIDv7("hex")); const requestId = RequestId(Bun.randomUUIDv7("hex"));
@@ -63,6 +67,9 @@ const app = new Elysia()
console.log(`${timestamp} ${method} ${request.url} ${ip}`); console.log(`${timestamp} ${method} ${request.url} ${ip}`);
}) })
// --- AUTHENTICATION ------------------------------------------------------
.get("/me", async ({ user }) => { .get("/me", async ({ user }) => {
if (user === null) { if (user === null) {
@@ -75,6 +82,7 @@ const app = new Elysia()
admin: user.admin !== 0, admin: user.admin !== 0,
}; };
}) })
.post("/login", async ({ db, body: { username, password }, cookie }) => { .post("/login", async ({ db, body: { username, password }, cookie }) => {
const user = await db const user = await db
@@ -118,6 +126,7 @@ const app = new Elysia()
password: t.String({ minLength: 1 }), password: t.String({ minLength: 1 }),
}), }),
}) })
.post("/logout", async ({ db, cookie, set }) => { .post("/logout", async ({ db, cookie, set }) => {
set.status = "No Content"; set.status = "No Content";
@@ -135,10 +144,13 @@ const app = new Elysia()
.where("sessionId", "=", SessionId(sessionId)) .where("sessionId", "=", SessionId(sessionId))
.execute(); .execute();
}) })
// --- PIECE CRUD ----------------------------------------------------------
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => { .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
if (user === null) { if (user === null) {
return error(401); return error("Unauthorized");
} }
const pieceId = PieceId(Bun.randomUUIDv7()); const pieceId = PieceId(Bun.randomUUIDv7());
@@ -158,10 +170,11 @@ const app = new Elysia()
arranger: t.Nullable(t.String({ minLength: 1 })), arranger: t.Nullable(t.String({ minLength: 1 })),
}), }),
}) })
.get("/piece", async ({ db, user }) => { .get("/piece", async ({ db, user }) => {
if (user === null) { if (user === null) {
return error(401); return error("Unauthorized");
} }
const res = await db const res = await db
@@ -172,10 +185,11 @@ const app = new Elysia()
return res; return res;
}) })
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => { .put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
if (user === null) { if (user === null) {
return error(401); return error("Unauthorized");
} }
const res = await db const res = await db
@@ -189,7 +203,7 @@ const app = new Elysia()
return error("Not Found"); return error("Not Found");
} }
return res; return res[0];
}, { }, {
body: t.Object({ body: t.Object({
name: t.String({ minLength: 1 }), name: t.String({ minLength: 1 }),
@@ -201,10 +215,11 @@ const app = new Elysia()
pieceId: tbranded<PieceId>(), pieceId: tbranded<PieceId>(),
}) })
}) })
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => { .delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => {
if (user === null) { if (user === null) {
return error(401); return error("Unauthorized");
} }
const res = await db const res = await db
@@ -223,6 +238,164 @@ const app = new Elysia()
pieceId: tbranded<PieceId>(), pieceId: tbranded<PieceId>(),
}), }),
}) })
// --- ATTACHMENT CRUD -----------------------------------------------------
.post("piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => {
if (user === null) {
return error("Unauthorized");
}
const attachmentId = AttachmentId(Bun.randomUUIDv7());
const dataArray = new Uint8Array(await data.arrayBuffer());
const sha256 = Sha256(new Uint8Array(Bun.SHA256.byteLength));
Bun.SHA256.hash(dataArray, sha256);
await db
.insertInto("File")
.ignore()
.values({ sha256, data: dataArray })
.execute();
const res = await db
.insertInto("Attachment")
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: user.userId, createdAt: sql`datetime()` })
.returningAll()
.executeTakeFirstOrThrow();
return res;
}, {
body: t.Object({
filename: t.String({ minLength: 1 }),
mediaType: t.String({ minLength: 1 }),
data: t.File(),
}),
params: t.Object({
pieceId: tbranded<PieceId>(),
}),
})
.get("piece/:pieceId/attachment", async ({ db, params: { pieceId }, user }) => {
if (user === null) {
return error("Unauthorized");
}
const res = await db
.selectFrom("Attachment")
.selectAll()
.where("pieceId", "=", pieceId)
.orderBy("filename")
.execute();
return res;
}, {
params: t.Object({
pieceId: tbranded<PieceId>(),
}),
})
/* 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 }, user }) => {
if (user === null) {
return error("Unauthorized");
}
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");
}
return form({
filename: res.filename,
mediaType: res.mediaType,
data: new File([res.data], res.filename, { type: res.mediaType }),
});
}, {
params: t.Object({
pieceId: tbranded<PieceId>(),
attachmentId: tbranded<AttachmentId>(),
}),
})
.put("piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => {
if (user === null) {
return error("Unauthorized");
}
const res = await db
.updateTable("Attachment")
.set({ filename, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
.where((eb) => eb.and([
eb("pieceId", "=", pieceId),
eb("attachmentId", "=", attachmentId),
]))
.returningAll()
.execute();
if (res.length === 0) {
return error("Not Found");
}
return res[0];
}, {
body: t.Object({
filename: t.String({ minLength: 1 }),
}),
params: t.Object({
pieceId: tbranded<PieceId>(),
attachmentId: tbranded<AttachmentId>(),
}),
})
.delete("piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => {
if (user === null) {
return error("Unauthorized");
}
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");
}
set.status = "No Content";
}, {
params: t.Object({
pieceId: tbranded<PieceId>(),
attachmentId: tbranded<AttachmentId>(),
}),
})
// -------------------------------------------------------------------------
.listen(process.env.PORT || 3000); .listen(process.env.PORT || 3000);
export type App = typeof app; export type App = typeof app;

View File

@@ -165,6 +165,13 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
.$call(systemInformation) .$call(systemInformation)
.execute(); .execute();
await db.schema
.createIndex("Attachment_pieceId_filename")
.ifNotExists()
.on("Attachment")
.columns(["pieceId", "filename"])
.execute();
const { count } = await db const { count } = await db
.selectFrom("User") .selectFrom("User")
.select((eb) => eb.fn.countAll().as("count")) .select((eb) => eb.fn.countAll().as("count"))

View File

@@ -1,8 +1,9 @@
import { useEffect } from "react"; import { FormEventHandler, useEffect, useId, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { client } from "../client"; import { client } from "../client";
import { useStore } from "../store"; import { Store, useStore } from "../store";
import { Button } from "../styled/Button"; import { Button } from "../styled/Button";
import { Input } from "../styled/Input";
export function Home() { export function Home() {
@@ -11,6 +12,20 @@ export function Home() {
const user = useStore(state => state.user); const user = useStore(state => state.user);
const setUser = useStore(state => state.setUser); const setUser = useStore(state => state.setUser);
const pieces = useStore(state => state.pieces);
const setPieces = useStore(state => state.setPieces);
const loadPieces = async () => {
const { data, error } = await client.piece.get();
if (error !== null) {
console.error(error.value);
return;
}
setPieces(data);
};
const init = async () => { const init = async () => {
if (user !== null) return; if (user !== null) return;
@@ -22,6 +37,7 @@ export function Home() {
} }
setUser(data); setUser(data);
await loadPieces();
}; };
useEffect(() => { useEffect(() => {
@@ -59,6 +75,110 @@ export function Home() {
</Button> </Button>
</div> </div>
</div> </div>
<div>
<table className="w-full">
<thead>
<tr>
<th className="p-2 border">Tytuł</th>
<th className="p-2 border">Kompozytor</th>
<th className="p-2 border">Słowa</th>
<th className="p-2 border">Opracowanie</th>
</tr>
</thead>
<tbody>
{(Object.values(pieces) as Store.Piece[]).map((piece) => (
<tr key={piece.pieceId}>
<td className="p-2 border">{piece.name}</td>
<td className="p-2 border">{piece.composer}</td>
<td className="p-2 border">{piece.lyricist}</td>
<td className="p-2 border">{piece.arranger}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="self-center">
<PieceForm />
</div>
</div> </div>
); );
} }
function PieceForm() {
const [name, setName] = useState("");
const [composer, setComposer] = useState("");
const [lyricist, setLyricist] = useState("");
const [arranger, setArranger] = useState("");
const nameId = useId();
const composerId = useId();
const lyricistId = useId();
const arrangerId = useId();
const addPiece = useStore(state => state.setPiece);
const autoFocusRef = useRef<HTMLInputElement>(null);
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
const { data, error } = await client.piece.post({
name,
composer: composer.length > 0 ? composer : null,
lyricist: lyricist.length > 0 ? lyricist : null,
arranger: arranger.length > 0 ? arranger : null,
});
if (error) {
console.error(error.value);
return;
}
setName("");
setComposer("");
setLyricist("");
setArranger("");
addPiece(data);
autoFocusRef.current?.focus();
}
return (
<form className="p-2 flex flex-col gap-2 border border-black rounded dark:border-white" onSubmit={onSubmit}>
<label htmlFor={nameId}>Tytuł</label>
<Input
ref={autoFocusRef}
id={nameId}
type="text"
value={name}
required
onChange={(e) => setName(e.target.value)}
/>
<label htmlFor={composerId}>Kompozytor</label>
<Input
id={composerId}
type="text"
value={composer}
onChange={(e) => setComposer(e.target.value)}
/>
<label htmlFor={lyricistId}>Słowa</label>
<Input
id={lyricistId}
type="text"
value={lyricist}
onChange={(e) => setLyricist(e.target.value)}
/>
<label htmlFor={arrangerId}>Opracowanie</label>
<Input
id={arrangerId}
type="text"
value={arranger}
onChange={(e) => setArranger(e.target.value)}
/>
<Button type="submit">
Dodaj
</Button>
</form>
);
}

View File

@@ -51,7 +51,7 @@ export function Login() {
value={loginUsername} value={loginUsername}
autoFocus autoFocus
required required
onInput={(e) => setLoginUsername(e.currentTarget.value)} onChange={(e) => setLoginUsername(e.target.value)}
/> />
<label htmlFor={passwordId}>Hasło</label> <label htmlFor={passwordId}>Hasło</label>
<Input <Input
@@ -59,7 +59,7 @@ export function Login() {
type="password" type="password"
value={loginPassword} value={loginPassword}
required required
onInput={(e) => setLoginPassword(e.currentTarget.value)} onChange={(e) => setLoginPassword(e.target.value)}
/> />
<Button type="submit"> <Button type="submit">
Zaloguj się Zaloguj się

View File

@@ -1,20 +1,46 @@
import { UserId } from "common"; import { PieceId, UserId } from "common";
import * as Function from "common/Function"; import * as Function from "common/Function";
import { useLayoutEffect, useState } from "react"; import { useLayoutEffect, useState } from "react";
export type Update<T> = T | ((prev: T) => T); export type Update<T> = T | ((prev: T) => T);
export type Updater<T> = (action: Update<T>) => void; export type Updater<T> = (action: Update<T>) => void;
export const mapProp = <const K extends string, T>(prop: K, action: T) => <O extends { readonly [_ in K]: T }>(object: O): O => { export const mapProp = <const K extends string, T>(prop: K, action: Update<T>) => <O extends { readonly [_ in K]: T }>(object: O): O => {
return Object.freeze({ ...object, [prop]: typeof action === "function" ? action(object[prop]) : action }); return Object.freeze({ ...object, [prop]: typeof action === "function" ? (action as (prev: T) => T)(object[prop]) : action });
};
export const removeProp = <const K extends string, T>(prop: K) => <O extends { readonly [_ in K]: T}>({ [prop]: _, ...rest }: O): Readonly<Omit<O, K>> => {
return Object.freeze(rest);
};
export const makeMap = <const K extends string, T extends { readonly [k in K]: PropertyKey }>(prop: K, array: readonly T[]): { readonly [_ in T[K]]: T } => {
return Object.freeze(Object.fromEntries(array.map((item) => [item[prop], item])) as { [k in T[K]]: T });
}; };
export namespace Store { export namespace Store {
export interface User { export interface User {
readonly username: string;
readonly userId: UserId; readonly userId: UserId;
readonly username: string;
readonly admin: boolean; readonly admin: boolean;
} }
export interface Piece {
readonly pieceId: PieceId;
readonly name: string;
readonly composer: string | null;
readonly lyricist: string | null;
readonly arranger: string | null;
readonly createdBy: UserId | null;
readonly createdAt: string;
readonly modifiedBy: UserId | null;
readonly modifiedAt: string | null;
}
export namespace Piece {
export interface Map {
readonly [_: PieceId]: Store.Piece;
}
}
} }
export interface Store { export interface Store {
@@ -22,11 +48,16 @@ export interface Store {
readonly loginPassword: string; readonly loginPassword: string;
readonly user: Store.User | null; readonly user: Store.User | null;
readonly pieces: Store.Piece.Map;
readonly setLoginUsername: Updater<string>; readonly setLoginUsername: Updater<string>;
readonly setLoginPassword: Updater<string>; readonly setLoginPassword: Updater<string>;
readonly setUser: Updater<Store.User | null>; readonly setUser: Updater<Store.User | null>;
readonly setPieces: (pieces: readonly Store.Piece[]) => void;
readonly setPiece: (piece: Store.Piece) => void;
readonly deletePiece: (pieceId: PieceId) => void;
} }
let store: Store = Object.freeze<Store>({ let store: Store = Object.freeze<Store>({
@@ -34,11 +65,16 @@ let store: Store = Object.freeze<Store>({
loginPassword: "", loginPassword: "",
user: null, user: null,
pieces: Object.freeze({}),
setLoginUsername: (action) => set(mapProp("loginUsername", action)), setLoginUsername: (action) => set(mapProp("loginUsername", action)),
setLoginPassword: (action) => set(mapProp("loginPassword", action)), setLoginPassword: (action) => set(mapProp("loginPassword", action)),
setUser: (action) => set(mapProp("user", action)), setUser: (action) => set(mapProp("user", action)),
setPieces: (pieces) => set(mapProp("pieces", makeMap("pieceId", pieces))),
setPiece: (piece) => set(mapProp("pieces", mapProp(piece.pieceId, piece))),
deletePiece: (pieceId) => set(mapProp("pieces", removeProp(pieceId))),
}); });
// --- STORE IMPLEMENTATION ---------------------------------------------------- // --- STORE IMPLEMENTATION ----------------------------------------------------

View File

@@ -1,13 +1,14 @@
import { DetailedHTMLProps, InputHTMLAttributes } from "react"; import { DetailedHTMLProps, forwardRef, InputHTMLAttributes } from "react";
export namespace Input { export namespace Input {
export type Props = Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "className">; export type Props = Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "className">;
} }
export function Input({ children, ...props }: Input.Props) { export const Input = forwardRef<HTMLInputElement, Input.Props>(function Input({ children, ...props }: Input.Props, ref) {
return ( return (
<input <input
{...props} {...props}
ref={ref}
className=" className="
w-[32ch] w-[32ch]
p-2 p-2
@@ -24,4 +25,4 @@ export function Input({ children, ...props }: Input.Props) {
{children} {children}
</input> </input>
); );
} });