From 66c9ef5c931940c0cc15dadd35d736765c9ec6a6 Mon Sep 17 00:00:00 2001 From: Bradley Shellnut Date: Sat, 14 Oct 2023 22:06:57 +1300 Subject: [PATCH] Updating types, adding BGG types, change mapper for additional fields, and large update to the creation of data (mechanics, categories, expansions, etc.) on game load from id path. --- src/lib/types.ts | 34 +- src/lib/utils/dbUtils.ts | 415 +++++++++++++++++++ src/lib/utils/gameMapper.ts | 66 +-- src/routes/(app)/game/[id]/+page.server.ts | 78 +++- src/routes/(app)/game/[id]/+page.svelte | 90 ++-- src/routes/(app)/search/+page.server.ts | 181 ++------ src/routes/(app)/search/+page.svelte | 2 - src/routes/api/external/game/[id]/+server.ts | 73 +++- src/routes/api/external/search/+server.ts | 9 +- src/routes/api/game/search/+server.ts | 25 +- 10 files changed, 697 insertions(+), 276 deletions(-) create mode 100644 src/lib/utils/dbUtils.ts diff --git a/src/lib/types.ts b/src/lib/types.ts index 1657896..86caf89 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -93,18 +93,37 @@ export type CategoryType = { export type PublisherType = { id: string; - name: string; }; export type DesignerType = { id: string; - name: string; +}; + +export type ArtistType = { + id: string; +} + +export type ExpansionType = { + id: string; +} + +export type BGGLinkType = + | 'boardgamecategory' + | 'boardgamemechanic' + | 'boardgameexpansion' + | 'boardgameartist' + | 'boardgamepublisher'; + +export type BGGLink = { + id: number; + type: BGGLinkType; + value: string; }; export type GameType = { id: string; - handle: string; name: string; + slug: string; url: string; edit_url: string; thumb_url: string; @@ -122,16 +141,17 @@ export type GameType = { primary_designer: DesignerType; designers: DesignerType[]; developers: String[]; - artists: String[]; + artists: ArtistType[]; + expansions: ExpansionType[]; min_players: number; max_players: number; min_playtime: number; max_playtime: number; min_age: number; description: string; - description_preview: string; players: string; - playtime: string; + playtime: number; + external_id: number; }; export type SearchQuery = { @@ -140,7 +160,7 @@ export type SearchQuery = { ids?: string[]; list_id?: string; random?: boolean; - name?: string; + q?: string; exact?: boolean; designer?: string; publisher?: string; diff --git a/src/lib/utils/dbUtils.ts b/src/lib/utils/dbUtils.ts new file mode 100644 index 0000000..8940c95 --- /dev/null +++ b/src/lib/utils/dbUtils.ts @@ -0,0 +1,415 @@ +import prisma from "$lib/prisma"; +import type { GameType } from "$lib/types"; +import type { Game } from "@prisma/client"; +import type { BggThingDto } from "boardgamegeekclient/dist/esm/dto"; +import type { BggLinkDto } from "boardgamegeekclient/dist/esm/dto/concrete/subdto"; +import kebabCase from "just-kebab-case"; +import { mapAPIGameToBoredGame } from "./gameMapper"; + +export async function createArtist(externalArtist: BggLinkDto) { + try { + let dbArtist = await prisma.artist.findFirst({ + where: { + external_id: externalArtist.id + }, + select: { + id: true, + name: true, + slug: true, + external_id: true + } + }); + if (dbArtist) { + return dbArtist; + } + console.log('Creating artist', JSON.stringify(externalArtist, null, 2)); + let artist = await prisma.artist.create({ + data: { + name: externalArtist.value, + external_id: externalArtist.id, + slug: kebabCase(externalArtist.value) + }, + select: { + id: true, + name: true, + slug: true, + external_id: true + } + }); + + console.log('Created artist', JSON.stringify(artist, null, 2)); + return artist; + } catch (e) { + console.error(e); + throw new Error('Something went wrong creating Artist'); + } +} + +export async function createDesigner(externalDesigner: BggLinkDto) { + try { + let dbDesigner = await prisma.designer.findFirst({ + where: { + external_id: externalDesigner.id + }, + select: { + id: true, + name: true, + slug: true, + external_id: true + } + }); + if (dbDesigner) { + return dbDesigner; + } + console.log('Creating designer', JSON.stringify(externalDesigner, null, 2)); + let designer = await prisma.designer.create({ + data: { + name: externalDesigner.value, + external_id: externalDesigner.id, + slug: kebabCase(externalDesigner.value) + }, + select: { + id: true, + name: true, + slug: true, + external_id: true + } + }); + + console.log('Created designer', JSON.stringify(designer, null, 2)); + return designer; + } catch (e) { + console.error(e); + throw new Error('Something went wrong creating Designer'); + } +} + +export async function createPublisher(externalPublisher: BggLinkDto) { + try { + let dbPublisher = await prisma.publisher.findFirst({ + where: { + external_id: externalPublisher.id + }, + select: { + id: true, + name: true, + slug: true, + external_id: true + } + }); + if (dbPublisher) { + return dbPublisher; + } + console.log('Creating publisher', JSON.stringify(externalPublisher, null, 2)); + let publisher = await prisma.publisher.create({ + data: { + name: externalPublisher.value, + external_id: externalPublisher.id, + slug: kebabCase(externalPublisher.value) + }, + select: { + id: true, + name: true, + slug: true, + external_id: true + } + }); + + console.log('Created publisher', JSON.stringify(publisher, null, 2)); + return publisher; + } catch (e) { + console.error(e); + throw new Error('Something went wrong creating Publisher'); + } +} + +export async function createCategory(externalCategory: BggLinkDto) { + try { + let dbCategory = await prisma.category.findFirst({ + where: { + external_id: externalCategory.id + }, + select: { + id: true, + name: true, + slug: true, + external_id: true + } + }); + if (dbCategory) { + return dbCategory; + } + console.log('Creating category', JSON.stringify(externalCategory, null, 2)); + let category = await prisma.category.create({ + data: { + name: externalCategory.value, + external_id: externalCategory.id, + slug: kebabCase(externalCategory.value) + }, + select: { + id: true, + name: true, + slug: true, + external_id: true + } + }); + + console.log('Created category', JSON.stringify(category, null, 2)); + + return category; + } catch (e) { + console.error(e); + throw new Error('Something went wrong creating Category'); + } +} + +export async function createMechanic(externalMechanic: BggLinkDto) { + try { + let dbMechanic = await prisma.mechanic.findFirst({ + where: { + external_id: externalMechanic.id + }, + select: { + id: true, + name: true, + slug: true, + external_id: true + } + }); + if (dbMechanic) { + return dbMechanic; + } + console.log('Creating mechanic', JSON.stringify(externalMechanic, null, 2)); + let mechanic = await prisma.mechanic.upsert({ + where: { + external_id: externalMechanic.id + }, + create: { + name: externalMechanic.value, + external_id: externalMechanic.id, + slug: kebabCase(externalMechanic.value) + }, + update: { + name: externalMechanic.value, + slug: kebabCase(externalMechanic.value) + } + }); + + console.log('Created mechanic', JSON.stringify(mechanic, null, 2)); + + return mechanic; + } catch (e) { + console.error(e); + throw new Error('Something went wrong creating Mechanic'); + } +} + +export async function createExpansion(game: Game, externalExpansion: BggLinkDto, gameIsExpansion: boolean, eventFetch: Function) { + try { + let dbExpansionGame = await prisma.game.findUnique({ + where: { + external_id: externalExpansion.id + } + }); + + if (!dbExpansionGame) { + const externalGameResponse = await eventFetch( + `/api/external/game/${externalExpansion.id}?simplified=true` + ); + if (externalGameResponse.ok) { + const externalGame = await externalGameResponse.json(); + console.log('externalGame', externalGame); + let boredGame = mapAPIGameToBoredGame(externalGame); + dbExpansionGame = await createOrUpdateGameMinimal(boredGame); + } else { + throw new Error(`${gameIsExpansion ? 'Base game' : 'Expansion game'} not found and failed to create.`); + } + } + + let dbExpansion; + let baseGameId; + let gameId; + if (gameIsExpansion) { + console.log('External expansion is expansion. Looking for base game', JSON.stringify(game, null, 2)); + dbExpansion = await prisma.expansion.findFirst({ + where: { + game_id: dbExpansionGame.id + }, + select: { + id: true, + base_game_id: true, + game_id: true + } + }); + baseGameId = game.id; + gameId = dbExpansionGame.id; + } else { + console.log('External Expansion is base game. Looking for expansion', JSON.stringify(game, null, 2)); + dbExpansion = await prisma.expansion.findFirst({ + where: { + base_game_id: dbExpansionGame.id + }, + select: { + id: true, + base_game_id: true, + game_id: true + } + }); + baseGameId = dbExpansionGame.id; + gameId = game.id; + } + + if (dbExpansion) { + console.log('Expansion already exists', JSON.stringify(dbExpansion, null, 2)); + return dbExpansion; + } + + console.log(`Creating expansion. baseGameId: ${baseGameId}, gameId: ${gameId}`); + let expansion = await prisma.expansion.create({ + data: { + base_game_id: baseGameId, + game_id: gameId + } + }); + + console.log('Created expansion', JSON.stringify(expansion, null, 2)); + + return expansion; + } catch (e) { + console.error(e); + throw new Error('Something went wrong creating Expansion'); + } +} + +export async function createOrUpdateGameMinimal(game: GameType) { + console.log('Creating or updating minimal game data', JSON.stringify(game, null, 2)); + const externalUrl = `https://boardgamegeek.com/boardgame/${game.external_id}`; + return await prisma.game.upsert({ + where: { + external_id: game.external_id + }, + create: { + name: game.name, + slug: kebabCase(game.name), + description: game.description, + external_id: game.external_id, + url: externalUrl, + thumb_url: game.thumb_url, + image_url: game.image_url, + min_age: game.min_age || 0, + min_players: game.min_players || 0, + max_players: game.max_players || 0, + min_playtime: game.min_playtime || 0, + max_playtime: game.max_playtime || 0, + year_published: game.year_published || 0 + }, + update: { + name: game.name, + slug: kebabCase(game.name), + description: game.description, + external_id: game.external_id, + url: externalUrl, + thumb_url: game.thumb_url, + image_url: game.image_url, + min_age: game.min_age || 0, + min_players: game.min_players || 0, + max_players: game.max_players || 0, + min_playtime: game.min_playtime || 0, + max_playtime: game.max_playtime || 0, + year_published: game.year_published || 0 + } + }); +} + +export async function createOrUpdateGame(game: GameType) { + console.log('Creating or updating game', JSON.stringify(game, null, 2)); + const categoryIds = game.categories; + const mechanicIds = game.mechanics; + const publisherIds = game.publishers; + const designerIds = game.designers; + const artistIds = game.artists; + const expansionIds = game.expansions; + const externalUrl = `https://boardgamegeek.com/boardgame/${game.external_id}`; + console.log('categoryIds', categoryIds); + console.log('mechanicIds', mechanicIds); + return await prisma.game.upsert({ + include: { + mechanics: true, + publishers: true, + designers: true, + artists: true, + expansions: true + }, + where: { + external_id: game.external_id + }, + create: { + name: game.name, + slug: kebabCase(game.name), + description: game.description, + external_id: game.external_id, + url: externalUrl, + thumb_url: game.thumb_url, + image_url: game.image_url, + min_age: game.min_age || 0, + min_players: game.min_players || 0, + max_players: game.max_players || 0, + min_playtime: game.min_playtime || 0, + max_playtime: game.max_playtime || 0, + year_published: game.year_published || 0, + last_sync_at: new Date(), + categories: { + connect: categoryIds + }, + mechanics: { + connect: mechanicIds + }, + publishers: { + connect: publisherIds + }, + designers: { + connect: designerIds + }, + artists: { + connect: artistIds + }, + expansions: { + connect: expansionIds + } + }, + update: { + name: game.name, + slug: kebabCase(game.name), + description: game.description, + external_id: game.external_id, + url: externalUrl, + thumb_url: game.thumb_url, + image_url: game.image_url, + min_age: game.min_age || 0, + min_players: game.min_players || 0, + max_players: game.max_players || 0, + min_playtime: game.min_playtime || 0, + max_playtime: game.max_playtime || 0, + year_published: game.year_published || 0, + last_sync_at: new Date(), + categories: { + connect: categoryIds + }, + mechanics: { + connect: mechanicIds + }, + publishers: { + connect: publisherIds + }, + designers: { + connect: designerIds + }, + artists: { + connect: artistIds + }, + expansions: { + connect: expansionIds + } + } + }); +} diff --git a/src/lib/utils/gameMapper.ts b/src/lib/utils/gameMapper.ts index a0f81ba..a55d8f8 100644 --- a/src/lib/utils/gameMapper.ts +++ b/src/lib/utils/gameMapper.ts @@ -1,4 +1,5 @@ import type { GameType, SavedGameType } from '$lib/types'; +import kebabCase from 'just-kebab-case'; export function convertToSavedGame(game: GameType | SavedGameType): SavedGameType { return { @@ -41,56 +42,21 @@ export function mapSavedGameToGame(game: SavedGameType): GameType { }; } -// TODO: Type API response -export function mapAPIGameToBoredGame(game: any): GameType { - const { - id, - handle, - name, - url, - thumb_url, - image_url, - year_published, - categories, - mechanics, - primary_designer, - designers, - primary_publisher, - publishers, - artists, - min_players, - max_players, - min_playtime, - max_playtime, - min_age, - description, - description_preview, - players, - playtime - } = game; +export function mapAPIGameToBoredGame(game: GameType): GameType { + // TODO: Fix types return { - id, - handle, - name, - url, - thumb_url, - image_url, - year_published, - categories, - mechanics, - primary_designer, - designers, - primary_publisher, - publishers, - artists, - min_players, - max_players, - min_playtime, - max_playtime, - min_age, - description, - description_preview, - players, - playtime + external_id: game.external_id, + name: game.name, + slug: kebabCase(game.name), + thumb_url: game.thumbnail, + image_url: game.image, + year_published: game.year_published, + min_players: game.min_players, + max_players: game.max_players, + min_playtime: game.min_playtime, + max_playtime: game.max_playtime, + min_age: game.min_age, + description: game.description, + playtime: game.playing_time }; } diff --git a/src/routes/(app)/game/[id]/+page.server.ts b/src/routes/(app)/game/[id]/+page.server.ts index 2af373e..8e8b674 100644 --- a/src/routes/(app)/game/[id]/+page.server.ts +++ b/src/routes/(app)/game/[id]/+page.server.ts @@ -1,13 +1,24 @@ import { error } from '@sveltejs/kit'; import prisma from '$lib/prisma.js'; +import type { GameType } from '$lib/types.js'; +import { createArtist, createCategory, createDesigner, createExpansion, createMechanic, createOrUpdateGame, createPublisher } from '$lib/utils/dbUtils.js'; +import { mapAPIGameToBoredGame } from '$lib/utils/gameMapper.js'; +import type { Game } from '@prisma/client'; -export const load = async ({ params, setHeaders, locals }) => { +export const load = async ({ params, setHeaders, locals, fetch }) => { try { const { user } = locals; const { id } = params; const game = await prisma.game.findUnique({ where: { id + }, + include: { + artists: true, + designers: true, + publishers: true, + mechanics: true, + categories: true } }); console.log('found game', game); @@ -16,6 +27,11 @@ export const load = async ({ params, setHeaders, locals }) => { throw error(404, 'not found'); } + const currentDate = new Date(); + if (game.last_sync_at === null || currentDate.getDate() - game.last_sync_at.getDate() > 7 * 24 * 60 * 60 * 1000) { + await syncGameAndConnectedData(game, fetch); + } + let wishlist; let collection; if (user) { @@ -60,3 +76,63 @@ export const load = async ({ params, setHeaders, locals }) => { throw error(404, 'not found'); }; + +async function syncGameAndConnectedData(game: Game, eventFetch: Function) { + console.log( + `Retrieving full external game details for external id: ${game.external_id} with name ${game.name}` + ); + const externalGameResponse = await eventFetch(`/api/external/game/${game.external_id}`); + if (externalGameResponse.ok) { + const externalGame = await externalGameResponse.json(); + console.log('externalGame', externalGame); + let categories = []; + let mechanics = []; + let artists = []; + let designers = []; + let publishers = []; + let expansions = []; + for (const externalCategory of externalGame.categories) { + const category = await createCategory(externalCategory); + categories.push({ + id: category.id + }); + } + for (const externalMechanic of externalGame.mechanics) { + const mechanic = await createMechanic(externalMechanic); + mechanics.push({ id: mechanic.id }); + } + for (const externalArtist of externalGame.artists) { + const artist = await createArtist(externalArtist); + artists.push({ id: artist.id }); + } + for (const externalDesigner of externalGame.designers) { + const designer = await createDesigner(externalDesigner); + designers.push({ id: designer.id }); + } + for (const externalPublisher of externalGame.publishers) { + const publisher = await createPublisher(externalPublisher); + publishers.push({ id: publisher.id }); + } + + for (const externalExpansion of externalGame.expansions) { + let expansion; + console.log('Inbound?', externalExpansion.inbound); + if (externalExpansion?.inbound === true) { + expansion = await createExpansion(game, externalExpansion, false, eventFetch); + } else { + expansion = await createExpansion(game, externalExpansion, true, eventFetch); + } + expansions.push({ id: expansion.id }); + } + + let boredGame = mapAPIGameToBoredGame(externalGame); + + boredGame.categories = categories; + boredGame.mechanics = mechanics; + boredGame.designers = designers; + boredGame.artists = artists; + boredGame.publishers = publishers; + boredGame.expansions = expansions; + return createOrUpdateGame(boredGame); + } +} \ No newline at end of file diff --git a/src/routes/(app)/game/[id]/+page.svelte b/src/routes/(app)/game/[id]/+page.svelte index 07d1ea6..c81e6a4 100644 --- a/src/routes/(app)/game/[id]/+page.svelte +++ b/src/routes/(app)/game/[id]/+page.svelte @@ -1,16 +1,13 @@ {game?.name} | Bored Game -

{game?.name}

+

{game?.name} + {#if game?.year_published} + ({game?.year_published}) + {/if} +

- - {#if game?.thumb_url && game?.name} - {`Image + {#if game?.image_url && game?.name} + {`Image {:else} {/if} - -
- {#if game?.year_published} -

Year: {game?.year_published}

- {/if} {#if game?.min_players && game?.max_players}

Players: {game.min_players} - {game.max_players}

{/if} @@ -57,10 +51,12 @@ {#if game?.min_age}

Minimum Age: {game.min_age}

{/if} - - Board Game Atlas - - + {#if game?.min_playtime && game?.max_playtime} +

Playtime: {game.min_playtime} - {game.max_playtime} minutes

+ {/if} +
{#if user?.username} @@ -71,34 +67,19 @@ {/if}
-{#if game?.description_preview} +
+ {@html game?.description} +
+ -{:else} -
- - {@html game?.description} - -
-{/if} +