diff --git a/.prettierignore b/.prettierignore index 5618d32..5f85a49 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,8 @@ *.png *.txt *.service +*.toml +*.prisma node_modules/ bun.lockb yarn.lock diff --git a/.prettierrc.yaml b/.prettierrc.yaml index c64bdfd..16143a0 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -1,11 +1,20 @@ semi: true tabWidth: 2 useTabs: false -endOfLine: 'lf' +endOfLine: lf printWidth: 100 singleQuote: true -proseWrap: 'always' -arrowParens: 'avoid' -trailingComma: 'es5' +proseWrap: always +arrowParens: avoid +trailingComma: es5 bracketSpacing: true -quoteProps: 'as-needed' +quoteProps: as-needed + +plugins: + - prettier-plugin-sql + +# for SQL +keywordCase: lower +dataTypeCase: lower +functionCase: lower +identifierCase: lower diff --git a/bun.lockb b/bun.lockb index 2f95abd..3ddd50e 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index f597e68..1e060ff 100644 --- a/package.json +++ b/package.json @@ -28,17 +28,22 @@ "start": "bun src/index.ts", "lint": "tsc --noEmit", "format": "prettier --write '**/?*.*'", - "wc": "find src tests -type f -exec wc {} +" + "wc": "find src tests -type f -exec wc {} +", + "sqlgen": "bun scripts/sqlgen.ts", + "typegen": "prisma generate" }, "dependencies": { "@discordjs/rest": "^2.4.0", "@elysiajs/bearer": "^1.2.0", "@elysiajs/static": "^1.2.0", + "@libsql/client": "^0.14.0", "@mongodb-js/zstd": "^2.0.0", + "@prisma/adapter-libsql": "^6.1.0", + "@prisma/client": "^6.1.0", "bytes": "^3.1.2", "date-fns": "^4.1.0", "discord-api-types": "^0.37.114", - "elysia": "^1.2.6", + "elysia": "^1.2.9", "ioredis": "^5.4.2", "lru-cache": "^11.0.2", "mongoose": "^8.9.2", @@ -51,6 +56,8 @@ "@types/bun": "^1.1.14", "@types/bytes": "^3.1.5", "prettier": "^3.4.2", + "prettier-plugin-sql": "^0.18.1", + "prisma": "^6.1.0", "typescript": "^5.7.2" }, "trustedDependencies": [ diff --git a/prisma/db.sql b/prisma/db.sql new file mode 100644 index 0000000..9ea42bf --- /dev/null +++ b/prisma/db.sql @@ -0,0 +1,134 @@ +--- SQL that was actually executed into database +--- Observe changes in generated.sql and apply them here +--- DO NOT REWRITE THIS FILE IN ANY WAY +--- Just add new changes to the end of the file +create table "Variable" ( + "id" text not null primary key, + "value" text, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + "updatedAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +create table "Game" ( + "id" text not null primary key, + "save" text not null, + "preview" text, + "name" text, + "turns" integer not null default 0, + "currentPlayer" text, + "playerId" text, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + "updatedAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +create table "User" ( + "id" text not null primary key, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +create table "UsersInGame" ( + "gameId" text not null, + "userId" text not null, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + primary key ("userId", "gameId"), + constraint "UsersInGame_gameId_fkey" foreign key ("gameId") references "Game" ("id") on delete restrict on update cascade, + constraint "UsersInGame_userId_fkey" foreign key ("userId") references "User" ("id") on delete restrict on update cascade +); + +create table "Profile" ( + "id" integer not null primary key autoincrement, + "discordId" bigint, + "rating" real not null default 1000, + "dmChannel" bigint, + "notifications" text not null default 'enabled', + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + "updatedAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +create table "UsersInProfile" ( + "userId" text not null, + "profileId" integer not null, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + primary key ("userId", "profileId"), + constraint "UsersInProfile_userId_fkey" foreign key ("userId") references "User" ("id") on delete restrict on update cascade, + constraint "UsersInProfile_profileId_fkey" foreign key ("profileId") references "Profile" ("id") on delete restrict on update cascade +); + +create table "ErrorLog" ( + "id" integer not null primary key autoincrement, + "type" text not null, + "message" text, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +create table "DiscordPoll" ( + "id" bigint not null primary key, + "authorId" bigint not null, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +create table "DiscordPollEntry" ( + "id" integer not null primary key autoincrement, + "label" text not null, + "pollId" bigint not null, + "count" integer not null default 0, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + "updatedAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + constraint "DiscordPollEntry_pollId_fkey" foreign key ("pollId") references "DiscordPoll" ("id") on delete restrict on update cascade +); + +create table "DiscordPollVote" ( + "id" integer not null primary key autoincrement, + "entryId" integer not null, + "discordId" bigint not null, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + constraint "DiscordPollVote_entryId_fkey" foreign key ("entryId") references "DiscordPollEntry" ("id") on delete restrict on update cascade +); + +create unique index "Variable_id_key" on "Variable" ("id"); + +create index "Variable_createdAt_idx" on "Variable" ("createdAt"); + +create index "Variable_updatedAt_idx" on "Variable" ("updatedAt"); + +create index "Game_playerId_idx" on "Game" ("playerId"); + +create index "Game_createdAt_idx" on "Game" ("createdAt"); + +create index "Game_updatedAt_idx" on "Game" ("updatedAt"); + +create index "User_createdAt_idx" on "User" ("createdAt"); + +create index "UsersInGame_userId_idx" on "UsersInGame" ("userId"); + +create index "UsersInGame_gameId_idx" on "UsersInGame" ("gameId"); + +create index "UsersInGame_createdAt_idx" on "UsersInGame" ("createdAt"); + +create index "Profile_createdAt_idx" on "Profile" ("createdAt"); + +create index "Profile_updatedAt_idx" on "Profile" ("updatedAt"); + +create index "Profile_discordId_idx" on "Profile" ("discordId"); + +create index "Profile_dmChannel_idx" on "Profile" ("dmChannel"); + +create unique index "UsersInProfile_userId_key" on "UsersInProfile" ("userId"); + +create index "UsersInProfile_userId_idx" on "UsersInProfile" ("userId"); + +create index "UsersInProfile_profileId_idx" on "UsersInProfile" ("profileId"); + +create index "UsersInProfile_createdAt_idx" on "UsersInProfile" ("createdAt"); + +create index "ErrorLog_createdAt_idx" on "ErrorLog" ("createdAt"); + +create index "DiscordPoll_createdAt_idx" on "DiscordPoll" ("createdAt"); + +create index "DiscordPollEntry_createdAt_idx" on "DiscordPollEntry" ("createdAt"); + +create index "DiscordPollEntry_updatedAt_idx" on "DiscordPollEntry" ("updatedAt"); + +create index "DiscordPollVote_createdAt_idx" on "DiscordPollVote" ("createdAt"); + +create unique index "DiscordPollVote_entryId_discordId_key" on "DiscordPollVote" ("entryId", "discordId"); diff --git a/prisma/generated.sql b/prisma/generated.sql new file mode 100644 index 0000000..e25d5df --- /dev/null +++ b/prisma/generated.sql @@ -0,0 +1,164 @@ +-- CreateTable +create table "Variable" ( + "id" text not null primary key, + "value" text, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + "updatedAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +-- CreateTable +create table "Game" ( + "id" text not null primary key, + "save" text not null, + "preview" text, + "name" text, + "turns" integer not null default 0, + "currentPlayer" text, + "playerId" text, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + "updatedAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +-- CreateTable +create table "User" ( + "id" text not null primary key, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +-- CreateTable +create table "UsersInGame" ( + "gameId" text not null, + "userId" text not null, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + primary key ("userId", "gameId"), + constraint "UsersInGame_gameId_fkey" foreign key ("gameId") references "Game" ("id") on delete restrict on update cascade, + constraint "UsersInGame_userId_fkey" foreign key ("userId") references "User" ("id") on delete restrict on update cascade +); + +-- CreateTable +create table "Profile" ( + "id" integer not null primary key autoincrement, + "discordId" bigint, + "rating" real not null default 1000, + "dmChannel" bigint, + "notifications" text not null default 'enabled', + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + "updatedAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +-- CreateTable +create table "UsersInProfile" ( + "userId" text not null, + "profileId" integer not null, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + primary key ("userId", "profileId"), + constraint "UsersInProfile_userId_fkey" foreign key ("userId") references "User" ("id") on delete restrict on update cascade, + constraint "UsersInProfile_profileId_fkey" foreign key ("profileId") references "Profile" ("id") on delete restrict on update cascade +); + +-- CreateTable +create table "ErrorLog" ( + "id" integer not null primary key autoincrement, + "type" text not null, + "message" text, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +-- CreateTable +create table "DiscordPoll" ( + "id" bigint not null primary key, + "authorId" bigint not null, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)) +); + +-- CreateTable +create table "DiscordPollEntry" ( + "id" integer not null primary key autoincrement, + "label" text not null, + "pollId" bigint not null, + "count" integer not null default 0, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + "updatedAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + constraint "DiscordPollEntry_pollId_fkey" foreign key ("pollId") references "DiscordPoll" ("id") on delete restrict on update cascade +); + +-- CreateTable +create table "DiscordPollVote" ( + "id" integer not null primary key autoincrement, + "entryId" integer not null, + "discordId" bigint not null, + "createdAt" bigint not null default (cast(1000 * unixepoch ('subsec') as integer)), + constraint "DiscordPollVote_entryId_fkey" foreign key ("entryId") references "DiscordPollEntry" ("id") on delete restrict on update cascade +); + +-- CreateIndex +create unique index "Variable_id_key" on "Variable" ("id"); + +-- CreateIndex +create index "Variable_createdAt_idx" on "Variable" ("createdAt"); + +-- CreateIndex +create index "Variable_updatedAt_idx" on "Variable" ("updatedAt"); + +-- CreateIndex +create index "Game_playerId_idx" on "Game" ("playerId"); + +-- CreateIndex +create index "Game_createdAt_idx" on "Game" ("createdAt"); + +-- CreateIndex +create index "Game_updatedAt_idx" on "Game" ("updatedAt"); + +-- CreateIndex +create index "User_createdAt_idx" on "User" ("createdAt"); + +-- CreateIndex +create index "UsersInGame_userId_idx" on "UsersInGame" ("userId"); + +-- CreateIndex +create index "UsersInGame_gameId_idx" on "UsersInGame" ("gameId"); + +-- CreateIndex +create index "UsersInGame_createdAt_idx" on "UsersInGame" ("createdAt"); + +-- CreateIndex +create index "Profile_createdAt_idx" on "Profile" ("createdAt"); + +-- CreateIndex +create index "Profile_updatedAt_idx" on "Profile" ("updatedAt"); + +-- CreateIndex +create index "Profile_discordId_idx" on "Profile" ("discordId"); + +-- CreateIndex +create index "Profile_dmChannel_idx" on "Profile" ("dmChannel"); + +-- CreateIndex +create unique index "UsersInProfile_userId_key" on "UsersInProfile" ("userId"); + +-- CreateIndex +create index "UsersInProfile_userId_idx" on "UsersInProfile" ("userId"); + +-- CreateIndex +create index "UsersInProfile_profileId_idx" on "UsersInProfile" ("profileId"); + +-- CreateIndex +create index "UsersInProfile_createdAt_idx" on "UsersInProfile" ("createdAt"); + +-- CreateIndex +create index "ErrorLog_createdAt_idx" on "ErrorLog" ("createdAt"); + +-- CreateIndex +create index "DiscordPoll_createdAt_idx" on "DiscordPoll" ("createdAt"); + +-- CreateIndex +create index "DiscordPollEntry_createdAt_idx" on "DiscordPollEntry" ("createdAt"); + +-- CreateIndex +create index "DiscordPollEntry_updatedAt_idx" on "DiscordPollEntry" ("updatedAt"); + +-- CreateIndex +create index "DiscordPollVote_createdAt_idx" on "DiscordPollVote" ("createdAt"); + +-- CreateIndex +create unique index "DiscordPollVote_entryId_discordId_key" on "DiscordPollVote" ("entryId", "discordId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..67ee6d5 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,139 @@ +generator client { + provider = "prisma-client-js" + previewFeatures = ["driverAdapters"] +} + +datasource db { + provider = "sqlite" + url = "file:temp.db" +} + +model Variable { + id String @id @unique + value String? + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + updatedAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + @@index([createdAt]) + @@index([updatedAt]) +} + +model Game { + id String @id + save String + preview String? + name String? + turns Int @default(0) + currentPlayer String? + playerId String? + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + updatedAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + players UsersInGame[] + + @@index([playerId]) + @@index([createdAt]) + @@index([updatedAt]) +} + +model User { + id String @id + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + games UsersInGame[] + profiles UsersInProfile[] + + @@index([createdAt]) +} + +model UsersInGame { + gameId String + userId String + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + game Game @relation(fields: [gameId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@id([userId, gameId]) + @@index([userId]) + @@index([gameId]) + @@index([createdAt]) +} + +model Profile { + id Int @id @default(autoincrement()) + discordId BigInt? + rating Float @default(1000) + dmChannel BigInt? + notifications String @default("enabled") + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + updatedAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + users UsersInProfile[] + + @@index([createdAt]) + @@index([updatedAt]) + @@index([discordId]) + @@index([dmChannel]) +} + +model UsersInProfile { + userId String @unique + profileId Int + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + user User @relation(fields: [userId], references: [id]) + profile Profile @relation(fields: [profileId], references: [id]) + + @@id([userId, profileId]) + @@index([userId]) + @@index([profileId]) + @@index([createdAt]) +} + +model ErrorLog { + id Int @id @default(autoincrement()) + type String + message String? + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + @@index([createdAt]) +} + +model DiscordPoll { + id BigInt @id + authorId BigInt + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + entries DiscordPollEntry[] + + @@index([createdAt]) +} + +model DiscordPollEntry { + id Int @id @default(autoincrement()) + label String + pollId BigInt + count Int @default(0) + + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + updatedAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + poll DiscordPoll @relation(fields: [pollId], references: [id]) + votes DiscordPollVote[] + + @@index([createdAt]) + @@index([updatedAt]) +} + +model DiscordPollVote { + id Int @id @default(autoincrement()) + entryId Int + discordId BigInt + createdAt BigInt @default(dbgenerated("(cast(1000 * unixepoch('subsec') as integer))")) + + entry DiscordPollEntry @relation(fields: [entryId], references: [id]) + + @@unique([entryId, discordId]) + @@index([createdAt]) +} diff --git a/scripts/sqlgen.ts b/scripts/sqlgen.ts new file mode 100644 index 0000000..8d5e4c9 --- /dev/null +++ b/scripts/sqlgen.ts @@ -0,0 +1,21 @@ +import { cp, rm } from 'fs/promises'; + +const PRISMA_FOLDER = 'prisma'; +const OUT_FILE = 'prisma/generated.sql'; + +const cleanup = async () => { + const glob = new Bun.Glob(`${PRISMA_FOLDER}/**.db*`).scan({ onlyFiles: true }); + + await Promise.all([ + rm(`${PRISMA_FOLDER}/migrations`, { force: true, recursive: true }), + Array.fromAsync(glob).then(files => Promise.all(files.map(file => rm(file)))), + ]); +}; + +await cleanup(); +await Bun.$`prisma migrate dev --name temp`; +await Array.fromAsync(new Bun.Glob(`${PRISMA_FOLDER}/**/migration.sql`).scan()).then(files => + Promise.all(files.map(file => cp(file, OUT_FILE))) +); +await Bun.$`prettier --write ${OUT_FILE}`; +await cleanup(); diff --git a/src/routes/files/delete.ts b/src/routes/files/delete.ts index 1d224c2..7d22c8a 100644 --- a/src/routes/files/delete.ts +++ b/src/routes/files/delete.ts @@ -1,9 +1,14 @@ import cache from '@services/cache'; import { db } from '@services/mongodb'; +import prisma from '@services/prisma'; import type { Elysia } from 'elysia'; export const deleteFile = (app: Elysia) => app.delete('/:gameId', async ({ params: { gameId } }) => { - await Promise.all([cache.del(gameId), db.UncivGame.deleteOne({ _id: gameId })]); + await Promise.all([ + cache.del(gameId), + prisma.game.delete({ where: { id: gameId } }), + db.UncivGame.deleteOne({ _id: gameId }), + ]); return 'Done!'; }); diff --git a/src/routes/files/get.ts b/src/routes/files/get.ts index 0cc73e7..d9c0781 100644 --- a/src/routes/files/get.ts +++ b/src/routes/files/get.ts @@ -1,17 +1,36 @@ import cache from '@services/cache'; import { db } from '@services/mongodb'; +import prisma, { getGameWithPrima } from '@services/prisma'; import type { Elysia } from 'elysia'; export const getFile = (app: Elysia) => app.get( '/:gameId', async ({ error, params: { gameId } }) => { - const game = await db.UncivGame.findById(gameId, { _id: 0, text: 1 }); + const pGame = await getGameWithPrima(gameId); + if (pGame) return pGame; - if (!game) return error(404); + const mGame = await db.UncivGame.findById(gameId, { _id: 0, text: 1 }); + if (!mGame) return error(404); - await cache.set(gameId, game.text); - return game.text; + const { text } = mGame; + const isPreview = gameId.endsWith('_Preview'); + prisma.game.upsert({ + where: { id: gameId.replace('_Preview', '') }, + create: { + id: gameId, + save: isPreview ? '' : text, + preview: isPreview ? text : undefined, + }, + update: { + updatedAt: Date.now(), + save: isPreview ? undefined : text, + preview: isPreview ? text : undefined, + }, + }); + + await cache.set(gameId, text); + return text; }, { beforeHandle: async ({ params: { gameId } }) => { diff --git a/src/routes/files/put.ts b/src/routes/files/put.ts index 93f0aac..729e7f2 100644 --- a/src/routes/files/put.ts +++ b/src/routes/files/put.ts @@ -6,6 +6,7 @@ import cache from '@services/cache'; import { isDiscordTokenValid, sendNewTurnNotification } from '@services/discord'; import { gameDataSecurityModifier } from '@services/gameDataSecurity'; import { db } from '@services/mongodb'; +import prisma from '@services/prisma'; import { pack, unpack } from '@services/uncivGame'; import { type Elysia, type Static, t } from 'elysia'; import random from 'random'; @@ -39,6 +40,59 @@ export const putFile = (app: Elysia) => { upsert: true } ).catch(err => console.error(`[MongoDB] Error saving game ${gameId}:`, err)); + const isPreview = gameId.endsWith('_Preview'); + + prisma.game + .upsert({ + where: { id: gameId.replace('_Preview', '') }, + create: { + id: gameId, + save: isPreview ? '' : body, + preview: isPreview ? body : undefined, + }, + update: { + updatedAt: Date.now(), + save: isPreview ? undefined : body, + preview: isPreview ? body : undefined, + }, + }) + .then(() => { + // Unique list of Players + if (!game || (game.turns && game.turns > 0)) return; + const players = [ + ...new Set( + [ + ...game.civilizations?.map(c => c.playerId), + ...game.gameParameters?.players.map(p => p.playerId), + ].filter(Boolean) + ), + ] as string[]; + + return Promise.all( + players.map(playerId => + prisma.user.upsert({ + where: { id: playerId }, + create: { + id: playerId, + games: { + connect: { + userId_gameId: { userId: playerId, gameId: game.gameId }, + }, + }, + }, + update: { + games: { + connect: { + userId_gameId: { userId: playerId, gameId: game.gameId }, + }, + }, + }, + }) + ) + ); + }) + .catch(err => console.error(`[Prisma] Error saving game ${gameId}:`, err)); + // sync with other servers server?.publish( 'sync', @@ -50,7 +104,7 @@ export const putFile = (app: Elysia) => ); // send turn notification - if (game !== null && isDiscordTokenValid && gameId.endsWith('_Preview')) { + if (game !== null && isDiscordTokenValid && isPreview) { sendNewTurnNotification(game!); } }, diff --git a/src/routes/jsons.ts b/src/routes/jsons.ts index e400161..62a1e3e 100644 --- a/src/routes/jsons.ts +++ b/src/routes/jsons.ts @@ -1,6 +1,7 @@ import { GAME_ID_WITH_PREVIEW_REGEX } from '@constants'; import cache from '@services/cache'; import { db } from '@services/mongodb'; +import { getGameWithPrima } from '@services/prisma'; import { unpackJSON } from '@services/uncivGame'; import { type Elysia, t } from 'elysia'; @@ -10,14 +11,17 @@ export const jsonsRoute = (app: Elysia) => async ({ error, set, params: { gameId } }) => { set.headers['content-type'] = 'application/json'; - const gameData = await cache.get(gameId); - if (gameData) return unpackJSON(gameData); + const cachedGame = await cache.get(gameId); + if (cachedGame) return unpackJSON(cachedGame); - const dbGame = await db.UncivGame.findById(gameId, { _id: 0, text: 1 }); - if (!dbGame) return error(404); + const pGame = await getGameWithPrima(gameId); + if (pGame) return unpackJSON(pGame); - await cache.set(gameId, dbGame.text); - return unpackJSON(dbGame.text); + const mGame = await db.UncivGame.findById(gameId, { _id: 0, text: 1 }); + if (!mGame) return error(404); + + await cache.set(gameId, mGame.text); + return unpackJSON(mGame.text); }, { params: t.Object({ gameId: t.RegExp(GAME_ID_WITH_PREVIEW_REGEX) }), diff --git a/src/services/discord.ts b/src/services/discord.ts index 93ed659..288777d 100644 --- a/src/services/discord.ts +++ b/src/services/discord.ts @@ -8,7 +8,7 @@ import { type RESTPostAPIChannelMessageResult, type RESTPostAPICurrentUserCreateDMChannelResult, } from 'discord-api-types/rest/v10'; -import { db } from './mongodb'; +import prisma from './prisma'; const DISCORD_TOKEN = process.env.DISCORD_TOKEN; @@ -26,7 +26,7 @@ discord.on('rateLimited', data => { }); discord.on('response', ({ path, method }, { status, statusText }) => { - console.log(`[Discord]`, method, path, status, statusText); + console.info(`[Discord]`, method, path, status, statusText); }); const createMessage = ( @@ -46,7 +46,7 @@ const getDMChannel = async (discordId: string) => { }; export const sendNewTurnNotification = async (game: UncivJSON) => { - const { turns, gameId, civilizations, currentPlayer, gameParameters } = game; + const { turns, gameId, civilizations, currentPlayer } = game; // find currentPlayer's ID const currentCiv = civilizations.find(c => c.civName === currentPlayer); @@ -57,45 +57,46 @@ export const sendNewTurnNotification = async (game: UncivJSON) => { // Check if the Player exists in DB const { playerId } = currentCiv; - const playerProfile = await db.PlayerProfile.findOne( - { uncivUserIds: playerId }, - { notifications: 1, dmChannel: 1 } - ); + const playerProfile = await prisma.profile.findFirst({ + where: { users: { some: { userId: playerId } } }, + select: { id: true, notifications: true, discordId: true, dmChannel: true }, + }); // if player has not registered or has disabled notifications, return - if (!playerProfile || playerProfile.notifications !== 'enabled') return; + if (!playerProfile) return; + const { id, discordId, notifications } = playerProfile; + if (!discordId || notifications !== 'enabled') return; // If the player doesn't have a DM channel, create one - if (!playerProfile.dmChannel) { + let { dmChannel } = playerProfile; + if (!dmChannel) { try { - playerProfile.dmChannel = await getDMChannel(playerProfile._id.toString()); - await playerProfile.save(); + dmChannel = await getDMChannel(discordId.toString()).then(BigInt); + + await prisma.profile.update({ + where: { id }, + data: { dmChannel, updatedAt: Date.now() }, + }); } catch (err) { - console.error('[TurnNotifier] error creating DM channel for:', playerProfile); - console.error(err); + console.error(`[TurnNotifier] error creating DM channel for ${discordId}:`, err); return; } } - // Unique list of Players - const players = [ - ...new Set( - [ - ...civilizations.map(c => c.playerId), - ...gameParameters.players.map(p => p.playerId), - ].filter(Boolean) - ), - ] as string[]; - // update game info on DB and return game name - const name = await db.UncivGame.findByIdAndUpdate( - //? always save metadata to preview file - `${gameId}_Preview`, - { $set: { currentPlayer, playerId, turns: turns || 0, players } }, - { projection: { _id: 0, name: 1 } } - ).then(game => game?.name); - - await createMessage(playerProfile.dmChannel, { + const name = await prisma.game + .update({ + where: { id: gameId }, + data: { + currentPlayer, + playerId, + turns: turns || 0, + }, + select: { name: true }, + }) + .then(game => game?.name); + + await createMessage(dmChannel!.toString(), { embeds: [ { color: getRandomColor(), @@ -141,12 +142,11 @@ export const sendNewTurnNotification = async (game: UncivJSON) => { SUPPORT_EMBED, ], }).catch(err => { - console.error('[TurnNotifier] error sending notification:', { + console.error(`[TurnNotifier] ${err} while sending notification:`, { gameId, playerId, currentPlayer, - dmChannel: playerProfile.dmChannel, + dmChannel, }); - console.error(err); }); }; diff --git a/src/services/prisma.ts b/src/services/prisma.ts new file mode 100644 index 0000000..ddb9954 --- /dev/null +++ b/src/services/prisma.ts @@ -0,0 +1,26 @@ +import { createClient } from '@libsql/client'; +import { PrismaLibSQL } from '@prisma/adapter-libsql'; +import { PrismaClient } from '@prisma/client'; + +export const libsql = createClient({ + url: process.env.TURSO_DATABASE_URL!, + authToken: process.env.TURSO_AUTH_TOKEN!, + intMode: 'bigint', +}); + +const adapter = new PrismaLibSQL(libsql); +export const prisma = new PrismaClient({ adapter }); + +export const getGameWithPrima = async (gameId: string) => { + const isPreview = gameId.endsWith('_Preview'); + const game = await prisma.game.findUnique({ + where: { id: isPreview ? gameId.slice(0, -8) : gameId }, + select: { + preview: isPreview, + save: !isPreview, + }, + }); + return game && (isPreview ? game.preview : game.save); +}; + +export default prisma; diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 6e63be5..1edbb42 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -10,5 +10,7 @@ declare namespace NodeJS { SYNC_SERVERS?: string; DISCORD_TOKEN?: string; MAX_CACHE_SIZE?: string; + TURSO_DATABASE_URL?: string; + TURSO_AUTH_TOKEN?: string; } } diff --git a/tsconfig.json b/tsconfig.json index 21e9e37..bdcde2f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { // include paths - "include": ["src", "tests"], + "include": ["src", "tests", "scripts", "test.ts"], "compilerOptions": { "lib": ["ESNext"],