feat: initial import.

This commit is contained in:
mattia
2025-01-04 14:56:35 +01:00
commit 7751d37249
33 changed files with 6201 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
HTTP_PORT=9283
HTTP_HOST=localhost
DATABASE_NAME=game_logger
DATABASE_HOST=localhost
DATABASE_USER=game_logger
DATABASE_PASSWORD=game_logger
DATABASE_PORT=3306
DATABASE_CONNECTIONLIMIT=10

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v23.5.0

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"esbenp.prettier-vscode"
]
}

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach by Process ID",
"port": 4321,
"request": "attach",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
}
]
}

20
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "dev",
"problemMatcher": [],
"label": "npm: dev",
"detail": "Run the development environment"
},
{
"type": "npm",
"script": "build",
"group": "build",
"problemMatcher": [],
"label": "npm: build",
"detail": "Build the production server"
}
]
}

5175
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "game-logger",
"version": "0.0.1",
"description": "",
"main": "dist/server/index.js",
"scripts": {
"dev:server": "ts-node-dev --project ./src/server/tsconfig.json --inspect=4321 --respawn --transpile-only --ignore-watch node_modules ./src/server/index.ts",
"dev:client": "vite serve ./src/client",
"dev:typecheck": "tsc --noEmit --watch --project ./src/client/tsconfig.json --preserveWatchOutput",
"dev": "concurrently -c magenta,blue,green npm:dev:*",
"build:server": "tsc --project ./src/server/tsconfig.json",
"build:client": "vite build ./src/client",
"build": "concurrently -c magenta,blue npm:build:*",
"start": "node dist/server/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.1",
"@mui/icons-material": "^6.3.1",
"@mui/material": "^6.3.1",
"@mui/x-date-pickers": "^7.23.3",
"@tanstack/react-query": "^4.0.0",
"@ts-rest/react-query": "^3.51.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.5",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"date-fns": "^4.1.0",
"material-react-table": "^3.1.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router": "^7.1.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"
},
"dependencies": {
"@ts-rest/core": "^3.51.0",
"@ts-rest/express": "^3.51.0",
"async-wrapper-express-ts": "^3.1.6",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.0.0",
"kysely": "^0.27.5",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0",
"zod": "^3.24.1"
}
}

0
src/client/App.css Normal file
View File

38
src/client/App.tsx Normal file
View File

@@ -0,0 +1,38 @@
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import { BrowserRouter, Route, Routes } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Sessions } from "./Sessions";
import { StrictMode } from "react";
import { ThemeProvider } from "@emotion/react";
import { CssBaseline } from "@mui/material";
import theme from "./theme";
import { SessionDetails } from "./SessionDetails";
import { Root } from "./Root";
const queryClient = new QueryClient();
export function App() {
return (
<StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Root />}>
<Route index element={<Sessions />} />
<Route
path="/session/:sessionId"
element={<SessionDetails />}
/>
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
</ThemeProvider>
</StrictMode>
);
}

46
src/client/Root.tsx Normal file
View File

