JUMBO refactor, still work in progress
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
.env
|
||||||
build
|
build
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
dist
|
dist
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"frontend:dev": "pnpm --filter frontend exec vite --open"
|
"frontend:dev": "pnpm --filter frontend exec vite --open"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@effect/language-service": "catalog:",
|
||||||
"@eslint/js": "catalog:",
|
"@eslint/js": "catalog:",
|
||||||
"@stylistic/eslint-plugin": "catalog:",
|
"@stylistic/eslint-plugin": "catalog:",
|
||||||
"eslint-plugin-react-hooks": "catalog:",
|
"eslint-plugin-react-hooks": "catalog:",
|
||||||
|
|||||||
@@ -8,12 +8,9 @@
|
|||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/cors": "catalog:",
|
"cbor2": "catalog:",
|
||||||
"@elysiajs/static": "catalog:",
|
|
||||||
"@elysiajs/swagger": "catalog:",
|
|
||||||
"common": "workspace:^",
|
"common": "workspace:^",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"elysia": "catalog:",
|
|
||||||
"kysely": "catalog:",
|
"kysely": "catalog:",
|
||||||
"kysely-bun-sqlite": "catalog:"
|
"kysely-bun-sqlite": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
106
packages/backend/src/api.ts
Normal file
106
packages/backend/src/api.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import * as Api from "common/Api";
|
||||||
|
import * as Cbor from "common/Cbor";
|
||||||
|
import * as Client from "common/Client";
|
||||||
|
import { Cause, Effect, Either, HashMap, Inspectable, Option, pipe } from "effect";
|
||||||
|
|
||||||
|
/* NOTE We shouldn't need to extract this to a separate type, but if we don't do
|
||||||
|
* it the TypeScript parser in VS Code sort of blows up and the syntax colors
|
||||||
|
* turn wrong.
|
||||||
|
*/
|
||||||
|
type Return<Impl extends Api.ApiBundleImpl<any>> = Effect.Effect<Response, never, Effect.Effect.Context<ReturnType<Impl[keyof Impl]>>>;
|
||||||
|
|
||||||
|
const catchToResponse = Effect.catchAll((error: unknown) => pipe(
|
||||||
|
error,
|
||||||
|
Inspectable.toJSON,
|
||||||
|
Cbor.encode,
|
||||||
|
Effect.map(Client.cborBody),
|
||||||
|
Effect.matchEffect({
|
||||||
|
onSuccess: ({ body, headers }) => Effect.fail(new Response(body, { status: 500, headers })),
|
||||||
|
onFailure: () => Effect.fail(new Response(null, { status: 500 })),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
|
export const implement = <
|
||||||
|
const Record extends { readonly [_: string]: Api.ApiAny },
|
||||||
|
const Impl extends Api.ApiBundleImpl<Record>,
|
||||||
|
>(
|
||||||
|
bundle: Api.ApiBundle<Record>,
|
||||||
|
impl: Impl,
|
||||||
|
): (key: string, request: Request) => Return<Impl> => {
|
||||||
|
return (key, requestObject) => {
|
||||||
|
/* Force both return types to be `Response`. We can use the error route
|
||||||
|
* for it's short-circuit capabilities.
|
||||||
|
*/
|
||||||
|
const effect: Effect.Effect<Response, Response, any> = Effect.gen(function* () {
|
||||||
|
const maybeApi = HashMap.get(bundle.map, key);
|
||||||
|
if (Option.isNone(maybeApi)) {
|
||||||
|
return RESPONSE_API_NOT_FOUND;
|
||||||
|
}
|
||||||
|
const { value: api } = maybeApi;
|
||||||
|
|
||||||
|
const fn = impl[key];
|
||||||
|
if (fn === undefined) {
|
||||||
|
return RESPONSE_API_NOT_IMPLEMENTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = yield* pipe(
|
||||||
|
requestObject,
|
||||||
|
Client.decodeBody(api.request),
|
||||||
|
catchToResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = yield* pipe(
|
||||||
|
request,
|
||||||
|
fn,
|
||||||
|
Effect.sandbox,
|
||||||
|
Effect.either,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status, response } = pipe(
|
||||||
|
result,
|
||||||
|
Either.match({
|
||||||
|
onLeft: (cause) => pipe(
|
||||||
|
cause,
|
||||||
|
Cause.failureOrCause,
|
||||||
|
Either.match({
|
||||||
|
onLeft: (error) => ({
|
||||||
|
status: 400,
|
||||||
|
response: pipe(
|
||||||
|
error,
|
||||||
|
Client.encodeBody(api.error),
|
||||||
|
catchToResponse,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
onRight: (die) => ({
|
||||||
|
status: 500,
|
||||||
|
response: pipe(
|
||||||
|
die,
|
||||||
|
Inspectable.toJSON,
|
||||||
|
Cbor.encode,
|
||||||
|
Effect.map(Client.cborBody),
|
||||||
|
catchToResponse,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onRight: (response) => ({
|
||||||
|
status: 200,
|
||||||
|
response: pipe(
|
||||||
|
response,
|
||||||
|
Client.encodeBody(api.response),
|
||||||
|
catchToResponse,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { body, headers } = yield* response;
|
||||||
|
return new Response(body, { status, headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
return Effect.catchAll(effect, Effect.succeed);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESPONSE_API_NOT_FOUND = new Response(null, { status: 404 });
|
||||||
|
const RESPONSE_API_NOT_IMPLEMENTED = new Response(null, { status: 501 });
|
||||||
@@ -1,864 +1,160 @@
|
|||||||
import cors from "@elysiajs/cors";
|
import * as Body from "common/Body";
|
||||||
import { staticPlugin } from "@elysiajs/static";
|
import { fetch } from "common/Fetch";
|
||||||
import { swagger } from "@elysiajs/swagger";
|
import { Cause, Effect, Layer, Option, pipe, Record, Redacted, Stream } from "effect";
|
||||||
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex } from "common";
|
import * as path from "node:path";
|
||||||
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 { config } from "./config";
|
||||||
import * as Db from "./database";
|
import * as Authentication from "./services/Authentication";
|
||||||
import * as Model from "./model";
|
import * as Database from "./services/Database";
|
||||||
import { DbFromInstance } from "./services/db";
|
import { handle } from "./the_api";
|
||||||
import { SessionFromValue } from "./services/session";
|
|
||||||
|
|
||||||
const app = new Elysia()
|
const FRONTEND_ROOT = "packages/frontend/build";
|
||||||
|
const FRONTEND_ASSETS_ROOT = path.join(FRONTEND_ROOT, "assets");
|
||||||
|
|
||||||
.use(swagger({
|
const assetRoutes = await pipe(
|
||||||
scalarConfig: {
|
Stream.fromAsyncIterable(
|
||||||
authentication: {
|
new Bun.Glob("**/*").scan(FRONTEND_ASSETS_ROOT),
|
||||||
securitySchemes: {
|
(error) => new Cause.UnknownException(error),
|
||||||
cookieAuth: {
|
),
|
||||||
type: "apiKey",
|
Stream.map((filepath): [string, Response] => [
|
||||||
in: "cookie",
|
`/assets/${filepath}`,
|
||||||
name: "sessionId",
|
new Response(Bun.file(path.join(FRONTEND_ASSETS_ROOT, filepath))),
|
||||||
},
|
]),
|
||||||
},
|
Stream.runCollect,
|
||||||
|
Effect.map(Record.fromEntries),
|
||||||
|
Effect.runPromise,
|
||||||
|
);
|
||||||
|
|
||||||
|
const CORS_HEADERS: [string, string][] = [
|
||||||
|
["Access-Control-Allow-Origin", "http://localhost:5173"],
|
||||||
|
["Access-Control-Allow-Methods", "POST, OPTIONS"],
|
||||||
|
["Access-Control-Allow-Credentials", "true"],
|
||||||
|
["Access-Control-Allow-Headers", "Content-Type"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const homepage = new Response(Bun.file(path.join(FRONTEND_ROOT, "index.html")));
|
||||||
|
|
||||||
|
const databaseLayer = Database.FromPath(config.DB_PATH);
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
routes: {
|
||||||
|
...assetRoutes,
|
||||||
|
"/login": {
|
||||||
|
GET: async (req) => {
|
||||||
|
const res = await pipe(
|
||||||
|
Authentication.Authentication,
|
||||||
|
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl({
|
||||||
|
external: new URL(req.url).searchParams.has("external"),
|
||||||
|
sessionId,
|
||||||
|
})),
|
||||||
|
Effect.map((url) => Response.redirect(url)),
|
||||||
|
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
|
||||||
|
Effect.runPromise,
|
||||||
|
);
|
||||||
|
|
||||||
|
return res;
|
||||||
},
|
},
|
||||||
},
|
POST: (req) => Effect.gen(function* () {
|
||||||
swaggerOptions: {
|
const { sessionId } = yield* Authentication.Authentication;
|
||||||
withCredentials: true,
|
const db = yield* Database.Database;
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
.use(cors({ origin: config.NODE_ENV === "production" ? false : "localhost:5173" }))
|
const data = yield* Body.formData(req);
|
||||||
|
|
||||||
.decorate("db", await Db.initDatabase(config.DB_PATH))
|
const code = data.get("code") as string | null;
|
||||||
|
const state = data.get("state") as string | null;
|
||||||
|
|
||||||
.resolve(async ({ db, cookie }) => {
|
const session = yield* db
|
||||||
await db
|
.selectFrom("Session")
|
||||||
.deleteFrom("Session")
|
.select(["external", "codeVerifier"])
|
||||||
.where(sql`datetime()`, ">=", "expiresAt")
|
.where("sessionId", "=", sessionId)
|
||||||
.execute();
|
.$call(Database.executeTakeFirst);
|
||||||
|
|
||||||
const sessionId = (cookie.sessionId.value as SessionId | undefined) ?? Db.generateSessionId();
|
const external = pipe(
|
||||||
|
|
||||||
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,
|
session.external,
|
||||||
Option.fromNullable,
|
Option.fromNullable,
|
||||||
Option.map((e) => e !== 0),
|
Option.map((external) => external !== 0),
|
||||||
),
|
);
|
||||||
state: Option.fromNullable(session.state),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
|
|
||||||
.onTransform(async ({ db, request, server }) => {
|
const codeVerifier = Option.fromNullable(session.codeVerifier);
|
||||||
|
|
||||||
const requestId = RequestId(Bun.randomUUIDv7("hex"));
|
if (code !== null && state !== null && Option.isSome(external) && Option.isSome(codeVerifier)) {
|
||||||
const timestamp = new Date().toISOString();
|
const { tokenEndpoint } = external.value
|
||||||
const { method } = request;
|
? Authentication.EXTERNAL_OAUTH_CONFIGURATION
|
||||||
const url = new URL(request.url);
|
: Authentication.INTERNAL_OAUTH_CONFIGURATION;
|
||||||
const { pathname } = url;
|
|
||||||
const query = JSON.stringify(Object.fromEntries(url.searchParams.entries()));
|
|
||||||
const ip = server?.requestIP(request)?.address ?? null;
|
|
||||||
|
|
||||||
await db
|
const res = yield* fetch(tokenEndpoint, {
|
||||||
.insertInto("AccessLog")
|
method: "POST",
|
||||||
.values({ requestId, timestamp, method, pathname, query, ip })
|
headers: {
|
||||||
.execute();
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
"client_id": config.CLIENT_ID,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": Authentication.REDIRECT_URI,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code_verifier": codeVerifier.value,
|
||||||
|
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
|
const {
|
||||||
})
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
id_token: idToken,
|
||||||
|
} = (yield* Body.json(res)) as {
|
||||||
|
access_token: string,
|
||||||
|
refresh_token: string,
|
||||||
|
id_token: string,
|
||||||
|
};
|
||||||
|
|
||||||
.use(staticPlugin({
|
yield* db
|
||||||
assets: "packages/frontend/build/assets",
|
.updateTable("Session")
|
||||||
prefix: "/assets",
|
.set({
|
||||||
alwaysStatic: true,
|
accessToken,
|
||||||
indexHTML: false,
|
refreshToken,
|
||||||
}))
|
idToken,
|
||||||
|
codeVerifier: null,
|
||||||
|
state: null,
|
||||||
|
})
|
||||||
|
.where("sessionId", "=", sessionId)
|
||||||
|
.$call(Database.execute);
|
||||||
|
}
|
||||||
|
|
||||||
.group("/api/v1", (app) => app
|
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||||
|
}).pipe(
|
||||||
// --- MARK: AUTHENTICATION --------------------------------------------
|
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
|
||||||
|
|
||||||
.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,
|
Effect.runPromise,
|
||||||
);
|
),
|
||||||
|
},
|
||||||
|
"/api/:key": async (req, server) => {
|
||||||
|
|
||||||
return redirect(url, 302) as unknown as void;
|
const timestamp = new Date().toISOString();
|
||||||
}, {
|
console.log(`${timestamp} ${req.method} ${req.url} ${server.requestIP(req)?.address}`);
|
||||||
response: {
|
|
||||||
302: t.Void(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
.post("/login", async ({ db, request, redirect, session: { sessionId, external, codeVerifier } }) => {
|
if (req.method === "OPTIONS") {
|
||||||
const data = await request.formData();
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
const code = data.get("code") as string | null;
|
headers: CORS_HEADERS,
|
||||||
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;
|
const authenticationLayer = Authentication.Live(req);
|
||||||
}, {
|
const layers = Layer.provideMerge(authenticationLayer, databaseLayer);
|
||||||
response: {
|
|
||||||
303: t.Void(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
.post("/logout", async ({ db, cookie, set }) => {
|
const response = await pipe(
|
||||||
|
handle(req.params.key, req),
|
||||||
set.status = "No Content";
|
Effect.provide(layers),
|
||||||
|
|
||||||
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,
|
Effect.runPromise,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Option.match(res, {
|
for (const [name, value] of CORS_HEADERS) {
|
||||||
onNone: () => error("Not Found", new Response() as unknown as void),
|
response.headers.set(name, value);
|
||||||
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 response;
|
||||||
return error("Forbidden", "Must be an Editor");
|
},
|
||||||
}
|
"/*": homepage,
|
||||||
|
},
|
||||||
const pieceId = PieceId(Bun.randomUUIDv7());
|
websocket: {
|
||||||
|
message: () => { },
|
||||||
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;
|
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
import { UserId } from "common";
|
|
||||||
import { DateTime, Duration, Effect, Option, pipe, Redacted } from "effect";
|
|
||||||
import { constant } from "effect/Function";
|
|
||||||
import { config } from "./config";
|
|
||||||
import * as Model from "./model";
|
|
||||||
import { Db } from "./services/db";
|
|
||||||
import { Session } from "./services/session";
|
|
||||||
|
|
||||||
export const OAUTH_SCOPE = "email offline_access openid profile https://graph.microsoft.com/User.Read.All";
|
|
||||||
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/api/v1/login` : "http://localhost:3000/api/v1/login";
|
|
||||||
|
|
||||||
export const EXPIRATION_BUFFER = Duration.seconds(10);
|
|
||||||
|
|
||||||
export interface OAuthConfiguration {
|
|
||||||
readonly authorizationEndpoint: string;
|
|
||||||
readonly tokenEndpoint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const INTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze<OAuthConfiguration>({
|
|
||||||
authorizationEndpoint: `https://login.microsoftonline.com/${config.TENANT_ID}/oauth2/v2.0/authorize`,
|
|
||||||
tokenEndpoint: `https://login.microsoftonline.com/${config.TENANT_ID}/oauth2/v2.0/token`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const EXTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze<OAuthConfiguration>({
|
|
||||||
authorizationEndpoint: `https://${config.TENANT_SUBDOMAIN}.ciamlogin.com/${config.TENANT_ID}/oauth2/v2.0/authorize`,
|
|
||||||
tokenEndpoint: `https://${config.TENANT_SUBDOMAIN}.ciamlogin.com/${config.TENANT_ID}/oauth2/v2.0/token`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export namespace makeAuthorizationUrl {
|
|
||||||
export interface Args {
|
|
||||||
readonly external: boolean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeAuthorizationUrl = Effect.fn("makeAuthorizationUrl")(
|
|
||||||
function* ({ external }: makeAuthorizationUrl.Args) {
|
|
||||||
const { db, execute } = yield* Db;
|
|
||||||
const sessionId = yield* Session;
|
|
||||||
|
|
||||||
const { codeVerifier, codeChallenge } = generateCodeVerifier();
|
|
||||||
const state = generateRandomState();
|
|
||||||
|
|
||||||
yield* db
|
|
||||||
.updateTable("Session")
|
|
||||||
.set({
|
|
||||||
codeVerifier,
|
|
||||||
state,
|
|
||||||
external: external ? 1 : 0,
|
|
||||||
accessToken: null,
|
|
||||||
idToken: null,
|
|
||||||
refreshToken: null,
|
|
||||||
})
|
|
||||||
.where("sessionId", "=", sessionId)
|
|
||||||
.$call(execute);
|
|
||||||
|
|
||||||
const { authorizationEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION;
|
|
||||||
|
|
||||||
const url = new URL(authorizationEndpoint);
|
|
||||||
url.searchParams.set("client_id", config.CLIENT_ID);
|
|
||||||
url.searchParams.set("response_type", "code");
|
|
||||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
||||||
url.searchParams.set("scope", OAUTH_SCOPE);
|
|
||||||
url.searchParams.set("response_mode", "form_post");
|
|
||||||
url.searchParams.set("state", state);
|
|
||||||
url.searchParams.set("prompt", "select_account");
|
|
||||||
url.searchParams.set("code_challenge", codeChallenge);
|
|
||||||
url.searchParams.set("code_challenge_method", "S256");
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export namespace revaildateTokens {
|
|
||||||
export interface Args {
|
|
||||||
readonly accessToken: Option.Option<string>;
|
|
||||||
readonly idToken: Option.Option<string>;
|
|
||||||
readonly refreshToken: Option.Option<string>;
|
|
||||||
readonly external: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Result {
|
|
||||||
readonly accessToken: Option.Option<{
|
|
||||||
readonly token: string,
|
|
||||||
readonly payload: Model.AccessTokenPayload,
|
|
||||||
}>;
|
|
||||||
readonly idToken: Option.Option<{
|
|
||||||
readonly token: string,
|
|
||||||
readonly payload: Model.IdTokenPayload,
|
|
||||||
}>;
|
|
||||||
readonly refreshToken: Option.Option<string>;
|
|
||||||
readonly userId: Option.Option<UserId>;
|
|
||||||
readonly roles: readonly string[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const revalidateTokens = Effect.fn("revaildateTokens")(
|
|
||||||
function* ({ accessToken, idToken, refreshToken, external }: revaildateTokens.Args) {
|
|
||||||
|
|
||||||
const accessTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.AccessTokenPayload>);
|
|
||||||
const idTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.IdTokenPayload>);
|
|
||||||
|
|
||||||
const expirationThreshold = yield* pipe(
|
|
||||||
DateTime.now,
|
|
||||||
Effect.map(DateTime.addDuration(EXPIRATION_BUFFER)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Token expired or missing
|
|
||||||
if (Option.match(accessTokenPayload, {
|
|
||||||
onNone: constant(false),
|
|
||||||
onSome: (atp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * atp.exp)),
|
|
||||||
}) || Option.match(idTokenPayload, {
|
|
||||||
onNone: constant(false),
|
|
||||||
onSome: (itp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * itp.exp)),
|
|
||||||
})) {
|
|
||||||
|
|
||||||
accessToken = Option.none();
|
|
||||||
idToken = Option.none();
|
|
||||||
|
|
||||||
// try refreshing
|
|
||||||
if (Option.isSome(refreshToken)) {
|
|
||||||
const refreshTokenValue = refreshToken.value;
|
|
||||||
const { tokenEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION;
|
|
||||||
|
|
||||||
const res = yield* Effect.promise((signal) => fetch(tokenEndpoint, {
|
|
||||||
method: "POST",
|
|
||||||
signal,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
"client_id": config.CLIENT_ID,
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": refreshTokenValue,
|
|
||||||
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
|
||||||
}).toString(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const json = (yield* Effect.promise(() => res.json())) as { access_token: string, refresh_token: string, id_token: string };
|
|
||||||
|
|
||||||
accessToken = Option.some(json.access_token);
|
|
||||||
idToken = Option.some(json.id_token);
|
|
||||||
refreshToken = Option.some(json.refresh_token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const it = Option.map(idToken, (it) => Object.freeze({
|
|
||||||
token: it,
|
|
||||||
payload: getJwtTokenPayload<Model.IdTokenPayload>(it),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
|
|
||||||
accessToken: Option.map(accessToken, (at) => Object.freeze({
|
|
||||||
token: at,
|
|
||||||
payload: getJwtTokenPayload<Model.AccessTokenPayload>(at),
|
|
||||||
})),
|
|
||||||
idToken: it,
|
|
||||||
refreshToken,
|
|
||||||
userId: Option.map(it, ({ payload: { oid } }) => oid),
|
|
||||||
roles: Option.match(it, {
|
|
||||||
onNone: constant(Object.freeze([])),
|
|
||||||
onSome: ({ payload: { roles } }) => roles ?? Object.freeze([]),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function getJwtTokenPayload<O extends object = object>(token: string): O {
|
|
||||||
return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8")) as O;
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace generateCodeVerifier {
|
|
||||||
export interface Result {
|
|
||||||
codeVerifier: string;
|
|
||||||
codeChallenge: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateCodeVerifier(byteLength: number = 32): generateCodeVerifier.Result {
|
|
||||||
const codeVerifierBytes = new Uint8Array(byteLength);
|
|
||||||
crypto.getRandomValues(codeVerifierBytes);
|
|
||||||
const codeVerifier = Buffer.from(codeVerifierBytes).toString("base64url");
|
|
||||||
|
|
||||||
const codeVerifierAsciiBuffer = Buffer.from(codeVerifier, "ascii");
|
|
||||||
const codeVerifierAsciiArray = new Uint8Array(
|
|
||||||
codeVerifierAsciiBuffer.buffer,
|
|
||||||
codeVerifierAsciiBuffer.byteOffset,
|
|
||||||
codeVerifierAsciiBuffer.length,
|
|
||||||
);
|
|
||||||
const codeChallenge = Bun.SHA256.hash(codeVerifierAsciiArray, "base64url");
|
|
||||||
|
|
||||||
return { codeVerifier, codeChallenge };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateRandomState(byteLength: number = 32): string {
|
|
||||||
const array = new Uint8Array(byteLength);
|
|
||||||
crypto.getRandomValues(array);
|
|
||||||
const state = Buffer.from(array).toString("base64url");
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace getUser {
|
|
||||||
export interface Args {
|
|
||||||
readonly accessToken: string;
|
|
||||||
readonly userId: UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Result {
|
|
||||||
readonly displayName: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getUser = Effect.fn("getUser")(
|
|
||||||
function* ({ accessToken, userId }: getUser.Args) {
|
|
||||||
const res = yield* Effect.promise((signal) => fetch(`https://graph.microsoft.com/v1.0/users/${userId}?$select=displayName`, {
|
|
||||||
signal,
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (res.status === 404) {
|
|
||||||
return Option.none();
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = (yield* Effect.promise(() => res.json())) as getUser.Result;
|
|
||||||
return Option.some(json);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -11,7 +11,7 @@ export const Config = Schema.Struct({
|
|||||||
),
|
),
|
||||||
DB_PATH: pipe(
|
DB_PATH: pipe(
|
||||||
Schema.String,
|
Schema.String,
|
||||||
Schema.optional,
|
Schema.optionalWith({ default: constant("db.sqlite3") }),
|
||||||
),
|
),
|
||||||
HOSTNAME: Schema.String,
|
HOSTNAME: Schema.String,
|
||||||
NODE_ENV: pipe(
|
NODE_ENV: pipe(
|
||||||
@@ -22,8 +22,10 @@ export const Config = Schema.Struct({
|
|||||||
Schema.NumberFromString,
|
Schema.NumberFromString,
|
||||||
Schema.optionalWith({ default: constant(3000) }),
|
Schema.optionalWith({ default: constant(3000) }),
|
||||||
),
|
),
|
||||||
TENANT_ID: Schema.UUID,
|
OAUTH_AUTHORIZATION_ENDPOINT: Schema.String,
|
||||||
TENANT_SUBDOMAIN: Schema.String,
|
OAUTH_TOKEN_ENDPOINT: Schema.String,
|
||||||
|
POCKET_ID_API_ORIGIN: Schema.String,
|
||||||
|
POCKET_ID_API_KEY: Schema.String,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = typeof Config.Type;
|
export type Config = typeof Config.Type;
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import * as Common from "common";
|
|
||||||
import { unsafeCoerce } from "effect";
|
|
||||||
import { t } from "elysia";
|
|
||||||
|
|
||||||
export interface AccessTokenPayload {
|
|
||||||
readonly aud: string;
|
|
||||||
readonly iss: string;
|
|
||||||
readonly iat: number;
|
|
||||||
readonly nbf: number;
|
|
||||||
readonly exp: number;
|
|
||||||
readonly name: string;
|
|
||||||
readonly oid: Common.UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IdTokenPayload {
|
|
||||||
readonly aud: string;
|
|
||||||
readonly iss: string;
|
|
||||||
readonly iat: number;
|
|
||||||
readonly nbf: number;
|
|
||||||
readonly exp: number;
|
|
||||||
readonly name: string;
|
|
||||||
readonly oid: Common.UserId;
|
|
||||||
readonly roles?: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const brandedString = <T>() => t.Transform(t.String())
|
|
||||||
.Decode(unsafeCoerce<string, T>)
|
|
||||||
.Encode(unsafeCoerce<T, string>);
|
|
||||||
|
|
||||||
export const Sha256_Hex = brandedString<Common.Sha256_Hex>();
|
|
||||||
export const AttachmentId = brandedString<Common.AttachmentId>();
|
|
||||||
export const PieceId = brandedString<Common.PieceId>();
|
|
||||||
export const RepertoireId = brandedString<Common.RepertoireId>();
|
|
||||||
export const RequestId = brandedString<Common.RequestId>();
|
|
||||||
export const SessionId = brandedString<Common.SessionId>();
|
|
||||||
export const UserId = brandedString<Common.UserId>();
|
|
||||||
|
|
||||||
const SystemInformation = Object.freeze({
|
|
||||||
createdBy: t.Nullable(UserId),
|
|
||||||
createdAt: t.String(),
|
|
||||||
modifiedBy: t.Nullable(UserId),
|
|
||||||
modifiedAt: t.Nullable(t.String()),
|
|
||||||
});
|
|
||||||
|
|
||||||
const Pagination = Object.freeze({
|
|
||||||
offset: t.Optional(t.Numeric({ minimum: 0 })),
|
|
||||||
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 100 })),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AccessLog = t.Object({
|
|
||||||
requestId: RequestId,
|
|
||||||
timestamp: t.String(),
|
|
||||||
method: t.String(),
|
|
||||||
pathname: t.String(),
|
|
||||||
query: t.String(),
|
|
||||||
ip: t.Nullable(t.String()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Attachment = t.Object({
|
|
||||||
attachmentId: AttachmentId,
|
|
||||||
pieceId: PieceId,
|
|
||||||
filename: t.String(),
|
|
||||||
sha256: Sha256_Hex,
|
|
||||||
mediaType: t.String(),
|
|
||||||
...SystemInformation,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Me = t.Object({
|
|
||||||
userId: UserId,
|
|
||||||
username: t.String(),
|
|
||||||
roles: t.Array(t.String()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Piece = t.Object({
|
|
||||||
pieceId: PieceId,
|
|
||||||
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 })),
|
|
||||||
attachments: t.Array(Attachment),
|
|
||||||
...SystemInformation,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Piece_Post = 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 })),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Piece_Query = t.Object({
|
|
||||||
name: t.Optional(t.String()),
|
|
||||||
author: t.Optional(t.String()),
|
|
||||||
...Pagination,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Repertoire = t.Object({
|
|
||||||
repertoireId: RepertoireId,
|
|
||||||
name: t.String(),
|
|
||||||
entries: t.Array(PieceId),
|
|
||||||
...SystemInformation,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Repertoire_Query = t.Object({
|
|
||||||
name: t.Optional(t.String()),
|
|
||||||
...Pagination,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const User = t.Object({
|
|
||||||
userId: UserId,
|
|
||||||
displayName: t.String(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AccessLog = typeof AccessLog.static;
|
|
||||||
export type Attachment = typeof Attachment.static;
|
|
||||||
export type Me = typeof Me.static;
|
|
||||||
export type Piece = typeof Piece.static;
|
|
||||||
export type Piece_Post = typeof Piece_Post.static;
|
|
||||||
export type Piece_Query = typeof Piece_Query.static;
|
|
||||||
export type Repertoire = typeof Repertoire.static;
|
|
||||||
export type Repertoire_Query = typeof Repertoire_Query.static;
|
|
||||||
export type User = typeof User.static;
|
|
||||||
397
packages/backend/src/services/Authentication.ts
Normal file
397
packages/backend/src/services/Authentication.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import { config } from "backend/config";
|
||||||
|
import { BunRequest } from "bun";
|
||||||
|
import { SessionId, UserId } from "common";
|
||||||
|
import { fetch, FetchError } from "common/Fetch";
|
||||||
|
import { Me, NotFound, Other, Role, Unauthenticated } from "common/the_api";
|
||||||
|
import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect";
|
||||||
|
import { constant } from "effect/Function";
|
||||||
|
import { sql } from "kysely";
|
||||||
|
import * as Database from "./Database";
|
||||||
|
|
||||||
|
export interface AuthenticationInterface {
|
||||||
|
readonly me: Effect.Effect<Me, Unauthenticated>;
|
||||||
|
readonly logout: Effect.Effect<void>;
|
||||||
|
readonly sessionId: SessionId;
|
||||||
|
readonly getUser: (userId: UserId) => Effect.Effect<Other, FetchError | Unauthenticated | NotFound>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Authentication extends Context.Tag("Authentication")<Authentication, AuthenticationInterface>() { }
|
||||||
|
|
||||||
|
export const OAUTH_SCOPE = "email openid profile offline_access";
|
||||||
|
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/login` : "http://localhost:3000/login";
|
||||||
|
|
||||||
|
export const EXPIRATION_BUFFER = Duration.seconds(10);
|
||||||
|
|
||||||
|
export const SESSION_COOKIE_NAME = "sessionId";
|
||||||
|
|
||||||
|
const ALL_ROLES = HashSet.fromIterable(Object.values(Role));
|
||||||
|
|
||||||
|
export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () {
|
||||||
|
const database = yield* Database.Database;
|
||||||
|
|
||||||
|
yield* database
|
||||||
|
.deleteFrom("Session")
|
||||||
|
.where(sql`datetime()`, ">=", "expiresAt")
|
||||||
|
.$call(Database.execute);
|
||||||
|
|
||||||
|
const sessionId = pipe(
|
||||||
|
request.cookies.get(SESSION_COOKIE_NAME),
|
||||||
|
Option.fromNullable,
|
||||||
|
Option.map(SessionId.make),
|
||||||
|
Option.getOrElse(generateSessionId),
|
||||||
|
);
|
||||||
|
|
||||||
|
request.cookies.set(SESSION_COOKIE_NAME, sessionId, {
|
||||||
|
expires: yield* pipe(
|
||||||
|
DateTime.now,
|
||||||
|
Effect.map(DateTime.addDuration("7 days")),
|
||||||
|
Effect.map(DateTime.toDateUtc),
|
||||||
|
),
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "none",
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const returning = [
|
||||||
|
"sessionId",
|
||||||
|
"accessToken",
|
||||||
|
"codeVerifier",
|
||||||
|
"idToken",
|
||||||
|
"refreshToken",
|
||||||
|
"state",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const session = yield* database
|
||||||
|
.updateTable("Session")
|
||||||
|
.set({ expiresAt: sql`datetime('now', '+7 days') ` })
|
||||||
|
.where("sessionId", "=", sessionId)
|
||||||
|
.returning(returning)
|
||||||
|
.$call(Database.executeTakeFirst)
|
||||||
|
.pipe(Effect.catchTag("NoSuchElementException", () => database
|
||||||
|
.insertInto("Session")
|
||||||
|
.values({ sessionId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||||
|
.returning(returning)
|
||||||
|
.$call(Database.executeTakeFirstOrDie)
|
||||||
|
));
|
||||||
|
|
||||||
|
const { accessToken, idToken, refreshToken } = yield* revalidateTokens({
|
||||||
|
accessToken: Option.fromNullable(session.accessToken),
|
||||||
|
idToken: Option.fromNullable(session.idToken),
|
||||||
|
refreshToken: Option.fromNullable(session.refreshToken),
|
||||||
|
});
|
||||||
|
|
||||||
|
yield* database
|
||||||
|
.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),
|
||||||
|
})
|
||||||
|
.$call(Database.execute);
|
||||||
|
|
||||||
|
const state = Object.freeze({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
accessToken,
|
||||||
|
idToken,
|
||||||
|
refreshToken,
|
||||||
|
codeVerifier: Option.fromNullable(session.codeVerifier),
|
||||||
|
state: Option.fromNullable(session.state),
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.freeze<AuthenticationInterface>({
|
||||||
|
me: pipe(
|
||||||
|
state.idToken,
|
||||||
|
Option.map(({ payload }) => {
|
||||||
|
const userData: Me = Object.freeze<Me>({
|
||||||
|
userId: payload.sub,
|
||||||
|
displayName: payload.display_name,
|
||||||
|
roles: ALL_ROLES,
|
||||||
|
});
|
||||||
|
return userData;
|
||||||
|
}),
|
||||||
|
Option.match({
|
||||||
|
onNone: () => Effect.fail(Unauthenticated.make()),
|
||||||
|
onSome: Effect.succeed,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
logout: database
|
||||||
|
.updateTable("Session")
|
||||||
|
.set({
|
||||||
|
accessToken: null,
|
||||||
|
codeVerifier: null,
|
||||||
|
expiresAt: sql`datetime('now', '+7 days')`,
|
||||||
|
idToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
state: null,
|
||||||
|
})
|
||||||
|
.where("sessionId", "=", state.sessionId)
|
||||||
|
.$call(Database.execute),
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
getUser: (userId) => pipe(
|
||||||
|
getDisplayName(userId),
|
||||||
|
Effect.map((displayName) => Object.freeze<Other>({ userId, displayName })),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Test = (me: Option.Option<Me>, other: HashMap.HashMap<UserId, Other>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({
|
||||||
|
me: Option.match(me, {
|
||||||
|
onNone: () => Effect.fail(Unauthenticated.make()),
|
||||||
|
onSome: (me) => Effect.succeed(me),
|
||||||
|
}),
|
||||||
|
logout: Effect.void,
|
||||||
|
sessionId: generateSessionId(),
|
||||||
|
getUser: Option.match(me, {
|
||||||
|
onNone: () => constant(Effect.fail(Unauthenticated.make())),
|
||||||
|
onSome: () => (userId) => pipe(
|
||||||
|
other,
|
||||||
|
HashMap.get(userId),
|
||||||
|
Option.match({
|
||||||
|
onNone: () => Effect.fail(NotFound.make()),
|
||||||
|
onSome: Effect.succeed,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})));
|
||||||
|
|
||||||
|
export const AccessTokenPayload = Schema.Struct({
|
||||||
|
aud: pipe(
|
||||||
|
Schema.String,
|
||||||
|
Schema.HashSet,
|
||||||
|
),
|
||||||
|
exp: Schema.DateTimeUtc,
|
||||||
|
iat: Schema.DateTimeUtc,
|
||||||
|
iss: Schema.String,
|
||||||
|
sub: UserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IdTokenPayload = Schema.Struct({
|
||||||
|
aud: pipe(
|
||||||
|
Schema.String,
|
||||||
|
Schema.HashSet,
|
||||||
|
),
|
||||||
|
exp: Schema.DateTimeUtc,
|
||||||
|
iat: Schema.DateTimeUtc,
|
||||||
|
iss: Schema.String,
|
||||||
|
sub: UserId,
|
||||||
|
|
||||||
|
name: Schema.String,
|
||||||
|
given_name: Schema.String,
|
||||||
|
family_name: Schema.String,
|
||||||
|
display_name: Schema.String,
|
||||||
|
preferred_username: Schema.String,
|
||||||
|
|
||||||
|
email: Schema.String,
|
||||||
|
email_verified: Schema.Boolean,
|
||||||
|
|
||||||
|
picture: Schema.String,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AccessTokenPayload = typeof AccessTokenPayload.Type;
|
||||||
|
export type IdTokenPayload = typeof IdTokenPayload.Type;
|
||||||
|
|
||||||
|
function generateCodeVerifier(byteLength: number = 32) {
|
||||||
|
const codeVerifierBytes = new Uint8Array(byteLength);
|
||||||
|
crypto.getRandomValues(codeVerifierBytes);
|
||||||
|
const codeVerifier = Buffer.from(codeVerifierBytes).toString("base64url");
|
||||||
|
|
||||||
|
const codeVerifierAsciiBuffer = Buffer.from(codeVerifier, "ascii");
|
||||||
|
const codeVerifierAsciiArray = new Uint8Array(
|
||||||
|
codeVerifierAsciiBuffer.buffer,
|
||||||
|
codeVerifierAsciiBuffer.byteOffset,
|
||||||
|
codeVerifierAsciiBuffer.length,
|
||||||
|
);
|
||||||
|
const codeChallenge = Bun.SHA256.hash(codeVerifierAsciiArray, "base64url");
|
||||||
|
|
||||||
|
return { codeVerifier, codeChallenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSessionId(byteLength: number = 32): SessionId {
|
||||||
|
const array = new Uint8Array(byteLength);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
const string = Buffer.from(array).toString("base64url");
|
||||||
|
return SessionId.make(string);
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateRandomState(byteLength: number = 32): string {
|
||||||
|
const array = new Uint8Array(byteLength);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
const state = Buffer.from(array).toString("base64url");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => {
|
||||||
|
const decoder = Schema.decodeUnknown(schema);
|
||||||
|
return (token: string) => {
|
||||||
|
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
|
||||||
|
return pipe(
|
||||||
|
decoder(json),
|
||||||
|
Effect.orDie,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayName = (userId: UserId) => Effect.gen(function* () {
|
||||||
|
const url = new URL(`/api/users/${userId}`, config.POCKET_ID_API_ORIGIN);
|
||||||
|
const res = yield* fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"X-API-KEY": config.POCKET_ID_API_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return yield* Effect.fail(NotFound.make());
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = yield* Effect.promise(() => res.json());
|
||||||
|
const displayName: string = json.displayName;
|
||||||
|
return displayName;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () {
|
||||||
|
const database = yield* Database.Database;
|
||||||
|
|
||||||
|
const { codeVerifier, codeChallenge } = generateCodeVerifier();
|
||||||
|
const state = generateRandomState();
|
||||||
|
|
||||||
|
yield* database
|
||||||
|
.updateTable("Session")
|
||||||
|
.set({
|
||||||
|
codeVerifier,
|
||||||
|
state,
|
||||||
|
accessToken: null,
|
||||||
|
idToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
})
|
||||||
|
.where("sessionId", "=", sessionId)
|
||||||
|
.$call(Database.execute);
|
||||||
|
|
||||||
|
const url = new URL(config.OAUTH_AUTHORIZATION_ENDPOINT);
|
||||||
|
url.searchParams.set("client_id", config.CLIENT_ID);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||||
|
url.searchParams.set("scope", OAUTH_SCOPE);
|
||||||
|
url.searchParams.set("response_mode", "form_post");
|
||||||
|
url.searchParams.set("state", state);
|
||||||
|
url.searchParams.set("code_challenge", codeChallenge);
|
||||||
|
url.searchParams.set("code_challenge_method", "S256");
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
namespace revaildateTokens {
|
||||||
|
export interface Args {
|
||||||
|
readonly accessToken: Option.Option<string>;
|
||||||
|
readonly idToken: Option.Option<string>;
|
||||||
|
readonly refreshToken: Option.Option<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
readonly accessToken: Option.Option<{
|
||||||
|
readonly token: string,
|
||||||
|
readonly payload: AccessTokenPayload,
|
||||||
|
}>;
|
||||||
|
readonly idToken: Option.Option<{
|
||||||
|
readonly token: string,
|
||||||
|
readonly payload: IdTokenPayload,
|
||||||
|
}>;
|
||||||
|
readonly refreshToken: Option.Option<string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revalidateTokens = ({ accessToken, idToken, refreshToken }: revaildateTokens.Args) => Effect.gen(function* () {
|
||||||
|
|
||||||
|
const accessTokenPayload = yield* pipe(
|
||||||
|
accessToken,
|
||||||
|
Effect.transposeMapOption(getJwtTokenPayload(AccessTokenPayload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const idTokenPayload = yield* pipe(
|
||||||
|
idToken,
|
||||||
|
Effect.transposeMapOption(getJwtTokenPayload(IdTokenPayload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const expirationThreshold = DateTime.addDuration(
|
||||||
|
DateTime.unsafeNow(),
|
||||||
|
EXPIRATION_BUFFER,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token expired or missing
|
||||||
|
if (Option.match(accessTokenPayload, {
|
||||||
|
onNone: constant(false),
|
||||||
|
onSome: (atp) => DateTime.greaterThan(expirationThreshold, atp.exp),
|
||||||
|
}) || Option.match(idTokenPayload, {
|
||||||
|
onNone: constant(false),
|
||||||
|
onSome: (itp) => DateTime.greaterThan(expirationThreshold, itp.exp),
|
||||||
|
})) {
|
||||||
|
|
||||||
|
accessToken = Option.none();
|
||||||
|
idToken = Option.none();
|
||||||
|
|
||||||
|
// try refreshing
|
||||||
|
if (Option.isSome(refreshToken)) {
|
||||||
|
const refreshTokenValue = refreshToken.value;
|
||||||
|
|
||||||
|
const res = yield* fetch(config.OAUTH_TOKEN_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
"client_id": config.CLIENT_ID,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refreshTokenValue,
|
||||||
|
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json: {
|
||||||
|
access_token: string,
|
||||||
|
refresh_token: string,
|
||||||
|
id_token: string,
|
||||||
|
} = yield* Effect.promise(() => res.json());
|
||||||
|
|
||||||
|
accessToken = Option.some(json.access_token);
|
||||||
|
idToken = Option.some(json.id_token);
|
||||||
|
refreshToken = Option.some(json.refresh_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const it = yield* pipe(
|
||||||
|
idToken,
|
||||||
|
Option.map((it) => pipe(
|
||||||
|
Effect.Do,
|
||||||
|
Effect.let("token", () => it),
|
||||||
|
Effect.bind("payload", ({ token }) => getJwtTokenPayload(IdTokenPayload)(token)),
|
||||||
|
)),
|
||||||
|
Effect.transposeOption,
|
||||||
|
Effect.map(Option.map((it) => Object.freeze(it))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const at = yield* pipe(
|
||||||
|
accessToken,
|
||||||
|
Option.map((at) => pipe(
|
||||||
|
Effect.Do,
|
||||||
|
Effect.let("token", () => at),
|
||||||
|
Effect.bind("payload", ({ token }) => getJwtTokenPayload(AccessTokenPayload)(token)),
|
||||||
|
)),
|
||||||
|
Effect.transposeOption,
|
||||||
|
Effect.map(Option.map((at) => Object.freeze(at))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
|
||||||
|
accessToken: at,
|
||||||
|
idToken: it,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
});
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Database as BunSqliteDatabase } from "bun:sqlite";
|
import { Database as BunSqliteDatabase } from "bun:sqlite";
|
||||||
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, UserId } from "common";
|
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common";
|
||||||
import { HashSet } from "effect";
|
import { Cause, Context, Effect, Either, HashSet, Layer, pipe, Runtime } from "effect";
|
||||||
import { ColumnType, CompiledQuery, CreateTableBuilder, Kysely, Selectable } from "kysely";
|
import { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, Transaction } from "kysely";
|
||||||
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
||||||
|
|
||||||
export function generateSessionId(byteLength: number = 32): SessionId {
|
// --- MARK: KYSELY SCHEMA -----------------------------------------------------
|
||||||
const array = new Uint8Array(byteLength);
|
|
||||||
crypto.getRandomValues(array);
|
|
||||||
const string = Buffer.from(array).toString("base64url");
|
|
||||||
return SessionId(string);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface Database {
|
export interface DatabaseSchema {
|
||||||
AccessLog: AccessLogTable;
|
|
||||||
Attachment: AttachmentTable;
|
Attachment: AttachmentTable;
|
||||||
File: FileTable;
|
File: FileTable;
|
||||||
Piece: PieceTable;
|
Piece: PieceTable;
|
||||||
@@ -30,18 +24,9 @@ export interface SystemInformation {
|
|||||||
modifiedAt: ColumnType<string | null, null, string>;
|
modifiedAt: ColumnType<string | null, null, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccessLogTable {
|
|
||||||
requestId: ColumnType<RequestId, RequestId, never>;
|
|
||||||
timestamp: ColumnType<string, string, never>;
|
|
||||||
method: ColumnType<string, string, never>;
|
|
||||||
pathname: ColumnType<string, string, never>;
|
|
||||||
query: ColumnType<string, string, never>;
|
|
||||||
ip: ColumnType<string | null, string | null, never>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AttachmentData {
|
export interface AttachmentData {
|
||||||
pieceId: ColumnType<PieceId, PieceId, never>;
|
pieceId: ColumnType<PieceId, PieceId, never>;
|
||||||
sha256: Sha256_Bin;
|
sha256: Sha256;
|
||||||
filename: string;
|
filename: string;
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
}
|
}
|
||||||
@@ -51,7 +36,7 @@ export interface AttachmentTable extends AttachmentData, SystemInformation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FileTable {
|
export interface FileTable {
|
||||||
sha256: ColumnType<Sha256_Bin, Sha256_Bin, never>;
|
sha256: ColumnType<Sha256, Sha256, never>;
|
||||||
data: ColumnType<Uint8Array, Uint8Array, never>;
|
data: ColumnType<Uint8Array, Uint8Array, never>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +76,6 @@ export interface SessionData {
|
|||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
idToken: string | null;
|
idToken: string | null;
|
||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
external: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionTable extends SessionData {
|
export interface SessionTable extends SessionData {
|
||||||
@@ -107,7 +91,6 @@ export interface SqliteSchemaTable {
|
|||||||
sql: ColumnType<string | null, never, never>;
|
sql: ColumnType<string | null, never, never>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccessLog = Selectable<AccessLogTable>;
|
|
||||||
export type Attachment = Selectable<AttachmentTable>;
|
export type Attachment = Selectable<AttachmentTable>;
|
||||||
export type File = Selectable<FileTable>;
|
export type File = Selectable<FileTable>;
|
||||||
export type Option = Selectable<OptionTable>;
|
export type Option = Selectable<OptionTable>;
|
||||||
@@ -117,65 +100,112 @@ export type RepertoireEntry = Selectable<RepertoireEntryTable>;
|
|||||||
export type Session = Selectable<SessionTable>;
|
export type Session = Selectable<SessionTable>;
|
||||||
export type SqliteSchema = Selectable<SqliteSchemaTable>;
|
export type SqliteSchema = Selectable<SqliteSchemaTable>;
|
||||||
|
|
||||||
function systemInformation<TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) {
|
// --- MARK: EFFECT LAYERS -----------------------------------------------------
|
||||||
return schema
|
|
||||||
|
export type ArrayOrSingle<T> = T | readonly T[];
|
||||||
|
|
||||||
|
export type MockData = {
|
||||||
|
readonly [K in keyof DatabaseSchema]?: ArrayOrSingle<Insertable<DatabaseSchema[K]>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Executable<O> {
|
||||||
|
execute(): Promise<O>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutableTakeFirst<O> {
|
||||||
|
executeTakeFirst(): Promise<O | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Database extends Context.Tag("Db")<Database, Kysely<DatabaseSchema>>() { }
|
||||||
|
|
||||||
|
export const FromPath = (path: string) => Layer.effect(Database, initDatabase(path));
|
||||||
|
|
||||||
|
export const Mocked = (data: MockData) => Layer.effect(Database, Effect.gen(function* () {
|
||||||
|
const database = yield* initDatabase(":memory:");
|
||||||
|
|
||||||
|
yield* transaction(database, (trx) => Effect.gen(function* () {
|
||||||
|
for (const [table, values] of Object.entries(data)) {
|
||||||
|
yield* trx
|
||||||
|
.insertInto(table as keyof typeof data)
|
||||||
|
.values(values)
|
||||||
|
.$call(execute);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return database;
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const execute = <O>(executable: Executable<O>) => Effect.promise(() => Object.freeze(executable.execute()));
|
||||||
|
|
||||||
|
export const executeTakeFirst = <O>(executable: ExecutableTakeFirst<O>) => pipe(
|
||||||
|
Effect.promise(() => executable.executeTakeFirst()),
|
||||||
|
Effect.flatMap(Effect.fromNullable),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const executeTakeFirstOrDie = <O>(executable: ExecutableTakeFirst<O>) => pipe(
|
||||||
|
Effect.promise(() => executable.executeTakeFirst()),
|
||||||
|
Effect.flatMap(Effect.fromNullable),
|
||||||
|
Effect.orDie,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const transaction = <Schema, A, E, R>(database: Kysely<Schema>, callback: (trx: Transaction<Schema>) => Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => Effect.gen(function* () {
|
||||||
|
const runtime = yield* Effect.runtime<R>();
|
||||||
|
const promise = database.transaction().execute((trx) => Runtime.runPromise(runtime, callback(trx)));
|
||||||
|
return yield* pipe(
|
||||||
|
Effect.tryPromise(() => promise),
|
||||||
|
Effect.catchAll((error) => {
|
||||||
|
if (!Runtime.isFiberFailure(error.error)) {
|
||||||
|
return Effect.die(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cause = error.error[Runtime.FiberFailureCauseId] as Cause.Cause<E>;
|
||||||
|
return Either.match(Cause.failureOrCause(cause), {
|
||||||
|
onLeft: Effect.fail,
|
||||||
|
onRight: Effect.die,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const initDatabase = (filename: string) => Effect.gen(function* () {
|
||||||
|
|
||||||
|
const systemInformation = <TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) => schema
|
||||||
.addColumn("createdBy", "text")
|
.addColumn("createdBy", "text")
|
||||||
.addColumn("createdAt", "text", (c) => c.notNull())
|
.addColumn("createdAt", "text", (c) => c.notNull())
|
||||||
.addColumn("modifiedBy", "text")
|
.addColumn("modifiedBy", "text")
|
||||||
.addColumn("modifiedAt", "text");
|
.addColumn("modifiedAt", "text");
|
||||||
}
|
|
||||||
|
|
||||||
export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kysely<Database>> {
|
|
||||||
|
|
||||||
const database = new BunSqliteDatabase(filename, { create: true, readwrite: true });
|
const database = new BunSqliteDatabase(filename, { create: true, readwrite: true });
|
||||||
const dialect = new BunSqliteDialect({ database });
|
const dialect = new BunSqliteDialect({ database });
|
||||||
const db = new Kysely<Database>({ dialect });
|
const db = new Kysely<DatabaseSchema>({ dialect });
|
||||||
|
|
||||||
await db.executeQuery(CompiledQuery.raw("PRAGMA foreign_keys = ON"));
|
yield* Effect.promise(() => db.executeQuery(CompiledQuery.raw("PRAGMA foreign_keys = ON")));
|
||||||
|
|
||||||
const tables = await db
|
const tables = yield* db
|
||||||
.selectFrom("sqlite_schema")
|
.selectFrom("sqlite_schema")
|
||||||
.select("name")
|
.select("name")
|
||||||
.where("type", "=", "table")
|
.where("type", "=", "table")
|
||||||
.execute()
|
.$call(execute)
|
||||||
.then((tables) => HashSet.make(...tables.map(({ name }) => name)));
|
.pipe(Effect.map((tables) => HashSet.make(...tables.map(({ name }) => name))));
|
||||||
|
|
||||||
if (!HashSet.has(tables, "Option")) {
|
if (!HashSet.has(tables, "Option")) {
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createTable("AccessLog")
|
|
||||||
.ifNotExists()
|
|
||||||
.addColumn("requestId", "text", (c) => c.notNull().primaryKey())
|
|
||||||
.addColumn("timestamp", "text", (c) => c.notNull())
|
|
||||||
.addColumn("method", "text", (c) => c.notNull())
|
|
||||||
.addColumn("pathname", "text", (c) => c.notNull())
|
|
||||||
.addColumn("query", "text", (c) => c.notNull())
|
|
||||||
.addColumn("ip", "text")
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex("AccessLog_timestamp")
|
|
||||||
.ifNotExists()
|
|
||||||
.on("AccessLog")
|
|
||||||
.column("timestamp")
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createTable("File")
|
.createTable("File")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.addColumn("sha256", "blob", (c) => c.notNull().primaryKey())
|
.addColumn("sha256", "blob", (c) => c.notNull().primaryKey())
|
||||||
.addColumn("data", "blob", (c) => c.notNull())
|
.addColumn("data", "blob", (c) => c.notNull())
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createTable("User")
|
.createTable("User")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
|
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
|
||||||
.addColumn("username", "text", (c) => c.notNull().unique())
|
.addColumn("username", "text", (c) => c.notNull().unique())
|
||||||
.addColumn("password", "text", (c) => c.notNull())
|
.addColumn("password", "text", (c) => c.notNull())
|
||||||
.addColumn("admin", "boolean", (c) => c.notNull())
|
.addColumn("admin", "boolean", (c) => c.notNull())
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createTable("Piece")
|
.createTable("Piece")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.addColumn("pieceId", "text", (c) => c.notNull().primaryKey())
|
.addColumn("pieceId", "text", (c) => c.notNull().primaryKey())
|
||||||
@@ -184,40 +214,40 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
|
|||||||
.addColumn("lyricist", "text")
|
.addColumn("lyricist", "text")
|
||||||
.addColumn("arranger", "text")
|
.addColumn("arranger", "text")
|
||||||
.$call(systemInformation)
|
.$call(systemInformation)
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createIndex("Piece_name_composer_arranger")
|
.createIndex("Piece_name_composer_arranger")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.on("Piece")
|
.on("Piece")
|
||||||
.columns(["name", "composer", "arranger"])
|
.columns(["name", "composer", "arranger"])
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createTable("Repertoire")
|
.createTable("Repertoire")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.addColumn("repertoireId", "text", (c) => c.notNull().primaryKey())
|
.addColumn("repertoireId", "text", (c) => c.notNull().primaryKey())
|
||||||
.addColumn("name", "text", (c) => c.notNull())
|
.addColumn("name", "text", (c) => c.notNull())
|
||||||
.$call(systemInformation)
|
.$call(systemInformation)
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createIndex("Repertoire_name")
|
.createIndex("Repertoire_name")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.on("Repertoire")
|
.on("Repertoire")
|
||||||
.column("name")
|
.column("name")
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createTable("RepertoireEntry")
|
.createTable("RepertoireEntry")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.addColumn("repertoireId", "text", (c) => c.notNull().references("Repertoire.repertoireId").onDelete("cascade").onUpdate("cascade"))
|
.addColumn("repertoireId", "text", (c) => c.notNull().references("Repertoire.repertoireId").onDelete("cascade").onUpdate("cascade"))
|
||||||
.addColumn("order", "integer", (c) => c.notNull())
|
.addColumn("order", "integer", (c) => c.notNull())
|
||||||
.addColumn("pieceId", "text", (c) => c.notNull().references("Piece.pieceId").onDelete("restrict").onUpdate("restrict"))
|
.addColumn("pieceId", "text", (c) => c.notNull().references("Piece.pieceId").onDelete("restrict").onUpdate("restrict"))
|
||||||
.addPrimaryKeyConstraint("pk_RepertoireEntry", ["repertoireId", "order"])
|
.addPrimaryKeyConstraint("pk_RepertoireEntry", ["repertoireId", "order"])
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createTable("Session")
|
.createTable("Session")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
|
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
|
||||||
@@ -226,11 +256,10 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
|
|||||||
.addColumn("accessToken", "text")
|
.addColumn("accessToken", "text")
|
||||||
.addColumn("idToken", "text")
|
.addColumn("idToken", "text")
|
||||||
.addColumn("refreshToken", "text")
|
.addColumn("refreshToken", "text")
|
||||||
.addColumn("external", "boolean")
|
|
||||||
.addColumn("expiresAt", "text", (c) => c.notNull())
|
.addColumn("expiresAt", "text", (c) => c.notNull())
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createTable("Attachment")
|
.createTable("Attachment")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.addColumn("attachmentId", "text", (c) => c.notNull().primaryKey())
|
.addColumn("attachmentId", "text", (c) => c.notNull().primaryKey())
|
||||||
@@ -239,27 +268,27 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
|
|||||||
.addColumn("filename", "text", (c) => c.notNull())
|
.addColumn("filename", "text", (c) => c.notNull())
|
||||||
.addColumn("mediaType", "text", (c) => c.notNull())
|
.addColumn("mediaType", "text", (c) => c.notNull())
|
||||||
.$call(systemInformation)
|
.$call(systemInformation)
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createIndex("Attachment_pieceId_filename")
|
.createIndex("Attachment_pieceId_filename")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.on("Attachment")
|
.on("Attachment")
|
||||||
.columns(["pieceId", "filename"])
|
.columns(["pieceId", "filename"])
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db.schema
|
yield* db.schema
|
||||||
.createTable("Option")
|
.createTable("Option")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.addColumn("key", "text", (c) => c.notNull().primaryKey())
|
.addColumn("key", "text", (c) => c.notNull().primaryKey())
|
||||||
.addColumn("value", "text", (c) => c.notNull())
|
.addColumn("value", "text", (c) => c.notNull())
|
||||||
.execute();
|
.$call(execute);
|
||||||
|
|
||||||
await db
|
yield* db
|
||||||
.insertInto("Option")
|
.insertInto("Option")
|
||||||
.values({ key: "database_version", value: "0" })
|
.values({ key: "database_version", value: "0" })
|
||||||
.execute();
|
.$call(execute);
|
||||||
}
|
}
|
||||||
|
|
||||||
return db;
|
return db;
|
||||||
}
|
});
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Cause, Context, Effect, Layer, pipe } from "effect";
|
|
||||||
import { Kysely } from "kysely";
|
|
||||||
import { Database } from "../database";
|
|
||||||
|
|
||||||
export interface Executable<O> {
|
|
||||||
execute(): Promise<O[]>;
|
|
||||||
executeTakeFirst(): Promise<O | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DbInterface {
|
|
||||||
readonly db: Kysely<Database>;
|
|
||||||
readonly execute: <O>(executable: Executable<O>) => Effect.Effect<readonly O[]>;
|
|
||||||
readonly executeTakeFirst: <O>(executable: Executable<O>) => Effect.Effect<O, Cause.NoSuchElementException>;
|
|
||||||
readonly executeTakeFirstOrDefect: <O>(executable: Executable<O>) => Effect.Effect<O>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Db extends Context.Tag("Db")<Db, DbInterface>() { }
|
|
||||||
|
|
||||||
export const DbFromInstance = (db: Kysely<Database>) => Layer.succeed(Db, Object.freeze<DbInterface>({
|
|
||||||
db,
|
|
||||||
execute: (executable) => Effect.promise(() => Object.freeze(executable.execute())),
|
|
||||||
executeTakeFirst: (executable) => pipe(
|
|
||||||
Effect.promise(() => executable.executeTakeFirst()),
|
|
||||||
Effect.flatMap(Effect.fromNullable),
|
|
||||||
),
|
|
||||||
executeTakeFirstOrDefect: (executable) => pipe(
|
|
||||||
Effect.promise(() => executable.executeTakeFirst()),
|
|
||||||
Effect.flatMap(Effect.fromNullable),
|
|
||||||
Effect.orDie,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { SessionId } from "common";
|
|
||||||
import { Context, Layer } from "effect";
|
|
||||||
|
|
||||||
export class Session extends Context.Tag("Session")<Session, SessionId>() { }
|
|
||||||
|
|
||||||
export const SessionFromValue = Layer.succeed(Session);
|
|
||||||
486
packages/backend/src/the_api.ts
Normal file
486
packages/backend/src/the_api.ts
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
import { AttachmentId, PieceId, RepertoireId, Sha256 } from "common";
|
||||||
|
import api, { NotFound, Role, Unauthorized } from "common/the_api";
|
||||||
|
import { DateTime, Effect, HashSet, Option, pipe } from "effect";
|
||||||
|
import { sql } from "kysely";
|
||||||
|
import { implement } from "./api";
|
||||||
|
import * as Authentication from "./services/Authentication";
|
||||||
|
import * as Database from "./services/Database";
|
||||||
|
|
||||||
|
const READ_ACCESS = HashSet.make(Role.Editor, Role.Viewer);
|
||||||
|
const WRITE_ACCESS = HashSet.make(Role.Editor);
|
||||||
|
|
||||||
|
const requireReadAccess = pipe(
|
||||||
|
Authentication.Authentication,
|
||||||
|
Effect.flatMap(({ me }) => me),
|
||||||
|
Effect.flatMap((user) => HashSet.isSubset(user.roles, READ_ACCESS)
|
||||||
|
? Effect.succeed(user)
|
||||||
|
: Effect.fail(Unauthorized.make())
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const requireWriteAccess = pipe(
|
||||||
|
Authentication.Authentication,
|
||||||
|
Effect.flatMap(({ me }) => me),
|
||||||
|
Effect.flatMap((user) => HashSet.isSubset(user.roles, WRITE_ACCESS)
|
||||||
|
? 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),
|
||||||
|
),
|
||||||
|
getUser: (userId) => pipe(
|
||||||
|
Authentication.Authentication,
|
||||||
|
Effect.flatMap(({ getUser }) => getUser(userId)),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- Piece CRUD ---
|
||||||
|
|
||||||
|
createPiece: (piece) => Effect.gen(function* () {
|
||||||
|
const { userId } = yield* requireWriteAccess;
|
||||||
|
const db = yield* Database.Database;
|
||||||
|
|
||||||
|
const res = yield* db
|
||||||
|
.insertInto("Piece")
|
||||||
|
.values({
|
||||||
|
pieceId: PieceId.make(Bun.randomUUIDv7()),
|
||||||
|
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* requireReadAccess;
|
||||||
|
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* requireReadAccess;
|
||||||
|
const db = yield* Database.Database;
|
||||||
|
|
||||||
|
let query = db
|
||||||
|
.selectFrom("Piece")
|
||||||
|
.select("pieceId")
|
||||||
|
.orderBy(["name", "composer", "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* requireWriteAccess;
|
||||||
|
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* requireWriteAccess;
|
||||||
|
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* requireWriteAccess;
|
||||||
|
const db = yield* Database.Database;
|
||||||
|
|
||||||
|
const sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
|
||||||
|
Bun.SHA256.hash(attachment.data, sha256);
|
||||||
|
|
||||||
|
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(Bun.randomUUIDv7()),
|
||||||
|
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* requireReadAccess;
|
||||||
|
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* requireWriteAccess;
|
||||||
|
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* requireWriteAccess;
|
||||||
|
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* requireWriteAccess;
|
||||||
|
const db = yield* Database.Database;
|
||||||
|
|
||||||
|
const repertoireId = RepertoireId.make(Bun.randomUUIDv7());
|
||||||
|
|
||||||
|
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* requireReadAccess;
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
queryRepertoire: ({ name, offset, limit }) => Effect.gen(function* () {
|
||||||
|
yield* requireReadAccess;
|
||||||
|
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* requireWriteAccess;
|
||||||
|
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* requireWriteAccess;
|
||||||
|
const db = yield* Database.Database;
|
||||||
|
|
||||||
|
yield* db
|
||||||
|
.deleteFrom("Repertoire")
|
||||||
|
.where("repertoireId", "=", repertoireId)
|
||||||
|
.returning("repertoireId")
|
||||||
|
.$call(Database.executeTakeFirst)
|
||||||
|
.pipe(Effect.mapError(() => NotFound.make()));
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -7,10 +7,12 @@
|
|||||||
".": { "import": "./src/index.ts" },
|
".": { "import": "./src/index.ts" },
|
||||||
"./*": { "import": "./src/*.ts" }
|
"./*": { "import": "./src/*.ts" }
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"effect": "catalog:"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bun": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cbor2": "catalog:",
|
||||||
|
"effect": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
packages/common/src/Api.test.ts
Normal file
67
packages/common/src/Api.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/// <reference types="bun" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { HashMap, Schema } from "effect";
|
||||||
|
import * as Api from "./Api";
|
||||||
|
|
||||||
|
describe("bundle", () => {
|
||||||
|
test("constructs a HashMap", () => {
|
||||||
|
const foo = Api.make(Schema.Void, Schema.String, Schema.String);
|
||||||
|
const bar = Api.make(Schema.Void, Schema.Number, Schema.String);
|
||||||
|
const bundle = Api.bundle({ foo, bar });
|
||||||
|
|
||||||
|
expect(HashMap.unsafeGet(bundle.map, "foo")).toBe(foo);
|
||||||
|
expect(HashMap.unsafeGet(bundle.map, "bar")).toBe(bar);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("constructs a record", () => {
|
||||||
|
const foo = Api.make(Schema.Void, Schema.String, Schema.String);
|
||||||
|
const bar = Api.make(Schema.Void, Schema.Number, Schema.String);
|
||||||
|
const bundle = Api.bundle({ foo, bar });
|
||||||
|
|
||||||
|
expect(bundle.record.foo).toBe(foo);
|
||||||
|
expect(bundle.record.bar).toBe(bar);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("freezes the object", () => {
|
||||||
|
const bundle = Api.bundle({
|
||||||
|
foo: Api.make(Schema.Void, Schema.String, Schema.String),
|
||||||
|
bar: Api.make(Schema.Void, Schema.Number, Schema.String),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.isFrozen(bundle)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("freezes the record", () => {
|
||||||
|
const bundle = Api.bundle({
|
||||||
|
foo: Api.make(Schema.Void, Schema.String, Schema.String),
|
||||||
|
bar: Api.make(Schema.Void, Schema.Number, Schema.String),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.isFrozen(bundle.record)).toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("make", () => {
|
||||||
|
test("constructs an object", () => {
|
||||||
|
const request = Schema.String.annotations({ title: "request" });
|
||||||
|
const response = Schema.String.annotations({ title: "response" });
|
||||||
|
const error = Schema.String.annotations({ title: "error" });
|
||||||
|
|
||||||
|
const api = Api.make(request, response, error);
|
||||||
|
|
||||||
|
expect(api.request).toBe(request);
|
||||||
|
expect(api.response).toBe(response);
|
||||||
|
expect(api.error).toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("freezes the object", () => {
|
||||||
|
const request = Schema.String.annotations({ title: "request" });
|
||||||
|
const response = Schema.String.annotations({ title: "response" });
|
||||||
|
const error = Schema.String.annotations({ title: "error" });
|
||||||
|
|
||||||
|
const api = Api.make(request, response, error);
|
||||||
|
|
||||||
|
expect(Object.isFrozen(api)).toBeTrue();
|
||||||
|
})
|
||||||
|
});
|
||||||
59
packages/common/src/Api.ts
Normal file
59
packages/common/src/Api.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Data, Effect, HashMap, ParseResult, Schema } from "effect";
|
||||||
|
|
||||||
|
export class InternalServerError extends Data.Error<{ data: unknown }> { }
|
||||||
|
export class RequestParseError extends Data.Error<{ cause: ParseResult.ParseError }> { }
|
||||||
|
export class ResponseParseError extends Data.Error<{ cause: ParseResult.ParseError }> { }
|
||||||
|
export class UnexpectedResponseContentType extends Data.Error<{ contentType: string }> { }
|
||||||
|
export class UnexpectedResponseStatus extends Data.Error<{ status: number, statusText: string }> { }
|
||||||
|
|
||||||
|
export interface Api<
|
||||||
|
RequestSchema extends Schema.Schema.AnyNoContext,
|
||||||
|
ResponseSchema extends Schema.Schema.AnyNoContext,
|
||||||
|
ErrorSchema extends Schema.Schema.AnyNoContext,
|
||||||
|
> {
|
||||||
|
readonly request: RequestSchema;
|
||||||
|
readonly response: ResponseSchema;
|
||||||
|
readonly error: ErrorSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiAny = Api<
|
||||||
|
Schema.Schema.AnyNoContext,
|
||||||
|
Schema.Schema.AnyNoContext,
|
||||||
|
Schema.Schema.AnyNoContext
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ApiBundle<
|
||||||
|
Record extends { readonly [_: string]: ApiAny },
|
||||||
|
> = {
|
||||||
|
readonly map: HashMap.HashMap<string, ApiAny>;
|
||||||
|
readonly record: Record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiBundleAny = ApiBundle<{ readonly [_: string]: ApiAny }>;
|
||||||
|
|
||||||
|
export type ApiBundleImpl<
|
||||||
|
Record extends { readonly [_: string]: ApiAny },
|
||||||
|
> = {
|
||||||
|
readonly [K in keyof Record]: (request: Record[K]["request"]["Type"]) => Effect.Effect<Record[K]["response"]["Type"], Record[K]["error"]["Type"], any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundle<
|
||||||
|
const Record extends { readonly [_: string]: ApiAny; }
|
||||||
|
>(record: Record): ApiBundle<Record> {
|
||||||
|
return Object.freeze({
|
||||||
|
map: HashMap.fromIterable(Object.entries(record)),
|
||||||
|
record: Object.freeze(Object.assign({}, record)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function make<
|
||||||
|
RequestSchema extends Schema.Schema.AnyNoContext = typeof Schema.Void,
|
||||||
|
ResponseSchema extends Schema.Schema.AnyNoContext = typeof Schema.Void,
|
||||||
|
ErrorSchema extends Schema.Schema.AnyNoContext = typeof Schema.Void,
|
||||||
|
>(
|
||||||
|
request: RequestSchema = Schema.Void as unknown as RequestSchema,
|
||||||
|
response: ResponseSchema = Schema.Void as unknown as ResponseSchema,
|
||||||
|
error: ErrorSchema = Schema.Void as unknown as ErrorSchema,
|
||||||
|
): Api<RequestSchema, ResponseSchema, ErrorSchema> {
|
||||||
|
return Object.freeze({ request, response, error });
|
||||||
|
};
|
||||||
92
packages/common/src/Body.test.ts
Normal file
92
packages/common/src/Body.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/// <reference types="bun" />
|
||||||
|
|
||||||
|
import { describe, test } from "bun:test";
|
||||||
|
import { Cause, Effect } from "effect";
|
||||||
|
import * as Body from "./Body";
|
||||||
|
import * as Test from "./Test";
|
||||||
|
|
||||||
|
describe("arrayBuffer", () => {
|
||||||
|
test("succeeds", async () => {
|
||||||
|
const buffer = new ArrayBuffer(4);
|
||||||
|
const body = { arrayBuffer: () => Promise.resolve(buffer) };
|
||||||
|
|
||||||
|
const effect = Body.arrayBuffer(body);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectSuccess(exit, buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails", async () => {
|
||||||
|
const error = new RangeError();
|
||||||
|
const body = { arrayBuffer: () => Promise.reject(error) };
|
||||||
|
|
||||||
|
const effect = Body.arrayBuffer(body);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectFailure(exit, Cause.fail(new Body.BodyError({ cause: error })));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dies", async () => {
|
||||||
|
const error = new Error();
|
||||||
|
const body = { arrayBuffer: () => Promise.reject(error) };
|
||||||
|
|
||||||
|
const effect = Body.arrayBuffer(body);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectFailure(exit, Cause.die(error));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("blob", () => {
|
||||||
|
test("succeeds", async () => {
|
||||||
|
const blob = new Blob(["foo"]);
|
||||||
|
const body = { blob: () => Promise.resolve(blob) };
|
||||||
|
|
||||||
|
const effect = Body.blob(body);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectSuccess(exit, blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dies", async () => {
|
||||||
|
const error = new Error();
|
||||||
|
const body = { blob: () => Promise.reject(error) };
|
||||||
|
|
||||||
|
const effect = Body.blob(body);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectFailure(exit, Cause.die(error));
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("body", () => {
|
||||||
|
test.todo("succeeds with none");
|
||||||
|
test.todo("succeeds with some");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bytes", () => {
|
||||||
|
test.todo("succeeds");
|
||||||
|
test.todo("fails");
|
||||||
|
test.todo("dies");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formData", () => {
|
||||||
|
test.todo("succeeds");
|
||||||
|
test.todo("fails");
|
||||||
|
test.todo("dies");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("json", () => {
|
||||||
|
test.todo("succeeds");
|
||||||
|
test.todo("fails");
|
||||||
|
test.todo("dies");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stream", () => {
|
||||||
|
test.todo("succeeds");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("json", () => {
|
||||||
|
test.todo("succeeds");
|
||||||
|
test.todo("dies");
|
||||||
|
});
|
||||||
70
packages/common/src/Body.ts
Normal file
70
packages/common/src/Body.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Data, Effect, Option, pipe, Stream } from "effect";
|
||||||
|
|
||||||
|
export class BodyError extends Data.TaggedClass("BodyError")<{ cause: RangeError | TypeError | SyntaxError }> { }
|
||||||
|
export class StreamError extends Data.TaggedClass("StreamError")<{ cause: unknown }> { }
|
||||||
|
|
||||||
|
export function arrayBuffer(body: { arrayBuffer(): Promise<ArrayBuffer> }): Effect.Effect<ArrayBuffer, BodyError> {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => body.arrayBuffer(),
|
||||||
|
catch: (cause) => {
|
||||||
|
if (cause instanceof RangeError) return new BodyError({ cause });
|
||||||
|
else throw cause;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blob(body: { blob(): Promise<Blob> }): Effect.Effect<Blob> {
|
||||||
|
return Effect.promise(() => body.blob());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function body(body: { readonly body: ReadableStream<Uint8Array> | null; }): Option.Option<Stream.Stream<Uint8Array, StreamError>> {
|
||||||
|
return pipe(
|
||||||
|
body.body,
|
||||||
|
Option.fromNullable,
|
||||||
|
Option.map((stream) => Stream.fromReadableStream({
|
||||||
|
evaluate: () => stream,
|
||||||
|
onError: (cause) => new StreamError({ cause }),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytes(body: { bytes(): Promise<Uint8Array> }): Effect.Effect<Uint8Array, BodyError> {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => body.bytes(),
|
||||||
|
catch: (cause) => {
|
||||||
|
if (cause instanceof RangeError) return new BodyError({ cause });
|
||||||
|
else throw cause;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formData(body: { formData(): Promise<FormData> }): Effect.Effect<FormData, BodyError> {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => body.formData(),
|
||||||
|
catch: (cause) => {
|
||||||
|
if (cause instanceof TypeError) return new BodyError({ cause });
|
||||||
|
else throw cause;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function json(body: { json(): Promise<unknown> }): Effect.Effect<unknown, BodyError> {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => body.json(),
|
||||||
|
catch: (cause) => {
|
||||||
|
if (cause instanceof SyntaxError) return new BodyError({ cause });
|
||||||
|
else throw cause;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stream(body: { stream(): ReadableStream<Uint8Array> }): Stream.Stream<Uint8Array, StreamError> {
|
||||||
|
return Stream.fromReadableStream({
|
||||||
|
evaluate: () => body.stream(),
|
||||||
|
onError: (cause) => new StreamError({ cause }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function text(body: { text(): Promise<string> }): Effect.Effect<string> {
|
||||||
|
return Effect.promise(() => body.text());
|
||||||
|
}
|
||||||
117
packages/common/src/Cbor.test.ts
Normal file
117
packages/common/src/Cbor.test.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/// <reference types="bun" />
|
||||||
|
|
||||||
|
import { describe, test } from "bun:test";
|
||||||
|
import * as cbor from "cbor2";
|
||||||
|
import { Effect, Schema } from "effect";
|
||||||
|
import * as Cbor from "./Cbor";
|
||||||
|
import * as Test from "./Test";
|
||||||
|
|
||||||
|
describe("encode", () => {
|
||||||
|
test("succeeds", async () => {
|
||||||
|
const object = {
|
||||||
|
x: "foo",
|
||||||
|
y: new Date(2025, 0, 1),
|
||||||
|
z: new Uint8Array([0, 1, 2, 3]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const effect = Cbor.encode(object);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectSuccess(exit, cbor.encode(object));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails", async () => {
|
||||||
|
const object = {
|
||||||
|
x: Symbol(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const effect = Cbor.encode(object);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectFailureTag(exit, "CborEncodeError");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decode", () => {
|
||||||
|
test("succeeds", async () => {
|
||||||
|
const object = {
|
||||||
|
x: "foo",
|
||||||
|
y: new Date(2025, 0, 1),
|
||||||
|
z: new Uint8Array([0, 1, 2, 3]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const effect = Cbor.decode(cbor.encode(object));
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectSuccess(exit, object);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails", async () => {
|
||||||
|
const array = new Uint8Array([0xFE]);
|
||||||
|
|
||||||
|
const effect = Cbor.decode(array);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectFailureTag(exit, "CborDecodeError");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("encodeSchema", () => {
|
||||||
|
test("succeeds", async () => {
|
||||||
|
const schema = Schema.Struct({
|
||||||
|
x: Schema.String,
|
||||||
|
y: Schema.DateFromSelf,
|
||||||
|
z: Schema.Uint8ArrayFromSelf,
|
||||||
|
});
|
||||||
|
|
||||||
|
const object = schema.make({
|
||||||
|
x: "foo",
|
||||||
|
y: new Date(2025, 0, 1),
|
||||||
|
z: new Uint8Array([0, 1, 2, 3]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const effect = Cbor.encodeSchema(schema)(object);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectSuccess(exit, cbor.encode(object));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.todo("fails");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeSchema", () => {
|
||||||
|
test("succeeds", async () => {
|
||||||
|
const schema = Schema.Struct({
|
||||||
|
x: Schema.String,
|
||||||
|
y: Schema.DateFromSelf,
|
||||||
|
z: Schema.Uint8ArrayFromSelf,
|
||||||
|
});
|
||||||
|
|
||||||
|
const object = schema.make({
|
||||||
|
x: "foo",
|
||||||
|
y: new Date(2025, 0, 1),
|
||||||
|
z: new Uint8Array([0, 1, 2, 3]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const effect = Cbor.decodeSchema(schema)(cbor.encode(object));
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectSuccess(exit, object);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails with CborDecodeError", async () => {
|
||||||
|
const array = new Uint8Array([0xFE]);
|
||||||
|
|
||||||
|
const effect = Cbor.decodeSchema(Schema.Any)(array);
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectFailureTag(exit, "CborDecodeError");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails with ParseError", async () => {
|
||||||
|
const effect = Cbor.decodeSchema(Schema.Number)(cbor.encode("foo"));
|
||||||
|
const exit = await Effect.runPromiseExit(effect);
|
||||||
|
|
||||||
|
Test.expectFailureTag(exit, "ParseError");
|
||||||
|
});
|
||||||
|
});
|
||||||
35
packages/common/src/Cbor.ts
Normal file
35
packages/common/src/Cbor.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as cbor from "cbor2";
|
||||||
|
import { Data, Effect, pipe, Schema } from "effect";
|
||||||
|
|
||||||
|
export class CborDecodeError extends Data.TaggedError("CborDecodeError")<{ cause: unknown }> { }
|
||||||
|
export class CborEncodeError extends Data.TaggedError("CborEncodeError")<{ cause: unknown }> { }
|
||||||
|
|
||||||
|
export function encode(u: unknown): Effect.Effect<Uint8Array, CborEncodeError> {
|
||||||
|
return Effect.try({
|
||||||
|
try: () => cbor.encode(u),
|
||||||
|
catch: (cause) => new CborEncodeError({ cause }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decode(u: Uint8Array): Effect.Effect<unknown, CborDecodeError> {
|
||||||
|
return Effect.try({
|
||||||
|
try: () => cbor.decode(u),
|
||||||
|
catch: (cause) => new CborDecodeError({ cause }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encodeSchema = <A, I>(schema: Schema.Schema<A, I>) => {
|
||||||
|
const schemaEncoder = Schema.encode(schema);
|
||||||
|
return (a: A) => pipe(
|
||||||
|
schemaEncoder(a),
|
||||||
|
Effect.flatMap((i) => encode(i)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeSchema = <A>(schema: Schema.Schema<A, any>) => {
|
||||||
|
const schemaDecoder = Schema.decodeUnknown(schema);
|
||||||
|
return (u: Uint8Array) => pipe(
|
||||||
|
decode(u),
|
||||||
|
Effect.flatMap((u) => schemaDecoder(u)),
|
||||||
|
);
|
||||||
|
};
|
||||||
109
packages/common/src/Client.ts
Normal file
109
packages/common/src/Client.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Data, Effect, Option, pipe, Record, Schema } from "effect";
|
||||||
|
import { constant } from "effect/Function";
|
||||||
|
import * as Api from "./Api";
|
||||||
|
import * as Body from "./Body";
|
||||||
|
import * as Cbor from "./Cbor";
|
||||||
|
import { fetch, FetchError } from "./Fetch";
|
||||||
|
|
||||||
|
export class ServerDie extends Data.TaggedError("ServerDie")<{ cause: unknown }> { }
|
||||||
|
export class UnexpectedStatus extends Data.TaggedError("UnexpectedStatusCode")<{ status: number }> { }
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
readonly baseUrl?: string | URL | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const client = <const Record extends { readonly [_: string]: Api.ApiAny }>(
|
||||||
|
bundle: Api.ApiBundle<Record>,
|
||||||
|
{ baseUrl }: ClientOptions = {},
|
||||||
|
) => {
|
||||||
|
return Object.freeze(Record.map(bundle.record, (api, key) => {
|
||||||
|
const url = new URL(`/api/${key}`, baseUrl);
|
||||||
|
|
||||||
|
const requestEncoder = encodeBody(api.request);
|
||||||
|
const responseDecoder = decodeBody(api.response);
|
||||||
|
const errorDecoder = decodeBody(api.error);
|
||||||
|
|
||||||
|
return Effect.fn(`call.${key}`)(function* (request: any) {
|
||||||
|
|
||||||
|
const body = yield* pipe(
|
||||||
|
request,
|
||||||
|
requestEncoder,
|
||||||
|
Effect.flatMap(({ body, headers }) => fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
})),
|
||||||
|
Effect.catchTags({
|
||||||
|
CborEncodeError: Effect.die,
|
||||||
|
ParseError: Effect.die,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (body.status) {
|
||||||
|
case 200: {
|
||||||
|
const result = yield* responseDecoder(body).pipe(Effect.orDie);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 400: {
|
||||||
|
const result = yield* errorDecoder(body).pipe(Effect.orDie);
|
||||||
|
return yield* Effect.fail(result);
|
||||||
|
}
|
||||||
|
case 500: {
|
||||||
|
const result = yield* readBody(body).pipe(Effect.orDie);
|
||||||
|
return yield* Effect.die(new ServerDie({ cause: result }));
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return yield* Effect.die(new UnexpectedStatus({ status: body.status }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})) as { readonly [K in keyof Record]: (request: Record[K]["request"]["Type"]) => Effect.Effect<Record[K]["response"]["Type"], Record[K]["error"]["Type"] | FetchError, never> };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readBody = (body: Body) => pipe(
|
||||||
|
body,
|
||||||
|
Body.bytes,
|
||||||
|
Effect.map((bytes) => bytes.byteLength > 0 ? Option.some(bytes) : Option.none()),
|
||||||
|
Effect.flatMap((maybeBytes) => pipe(
|
||||||
|
maybeBytes,
|
||||||
|
Effect.transposeMapOption(Cbor.decode),
|
||||||
|
)),
|
||||||
|
Effect.map(Option.getOrUndefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const decodeBody = <A>(schema: Schema.Schema<A, any>) => {
|
||||||
|
const decoder = Schema.decodeUnknown(schema);
|
||||||
|
return (body: Body) => Effect.flatMap(readBody(body), decoder);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BodyData {
|
||||||
|
readonly body: null | Uint8Array;
|
||||||
|
readonly headers: { readonly [_: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encodeBody = <A>(schema: Schema.Schema<A, any>) => {
|
||||||
|
const encoder = Cbor.encodeSchema(schema);
|
||||||
|
return (a: A) => pipe(
|
||||||
|
a,
|
||||||
|
Option.fromNullable,
|
||||||
|
Effect.transposeMapOption(encoder),
|
||||||
|
Effect.map(Option.match({
|
||||||
|
onNone: emptyBody,
|
||||||
|
onSome: cborBody,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyBody: () => BodyData = constant(Object.freeze<BodyData>({
|
||||||
|
body: null,
|
||||||
|
headers: Object.freeze({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const cborBody: (bytes: Uint8Array) => BodyData = (bytes) => Object.freeze<BodyData>({
|
||||||
|
body: bytes,
|
||||||
|
headers: Object.freeze({
|
||||||
|
"Content-Type": "application/cbor",
|
||||||
|
"Content-Length": String(bytes.byteLength),
|
||||||
|
}),
|
||||||
|
});
|
||||||
15
packages/common/src/Fetch.ts
Normal file
15
packages/common/src/Fetch.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Data, Effect } from "effect";
|
||||||
|
|
||||||
|
export class FetchError extends Data.TaggedError("FetchError")<{ cause: TypeError }> { }
|
||||||
|
|
||||||
|
const _fetch = (input: string | URL, init?: RequestInit) => Effect.tryPromise({
|
||||||
|
try: (signal) => fetch(input, { ...init, signal }),
|
||||||
|
catch: (cause) => {
|
||||||
|
if (cause instanceof TypeError) return new FetchError({ cause });
|
||||||
|
else throw cause;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export {
|
||||||
|
_fetch as fetch,
|
||||||
|
};
|
||||||
40
packages/common/src/Test.ts
Normal file
40
packages/common/src/Test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/// <reference types="bun" />
|
||||||
|
|
||||||
|
import { expect } from "bun:test";
|
||||||
|
import { Cause, Exit, Predicate } from "effect";
|
||||||
|
|
||||||
|
export function expectSuccess<A, E>(
|
||||||
|
actual: Exit.Exit<A, E>,
|
||||||
|
expected: A,
|
||||||
|
): asserts actual is Exit.Success<A, never> {
|
||||||
|
expect<Exit.Exit<A, E>>(Exit.succeed(expected)).toStrictEqual(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectFailure<A, E>(
|
||||||
|
actual: Exit.Exit<A, E>,
|
||||||
|
expected: Cause.Cause<E>,
|
||||||
|
): asserts actual is Exit.Failure<never, E> {
|
||||||
|
expect<Exit.Exit<A, E>>(Exit.failCause(expected)).toStrictEqual(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectFailureTag<A, E, Tag extends string>(
|
||||||
|
actual: Exit.Exit<A, E>,
|
||||||
|
tag: Tag,
|
||||||
|
): asserts actual is Exit.Failure<never, E & { readonly _tag: Tag }> {
|
||||||
|
if (!Exit.isFailure(actual)) {
|
||||||
|
expect(actual).fail("Expected Exit to be a Failure");
|
||||||
|
throw new Error("Unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cause } = actual;
|
||||||
|
if (!Cause.isFailType(cause)) {
|
||||||
|
expect(cause).fail("Expected Cause to be a Fail");
|
||||||
|
throw new Error("Unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = cause;
|
||||||
|
if (!Predicate.isTagged(tag)) {
|
||||||
|
expect(error).fail(`Expected error to be tagged with ${JSON.stringify(tag)}`);
|
||||||
|
throw new Error("Unreachable");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,26 @@
|
|||||||
import { Brand } from "effect";
|
import { pipe, Schema } from "effect";
|
||||||
|
|
||||||
export type UUID = Brand.Branded<string, "UUID">;
|
export const Sha256 = pipe(
|
||||||
export const UUID = Brand.nominal<UUID>();
|
Schema.Uint8ArrayFromSelf,
|
||||||
|
Schema.filter((arr) => arr.byteLength === 32),
|
||||||
|
Schema.brand("Sha256"),
|
||||||
|
);
|
||||||
|
export type Sha256 = typeof Sha256.Type;
|
||||||
|
|
||||||
export type Sha256_Bin = Brand.Branded<Uint8Array, "Sha256">;
|
export const SessionId = pipe(Schema.String, Schema.brand("SessionId"));
|
||||||
export const Sha256_Bin = Brand.nominal<Sha256_Bin>();
|
export type SessionId = typeof SessionId.Type;
|
||||||
|
|
||||||
export type Sha256_Hex = Brand.Branded<string, "Sha256">;
|
export const AttachmentId = pipe(Schema.UUID, Schema.brand("AttachmentId"));
|
||||||
export const Sha256_Hex = Brand.nominal<Sha256_Hex>();
|
export type AttachmentId = typeof AttachmentId.Type;
|
||||||
|
|
||||||
export type AttachmentId = Brand.Branded<UUID, "AttachmentId">;
|
export const PieceId = pipe(Schema.UUID, Schema.brand("PieceId"));
|
||||||
export const AttachmentId = Brand.nominal<AttachmentId>();
|
export type PieceId = typeof PieceId.Type;
|
||||||
|
|
||||||
export type PieceId = Brand.Branded<UUID, "PieceId">;
|
export const RepertoireId = pipe(Schema.UUID, Schema.brand("RepertoireId"));
|
||||||
export const PieceId = Brand.nominal<PieceId>();
|
export type RepertoireId = typeof RepertoireId.Type;
|
||||||
|
|
||||||
export type RepertoireId = Brand.Branded<UUID, "RepertoireId">;
|
export const RequestId = pipe(Schema.UUID, Schema.brand("RequestId"));
|
||||||
export const RepertoireId = Brand.nominal<RepertoireId>();
|
export type RequestId = typeof RequestId.Type;
|
||||||
|
|
||||||
export type RequestId = Brand.Branded<UUID, "RequestId">;
|
export const UserId = pipe(Schema.UUID, Schema.brand("UserId"));
|
||||||
export const RequestId = Brand.nominal<RequestId>();
|
export type UserId = typeof UserId.Type;
|
||||||
|
|
||||||
export type SessionId = Brand.Branded<string, "SessionId">;
|
|
||||||
export const SessionId = Brand.nominal<SessionId>();
|
|
||||||
|
|
||||||
export type UserId = Brand.Branded<UUID, "UserId">;
|
|
||||||
export const UserId = Brand.nominal<UserId>();
|
|
||||||
|
|||||||
206
packages/common/src/the_api.ts
Normal file
206
packages/common/src/the_api.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { pipe, Schema } from "effect";
|
||||||
|
import * as Api from "./Api";
|
||||||
|
import { AttachmentId, PieceId, RepertoireId, Sha256, UserId } from "common";
|
||||||
|
import { constant } from "effect/Function";
|
||||||
|
|
||||||
|
// --- MARK: COMMON TYPES ------------------------------------------------------
|
||||||
|
|
||||||
|
export enum Role {
|
||||||
|
Viewer = "Viewer",
|
||||||
|
Editor = "Editor",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SystemInformation = Schema.Struct({
|
||||||
|
createdBy: pipe(UserId, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
createdAt: Schema.DateTimeUtc,
|
||||||
|
modifiedBy: pipe(UserId, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
modifiedAt: pipe(Schema.DateTimeUtc, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Pagination = Schema.Struct({
|
||||||
|
offset: pipe(Schema.Int, Schema.greaterThanOrEqualTo(0), Schema.optionalWith({ default: constant(0), exact: true })),
|
||||||
|
limit: pipe(Schema.Int, Schema.between(1, 100), Schema.optionalWith({ default: constant(100), exact: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SystemInformation = typeof SystemInformation.Type;
|
||||||
|
export type Pagination = typeof Pagination.Type;
|
||||||
|
|
||||||
|
// --- MARK: RESPONSE TYPES ----------------------------------------------------
|
||||||
|
|
||||||
|
export const Me = Schema.Struct({
|
||||||
|
userId: UserId,
|
||||||
|
displayName: Schema.NonEmptyString,
|
||||||
|
roles: Schema.HashSet(Schema.Enums(Role)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Other = Schema.Struct({
|
||||||
|
userId: UserId,
|
||||||
|
displayName: Schema.NonEmptyString,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Attachment = Schema.Struct({
|
||||||
|
attachmentId: AttachmentId,
|
||||||
|
pieceId: PieceId,
|
||||||
|
filename: Schema.NonEmptyString,
|
||||||
|
sha256: Sha256,
|
||||||
|
mediaType: Schema.NonEmptyString,
|
||||||
|
}).pipe(Schema.extend(SystemInformation));
|
||||||
|
|
||||||
|
export const Piece = Schema.Struct({
|
||||||
|
pieceId: PieceId,
|
||||||
|
name: Schema.NonEmptyString,
|
||||||
|
composer: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
lyricist: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
arranger: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
attachments: pipe(Attachment, Schema.Array),
|
||||||
|
}).pipe(Schema.extend(SystemInformation));
|
||||||
|
|
||||||
|
export const Piece_Create = Schema.Struct({
|
||||||
|
name: Schema.NonEmptyString,
|
||||||
|
composer: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
lyricist: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
arranger: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Piece_Query = Schema.Struct({
|
||||||
|
name: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
author: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
}).pipe(Schema.extend(Pagination));
|
||||||
|
|
||||||
|
export const Repertoire = Schema.Struct({
|
||||||
|
repertoireId: RepertoireId,
|
||||||
|
name: Schema.NonEmptyString,
|
||||||
|
entries: pipe(PieceId, Schema.Array),
|
||||||
|
}).pipe(Schema.extend(SystemInformation));
|
||||||
|
|
||||||
|
export const Repertoire_Create = Schema.Struct({
|
||||||
|
name: Schema.NonEmptyString,
|
||||||
|
entries: pipe(PieceId, Schema.Array),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Repertoire_Query = Schema.Struct({
|
||||||
|
name: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
}).pipe(Schema.extend(Pagination));
|
||||||
|
|
||||||
|
export type Me = typeof Me.Type;
|
||||||
|
export type Other = typeof Other.Type;
|
||||||
|
export type Attachment = typeof Attachment.Type;
|
||||||
|
export type Piece = typeof Piece.Type;
|
||||||
|
export type Piece_Create = typeof Piece_Create.Type;
|
||||||
|
export type Piece_Query = typeof Piece_Query.Type;
|
||||||
|
export type Repertoire = typeof Repertoire.Type;
|
||||||
|
export type Repertoire_Query = typeof Repertoire_Query.Type;
|
||||||
|
|
||||||
|
// --- MARK: ERROR TYPES -------------------------------------------------------
|
||||||
|
|
||||||
|
export const Unauthenticated = Schema.TaggedStruct("Unauthenticated", {});
|
||||||
|
export const Unauthorized = Schema.TaggedStruct("Unauthorized", {});
|
||||||
|
export const NotFound = Schema.TaggedStruct("NotFound", {});
|
||||||
|
|
||||||
|
export type Unauthenticated = typeof Unauthenticated.Type;
|
||||||
|
export type Unauthorized = typeof Unauthorized.Type;
|
||||||
|
export type NotFound = typeof NotFound.Type;
|
||||||
|
|
||||||
|
export default Api.bundle({
|
||||||
|
|
||||||
|
// --- Authentication ---
|
||||||
|
|
||||||
|
me: Api.make(Schema.Void, Me, Unauthenticated),
|
||||||
|
logout: Api.make(Schema.Void, Schema.Void),
|
||||||
|
getUser: Api.make(
|
||||||
|
UserId,
|
||||||
|
Other,
|
||||||
|
Schema.Union(Unauthenticated, NotFound),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- Piece CRUD ---
|
||||||
|
|
||||||
|
createPiece: Api.make(
|
||||||
|
Piece_Create,
|
||||||
|
Piece,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized),
|
||||||
|
),
|
||||||
|
getPiece: Api.make(
|
||||||
|
PieceId,
|
||||||
|
Piece,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
queryPieces: Api.make(
|
||||||
|
Piece_Query,
|
||||||
|
pipe(PieceId, Schema.Array),
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized),
|
||||||
|
),
|
||||||
|
updatePiece: Api.make(
|
||||||
|
Piece_Create.pipe(Schema.extend(Schema.Struct({ pieceId: PieceId }))),
|
||||||
|
Piece,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
deletePiece: Api.make(
|
||||||
|
PieceId,
|
||||||
|
Schema.Void,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- Attachment CRUD ---
|
||||||
|
|
||||||
|
createAttachment: Api.make(
|
||||||
|
Schema.Struct({
|
||||||
|
pieceId: PieceId,
|
||||||
|
filename: Schema.NonEmptyString,
|
||||||
|
mediaType: Schema.NonEmptyString,
|
||||||
|
data: Schema.Uint8ArrayFromSelf,
|
||||||
|
}),
|
||||||
|
Attachment,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
getAttachment: Api.make(
|
||||||
|
AttachmentId,
|
||||||
|
Schema.Struct({
|
||||||
|
filename: Schema.NonEmptyString,
|
||||||
|
mediaType: Schema.NonEmptyString,
|
||||||
|
data: Schema.Uint8ArrayFromSelf,
|
||||||
|
}),
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
updateAttachment: Api.make(
|
||||||
|
Schema.Struct({
|
||||||
|
attachmentId: AttachmentId,
|
||||||
|
filename: Schema.NonEmptyString,
|
||||||
|
}),
|
||||||
|
Attachment,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
deleteAttachment: Api.make(
|
||||||
|
AttachmentId,
|
||||||
|
Schema.Void,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- Repertoire CRUD ---
|
||||||
|
|
||||||
|
createRepertoire: Api.make(
|
||||||
|
Repertoire_Create,
|
||||||
|
Repertoire,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized),
|
||||||
|
),
|
||||||
|
getRepertoire: Api.make(
|
||||||
|
RepertoireId,
|
||||||
|
Repertoire,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
queryRepertoire: Api.make(
|
||||||
|
Repertoire_Query,
|
||||||
|
pipe(RepertoireId, Schema.Array),
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized),
|
||||||
|
),
|
||||||
|
updateRepertoire: Api.make(
|
||||||
|
Repertoire_Create.pipe(Schema.extend(Schema.Struct({ repertoireId: RepertoireId }))),
|
||||||
|
Repertoire,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
deleteRepertoire: Api.make(
|
||||||
|
RepertoireId,
|
||||||
|
Schema.Void,
|
||||||
|
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "default",
|
"style": "new-york",
|
||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"config": "tailwind.config.js",
|
||||||
"css": "src/style.css",
|
"css": "src/style.css",
|
||||||
"baseColor": "stone",
|
"baseColor": "stone",
|
||||||
"cssVariables": false,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
|
|||||||
@@ -4,20 +4,19 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"@vitejs/plugin-react": "catalog:",
|
"@vitejs/plugin-react": "catalog:",
|
||||||
"autoprefixer": "catalog:",
|
"babel-plugin-react-compiler": "catalog:",
|
||||||
"backend": "workspace:^",
|
"backend": "workspace:^",
|
||||||
"class-variance-authority": "catalog:",
|
"class-variance-authority": "catalog:",
|
||||||
"elysia": "catalog:",
|
|
||||||
"postcss": "catalog:",
|
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
|
"tw-animate-css": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:"
|
"vite": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/eden": "catalog:",
|
|
||||||
"@radix-ui/react-dialog": "catalog:",
|
"@radix-ui/react-dialog": "catalog:",
|
||||||
"@radix-ui/react-dropdown-menu": "catalog:",
|
"@radix-ui/react-dropdown-menu": "catalog:",
|
||||||
"@radix-ui/react-label": "catalog:",
|
"@radix-ui/react-label": "catalog:",
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-router-dom": "catalog:",
|
"react-router-dom": "catalog:",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:"
|
||||||
"tailwindcss-animate": "catalog:"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Home } from "@/routes/Home";
|
import { Home } from "@/routes/Home";
|
||||||
|
import { Login } from "@/routes/Login";
|
||||||
import { Piece } from "@/routes/Piece";
|
import { Piece } from "@/routes/Piece";
|
||||||
import { Pieces } from "@/routes/Pieces";
|
import { Pieces } from "@/routes/Pieces";
|
||||||
import { Repertoire } from "@/routes/Repertoire";
|
import { Repertoire } from "@/routes/Repertoire";
|
||||||
@@ -62,21 +63,17 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
], {
|
{
|
||||||
future: {
|
path: "/login",
|
||||||
v7_fetcherPersist: true,
|
Component: Login,
|
||||||
v7_normalizeFormMethod: true,
|
|
||||||
v7_partialHydration: true,
|
|
||||||
v7_relativeSplatPath: true,
|
|
||||||
v7_skipActionErrorRevalidation: true,
|
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
|
|
||||||
const rootElement = document.getElementById("root") as HTMLDivElement;
|
const rootElement = document.getElementById("root") as HTMLDivElement;
|
||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
<RouterProvider router={router} />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,66 +1,21 @@
|
|||||||
import type * as Db from "backend/database";
|
import { PieceId, RepertoireId, UserId } from "common";
|
||||||
import { AttachmentId, PieceId, RepertoireId, UserId } from "common";
|
import the_api, { SystemInformation } from "common/the_api";
|
||||||
import { Cache, Duration, Effect, Option, pipe } from "effect";
|
import { Array, Cache, Duration, Effect, pipe } from "effect";
|
||||||
import { client, mapResponse } from "./client";
|
import { client } from "./client";
|
||||||
|
|
||||||
export interface User {
|
export const denormalizeSystemInformation = <T extends SystemInformation>({
|
||||||
readonly userId: UserId;
|
|
||||||
readonly displayName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemInformation {
|
|
||||||
readonly createdBy: Option.Option<User | null>;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly modifiedBy: Option.Option<User | null>;
|
|
||||||
readonly modifiedAt: Option.Option<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Attachment extends SystemInformation {
|
|
||||||
readonly attachmentId: AttachmentId;
|
|
||||||
readonly pieceId: PieceId;
|
|
||||||
readonly sha256: string;
|
|
||||||
readonly filename: string;
|
|
||||||
readonly mediaType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Piece extends SystemInformation {
|
|
||||||
readonly pieceId: PieceId;
|
|
||||||
readonly name: string;
|
|
||||||
readonly composer: Option.Option<string>;
|
|
||||||
readonly lyricist: Option.Option<string>;
|
|
||||||
readonly arranger: Option.Option<string>;
|
|
||||||
readonly attachments: readonly Attachment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Repertoire extends SystemInformation {
|
|
||||||
readonly repertoireId: RepertoireId;
|
|
||||||
readonly name: string;
|
|
||||||
readonly entries: readonly Piece[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DbSystemInformation {
|
|
||||||
readonly createdBy: UserId | null;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly modifiedBy: UserId | null;
|
|
||||||
readonly modifiedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const denormalizeSystemInformation = <T extends DbSystemInformation>({
|
|
||||||
createdBy,
|
createdBy,
|
||||||
modifiedBy,
|
modifiedBy,
|
||||||
modifiedAt,
|
|
||||||
...rest
|
...rest
|
||||||
}: T) => pipe(
|
}: T) => pipe(
|
||||||
Effect.all({
|
Effect.all({
|
||||||
createdBy: pipe(
|
createdBy: pipe(
|
||||||
createdBy,
|
createdBy,
|
||||||
Effect.fromNullable,
|
|
||||||
Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))),
|
Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))),
|
||||||
Effect.optionFromOptional,
|
Effect.optionFromOptional,
|
||||||
),
|
),
|
||||||
modifiedBy: pipe(
|
modifiedBy: pipe(
|
||||||
modifiedBy,
|
modifiedBy,
|
||||||
Effect.fromNullable,
|
|
||||||
Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))),
|
Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))),
|
||||||
Effect.optionFromOptional,
|
Effect.optionFromOptional,
|
||||||
),
|
),
|
||||||
@@ -68,26 +23,19 @@ export const denormalizeSystemInformation = <T extends DbSystemInformation>({
|
|||||||
Effect.map((si) => Object.freeze({
|
Effect.map((si) => Object.freeze({
|
||||||
...rest,
|
...rest,
|
||||||
...si,
|
...si,
|
||||||
modifiedAt: Option.fromNullable(modifiedAt),
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const denormalizePiece = ({
|
export const denormalizePiece = ({
|
||||||
composer,
|
|
||||||
lyricist,
|
|
||||||
arranger,
|
|
||||||
attachments,
|
attachments,
|
||||||
...rest
|
...rest
|
||||||
}: Db.Piece & { attachments: (Omit<Db.Attachment, "sha256"> & { sha256: string })[] }) => pipe(
|
}: typeof the_api.record.getPiece.response.Type) => pipe(
|
||||||
Effect.all({
|
Effect.all({
|
||||||
attachments: Effect.all(attachments.map(denormalizeSystemInformation), { concurrency: "unbounded" }),
|
attachments: Effect.all(attachments.map(denormalizeSystemInformation), { concurrency: "unbounded" }),
|
||||||
}, { concurrency: "unbounded" }),
|
}, { concurrency: "unbounded" }),
|
||||||
Effect.map((piece) => Object.freeze({
|
Effect.map((piece) => Object.freeze({
|
||||||
...rest,
|
...rest,
|
||||||
...piece,
|
...piece,
|
||||||
composer: Option.fromNullable(composer),
|
|
||||||
lyricist: Option.fromNullable(lyricist),
|
|
||||||
arranger: Option.fromNullable(arranger),
|
|
||||||
})),
|
})),
|
||||||
Effect.flatMap(denormalizeSystemInformation),
|
Effect.flatMap(denormalizeSystemInformation),
|
||||||
);
|
);
|
||||||
@@ -95,7 +43,7 @@ export const denormalizePiece = ({
|
|||||||
export const denormalizeRepertoire = ({
|
export const denormalizeRepertoire = ({
|
||||||
entries,
|
entries,
|
||||||
...rest
|
...rest
|
||||||
}: Db.Repertoire & { entries: PieceId[] }) => pipe(
|
}: typeof the_api.record.getRepertoire.response.Type) => pipe(
|
||||||
Effect.all({
|
Effect.all({
|
||||||
entries: Effect.all(entries.map((entry) => Effect.uninterruptible(pieceCache.get(entry))), { concurrency: "unbounded" }),
|
entries: Effect.all(entries.map((entry) => Effect.uninterruptible(pieceCache.get(entry))), { concurrency: "unbounded" }),
|
||||||
}, { concurrency: "unbounded" }),
|
}, { concurrency: "unbounded" }),
|
||||||
@@ -110,44 +58,38 @@ const UserSemaphore = Effect.unsafeMakeSemaphore(1);
|
|||||||
const PieceSemaphore = Effect.unsafeMakeSemaphore(4);
|
const PieceSemaphore = Effect.unsafeMakeSemaphore(4);
|
||||||
const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
|
const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
|
||||||
|
|
||||||
export const userLookup = (userId: UserId) => pipe(
|
|
||||||
Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })),
|
|
||||||
Effect.flatMap(mapResponse),
|
|
||||||
Effect.catchAll((error) => error.status === 404 ? Effect.succeed(null) : Effect.fail(error)),
|
|
||||||
Effect.map((x): User | null => x), // safely coerce to interface
|
|
||||||
UserSemaphore.withPermits(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const pieceLookup = (pieceId: PieceId) => pipe(
|
|
||||||
Effect.promise((signal) => client.piece({ pieceId }).get({ fetch: { signal } })),
|
|
||||||
Effect.flatMap(mapResponse),
|
|
||||||
Effect.flatMap(denormalizePiece),
|
|
||||||
Effect.map((x): Piece => x), // safely coerce to interface
|
|
||||||
PieceSemaphore.withPermits(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const repertoireLookup = (repertoireId: RepertoireId) => pipe(
|
|
||||||
Effect.promise((signal) => client.repertoire({ repertoireId }).get({ fetch: { signal } })),
|
|
||||||
Effect.flatMap(mapResponse),
|
|
||||||
Effect.flatMap(denormalizeRepertoire),
|
|
||||||
Effect.map((x): Repertoire => x), // safely coerce to interface
|
|
||||||
RepertoireSemaphore.withPermits(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const userCache = Effect.runSync(Cache.make({
|
export const userCache = Effect.runSync(Cache.make({
|
||||||
capacity: Infinity,
|
capacity: Infinity,
|
||||||
timeToLive: Duration.days(1),
|
timeToLive: Duration.days(1),
|
||||||
lookup: userLookup,
|
lookup: (userId: UserId) => pipe(
|
||||||
|
client.getUser(userId),
|
||||||
|
UserSemaphore.withPermits(1),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const pieceCache = Effect.runSync(Cache.make({
|
export const pieceCache = Effect.runSync(Cache.make({
|
||||||
capacity: Infinity,
|
capacity: Infinity,
|
||||||
timeToLive: Duration.days(1),
|
timeToLive: Duration.days(1),
|
||||||
lookup: pieceLookup,
|
lookup: (pieceId: PieceId) => pipe(
|
||||||
|
client.getPiece(pieceId),
|
||||||
|
Effect.flatMap(denormalizePiece),
|
||||||
|
PieceSemaphore.withPermits(1),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const repertoireCache = Effect.runSync(Cache.make({
|
export const repertoireCache = Effect.runSync(Cache.make({
|
||||||
capacity: Infinity,
|
capacity: Infinity,
|
||||||
timeToLive: Duration.days(1),
|
timeToLive: Duration.days(1),
|
||||||
lookup: repertoireLookup,
|
lookup: (repertoireId: RepertoireId) => pipe(
|
||||||
|
client.getRepertoire(repertoireId),
|
||||||
|
Effect.flatMap(denormalizeRepertoire),
|
||||||
|
RepertoireSemaphore.withPermits(1),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export type User = Effect.Effect.Success<ReturnType<typeof userCache["get"]>>;
|
||||||
|
export type Piece = Effect.Effect.Success<ReturnType<typeof pieceCache["get"]>>;
|
||||||
|
export type Repertoire = Effect.Effect.Success<ReturnType<typeof repertoireCache["get"]>>;
|
||||||
|
|
||||||
|
export type Attachment = Array.ReadonlyArray.Infer<Piece["attachments"]>;
|
||||||
|
export type DenormalizedSystemInformation = Effect.Effect.Success<ReturnType<typeof denormalizeSystemInformation<SystemInformation>>>;
|
||||||
|
|||||||
@@ -1,30 +1,6 @@
|
|||||||
import { Treaty, treaty } from "@elysiajs/eden";
|
import * as Client from "common/Client";
|
||||||
import type { App } from "backend/app";
|
import the_api from "common/the_api";
|
||||||
import { ACCEPTED_MEDIA_TYPES } from "common/MediaType";
|
|
||||||
import { Effect } from "effect";
|
|
||||||
|
|
||||||
export type ResponseEffect<R extends Record<number, unknown>> = Effect.Effect<R[200], Exclude<keyof R, 200> extends never ? never : { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude<keyof R, 200>]>;
|
export const API_URL_PREFIX = process.env.NODE_ENV === "production" ? undefined : "http://localhost:3000";
|
||||||
|
|
||||||
export const API_URL_PREFIX = process.env.NODE_ENV === "production" ? "" : "http://localhost:3000";
|
export const client = Client.client(the_api, { baseUrl: API_URL_PREFIX });
|
||||||
|
|
||||||
export const client = treaty<App>(API_URL_PREFIX, {
|
|
||||||
fetch: {
|
|
||||||
credentials: "include",
|
|
||||||
},
|
|
||||||
keepDomain: true,
|
|
||||||
onResponse: async (res) => {
|
|
||||||
const contentType = res.headers.get('Content-Type')?.split(';')[0];
|
|
||||||
if (contentType !== undefined && ACCEPTED_MEDIA_TYPES.includes(contentType)) {
|
|
||||||
const blob = await res.blob();
|
|
||||||
// TODO Decode filename from Content-Disposition header
|
|
||||||
const file = new File([blob], "", { type: contentType });
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}).api.v1;
|
|
||||||
|
|
||||||
export const mapResponse = <R extends Record<number, unknown>>({ error, data }: Treaty.TreatyResponse<R>): ResponseEffect<R> => {
|
|
||||||
return error !== null
|
|
||||||
? Effect.fail(error as Exclude<keyof R, 200> extends never ? never : { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude<keyof R, 200>])
|
|
||||||
: Effect.succeed(data);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { API_URL_PREFIX } from "@/client";
|
|
||||||
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
||||||
import { Treaty } from "@elysiajs/eden";
|
import { Console, Effect, Fiber, pipe } from "effect";
|
||||||
import { Effect, Fiber, pipe } from "effect";
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
export namespace Loading {
|
export namespace Loading {
|
||||||
export interface Pending {
|
export interface Pending {
|
||||||
@@ -34,57 +31,6 @@ export type Loading<A, E> =
|
|||||||
| Loading.Error<E>
|
| Loading.Error<E>
|
||||||
;
|
;
|
||||||
|
|
||||||
export type ErrorResponses<R extends Record<number, unknown>> =
|
|
||||||
Exclude<keyof R, 200 | 401> extends never
|
|
||||||
? { status: unknown, value: unknown }
|
|
||||||
: { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude<keyof R, 200 | 401>];
|
|
||||||
|
|
||||||
export type LoadingResult<R extends Record<number, unknown>> = Loading<R[200], ErrorResponses<R>>;
|
|
||||||
|
|
||||||
export function useLoading<R extends Record<number, unknown>>(fn: () => Promise<Treaty.TreatyResponse<R>>, deps: React.DependencyList) {
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [result, setResult] = useState<LoadingResult<R>>(IS_LOADING);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setResult(IS_LOADING);
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
fn().then(({ error, data }) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
if (error.status === 401) {
|
|
||||||
window.location.href = `${API_URL_PREFIX}/api/v1/login`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setResult(Object.freeze<Loading.Error<ErrorResponses<R>>>({
|
|
||||||
isLoading: false,
|
|
||||||
data: null,
|
|
||||||
error: error as ErrorResponses<R>,
|
|
||||||
setData: null,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setResult({
|
|
||||||
isLoading: false,
|
|
||||||
error,
|
|
||||||
data,
|
|
||||||
setData: (action) => setResult(mapProp("data", action) as Update<LoadingResult<R>>),
|
|
||||||
} as LoadingResult<R>);
|
|
||||||
}
|
|
||||||
}, (error) => {
|
|
||||||
console.error(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [navigate, ...deps]);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IS_LOADING = Object.freeze({
|
const IS_LOADING = Object.freeze({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
data: null,
|
data: null,
|
||||||
@@ -92,10 +38,9 @@ const IS_LOADING = Object.freeze({
|
|||||||
setData: null,
|
setData: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useLoadingEffect<A, E>(effect: Effect.Effect<A, E>, deps: React.DependencyList) {
|
export function useLoading<A, E>(effect: Effect.Effect<A, E>, deps: React.DependencyList) {
|
||||||
|
|
||||||
const [result, setResult] = useState<Loading<A, E>>(IS_LOADING);
|
const [result, setResult] = useState<Loading<A, E>>(IS_LOADING);
|
||||||
|
|
||||||
const setResultEffect = useCallback((action: Loading<A, E>) => Effect.sync(() => setResult(action)), []);
|
const setResultEffect = useCallback((action: Loading<A, E>) => Effect.sync(() => setResult(action)), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,6 +62,7 @@ export function useLoadingEffect<A, E>(effect: Effect.Effect<A, E>, deps: React.
|
|||||||
setData: null,
|
setData: null,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
|
Effect.catchAllDefect(Console.error),
|
||||||
Effect.runFork,
|
Effect.runFork,
|
||||||
);
|
);
|
||||||
const interruptEffect = Fiber.interrupt(fiber);
|
const interruptEffect = Fiber.interrupt(fiber);
|
||||||
@@ -125,7 +71,7 @@ export function useLoadingEffect<A, E>(effect: Effect.Effect<A, E>, deps: React.
|
|||||||
Effect.runFork(interruptEffect);
|
Effect.runFork(interruptEffect);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [setResultEffect, ...deps]);
|
}, deps);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { UserId } from "common";
|
import { type Me } from "common/the_api";
|
||||||
import { identity } from "effect";
|
import { identity } from "effect";
|
||||||
import { useLayoutEffect, useState } from "react";
|
import { useLayoutEffect, useState } from "react";
|
||||||
|
|
||||||
@@ -9,23 +9,15 @@ export const mapProp = <const K extends string, T>(prop: K, action: Update<T>) =
|
|||||||
return Object.freeze({ ...object, [prop]: typeof action === "function" ? (action as (prev: T) => T)(object[prop]) : action });
|
return Object.freeze({ ...object, [prop]: typeof action === "function" ? (action as (prev: T) => T)(object[prop]) : action });
|
||||||
};
|
};
|
||||||
|
|
||||||
export namespace Store {
|
|
||||||
export interface User {
|
|
||||||
readonly userId: UserId;
|
|
||||||
readonly username: string;
|
|
||||||
readonly roles: readonly string[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Store {
|
export interface Store {
|
||||||
readonly user: Store.User | null;
|
readonly user: Me | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let store: Store = Object.freeze<Store>({
|
let store: Store = Object.freeze<Store>({
|
||||||
user: null,
|
user: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function setUser(action: Update<Store.User | null>) {
|
export function setUser(action: Update<Me | null>) {
|
||||||
set(mapProp("user", action));
|
set(mapProp("user", action));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
packages/frontend/src/icons/microsoft-entra-id.svg
Normal file
9
packages/frontend/src/icons/microsoft-entra-id.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="uuid-f8d4d392-7c12-4bd9-baff-66fbf7814b91" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
|
||||||
|
<path d="m3.802,14.032c.388.242,1.033.511,1.715.511.621,0,1.198-.18,1.676-.487,0,0,.001,0,.002-.001l1.805-1.128v4.073c-.286,0-.574-.078-.824-.234l-4.374-2.734Z" fill="#225086"/>
|
||||||
|
<path d="m7.853,1.507L.353,9.967c-.579.654-.428,1.642.323,2.111,0,0,2.776,1.735,3.126,1.954.388.242,1.033.511,1.715.511.621,0,1.198-.18,1.676-.487,0,0,.001,0,.002-.001l1.805-1.128-4.364-2.728,4.365-4.924V1s0,0,0,0c-.424,0-.847.169-1.147.507Z" fill="#6df"/>
|
||||||
|
<polygon points="4.636 10.199 4.688 10.231 9 12.927 9.001 12.927 9.001 12.927 9.001 5.276 9 5.275 4.636 10.199" fill="#cbf8ff"/>
|
||||||
|
<path d="m17.324,12.078c.751-.469.902-1.457.323-2.111l-4.921-5.551c-.397-.185-.842-.291-1.313-.291-.925,0-1.752.399-2.302,1.026l-.109.123h0s4.364,4.924,4.364,4.924h0s0,0,0,0l-4.365,2.728v4.073c.287,0,.573-.078.823-.234l7.5-4.688Z" fill="#074793"/>
|
||||||
|
<path d="m9.001,1v4.275s.109-.123.109-.123c.55-.627,1.377-1.026,2.302-1.026.472,0,.916.107,1.313.291l-2.579-2.909c-.299-.338-.723-.507-1.146-.507Z" fill="#0294e4"/>
|
||||||
|
<polygon points="13.365 10.199 13.365 10.199 13.365 10.199 9.001 5.276 9.001 12.926 13.365 10.199" fill="#96bcc2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import { client } from "@/client";
|
import { client } from "@/client";
|
||||||
import { useLoading } from "@/hooks/useLoading.ts";
|
import { useLoading } from "@/hooks/useLoading.ts";
|
||||||
import { AttachmentId, PieceId } from "common";
|
import { AttachmentId } from "common";
|
||||||
import { Match } from "effect";
|
import { Match } from "effect";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { OpenSheetMusicDisplay } from "opensheetmusicdisplay";
|
import { OpenSheetMusicDisplay } from "opensheetmusicdisplay";
|
||||||
@@ -10,10 +10,9 @@ import { useParams } from "react-router-dom";
|
|||||||
export default function Attachment() {
|
export default function Attachment() {
|
||||||
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const pieceId = PieceId(params.pieceId!);
|
const attachmentId = AttachmentId.make(params.attachmentId!);
|
||||||
const attachmentId = AttachmentId(params.attachmentId!);
|
|
||||||
|
|
||||||
const { isLoading, error, data } = useLoading(() => client.piece({ pieceId }).attachment({ attachmentId }).get(), [pieceId, attachmentId]);
|
const { isLoading, error, data } = useLoading(client.getAttachment(attachmentId), [attachmentId]);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const renderFn = useRef<null | (() => void)>(null);
|
const renderFn = useRef<null | (() => void)>(null);
|
||||||
@@ -22,14 +21,14 @@ export default function Attachment() {
|
|||||||
|
|
||||||
if (isLoading || error !== null) return;
|
if (isLoading || error !== null) return;
|
||||||
|
|
||||||
let musixXmlBlob: Blob = data;
|
let musixXmlData: Uint8Array = data.data;
|
||||||
|
|
||||||
/* If the file is the compressed .mxl file, we do the uncompression
|
/* If the file is the compressed .mxl file, we do the uncompression
|
||||||
* ourselves, because apparently OpenSheetMusicDisplay is incapable.
|
* ourselves, because apparently OpenSheetMusicDisplay is incapable.
|
||||||
*/
|
*/
|
||||||
if (data.type === "application/vnd.recordare.musicxml") {
|
if (data.mediaType === "application/vnd.recordare.musicxml") {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
await zip.loadAsync(data);
|
await zip.loadAsync(musixXmlData);
|
||||||
|
|
||||||
const containerFile = zip.file("META-INF/container.xml");
|
const containerFile = zip.file("META-INF/container.xml");
|
||||||
if (containerFile === null) {
|
if (containerFile === null) {
|
||||||
@@ -58,10 +57,10 @@ export default function Attachment() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
musixXmlBlob = await musicXmlFile.async("blob");
|
musixXmlData = await musicXmlFile.async("uint8array");
|
||||||
}
|
}
|
||||||
|
|
||||||
const musicXml = await musixXmlBlob.text();
|
const musicXml = new TextDecoder().decode(musixXmlData);
|
||||||
|
|
||||||
const osmd = new OpenSheetMusicDisplay(containerRef.current!, {
|
const osmd = new OpenSheetMusicDisplay(containerRef.current!, {
|
||||||
autoResize: false,
|
autoResize: false,
|
||||||
@@ -105,8 +104,10 @@ export default function Attachment() {
|
|||||||
<div className="w-full h-full overflow-hidden flex items-center justify-center">
|
<div className="w-full h-full overflow-hidden flex items-center justify-center">
|
||||||
<div>
|
<div>
|
||||||
Wystąpił błąd: {Match.value(error).pipe(
|
Wystąpił błąd: {Match.value(error).pipe(
|
||||||
Match.when({ status: 422 }, ({ value }) => value.message),
|
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
|
||||||
Match.when({ status: 404 }, () => "Załącznik nie istnieje"),
|
Match.tag("NotFound", () => "Załącznik nie istnieje"),
|
||||||
|
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
|
||||||
|
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
|
||||||
Match.exhaustive,
|
Match.exhaustive,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
40
packages/frontend/src/routes/Login.tsx
Normal file
40
packages/frontend/src/routes/Login.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { API_URL_PREFIX } from "@/client";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import microsoftEntraId from "@/icons/microsoft-entra-id.svg";
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
|
||||||
|
const internalUrl = `${API_URL_PREFIX}/login`;
|
||||||
|
const externalUrl = `${API_URL_PREFIX}/login?external`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Repozytorium muzyczne</CardTitle>
|
||||||
|
<CardDescription>Zaloguj się, aby kontynuować</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2 content-stretch max-w-sm">
|
||||||
|
<div
|
||||||
|
className="text-sm text-stone-500 dark:text-stone-400"
|
||||||
|
>
|
||||||
|
Użyj emaila i hasła, konta Microsoft lub konta Google.
|
||||||
|
</div>
|
||||||
|
<a className={buttonVariants()} href={externalUrl}>
|
||||||
|
Konto zewnętrzne
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
className="text-sm text-stone-500 dark:text-stone-400 mt-4"
|
||||||
|
>
|
||||||
|
Użyj konta firmowego.
|
||||||
|
</div>
|
||||||
|
<a className={buttonVariants()} href={internalUrl}>
|
||||||
|
<img src={microsoftEntraId} />
|
||||||
|
<div>Konto firmowe</div>
|
||||||
|
</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Attachment, denormalizeSystemInformation, type Piece, pieceCache } from "@/cache";
|
import { Attachment, denormalizeSystemInformation, pieceCache, type Piece } from "@/cache";
|
||||||
import { API_URL_PREFIX, client } from "@/client";
|
import { API_URL_PREFIX, client } from "@/client";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { useLoadingEffect } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
||||||
import { created, modified, saveDelay } from "@/snippets";
|
import { created, modified, SAVE_DELAY, saveDelay } from "@/snippets";
|
||||||
import { Label } from "@radix-ui/react-label";
|
import { Label } from "@radix-ui/react-label";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { PieceId } from "common";
|
import { PieceId } from "common";
|
||||||
|
import * as Body from "common/Body";
|
||||||
import { getMediaTypeForFilename } from "common/MediaType";
|
import { getMediaTypeForFilename } from "common/MediaType";
|
||||||
import { Cause, Effect, Option } from "effect";
|
import { Cause, Clock, DateTime, Effect, Exit, Fiber, Option, pipe, Scope } from "effect";
|
||||||
import { constant } from "effect/Function";
|
import { constant } from "effect/Function";
|
||||||
import { Download, Loader2, Trash, UploadCloud } from "lucide-react";
|
import { Download, Loader2, Trash, UploadCloud } from "lucide-react";
|
||||||
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
|
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
|
||||||
@@ -18,9 +19,9 @@ import { Link, useNavigate, useParams } from "react-router-dom";
|
|||||||
|
|
||||||
export function Piece() {
|
export function Piece() {
|
||||||
|
|
||||||
const id = PieceId(useParams().pieceId!);
|
const id = PieceId.make(useParams().pieceId!);
|
||||||
|
|
||||||
const { isLoading, error, data, setData } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(id)), [id]);
|
const { isLoading, error, data, setData } = useLoading(Effect.uninterruptible(pieceCache.get(id)), [id]);
|
||||||
|
|
||||||
const setAttachments = useCallback((action: Update<readonly Attachment[]>) => {
|
const setAttachments = useCallback((action: Update<readonly Attachment[]>) => {
|
||||||
setData!(mapProp("attachments", action));
|
setData!(mapProp("attachments", action));
|
||||||
@@ -38,7 +39,7 @@ export function Piece() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
|
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
|
||||||
{error !== null ? (
|
{error !== null ? (
|
||||||
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`
|
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`
|
||||||
) : (<>
|
) : (<>
|
||||||
<div className="flex flex-col gap-4 p-4 border rounded">
|
<div className="flex flex-col gap-4 p-4 border rounded">
|
||||||
<h3 className="font-bold text-lg">Utwór</h3>
|
<h3 className="font-bold text-lg">Utwór</h3>
|
||||||
@@ -82,17 +83,15 @@ function PieceForm(props: PieceForm.Props) {
|
|||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
const { error } = await client.piece({ pieceId: props.piece.pieceId }).put({
|
await client.updatePiece({
|
||||||
|
pieceId: props.piece.pieceId,
|
||||||
name,
|
name,
|
||||||
composer: composer.length > 0 ? composer : null,
|
composer: composer.length > 0 ? Option.some(composer) : Option.none(),
|
||||||
lyricist: lyricist.length > 0 ? lyricist : null,
|
lyricist: lyricist.length > 0 ? Option.some(lyricist) : Option.none(),
|
||||||
arranger: arranger.length > 0 ? arranger : null,
|
arranger: arranger.length > 0 ? Option.some(arranger) : Option.none(),
|
||||||
});
|
}).pipe(Effect.runPromise);
|
||||||
|
} catch (error) {
|
||||||
if (error !== null) {
|
console.error(error);
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await delay;
|
await delay;
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -103,17 +102,14 @@ function PieceForm(props: PieceForm.Props) {
|
|||||||
try {
|
try {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
||||||
const { error } = await client
|
await Effect.runPromise(pipe(
|
||||||
.piece({ pieceId: props.piece.pieceId })
|
client.deletePiece(props.piece.pieceId),
|
||||||
.delete();
|
Effect.andThen(pieceCache.invalidate(props.piece.pieceId)),
|
||||||
|
));
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Effect.runFork(pieceCache.invalidate(props.piece.pieceId));
|
|
||||||
navigate("..");
|
navigate("..");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -214,45 +210,47 @@ namespace AttachmentRow {
|
|||||||
|
|
||||||
function AttachmentRow(props: AttachmentRow.Props) {
|
function AttachmentRow(props: AttachmentRow.Props) {
|
||||||
|
|
||||||
const url = `${API_URL_PREFIX}/api/v1/piece/${props.attachment.pieceId}/attachment/${props.attachment.attachmentId}`;
|
const url = `${API_URL_PREFIX}/api/piece/${props.attachment.pieceId}/attachment/${props.attachment.attachmentId}`;
|
||||||
|
|
||||||
const open = useCallback(async (event: MouseEvent<HTMLAnchorElement>) => {
|
const download = () => Effect.gen(function* () {
|
||||||
|
const { data, mediaType, filename } = yield* client.getAttachment(props.attachment.attachmentId);
|
||||||
|
|
||||||
|
const file = new File([data], filename, {
|
||||||
|
type: mediaType,
|
||||||
|
lastModified: pipe(
|
||||||
|
props.attachment.modifiedAt,
|
||||||
|
Option.getOrElse(() => props.attachment.createdAt),
|
||||||
|
DateTime.toEpochMillis,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}).pipe(Effect.runPromise);
|
||||||
|
|
||||||
|
const open = (event: MouseEvent<HTMLAnchorElement>) => Effect.gen(function* () {
|
||||||
if (props.attachment.mediaType !== "application/pdf") {
|
if (props.attachment.mediaType !== "application/pdf") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const { error, data } = await client
|
const { data, mediaType } = yield* client.getAttachment(props.attachment.attachmentId);
|
||||||
.piece({ pieceId: props.attachment.pieceId })
|
const blob = new Blob([data], { type: mediaType });
|
||||||
.attachment({ attachmentId: props.attachment.attachmentId })
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (error !== null) {
|
const url = URL.createObjectURL(blob);
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(data);
|
|
||||||
window.open(url, "_target");
|
window.open(url, "_target");
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [props.attachment.attachmentId, props.attachment.mediaType, props.attachment.pieceId]);
|
}).pipe(Effect.runPromise);
|
||||||
|
|
||||||
const doDelete = useCallback(async () => {
|
|
||||||
|
|
||||||
const { error } = await client
|
|
||||||
.piece({ pieceId: props.attachment.pieceId })
|
|
||||||
.attachment({ attachmentId: props.attachment.attachmentId })
|
|
||||||
.delete();
|
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const doDelete = () => Effect.gen(function* () {
|
||||||
|
yield* client.deleteAttachment(props.attachment.attachmentId);
|
||||||
props.setAttachments((prev) => prev.filter((a) => a.attachmentId !== props.attachment.attachmentId));
|
props.setAttachments((prev) => prev.filter((a) => a.attachmentId !== props.attachment.attachmentId));
|
||||||
|
}).pipe(Effect.runPromise);
|
||||||
}, [props]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -277,9 +275,9 @@ function AttachmentRow(props: AttachmentRow.Props) {
|
|||||||
{modified(props.attachment)}
|
{modified(props.attachment)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center flex justify-center gap-4">
|
<TableCell className="text-center flex justify-center gap-4">
|
||||||
<a href={url} className={buttonVariants({ variant: "ghost", size: "icon" })} title="Pobierz" download={props.attachment.filename}>
|
<Button type="button" variant="ghost" size="icon" title="Pobierz" onClick={download}>
|
||||||
<Download />
|
<Download />
|
||||||
</a>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={doDelete}>
|
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={doDelete}>
|
||||||
<Trash />
|
<Trash />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -304,15 +302,22 @@ function AttachmentForm(props: AttachmentForm.Props) {
|
|||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDrop: DragEventHandler<HTMLElement> = async (e) => {
|
const onDrop: DragEventHandler<HTMLElement> = (e) => Effect.gen(function* () {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = saveDelay();
|
const delay = yield* Effect.fork(SAVE_DELAY);
|
||||||
try {
|
|
||||||
|
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
|
||||||
|
|
||||||
|
yield* Scope.addFinalizer(scope, Effect.gen(function* () {
|
||||||
|
yield* Fiber.join(delay);
|
||||||
|
setIsLoading(false);
|
||||||
|
}));
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
for (const file of e.dataTransfer.files) {
|
for (const file of e.dataTransfer.files) {
|
||||||
@@ -321,18 +326,21 @@ function AttachmentForm(props: AttachmentForm.Props) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await client.piece({ pieceId: props.pieceId }).attachment.post({
|
const data = yield* Body.bytes(file);
|
||||||
|
|
||||||
|
const exit = yield* Effect.exit(client.createAttachment({
|
||||||
|
pieceId: props.pieceId,
|
||||||
|
data,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mediaType,
|
mediaType,
|
||||||
data: file,
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
if (error !== null) {
|
if (Exit.isFailure(exit)) {
|
||||||
console.error(error.value);
|
console.error(exit.cause);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = await Effect.runPromise(denormalizeSystemInformation(data));
|
const attachment = yield* denormalizeSystemInformation(exit.exitValue);
|
||||||
|
|
||||||
props.setAttachments((prev) => {
|
props.setAttachments((prev) => {
|
||||||
const next = [...prev, attachment];
|
const next = [...prev, attachment];
|
||||||
@@ -340,11 +348,8 @@ function AttachmentForm(props: AttachmentForm.Props) {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
}));
|
||||||
await delay;
|
}).pipe(Effect.runPromise);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogT
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { useLoadingEffect } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { authors, created, DEBOUNCE, modified } from "@/snippets";
|
import { authors, created, DEBOUNCE, modified } from "@/snippets";
|
||||||
import { PieceId } from "common";
|
import { PieceId } from "common";
|
||||||
import { Cause, Effect, Match } from "effect";
|
import { Cause, Effect, Match, Option, Scope } from "effect";
|
||||||
import { Loader2, Plus } from "lucide-react";
|
import { Loader2, Plus } from "lucide-react";
|
||||||
import { FormEventHandler, useId, useRef, useState } from "react";
|
import { FormEventHandler, useId, useRef, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
@@ -20,21 +20,15 @@ export function Pieces() {
|
|||||||
|
|
||||||
const debounce = useRef(Effect.void);
|
const debounce = useRef(Effect.void);
|
||||||
|
|
||||||
const { isLoading, error, data: pieceIds } = useLoadingEffect(Effect.gen(function* () {
|
const { isLoading, error, data: pieceIds } = useLoading(Effect.gen(function* () {
|
||||||
yield* debounce.current;
|
yield* debounce.current;
|
||||||
const { error, data } = yield* Effect.promise((signal) => client.piece.get({
|
const data = yield* client.queryPieces({
|
||||||
query: {
|
name: name !== "" ? Option.some(name) : Option.none(),
|
||||||
...(name !== "" ? { name } : undefined),
|
author: author !== "" ? Option.some(author) : Option.none(),
|
||||||
...(author !== "" ? { author } : undefined),
|
offset: 0,
|
||||||
},
|
limit: 100,
|
||||||
fetch: { signal },
|
});
|
||||||
}));
|
return data;
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
return yield* Effect.fail(error);
|
|
||||||
} else {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}), [name, author]);
|
}), [name, author]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,7 +85,7 @@ export function Pieces() {
|
|||||||
) : error !== null ? (
|
) : error !== null ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center">
|
<TableCell colSpan={4} className="text-center">
|
||||||
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`}
|
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -111,7 +105,7 @@ namespace PieceRow {
|
|||||||
|
|
||||||
function PieceRow(props: PieceRow.Props) {
|
function PieceRow(props: PieceRow.Props) {
|
||||||
|
|
||||||
const { isLoading, error, data: piece } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
|
const { isLoading, error, data: piece } = useLoading(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -126,9 +120,10 @@ function PieceRow(props: PieceRow.Props) {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4}>
|
<TableCell colSpan={4}>
|
||||||
Wystąpił błąd: {Match.value(error).pipe(
|
Wystąpił błąd: {Match.value(error).pipe(
|
||||||
Match.when({ status: 401 }, () => "Zaloguj się ponownie"),
|
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
|
||||||
Match.when({ status: 422 }, ({ value }) => value.message),
|
Match.tag("NotFound", () => "Utwór nie istnieje"),
|
||||||
Match.when({ status: 404 }, () => "Utwór nie istnieje"),
|
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
|
||||||
|
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
|
||||||
Match.exhaustive,
|
Match.exhaustive,
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -170,29 +165,25 @@ function AddPieceDialogContent() {
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
const onSubmit: FormEventHandler<HTMLFormElement> = (e) => Effect.gen(function* () {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
try {
|
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
|
||||||
|
|
||||||
|
yield* Scope.addFinalizer(scope, Effect.sync(() => setIsLoading(false)));
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const { data, error } = await client.piece.post({
|
const { pieceId } = yield* client.createPiece({
|
||||||
name,
|
name,
|
||||||
composer: composer.length > 0 ? composer : null,
|
composer: composer.length > 0 ? Option.some(composer) : Option.none(),
|
||||||
lyricist: lyricist.length > 0 ? lyricist : null,
|
lyricist: lyricist.length > 0 ? Option.some(lyricist) : Option.none(),
|
||||||
arranger: arranger.length > 0 ? arranger : null,
|
arranger: arranger.length > 0 ? Option.some(arranger) : Option.none(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error !== null) {
|
navigate(pieceId);
|
||||||
console.error(error.value);
|
}));
|
||||||
return;
|
}).pipe(Effect.runPromise);
|
||||||
}
|
|
||||||
|
|
||||||
navigate(data.pieceId);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { useLoadingEffect } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
||||||
import { authors, DEBOUNCE, saveDelay } from "@/snippets";
|
import { authors, DEBOUNCE, SAVE_DELAY, saveDelay } from "@/snippets";
|
||||||
import { PieceId, RepertoireId } from "common";
|
import { PieceId, RepertoireId } from "common";
|
||||||
import { Array, Cause, Effect, Match, Option, pipe } from "effect";
|
import { Array, Cause, Effect, Fiber, Match, Option, pipe, Scope } from "effect";
|
||||||
import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react";
|
import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react";
|
||||||
import { FormEventHandler, useCallback, useId, useMemo, useRef, useState } from "react";
|
import { FormEventHandler, useCallback, useId, useRef, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
export function Repertoire() {
|
export function Repertoire() {
|
||||||
|
|
||||||
const id = RepertoireId(useParams().repertoireId!);
|
const id = RepertoireId.make(useParams().repertoireId!);
|
||||||
|
|
||||||
const { isLoading, error, data, setData } = useLoadingEffect(Effect.uninterruptible(repertoireCache.get(id)), [id]);
|
const { isLoading, error, data, setData } = useLoading(Effect.uninterruptible(repertoireCache.get(id)), [id]);
|
||||||
|
|
||||||
const setEntries = useCallback((action: Update<readonly Piece[]>) => {
|
const setEntries = useCallback((action: Update<readonly Piece[]>) => {
|
||||||
setData!(mapProp("entries", action));
|
setData!(mapProp("entries", action));
|
||||||
@@ -36,7 +36,7 @@ export function Repertoire() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
|
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
|
||||||
{error !== null ? (
|
{error !== null ? (
|
||||||
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`
|
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`
|
||||||
) : (<>
|
) : (<>
|
||||||
<div className="flex flex-col gap-4 p-4 border rounded">
|
<div className="flex flex-col gap-4 p-4 border rounded">
|
||||||
<h3 className="font-bold">Repertuar</h3>
|
<h3 className="font-bold">Repertuar</h3>
|
||||||
@@ -65,47 +65,39 @@ function RepertoireForm(props: RepertoireForm.Props) {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => Effect.gen(function* () {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const delay = saveDelay();
|
const delay = yield* Effect.fork(SAVE_DELAY);
|
||||||
try {
|
|
||||||
|
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
|
||||||
|
|
||||||
|
yield* Scope.addFinalizer(scope, Effect.gen(function* () {
|
||||||
|
yield* Fiber.join(delay);
|
||||||
|
setIsSaving(false);
|
||||||
|
}));
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
const { error } = await client.repertoire({ repertoireId: props.repertoire.repertoireId }).put({
|
yield* client.updateRepertoire({
|
||||||
|
repertoireId: props.repertoire.repertoireId,
|
||||||
name,
|
name,
|
||||||
entries: props.repertoire.entries.map(({ pieceId }) => pieceId),
|
entries: props.repertoire.entries.map(({ pieceId }) => pieceId),
|
||||||
});
|
});
|
||||||
|
}));
|
||||||
|
}).pipe(Effect.runPromise);
|
||||||
|
|
||||||
if (error !== null) {
|
const doDelete = () => Effect.scopedWith((scope) => Effect.gen(function* () {
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await delay;
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doDelete = useCallback(async () => {
|
yield* Scope.addFinalizer(scope, Effect.sync(() => setIsSaving(false)));
|
||||||
try {
|
|
||||||
setIsDeleting(true);
|
|
||||||
|
|
||||||
const { error } = await client
|
setIsDeleting(true);
|
||||||
.repertoire({ repertoireId: props.repertoire.repertoireId })
|
|
||||||
.delete();
|
|
||||||
|
|
||||||
if (error !== null) {
|
yield* client.deleteRepertoire(props.repertoire.repertoireId);
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Effect.runFork(repertoireCache.invalidate(props.repertoire.repertoireId));
|
yield* repertoireCache.invalidate(props.repertoire.repertoireId);
|
||||||
navigate("..");
|
navigate("..");
|
||||||
} finally {
|
})).pipe(Effect.runPromise);
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
}, [props.repertoire.repertoireId, navigate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||||
@@ -196,47 +188,41 @@ function EntryRow({
|
|||||||
setEntries,
|
setEntries,
|
||||||
}: EntryRow.Props) {
|
}: EntryRow.Props) {
|
||||||
|
|
||||||
const moveUpAction = useCallback((entries: readonly Piece[]) => pipe(
|
const moveUpAction = (entries: readonly Piece[]) => pipe(
|
||||||
entries,
|
entries,
|
||||||
Array.remove(no - 1),
|
Array.remove(no - 1),
|
||||||
Array.insertAt(no - 2, piece),
|
Array.insertAt(no - 2, piece),
|
||||||
Option.getOrThrow,
|
Option.getOrThrow,
|
||||||
), [no, piece]);
|
);
|
||||||
|
|
||||||
const moveDownAction = useCallback((entries: readonly Piece[]) => pipe(
|
const moveDownAction = (entries: readonly Piece[]) => pipe(
|
||||||
entries,
|
entries,
|
||||||
Array.remove(no - 1),
|
Array.remove(no - 1),
|
||||||
Array.insertAt(no, piece),
|
Array.insertAt(no, piece),
|
||||||
Option.getOrThrow,
|
Option.getOrThrow,
|
||||||
), [no, piece]);
|
);
|
||||||
|
|
||||||
const removeAction = useCallback((entries: readonly Piece[]) => pipe(
|
const removeAction = (entries: readonly Piece[]) => pipe(
|
||||||
entries,
|
entries,
|
||||||
Array.filter((p) => p.pieceId !== piece.pieceId),
|
Array.filter((p) => p.pieceId !== piece.pieceId),
|
||||||
), [piece.pieceId]);
|
);
|
||||||
|
|
||||||
const update = useCallback(async (action: (prev: readonly Piece[]) => readonly Piece[]) => {
|
const update = (action: (prev: readonly Piece[]) => readonly Piece[]) => Effect.gen(function* () {
|
||||||
|
|
||||||
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
||||||
|
|
||||||
const { error } = await client
|
yield* client.updateRepertoire({
|
||||||
.repertoire({ repertoireId: repertoire.repertoireId })
|
repertoireId: repertoire.repertoireId,
|
||||||
.put({
|
name: repertoire.name,
|
||||||
name: repertoire.name,
|
entries: pipe(repertoire.entries, action, mapToId),
|
||||||
entries: pipe(repertoire.entries, action, mapToId),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEntries(action);
|
setEntries(action);
|
||||||
}, [repertoire.entries, repertoire.name, repertoire.repertoireId, setEntries]);
|
});
|
||||||
|
|
||||||
const moveUp = useMemo(() => update.bind(undefined, moveUpAction), [moveUpAction, update]);
|
const moveUp = () => Effect.runPromise(update(moveUpAction));
|
||||||
const moveDown = useMemo(() => update.bind(undefined, moveDownAction), [moveDownAction, update]);
|
const moveDown = () => Effect.runPromise(update(moveDownAction));
|
||||||
const remove = useMemo(() => update.bind(undefined, removeAction), [removeAction, update]);
|
const remove = () => Effect.runPromise(update(removeAction));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -283,22 +269,16 @@ function AddEntryDialogContent(props: AddEntryDialogContent.Props) {
|
|||||||
|
|
||||||
const debounce = useRef(Effect.void);
|
const debounce = useRef(Effect.void);
|
||||||
|
|
||||||
const { isLoading, error, data: pieceIds } = useLoadingEffect(Effect.gen(function* () {
|
const { isLoading, error, data: pieceIds } = useLoading(Effect.gen(function* () {
|
||||||
yield* debounce.current;
|
yield* debounce.current;
|
||||||
const { error, data } = yield* Effect.promise((signal) => client.piece.get({
|
const data = yield* client.queryPieces({
|
||||||
query: {
|
name: name !== "" ? Option.some(name) : Option.none(),
|
||||||
...(name !== "" ? { name } : undefined),
|
author: author !== "" ? Option.some(author) : Option.none(),
|
||||||
...(author !== "" ? { author } : undefined),
|
offset: 0,
|
||||||
limit: ADD_ENTRY_DIALOG_LIMIT,
|
limit: ADD_ENTRY_DIALOG_LIMIT,
|
||||||
},
|
});
|
||||||
fetch: { signal },
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (error !== null) {
|
return data;
|
||||||
return yield* Effect.fail(error);
|
|
||||||
} else {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}), [name, author]);
|
}), [name, author]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -343,7 +323,7 @@ function AddEntryDialogContent(props: AddEntryDialogContent.Props) {
|
|||||||
) : error !== null ? (
|
) : error !== null ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center">
|
<TableCell colSpan={4} className="text-center">
|
||||||
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`}
|
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -375,29 +355,22 @@ namespace EntryDialogPieceRow {
|
|||||||
|
|
||||||
function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
|
function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
|
||||||
|
|
||||||
const { isLoading, error, data: piece } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
|
const { isLoading, error, data: piece } = useLoading(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
|
||||||
|
|
||||||
const onClick = useCallback(async () => {
|
const onClick = () => Effect.gen(function* () {
|
||||||
|
|
||||||
const action = Array.append(piece!);
|
const action = Array.append(piece!);
|
||||||
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
||||||
|
|
||||||
const { error } = await client
|
yield* client.updateRepertoire({
|
||||||
.repertoire({ repertoireId: props.repertoire.repertoireId })
|
repertoireId: props.repertoire.repertoireId,
|
||||||
.put({
|
name: props.repertoire.name,
|
||||||
name: props.repertoire.name,
|
entries: pipe(props.repertoire.entries, action, mapToId),
|
||||||
entries: pipe(props.repertoire.entries, action, mapToId),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
props.setEntries(action);
|
props.setEntries(action);
|
||||||
props.setDialogOpen(false);
|
props.setDialogOpen(false);
|
||||||
|
}).pipe(Effect.runPromise);
|
||||||
}, [piece, props]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -412,9 +385,10 @@ function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2}>
|
<TableCell colSpan={2}>
|
||||||
Wystąpił błąd: {Match.value(error).pipe(
|
Wystąpił błąd: {Match.value(error).pipe(
|
||||||
Match.when({ status: 401 }, () => "Zaloguj się ponownie"),
|
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
|
||||||
Match.when({ status: 422 }, ({ value }) => value.message),
|
Match.tag("NotFound", () => "Repertuar nie istnieje"),
|
||||||
Match.when({ status: 404 }, () => "Utwór nie istnieje"),
|
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
|
||||||
|
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
|
||||||
Match.exhaustive,
|
Match.exhaustive,
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogT
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { useLoadingEffect } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { created, DEBOUNCE, modified } from "@/snippets";
|
import { created, DEBOUNCE, modified } from "@/snippets";
|
||||||
import { RepertoireId } from "common";
|
import { RepertoireId } from "common";
|
||||||
import { Cause, Effect, Match } from "effect";
|
import { Cause, Effect, Match, Option, Scope } from "effect";
|
||||||
import { Loader2, Plus } from "lucide-react";
|
import { Loader2, Plus } from "lucide-react";
|
||||||
import { FormEventHandler, ReactNode, useId, useRef, useState } from "react";
|
import { FormEventHandler, ReactNode, useId, useRef, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
@@ -19,20 +19,14 @@ export function Repertoires() {
|
|||||||
|
|
||||||
const debounce = useRef(Effect.void);
|
const debounce = useRef(Effect.void);
|
||||||
|
|
||||||
const { isLoading, error, data: repertoireIds } = useLoadingEffect(Effect.gen(function* () {
|
const { isLoading, error, data: repertoireIds } = useLoading(Effect.gen(function* () {
|
||||||
yield* debounce.current;
|
yield* debounce.current;
|
||||||
const { error, data } = yield* Effect.promise((signal) => client.repertoire.get({
|
const data = yield* client.queryRepertoire({
|
||||||
query: {
|
name: name !== "" ? Option.some(name) : Option.none(),
|
||||||
...(name !== "" ? { name } : undefined),
|
offset: 0,
|
||||||
},
|
limit: 100,
|
||||||
fetch: { signal },
|
});
|
||||||
}));
|
return data;
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
return yield* Effect.fail(error);
|
|
||||||
} else {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}), [name]);
|
}), [name]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,7 +73,7 @@ export function Repertoires() {
|
|||||||
) : error !== null ? (
|
) : error !== null ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center">
|
<TableCell colSpan={4} className="text-center">
|
||||||
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`}
|
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -99,7 +93,7 @@ namespace RepertoireRow {
|
|||||||
|
|
||||||
function RepertoireRow(props: RepertoireRow.Props) {
|
function RepertoireRow(props: RepertoireRow.Props) {
|
||||||
|
|
||||||
const { isLoading, error, data: repertoire } = useLoadingEffect(Effect.uninterruptible(repertoireCache.get(props.repertoireId)), [props.repertoireId]);
|
const { isLoading, error, data: repertoire } = useLoading(Effect.uninterruptible(repertoireCache.get(props.repertoireId)), [props.repertoireId]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -114,9 +108,10 @@ function RepertoireRow(props: RepertoireRow.Props) {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4}>
|
<TableCell colSpan={4}>
|
||||||
Wystąpił błąd: {Match.value(error).pipe(
|
Wystąpił błąd: {Match.value(error).pipe(
|
||||||
Match.when({ status: 401 }, () => "Zaloguj się ponownie"),
|
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
|
||||||
Match.when({ status: 422 }, ({ value }) => value.message),
|
Match.tag("NotFound", () => "Repertuar nie istnieje"),
|
||||||
Match.when({ status: 404 }, () => "Repertuar nie istnieje"),
|
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
|
||||||
|
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
|
||||||
Match.exhaustive,
|
Match.exhaustive,
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -166,27 +161,23 @@ function AddRepertoireDialogContent() {
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
const onSubmit: FormEventHandler<HTMLFormElement> = (e) => Effect.gen(function* () {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
try {
|
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
|
||||||
|
|
||||||
|
yield* Scope.addFinalizer(scope, Effect.sync(() => setIsLoading(false)));
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const { data, error } = await client.repertoire.post({
|
const { repertoireId } = yield* client.createRepertoire({
|
||||||
name,
|
name,
|
||||||
entries: [],
|
entries: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error !== null) {
|
navigate(repertoireId);
|
||||||
console.error(error.value);
|
}));
|
||||||
return;
|
}).pipe(Effect.runPromise);
|
||||||
}
|
|
||||||
|
|
||||||
navigate(data.repertoireId);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
import { API_URL_PREFIX, client } from "@/client";
|
import { client } from "@/client";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { setUser, useStore } from "@/hooks/useStore";
|
import { setUser, useStore } from "@/hooks/useStore";
|
||||||
import { Settings, User } from "lucide-react";
|
import { Effect, pipe } from "effect";
|
||||||
|
import { LogOut, Settings, User } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Link, Outlet } from "react-router-dom";
|
import { Link, Outlet, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export function Root() {
|
export function Root() {
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const user = useStore(state => state.user);
|
const user = useStore(state => state.user);
|
||||||
|
|
||||||
const init = async () => {
|
const init = Effect.gen(function* () {
|
||||||
if (user !== null) return;
|
if (user !== null) return;
|
||||||
|
|
||||||
const { data, error } = await client.me.get();
|
const data = yield* pipe(
|
||||||
|
client.me(),
|
||||||
if (error !== null) {
|
Effect.tapErrorTag("Unauthenticated", () => Effect.sync(() => {
|
||||||
window.location.href = `${API_URL_PREFIX}/api/v1/login`;
|
navigate("/login");
|
||||||
return;
|
})),
|
||||||
}
|
);
|
||||||
|
|
||||||
setUser(data);
|
setUser(data);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
const onLogoutClick = () => Effect.gen(function* () {
|
||||||
|
yield* client.logout();
|
||||||
|
|
||||||
|
setUser(null);
|
||||||
|
navigate("/login");
|
||||||
|
}).pipe(Effect.runPromise);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => void init(), []);
|
useEffect(() => void Effect.runFork(init), []);
|
||||||
|
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +53,7 @@ export function Root() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant="outline">
|
<Button type="button" variant="outline">
|
||||||
<User />{user.username}
|
<User />{user.displayName}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -52,6 +62,9 @@ export function Root() {
|
|||||||
<Settings />Ustawienia
|
<Settings />Ustawienia
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onLogoutClick}>
|
||||||
|
<LogOut />Wyloguj się
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export function Settings() {
|
export function Settings() {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 overflow-y-auto grow flex flex-wrap items-start gap-4">
|
<div className="p-4 overflow-y-auto grow flex flex-wrap items-start gap-4">
|
||||||
|
Jakby były ustawienia, to by tu były.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Piece, SystemInformation } from "@/cache";
|
import { Piece, DenormalizedSystemInformation } from "@/cache";
|
||||||
import { timeout } from "@/lib/utils";
|
import { timeout } from "@/lib/utils";
|
||||||
import { Clock, Duration, Option } from "effect";
|
import { Clock, DateTime, Duration, Option } from "effect";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export const DEBOUNCE = Clock.sleep(Duration.millis(250));
|
export const DEBOUNCE = Clock.sleep(Duration.millis(250));
|
||||||
|
export const SAVE_DELAY = Clock.sleep(Duration.millis(250));
|
||||||
|
|
||||||
export const saveDelay = () => timeout(250);
|
export const saveDelay = () => timeout(250);
|
||||||
|
|
||||||
@@ -30,9 +31,9 @@ export function authors(piece: Piece): ReactNode {
|
|||||||
return nodes.flatMap((x, i, a) => i < a.length - 1 ? [x, <br />] : [x]);
|
return nodes.flatMap((x, i, a) => i < a.length - 1 ? [x, <br />] : [x]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function created({ createdAt, createdBy }: SystemInformation): ReactNode {
|
export function created({ createdAt, createdBy }: DenormalizedSystemInformation): ReactNode {
|
||||||
|
|
||||||
const nodes: ReactNode[] = [createdAt];
|
const nodes: ReactNode[] = [DateTime.formatLocal(createdAt)];
|
||||||
|
|
||||||
if (Option.isSome(createdBy)) {
|
if (Option.isSome(createdBy)) {
|
||||||
nodes.push(<br />);
|
nodes.push(<br />);
|
||||||
@@ -46,7 +47,7 @@ export function created({ createdAt, createdBy }: SystemInformation): ReactNode
|
|||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNode {
|
export function modified({ modifiedAt, modifiedBy }: DenormalizedSystemInformation): ReactNode {
|
||||||
|
|
||||||
if (Option.isNone(modifiedAt)) {
|
if (Option.isNone(modifiedAt)) {
|
||||||
if (Option.isNone(modifiedBy)) {
|
if (Option.isNone(modifiedBy)) {
|
||||||
@@ -60,7 +61,7 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes: ReactNode[] = [modifiedAt.value];
|
const nodes: ReactNode[] = [DateTime.formatLocal(modifiedAt.value)];
|
||||||
|
|
||||||
if (Option.isSome(modifiedBy)) {
|
if (Option.isSome(modifiedBy)) {
|
||||||
nodes.push(<br />);
|
nodes.push(<br />);
|
||||||
|
|||||||
@@ -1,9 +1,123 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
@import "tw-animate-css";
|
||||||
@tailwind utilities;
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-sans: Lato, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
--accent-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--accent: oklch(0.97 0.001 106.424);
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--border: oklch(0.923 0.003 48.717);
|
||||||
|
--card-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--input: oklch(0.923 0.003 48.717);
|
||||||
|
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||||
|
--muted: oklch(0.97 0.001 106.424);
|
||||||
|
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--primary: oklch(0.216 0.006 56.043);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--ring: oklch(0.709 0.01 56.259);
|
||||||
|
--secondary-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--secondary: oklch(0.97 0.001 106.424);
|
||||||
|
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--sidebar-accent: oklch(0.97 0.001 106.424);
|
||||||
|
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||||
|
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-primary: oklch(0.216 0.006 56.043);
|
||||||
|
--sidebar-ring: oklch(0.709 0.01 56.259);
|
||||||
|
--sidebar: oklch(0.985 0.001 106.423);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--accent-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--accent: oklch(0.268 0.007 34.298);
|
||||||
|
--background: oklch(0.147 0.004 49.25);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--card-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--card: oklch(0.216 0.006 56.043);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||||
|
--muted: oklch(0.268 0.007 34.298);
|
||||||
|
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--popover: oklch(0.216 0.006 56.043);
|
||||||
|
--primary-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--primary: oklch(0.923 0.003 48.717);
|
||||||
|
--ring: oklch(0.553 0.013 58.071);
|
||||||
|
--secondary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--secondary: oklch(0.268 0.007 34.298);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-accent: oklch(0.268 0.007 34.298);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||||
|
--sidebar: oklch(0.216 0.006 56.043);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
* {
|
||||||
--radius: 0.5rem;
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import tailwindcssAnimate from "tailwindcss-animate";
|
|
||||||
|
|
||||||
/** @type {import("tailwindcss").Config} */
|
|
||||||
export default {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ["Lato", "sans-serif"],
|
|
||||||
mono: ["JetBrains Mono", "monospace"],
|
|
||||||
},
|
|
||||||
extend: {
|
|
||||||
borderRadius: {
|
|
||||||
lg: "var(--radius)",
|
|
||||||
md: "calc(var(--radius) - 2px)",
|
|
||||||
sm: "calc(var(--radius) - 4px)",
|
|
||||||
},
|
|
||||||
colors: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [tailwindcssAnimate],
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
const ReactCompilerConfig = {
|
||||||
|
target: "19",
|
||||||
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
react({
|
||||||
|
babel: {
|
||||||
|
plugins: [
|
||||||
|
["babel-plugin-react-compiler", ReactCompilerConfig],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
|||||||
3538
pnpm-lock.yaml
generated
3538
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,42 +1,44 @@
|
|||||||
packages:
|
packages:
|
||||||
|
- '.'
|
||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
|
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- 'gl'
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
|
- '@tailwindcss/oxide'
|
||||||
- 'esbuild'
|
- 'esbuild'
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
'@elysiajs/cors': '^1.2.0'
|
'@effect/language-service': '^0.21.4'
|
||||||
'@elysiajs/eden': '^1.2.0'
|
'@eslint/js': '^9.29.0'
|
||||||
'@elysiajs/static': '^1.2.0'
|
'@radix-ui/react-dialog': '^1.1.14'
|
||||||
'@elysiajs/swagger': '^1.2.2'
|
'@radix-ui/react-dropdown-menu': '^2.1.15'
|
||||||
'@eslint/js': '^9.23.0'
|
'@radix-ui/react-label': '^2.1.7'
|
||||||
'@radix-ui/react-dialog': '^1.1.6'
|
'@radix-ui/react-slot': '^1.2.3'
|
||||||
'@radix-ui/react-dropdown-menu': '^2.1.6'
|
'@stylistic/eslint-plugin': '^4.4.1'
|
||||||
'@radix-ui/react-label': '^2.1.2'
|
'@tailwindcss/vite': '^4.1.10'
|
||||||
'@radix-ui/react-slot': '^1.1.2'
|
'@types/bun': '^1.2.16'
|
||||||
'@stylistic/eslint-plugin': '^4.2.0'
|
'@types/react': '^19.1.8'
|
||||||
'@types/bun': '^1.2.8'
|
'@types/react-dom': '^19.1.6'
|
||||||
'@types/react': '^18.3.12'
|
'@vitejs/plugin-react': '^4.5.2'
|
||||||
'@types/react-dom': '^18.3.1'
|
babel-plugin-react-compiler: '^19.1.0-rc.2'
|
||||||
'@vitejs/plugin-react': '^4.3.4'
|
cbor2: '^2.0.1'
|
||||||
autoprefixer: '^10.4.21'
|
|
||||||
class-variance-authority: '^0.7.1'
|
class-variance-authority: '^0.7.1'
|
||||||
clsx: '^2.1.1'
|
clsx: '^2.1.1'
|
||||||
effect: '^3.14.2'
|
effect: '^3.16.8'
|
||||||
elysia: '^1.2.2'
|
eslint-plugin-react-hooks: '6.0.0-rc1'
|
||||||
eslint-plugin-react-hooks: '^5.2.0'
|
|
||||||
jszip: '^3.10.1'
|
jszip: '^3.10.1'
|
||||||
kysely: '^0.27.6'
|
kysely: '^0.28.2'
|
||||||
kysely-bun-sqlite: '^0.3.2'
|
kysely-bun-sqlite: '^0.4.0'
|
||||||
lucide-react: '^0.485.0'
|
lucide-react: '^0.518.0'
|
||||||
opensheetmusicdisplay: '^1.9.0'
|
opensheetmusicdisplay: '^1.9.0'
|
||||||
postcss: '^8.5.3'
|
react: '^19.1.0'
|
||||||
react: '^18.3.1'
|
react-dom: '^19.1.0'
|
||||||
react-dom: '^18.3.1'
|
react-router-dom: '^7.6.2'
|
||||||
react-router-dom: '^6.30.0'
|
tailwind-merge: '^3.3.1'
|
||||||
tailwind-merge: '^2.6.0'
|
tailwindcss: '^4.1.10'
|
||||||
tailwindcss: '^3.4.17'
|
tw-animate-css: '^1.3.4'
|
||||||
tailwindcss-animate: '^1.0.7'
|
typescript: '^5.8.3'
|
||||||
typescript: '^5.8.2'
|
typescript-eslint: '^8.34.1'
|
||||||
typescript-eslint: '^8.28.0'
|
vite: '^6.3.5'
|
||||||
vite: '^6.2.3'
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
"backend": ["./packages/backend/src/index.ts"],
|
"backend": ["./packages/backend/src/index.ts"],
|
||||||
"backend/*": ["./packages/backend/src/*.ts"],
|
"backend/*": ["./packages/backend/src/*.ts"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"plugins": [
|
||||||
|
{ "name": "@effect/language-service" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"include": ["${configDir}/src"],
|
"include": ["${configDir}/src"],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user