Tournament data from tRPC

This commit is contained in:
Kalle (Sendou) 2021-11-14 13:32:40 +02:00
parent ca16aa5d07
commit a8a255fba6
20 changed files with 2527 additions and 1586 deletions

View File

@ -1,6 +1,6 @@
import { Routes as SolidAppRoutes, Route } from "solid-app-router";
import { lazy } from "solid-js";
import HelloData from "./scenes/tournament/components/TournamentsPage.data";
import TournamentData from "./scenes/tournament/components/TournamentsPage.data";
const TournamentsPage = lazy(
() => import("./scenes/tournament/components/TournamentsPage")
@ -9,15 +9,11 @@ const TournamentsPage = lazy(
export function Routes() {
return (
<SolidAppRoutes>
{/* <Route path="/users/:id" element={<User />}>
<Route path="/" element={<UserHome />} />
<Route path="/settings" element={<UserSettings />} />
<Route path="/*all" element={<UserNotFound />} />
</Route> */}
<Route
path="/to/:identifier"
element={<TournamentsPage />}
data={HelloData}
// TODO: fix type error
data={TournamentData as any}
>
<Route path="/*all" element={() => <>overview</>} />
<Route path="/map-pool" element={() => <>map pool</>} />

2046
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,42 @@
{
"name": "sendou.ink",
"type": "module",
"version": "0.0.0",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:server\"",
"dev:frontend": "vite",
"dev:server": "node --experimental-specifier-resolution=node --loader ts-node/esm server.ts",
"build:frontend": "vite build",
"migration:create": "npx prisma migrate dev --create-only",
"migration:apply:dev": "npx prisma migrate dev",
"migration:apply:prod": "npx prisma migrate deploy",
"seed": "npx prisma db seed",
"seed:reset": "npx prisma migrate reset"
},
"license": "MIT",
"dependencies": {
"@prisma/client": "^3.4.2",
"@trpc/client": "^9.13.0",
"@trpc/server": "^9.13.0",
"cors": "^2.8.5",
"express": "^4.17.1",
"normalize.css": "^8.0.1",
"solid-app-router": "^0.1.11",
"solid-js": "^1.1.3",
"zod": "^3.11.6"
},
"devDependencies": {
"typescript": "^4.4.3",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/node": "^16.11.7",
"concurrently": "^6.4.0",
"prisma": "^3.4.2",
"ts-node": "^10.4.0",
"typescript": "^4.4.4",
"vite": "^2.5.7",
"vite-plugin-solid": "^2.0.3"
},
"dependencies": {
"@trpc/client": "^9.13.0",
"normalize.css": "^8.0.1",
"solid-app-router": "^0.1.11",
"solid-js": "^1.1.3"
"prisma": {
"seed": "node --experimental-specifier-resolution=node --loader ts-node/esm prisma/seed.ts"
}
}

6
prisma/client.ts Normal file
View File

@ -0,0 +1,6 @@
import pkg from "@prisma/client";
const { PrismaClient } = pkg;
const prisma = new PrismaClient();
export default prisma;

View File

@ -0,0 +1,92 @@
-- CreateEnum
CREATE TYPE "Mode" AS ENUM ('TW', 'SZ', 'TC', 'RM', 'CB');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"discordId" TEXT NOT NULL,
"discordName" TEXT NOT NULL,
"discordDiscriminator" TEXT NOT NULL,
"discordAvatar" TEXT,
"discordRefreshToken" TEXT NOT NULL,
"twitch" TEXT,
"twitter" TEXT,
"youtubeId" TEXT,
"youtubeName" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Organization" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"nameForUrl" TEXT NOT NULL,
"ownerId" INTEGER NOT NULL,
"discordInvite" TEXT NOT NULL,
"twitter" TEXT,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tournament" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"nameForUrl" TEXT NOT NULL,
"description" TEXT,
"startTime" TIMESTAMP(3) NOT NULL,
"checkInTime" TIMESTAMP(3) NOT NULL,
"bannerBackground" TEXT NOT NULL,
"bannerTextHSLArgs" TEXT NOT NULL,
"organizerId" INTEGER NOT NULL,
CONSTRAINT "Tournament_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Stage" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"mode" "Mode" NOT NULL,
CONSTRAINT "Stage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_StageToTournament" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_discordId_key" ON "User"("discordId");
-- CreateIndex
CREATE UNIQUE INDEX "Organization_nameForUrl_key" ON "Organization"("nameForUrl");
-- CreateIndex
CREATE UNIQUE INDEX "Organization_ownerId_key" ON "Organization"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "Tournament_nameForUrl_organizerId_key" ON "Tournament"("nameForUrl", "organizerId");
-- CreateIndex
CREATE UNIQUE INDEX "_StageToTournament_AB_unique" ON "_StageToTournament"("A", "B");
-- CreateIndex
CREATE INDEX "_StageToTournament_B_index" ON "_StageToTournament"("B");
-- AddForeignKey
ALTER TABLE "Organization" ADD CONSTRAINT "Organization_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tournament" ADD CONSTRAINT "Tournament_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_StageToTournament" ADD FOREIGN KEY ("A") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_StageToTournament" ADD FOREIGN KEY ("B") REFERENCES "Tournament"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

70
prisma/schema.prisma Normal file
View File

@ -0,0 +1,70 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
discordId String @unique
discordName String
discordDiscriminator String
discordAvatar String?
discordRefreshToken String
twitch String?
twitter String?
youtubeId String?
youtubeName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ownedOrganization Organization?
}
model Organization {
id Int @id @default(autoincrement())
name String
/// Name in lower case to show in URL
nameForUrl String @unique
ownerId Int @unique
owner User @relation(fields: [ownerId], references: [id])
discordInvite String
twitter String?
tournaments Tournament[]
}
model Tournament {
id Int @id @default(autoincrement())
name String
/// Name in lower case to show in URL
nameForUrl String
description String?
startTime DateTime
checkInTime DateTime
/// CSS for tournament banner's background value
bannerBackground String
/// CSS for tournament banner's color value
bannerTextHSLArgs String
mapPool Stage[]
organizerId Int
organizer Organization @relation(fields: [organizerId], references: [id])
// There might be duplicate nameForUrl's but inside an organization they're unique
@@unique([nameForUrl, organizerId])
}
enum Mode {
TW
SZ
TC
RM
CB
}
model Stage {
id Int @id @default(autoincrement())
name String
mode Mode
tournamentMapPools Tournament[]
}

104
prisma/seed.ts Normal file
View File

@ -0,0 +1,104 @@
import pkg from "@prisma/client";
import { stages as stagesList } from "../utils/constants";
const { PrismaClient } = pkg;
const prisma = new PrismaClient();
async function main() {
const user = await users();
const organization = await organizations(user.id);
const tournament = await tournaments(organization.id);
await stages();
await tournamentAddMaps(tournament.id);
}
async function users() {
return prisma.user.create({
data: {
discordDiscriminator: "4059",
discordId: "79237403620945920",
discordName: "Sendou",
discordRefreshToken: "none",
twitch: "Sendou",
youtubeId: "UCWbJLXByvsfQvTcR4HLPs5Q",
youtubeName: "Sendou",
discordAvatar: "fcfd65a3bea598905abb9ca25296816b",
twitter: "sendouc",
},
});
}
async function organizations(userId: number) {
return prisma.organization.create({
data: {
name: "Sendou's Tournaments",
discordInvite: "sendou",
nameForUrl: "sendou",
twitter: "sendouc",
ownerId: userId,
},
});
}
const modesList = ["TW", "SZ", "TC", "RM", "CB"] as const;
async function tournaments(organizationId: number) {
return prisma.tournament.create({
data: {
bannerBackground: "linear-gradient(to bottom, #9796f0, #fbc7d4)",
bannerTextHSLArgs: "231, 9%, 16%",
checkInTime: new Date(2025, 11, 17, 11),
startTime: new Date(2025, 11, 17, 12),
name: "In The Zone X",
nameForUrl: "in-the-zone-x",
organizerId: organizationId,
description: "In The Zone eXtreme",
},
});
}
function getRandomInt(maxInclusive: number) {
let result = -1;
while (result < 24) {
result = Math.floor(Math.random() * maxInclusive) + 1;
}
return result;
}
// TODO: why this can't be done while creating?
async function tournamentAddMaps(id: number) {
const ids = Array.from(
new Set(new Array(24).fill(null).map(() => ({ id: getRandomInt(115) })))
);
return prisma.tournament.update({
where: { id },
data: {
mapPool: {
connect: ids,
},
},
});
}
async function stages() {
return prisma.stage.createMany({
data: modesList.flatMap((mode) => {
return stagesList.map((name) => {
return {
name,
mode,
};
});
}),
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -1,19 +1,19 @@
import { useData } from "solid-app-router";
import { createResource } from "solid-js";
import { trpcClient } from "../../../utils/trpc";
import { InferQueryOutput, trpcClient } from "../../../utils/trpc-client";
function fetchHello(input: string) {
return trpcClient.query("hello", input);
}
export default function HelloData({
export default function TournamentData({
params,
}: {
params: { identifier: string };
}) {
const [user] = createResource(() => params.identifier, fetchHello);
const [user] = createResource(
() => params.identifier,
(identifier: string) => trpcClient.query("tournament.get", identifier)
);
return user;
}
export const useHelloData = () => useData<() => string>();
export const useTournamentData = () =>
useData<() => InferQueryOutput<"tournament.get">>();

View File

@ -1,11 +1,12 @@
import { Outlet, useData } from "solid-app-router";
import { Outlet } from "solid-app-router";
import { useTournamentData } from "./TournamentsPage.data";
export default function TournamentsPage() {
const user = useData<() => string>();
const tournament = useTournamentData();
return (
<>
response: "{user()}"
response: "<pre>{JSON.stringify(tournament(), null, 2)}</pre>"
<p>
<Outlet />
</p>

View File

@ -0,0 +1,12 @@
import { z } from "zod";
import { createRouter } from "../../utils/trpc-server";
import { findTournamentByNameForUrl } from "./tournament-service";
export const tournament = createRouter().query("get", {
input: z.string(),
resolve: ({ input }) =>
findTournamentByNameForUrl({
organizationNameForUrl: "sendou",
tournamentNameForUrl: input,
}),
});

View File

@ -0,0 +1,61 @@
import prisma from "../../prisma/client";
export async function findTournamentByNameForUrl({
organizationNameForUrl,
tournamentNameForUrl,
}: {
organizationNameForUrl: string;
tournamentNameForUrl: string;
}) {
const tournaments = await prisma.tournament.findMany({
where: {
nameForUrl: tournamentNameForUrl.toLowerCase(),
},
select: {
name: true,
description: true,
startTime: true,
checkInTime: true,
bannerBackground: true,
bannerTextHSLArgs: true,
organizer: {
select: {
name: true,
discordInvite: true,
twitter: true,
nameForUrl: true,
},
},
mapPool: {
select: {
mode: true,
name: true,
},
},
},
});
const result = tournaments.find(
(tournament) =>
tournament.organizer.nameForUrl === organizationNameForUrl.toLowerCase()
);
if (!result) return result;
result.organizer.twitter = twitterToUrl(result.organizer.twitter);
result.organizer.discordInvite = discordInviteToUrl(
result.organizer.discordInvite
);
return result;
}
function twitterToUrl(twitter: string | null) {
if (!twitter) return twitter;
return `https://twitter.com/${twitter}`;
}
function discordInviteToUrl(discordInvite: string) {
return `https://discord.com/invite/${discordInvite}`;
}

35
server.ts Normal file
View File

@ -0,0 +1,35 @@
import * as trpcExpress from "@trpc/server/adapters/express/dist/trpc-server-adapters-express.cjs";
import cors from "cors";
import express from "express";
import { tournament as tournamentRouter } from "./scenes/tournament/tournament-router";
import { createContext, createRouter } from "./utils/trpc-server";
export const appRouter = createRouter().merge("tournament.", tournamentRouter);
export type AppRouter = typeof appRouter;
async function main() {
const app = express();
app.use(cors());
app.use((req, _res, next) => {
console.log("⬅️ ", req.method, req.path, req.body ?? req.query);
next();
});
app.use(
"/trpc",
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
})
);
app.listen(2021, () => {
console.log("listening on port 2021");
});
}
main();

View File

@ -1,147 +0,0 @@
import { EventEmitter } from "events";
import express from "express";
import cors from "cors";
import * as trpc from "@trpc/server";
import { z } from "zod";
import * as trpcExpress from "@trpc/server/adapters/express/dist/trpc-server-adapters-express.cjs";
import { TRPCError } from "@trpc/server";
const createContext = ({
req,
res,
}: trpcExpress.CreateExpressContextOptions) => {
const getUser = () => {
if (req.headers.authorization !== "secret") {
return null;
}
return {
name: "alex",
};
};
return {
req,
res,
user: getUser(),
};
};
type Context = trpc.inferAsyncReturnType<typeof createContext>;
function createRouter() {
return trpc.router<Context>();
}
// --------- create procedures etc
let id = 0;
const ee = new EventEmitter();
const db = {
posts: [
{
id: ++id,
title: "hello",
},
],
messages: [createMessage("initial message")],
};
function createMessage(text: string) {
const msg = {
id: ++id,
text,
createdAt: Date.now(),
updatedAt: Date.now(),
};
ee.emit("newMessage", msg);
return msg;
}
const posts = createRouter()
.mutation("create", {
input: z.object({
title: z.string(),
}),
resolve: ({ input }) => {
const post = {
id: ++id,
...input,
};
db.posts.push(post);
return post;
},
})
.query("list", {
resolve: () => db.posts,
});
const messages = createRouter()
.query("list", {
resolve: () => db.messages,
})
.mutation("add", {
input: z.string(),
resolve: async ({ input }) => {
const msg = createMessage(input);
db.messages.push(msg);
return msg;
},
});
// root router to call
export const appRouter = createRouter()
.query("hello", {
input: z.string().nullish(),
resolve: ({ input, ctx }) => {
return `hello ${input ?? ctx.user?.name ?? "world"}`;
},
})
.merge("post.", posts)
.merge(
"admin.",
createRouter().query("secret", {
resolve: ({ ctx }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (ctx.user?.name !== "alex") {
throw new TRPCError({ code: "FORBIDDEN" });
}
return {
secret: "sauce",
};
},
})
)
.merge("messages.", messages);
export type AppRouter = typeof appRouter;
async function main() {
// express implementation
const app = express();
app.use(cors());
app.use((req, _res, next) => {
// request logger
console.log("⬅️ ", req.method, req.path, req.body ?? req.query);
next();
});
app.use(
"/trpc",
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
})
);
app.get("/", (_req, res) => res.send("hello"));
app.listen(2021, () => {
console.log("listening on port 2021");
});
}
main();

1369
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +0,0 @@
{
"name": "@sendou.ink/server",
"version": "0.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "node --experimental-specifier-resolution=node --loader ts-node/esm index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@trpc/server": "^9.13.0",
"cors": "^2.8.5",
"express": "^4.17.1",
"zod": "^3.11.6"
},
"devDependencies": {
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"ts-node": "^10.4.0"
}
}

27
utils/constants.ts Normal file
View File

@ -0,0 +1,27 @@
export const stages = [
"The Reef",
"Musselforge Fitness",
"Starfish Mainstage",
"Humpback Pump Track",
"Inkblot Art Academy",
"Sturgeon Shipyard",
"Moray Towers",
"Port Mackerel",
"Manta Maria",
"Kelp Dome",
"Snapper Canal",
"Blackbelly Skatepark",
"MakoMart",
"Walleye Warehouse",
"Shellendorf Institute",
"Arowana Mall",
"Goby Arena",
"Piranha Pit",
"Camp Triggerfish",
"Wahoo World",
"New Albacore Hotel",
"Ancho-V Games",
"Skipper Pavilion",
];
export const modesShort = ["TW", "SZ", "TC", "RM", "CB"];

11
utils/trpc-client.ts Normal file
View File

@ -0,0 +1,11 @@
import { createTRPCClient } from "@trpc/client";
import type { inferProcedureOutput } from "@trpc/server";
import type { AppRouter } from "../server";
export const trpcClient = createTRPCClient<AppRouter>({
url: "http://localhost:2021/trpc",
});
export type InferQueryOutput<
TRouteKey extends keyof AppRouter["_def"]["queries"]
> = inferProcedureOutput<AppRouter["_def"]["queries"][TRouteKey]>;

27
utils/trpc-server.ts Normal file
View File

@ -0,0 +1,27 @@
import * as trpc from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express/dist/trpc-server-adapters-express.cjs";
export const createContext = ({
req,
res,
}: trpcExpress.CreateExpressContextOptions) => {
const getUser = () => {
if (req.headers.authorization !== "secret") {
return null;
}
return {
name: "alex",
};
};
return {
req,
res,
user: getUser(),
};
};
type Context = trpc.inferAsyncReturnType<typeof createContext>;
export function createRouter() {
return trpc.router<Context>();
}

View File

@ -1,6 +0,0 @@
import type { AppRouter } from "../server";
import { createTRPCClient } from "@trpc/client";
export const trpcClient = createTRPCClient<AppRouter>({
url: "http://localhost:2021/trpc",
});