@@ -0,0 +1,46 @@
import {
AppBar,
Breadcrumbs,
Container,
Link,
Stack,
Toolbar,
} from "@mui/material";
import {
Outlet,
Link as RouterLink,
useLocation,
useParams,
} from "react-router";
import "./theme";
export function Root() {
const location = useLocation();
const params = useParams();
return (
<Container maxWidth="lg">
<Stack spacing={2}>
<AppBar position="static">
<Toolbar>
<Breadcrumbs aria-label="breadcrumb">
<Link variant="breadcrumb" component={RouterLink} to="/">
Sessions
</Link>
{location.pathname.includes("/session/") && (
<Link
variant="breadcrumb"
component={RouterLink}
to={`/session/${params.sessionId}`}
>
{`${params.sessionId}`}
</Link>
)}
</Breadcrumbs>
</Toolbar>
</AppBar>
<Outlet />
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,102 @@
import { useParams } from "react-router";
import { client } from "./client";
import {
Card,
CardContent,
List,
ListItem,
ListItemText,
Stack,
Typography,
} from "@mui/material";
import { useMemo } from "react";
import {
MaterialReactTable,
MRT_ColumnDef,
useMaterialReactTable,
} from "material-react-table";
import { GetSessionResponse } from "./contract";
import { format } from "date-fns";
export function SessionDetails() {
const { sessionId } = useParams<{ sessionId: string }>();
if (sessionId === undefined) {
throw new Error("session id is required");
}
const getSessionQuery = client.getSession.useQuery(["session", sessionId], {
params: { id: sessionId },
});
const columns = useMemo<
MRT_ColumnDef<GetSessionResponse["log_entries"][0]>[]
>(
() => [
{
accessorKey: "message",
header: "Message",
},
{
accessorKey: "timestamp",
header: "Timestamp",
Cell: (x) => <span>{format(x.row.original.timestamp, "Pp")}</span>,
},
{
accessorKey: "metadata",
header: "Metadata",
Cell: (x) => {
const metadata = x.row.original.metadata;
return (
<List dense>
{Object.keys(metadata)
.sort()
.map((key) => (
<ListItem key={key}>
<ListItemText>{key}: {metadata[key]}</ListItemText>
</ListItem>
))}
</List>
);
},
},
],
[]
);
const table = useMaterialReactTable({
columns,
data:
getSessionQuery.status === "success"
? getSessionQuery.data.body.log_entries
: [],
});
return getSessionQuery.status === "error" ? (
<p>Error</p>
) : (
<Stack spacing={2}>
<Card>
<CardContent>
<Typography sx={{ color: "text.secondary" }}>
Session ID: {sessionId}
</Typography>
<Typography variant="h5" component="div">
Game:{" "}
{getSessionQuery.status === "loading"
? "..."
: getSessionQuery.data.body.game_name}
</Typography>
<Typography variant="body2">
Version{" "}
{getSessionQuery.status === "loading"
? "..."
: getSessionQuery.data.body.version}
</Typography>
</CardContent>
</Card>
{getSessionQuery.status === "success" && (
<MaterialReactTable table={table} />
)}
</Stack>
);
}

142
src/client/Sessions.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { client } from "./client";
import {
MaterialReactTable,
MRT_ColumnFiltersState,
MRT_Updater,
useMaterialReactTable,
type MRT_ColumnDef,
type MRT_PaginationState,
} from "material-react-table";
import { GetSessionsResponse } from "../common/contract";
import { format, formatDuration, interval, intervalToDuration } from "date-fns";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
export function Sessions() {
const [paginationState, setPaginationState] = useState<MRT_PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [columnFilters, setColumnFilters] = useState<MRT_ColumnFiltersState>(
[]
);
const getSessionsQuery = client.getSessions.useQuery(
["sessions", { columnFilters, paginationState }],
{
query: {
skip: paginationState.pageIndex * paginationState.pageSize,
count: paginationState.pageSize,
id: columnFilters.find((f) => f.id === "id")?.value as
| string
| undefined,
},
},
{
keepPreviousData: true,
}
);
return getSessionsQuery.status === "error" ? (
<p>Error</p>
) : (
<SessionsTable
data={
getSessionsQuery.status === "loading"
? { total: 0, sessions: [] }
: getSessionsQuery.data.body
}
paginationState={paginationState}
setPaginationState={setPaginationState}
isLoading={getSessionsQuery.status === "loading"}
isRefetching={getSessionsQuery.isRefetching}
columnFilters={columnFilters}
setColumnFilters={setColumnFilters}
/>
);
}
function SessionsTable({
data: { sessions, total },
paginationState,
columnFilters,
setPaginationState,
setColumnFilters,
isLoading,
isRefetching,
}: {
data: GetSessionsResponse;
paginationState: MRT_PaginationState;
setPaginationState: (updater: MRT_Updater<MRT_PaginationState>) => void;
setColumnFilters: (updater: MRT_Updater<MRT_ColumnFiltersState>) => void;
isLoading: boolean;
isRefetching: boolean;
columnFilters: MRT_ColumnFiltersState;
}) {
const columns = useMemo<MRT_ColumnDef<GetSessionsResponse["sessions"][0]>[]>(
() => [
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "game_name",
header: "Game Name",
},
{
accessorKey: "version",
header: "Version",
},
{
accessorKey: "time",
header: "Time",
Cell: ({ row }) => {
const rangeInterval = interval(
row.original.start_time,
row.original.end_time
);
const duration = intervalToDuration(rangeInterval);
const formattedStartTime = format(row.original.start_time, "Pp");
const formattedDuration = formatDuration(duration);
return (
<span>
{formattedStartTime}
<br />({formattedDuration || "very quick"})
</span>
);
},
},
],
[]
);
const navigate = useNavigate();
const table = useMaterialReactTable({
data: sessions,
columns,
muiTableBodyRowProps: ({ row }) => ({
onClick: (_) => {
navigate(`/session/${row.original.id}`);
},
sx: {
cursor: "pointer",
},
}),
manualPagination: true,
rowCount: total,
onPaginationChange: setPaginationState,
onColumnFiltersChange: setColumnFilters,
state: {
pagination: paginationState,
isLoading,
showProgressBars: isRefetching,
columnFilters,
},
enableSorting: false,
enableGlobalFilter: false,
manualFiltering: true,
});
return <MaterialReactTable table={table} />;
}

10
src/client/api.ts Normal file
View File

@@ -0,0 +1,10 @@
const serverRoot =
import.meta.env.MODE === "development"
? "http://localhost:9283"
: window.location.protocol + "//" + window.location.host;
const getSessions = () =>
fetch(`${serverRoot}/api/get-sessions`)
.then((x) => x.json())
.then(console.log)
.catch(console.error);

13
src/client/client.ts Normal file
View File

@@ -0,0 +1,13 @@
// client.ts
import { initQueryClient } from "@ts-rest/react-query";
import { contract } from "../common/contract";
const serverRoot =
import.meta.env.MODE === "development"
? "http://localhost:9283"
: window.location.protocol + "//" + window.location.host;
export const client = initQueryClient(contract, {
baseUrl: serverRoot,
baseHeaders: {},
});

12
src/client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="initial-scale=1, width=device-width" />
<title>Game Logger</title>
<script type="module" src="./index.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

10
src/client/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
const domRoot = document.getElementById("root");
if (!domRoot) {
alert("Cannot find note root");
} else {
const root = createRoot(domRoot);
root.render(<App />);
}

50
src/client/theme.ts Normal file
View File

@@ -0,0 +1,50 @@
import { createTheme } from "@mui/material/styles";
import { red } from "@mui/material/colors";
import { TypographyOwnProps, TypographyPropsVariantOverrides } from "@mui/material";
import { OverridableStringUnion} from "@mui/types"
import { Variant } from "@mui/material/styles/createTypography";
declare module "@mui/material/Typography" {
// interface ButtonPropsVariantOverrides {
// dashed: true;
// }
interface TypographyPropsVariantOverrides {
breadcrumb: true;
}
}
// const x: LinkPropsVariantOverrides;
// A custom theme for this app
const theme = createTheme({
cssVariables: true,
palette: {
primary: {
main: "#556cd6",
},
secondary: {
main: "#19857b",
},
error: {
main: red.A400,
},
},
components: {
MuiLink: {
styleOverrides: {
root: {
variants: [
{
props: { variant: "breadcrumb" },
style: {
color: "#fff",
},
},
],
},
},
},
},
});
export default theme;

24
src/client/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"include": [
"**/*"
],
"exclude": [
"../../node_modules"
],
"compilerOptions": {
"isolatedModules": true,
"strict": true,
"allowSyntheticDefaultImports": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"types": [
"vite/client"
],
"jsx": "react-jsx",
"rootDirs": [
".",
"../common"
]
}
}

11
src/client/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
clearScreen: false,
build: {
outDir: "../../dist/client",
emptyOutDir: true
}
});

64
src/common/contract.ts Normal file
View File

@@ -0,0 +1,64 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
const c = initContract();
//////
const GetSessionsResponseSchema = z.object({
total: z.number().positive(),
sessions: z
.object({
id: z.string(),
game_name: z.string(),
version: z.string(),
start_time: z.date(),
end_time: z.date(),
num_events: z.number().positive(),
})
.array(),
});
export type GetSessionsResponse = z.infer<typeof GetSessionsResponseSchema>;
const GetSessionsRequestQuery = z.object({
skip: z.coerce.number().optional(),
count: z.coerce.number().optional(),
id: z.string().optional(),
});
//////
const GetSessionResponseSchema = z.object({
game_name: z.string(),
version: z.string(),
log_entries: z.object({
message: z.string(),
timestamp: z.date(),
metadata: z.record(z.string(), z.string())
}).array(),
});
export type GetSessionResponse = z.infer<typeof GetSessionResponseSchema>;
//////
export const contract = c.router({
getSessions: {
method: "GET",
path: "/api/get-sessions",
responses: {
200: GetSessionsResponseSchema,
},
query: GetSessionsRequestQuery,
summary: "get all the sessions",
},
getSession: {
method: "GET",
path: "/api/get-session/:id",
responses: {
200: GetSessionResponseSchema,
},
summary: "get a session by id",
}
});

16
src/common/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"include": [
"**/*"
],
"exclude": ["../../node_modules"],
"compilerOptions": {
"isolatedModules": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"types": [
"vite/client"
],
"jsx": "react-jsx"
}
}

View File

@@ -0,0 +1,44 @@
import { db } from "./init";
export async function addEntry(
sessionGuid: string,
gameName: string,
version: string,
message: string,
metadata: [string, string][]
) {
await db.transaction().execute(async (trx) => {
// upsert the session info
await trx
.insertInto("session_info")
.ignore()
.values({
id: sessionGuid,
game_name: gameName,
version,
})
.execute();
// add the main log line
const insertResult = await trx
.insertInto("log_entries")
.values({
session_info_id: sessionGuid,
message: message,
timestamp: new Date(),
})
.executeTakeFirstOrThrow();
// add all the metadata
if (metadata.length > 0) {
await trx
.insertInto("log_metadata")
.values(
metadata.map(([key, value]) => ({
log_entry_id: Number(insertResult.insertId),
key,
value,
}))
)
.execute();
}
});
}

View File

@@ -0,0 +1,43 @@
import { GetSessionResponse } from "../contract";
import { db } from "./init";
export async function getSession(id: string): Promise<GetSessionResponse> {
// extract the session
const session = await db
.selectFrom("session_info")
.where("id", "=", id)
.select(["game_name", "version"])
.executeTakeFirstOrThrow();
// get all the connected log entries
const logEntries = await db
.selectFrom("log_entries")
.where("session_info_id", "=", id)
.select(["id", "message", "timestamp"])
.orderBy("timestamp", "asc")
.execute();
// get all the metadata for the log entries
const metadata = await db
.selectFrom("log_metadata")
.where(
"log_entry_id",
"in",
logEntries.map((entry) => entry.id)
)
.select(["log_entry_id", "key", "value"])
.execute();
// buidl the object accordingly
return {
game_name: session.game_name,
version: session.version,
log_entries: logEntries.map((entry) => ({
message: entry.message,
timestamp: entry.timestamp,
metadata: metadata
.filter((m) => m.log_entry_id === entry.id)
.reduce((acc, m) => ({ ...acc, [m.key]: m.value }), {}),
})),
};
}

View File

@@ -0,0 +1,46 @@
import { GetSessionsResponse } from "../contract";
import { db } from "./init";
import { toNumber } from "./to-number";
export async function getSessions(
skip?: number,
count?: number,
id?: string
): Promise<GetSessionsResponse> {
await new Promise((resolve) => setTimeout(resolve, 1000));
let countQuery = db
.selectFrom("session_info")
.select((eb) => eb.fn.count("id").as("total_value"));
if (id) {
countQuery = countQuery.where("session_info.id", "like", `%${id}%`);
}
const { total_value } = await countQuery.executeTakeFirstOrThrow();
let total = toNumber(total_value);
let query = db
.selectFrom("session_info")
.innerJoin("log_entries", "log_entries.session_info_id", "session_info.id")
.groupBy("session_info.id")
.select(({ fn }) => [
"session_info.id",
"session_info.game_name",
"session_info.version",
fn.min("log_entries.timestamp").as("start_time"),
fn.max("log_entries.timestamp").as("end_time"),
fn.count("log_entries.id").as("num_events"),
])
.orderBy("start_time", "desc")
.limit(Math.min(50, count || 10))
.offset(skip || 0);
if (id) {
query = query.where("session_info.id", "like", `%${id}%`);
}
const sessions = await query.execute();
return {
total,
sessions: sessions.map((session) => ({
...session,
num_events: toNumber(session.num_events),
})),
};
}

1
src/server/db/index.ts Normal file
View File

@@ -0,0 +1 @@
export { addEntry } from "./add-entry";

39
src/server/db/init.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Database } from "./types"; // this is the Database interface we defined earlier
import { createPool } from "mysql2"; // do not use 'mysql2/promises'!
import { Kysely, MysqlDialect } from "kysely";
import { env } from "process";
const dialect = new MysqlDialect({
pool: createPool({
database: env.DATABASE_NAME || "",
host: env.DATABASE_HOST || "",
user: env.DATABASE_USER || "",
password: env.DATABASE_PASSWORD || "",
port: parseInt(env.DATABASE_PORT || "3308", 10),
connectionLimit: parseInt(
env.CONDATABASE_CONNECTIONLIMITNECTIONLIMIT || "10",
10
),
}),
});
export const db = new Kysely<Database>({
dialect,
log(event) {
if (event.level === "error") {
console.error("Query failed : ", {
durationMs: Math.round(event.queryDurationMillis),
error: event.error,
sql: event.query.sql,
params: event.query.parameters,
});
} else {
// `'query'`
console.log("Query executed : ", {
durationMs: Math.round(event.queryDurationMillis),
sql: event.query.sql,
params: event.query.parameters,
});
}
},
});

View File

@@ -0,0 +1,13 @@
export function toNumber(x: string | number | bigint): number {
if (typeof x === "string") {
return parseInt(x, 10);
} else if (typeof x === "bigint") {
if (x > Number.MAX_SAFE_INTEGER) {
throw new Error("Number is too large to be represented as a number");
} else {
return Number(x);
}
} else {
return x;
}
}

39
src/server/db/types.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Generated, Insertable, Selectable } from "kysely";
export interface Database {
log_entries: LogEntriesTable;
log_metadata: LogMetadataTable;
session_info: SessionInfoTable;
}
export interface LogEntriesTable
{
id: Generated<number>;
session_info_id: string;
message: string;
timestamp: Date;
}
export type LogEntry = Selectable<LogEntriesTable>;
export type NewLogEntry = Insertable<LogEntriesTable>;
export interface LogMetadataTable
{
id: Generated<number>;
log_entry_id: number;
key: string;
value: string;
}
export type LogMetadata = Selectable<LogMetadataTable>;
export type NewLogMetadata = Insertable<LogMetadataTable>;
export interface SessionInfoTable
{
id: string;
game_name: string;
version: string;
}
export type SessionInfo = Selectable<SessionInfoTable>;
export type NewSessionInfoTable = Insertable<SessionInfoTable>;

42
src/server/index.ts Normal file
View File

@@ -0,0 +1,42 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import { registerPostMessage } from "./post-message";
import { installRouter } from "./router";
import path from "path";
const app = express();
// serve che client files
app.use(express.static("./dist/client"));
// activate cors, but only if in dev mode (in prodution, the client is served by the same server)
if (process.env.TS_NODE_DEV) {
app.use(cors());
}
// enable body parser to parse json and urlencoded data
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// install the ts-rest router
installRouter(app);
// register the POST in multipart to add messages
registerPostMessage(app);
// handle every other GET route with index.html
app.get("*", function (_req, res, next) {
if (_req.method === "GET") {
res.sendFile(path.join(__dirname, "../client/index.html"));
} else {
next();
}
});
const port = parseInt(process.env.HTTP_PORT || "1111", 10);
const host = process.env.HTTP_HOST || "localhost";
app.listen(port, host, () => {
console.log(`Listening on http://${host}:${port}`);
});

View File

@@ -0,0 +1,59 @@
import type { Express } from "express";
import multer from "multer";
import { addEntry } from "./db";
const upload = multer();
export function registerPostMessage(app: Express) {
app.post("/", upload.none(), (req, res) => {
console.log("\nReceived logging message:");
const metadata: [string, string][] = [];
let gameName: string = "";
let sessionGuid: string = "";
let message: string = "";
let version: string = "";
for (const key of Object.keys(req.body)) {
const value = req.body[key];
console.log(` ${key}: ${value}`);
switch (key) {
case "gameName":
gameName = value;
break;
case "sessionGuid":
sessionGuid = value;
break;
case "message":
message = value;
break;
case "version":
version = value;
break;
default:
metadata.push([key, req.body[key]]);
break;
}
}
if (
gameName === "" ||
sessionGuid === "" ||
message === "" ||
version === ""
) {
res.status(422); // unprocessable entity: used when the body has correct syntax but wrong semantics
const errorMessage = `One of the necessary fields was missing: gameName=${gameName}, sessionGuid=${sessionGuid}, message=${message}, version=${version}`;
console.error(errorMessage);
res.send(errorMessage);
} else {
addEntry(sessionGuid, gameName, version, message, metadata)
.then(() => {
res.status(204); // no content
res.send("");
})
.catch((err) => {
console.error(err);
res.status(500); // internal server error
res.send("Internal server error");
});
}
});
}

27
src/server/router.ts Normal file
View File

@@ -0,0 +1,27 @@
import { createExpressEndpoints, initServer } from "@ts-rest/express";
import type { Express } from "express";
import { contract } from "../common/contract";
import { getSessions } from "./db/get-sessions";
import { getSession } from "./db/get-session";
export function installRouter(app: Express) {
const s = initServer();
const router = s.router(contract, {
getSessions: async ({ query: { skip, count, id } }) => {
const body = await getSessions(skip, count, id);
return {
status: 200,
body,
};
},
getSession: async ({ params: { id } }) => {
const body = await getSession(id);
return {
status: 200,
body,
};
},
});
createExpressEndpoints(contract, router, app);
}

27
src/server/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"include": [
"./**/*", "../common/**/*"
],
"exclude": ["../../node_modules"],
"compilerOptions": {
"types": [
"node",
"express"
],
"target": "ESNext",
"module": "CommonJS",
// "rootDir": ".",
"rootDirs": [".", "../common"],
"moduleResolution": "Node",
"typeRoots": [
"../../node_modules/@types"
],
"sourceMap": true,
"outDir": "../../dist/",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"strictNullChecks": true,
"skipLibCheck": true
}
}