diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..8dc105ef --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +build-api: + cd api && docker build -t bingo-api:local -f Containerfile . + +build-web: + cd web && docker build -t bingo-web:local -f Containerfile . + +compose: + docker compose up --build \ No newline at end of file diff --git a/README.md b/README.md index 822babfb..9317dc2e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ function. ##### Web - NEXT_PUBLIC_API_PATH is the url that the api service is running at +- NEXT_PUBLIC_SOCKET_PATH is the url that the web socket is available at #### Optional ##### API diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 00000000..a67d3628 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,4 @@ +*.log +*.db +node_modules/* +build/* \ No newline at end of file diff --git a/api/Containerfile b/api/Containerfile new file mode 100644 index 00000000..2bd9d765 --- /dev/null +++ b/api/Containerfile @@ -0,0 +1,16 @@ +FROM node:20 + +WORKDIR /api + +COPY tsconfig.json package.json package-lock.json ./ +COPY ./prisma ./prisma +COPY .eslintrc ./ +COPY .prettierignore ./ + +RUN npm i && npm i -g tsx + + +COPY ./src ./src +COPY start.sh ./ + +CMD [ "bash", "start.sh" ] \ No newline at end of file diff --git a/api/prisma/ready.ts b/api/prisma/ready.ts new file mode 100644 index 00000000..858c8f72 --- /dev/null +++ b/api/prisma/ready.ts @@ -0,0 +1,38 @@ +import { PrismaClient } from "@prisma/client"; +import { exec } from "child_process"; +import util from "util"; + +const prisma = new PrismaClient(); +const execAsync = util.promisify(exec); + +const waitForDatabase = async (retries: number = 5, delay: number = 2000) => { + for (let i = 0; i < retries; i++) { + try { + await prisma.$connect(); + console.log("Database is up!"); + return; + } catch { + console.log(`Attempt ${i + 1} failed. Retrying in ${delay / 1000}s...`); + await new Promise(res => setTimeout(res, delay)); + } + } + throw new Error("Database is not reachable after retries."); +}; + +const checkDatabaseAndMigrate = async () => { + try { + await waitForDatabase(); + console.log("Running Prisma migrations..."); + const { stdout, stderr } = await execAsync("npx prisma migrate dev"); + console.log(stdout); + if (stderr) console.error(stderr); + console.log("Migrations applied successfully."); + } catch (error: any) { + console.error("Error:", error.message); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +}; + +checkDatabaseAndMigrate(); diff --git a/api/prisma/seed.ts b/api/prisma/seed.ts index 88f6f5fc..6538b4c6 100644 --- a/api/prisma/seed.ts +++ b/api/prisma/seed.ts @@ -6,6 +6,12 @@ const prisma = new PrismaClient(); async function main() { console.log('Seeding database'); + const users = await prisma.user.findMany() + if (users.length != 0) { + console.log('Database already has some users, skipping seeding') + return + } + console.log('Creating users'); await prisma.user.deleteMany(); const salt = randomBytes(16); @@ -338,9 +344,8 @@ async function main() { }, ...Array.from({ length: 54 }).map((_, i) => ({ goal: `Side Quest ${i + 27}`, - description: `Complete side quest number ${ - i + 27 - } for extra rewards.`, + description: `Complete side quest number ${i + 27 + } for extra rewards.`, categories: [realisticCategories[i % realisticCategories.length]], difficulty: ((i + 1) % 25) + 1, })), diff --git a/api/start.sh b/api/start.sh new file mode 100644 index 00000000..6da6b268 --- /dev/null +++ b/api/start.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +tsx prisma/ready.ts + +npx prisma migrate dev + +tsx prisma/seed.ts + +npm run dev \ No newline at end of file diff --git a/api/tsconfig.json b/api/tsconfig.json index 37e67b0c..92fdf21f 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -10,10 +10,22 @@ "outDir": "build", "baseUrl": "./src", "declarationMap": true, + "types": [ + "node" + ] }, "ts-node": { "esm": true }, - "include": ["src/**/*.ts", "src/**/*.d.ts", "src/main.ts", "prisma/seed.ts"], - "exclude": ["src/__tests__/**/*", "src/coverage/**/*"] -} + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/main.ts", + "prisma/seed.ts", + "prisma/ready.ts" + ], + "exclude": [ + "src/__tests__/**/*", + "src/coverage/**/*" + ] +} \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..bbb51e54 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,53 @@ +version: "3.9" + +services: + api: + build: + context: ./api + dockerfile: Containerfile + container_name: api + ports: + - "8000:8000" # Map container port to host port + environment: + ROOM_TOKEN_SECRET: your-room-token-secret + SESSION_SECRET: your-session-secret + CLIENT_URL: http://web:3000 # Assuming the Web service runs on this URL + DATABASE_URL: postgres://devuser:devpassword@database:5432/devdb + # SMTP_HOST: your-smtp-host + # SMTP_USER: your-email-address + # SMTP_PASSWORD: your-email-password + PORT: 8000 + # volumes: + # - ./api:/api + depends_on: + - database # Optional if you have a database container defined separately + + web: + build: + context: ./web + dockerfile: Containerfile + container_name: web + ports: + - "3000:3000" # Map container port to host port + environment: + NEXT_PUBLIC_API_PATH: http://api:8000 # Assuming the API service runs on this URL + NEXT_PUBLIC_SOCKET_PATH: "ws://localhost:8000" # this needs to be routable from the client + # volumes: + # - ./web:/web + depends_on: + - api + + database: + image: postgres:latest + container_name: database + ports: + - "5432:5432" + environment: + POSTGRES_USER: devuser + POSTGRES_PASSWORD: devpassword + POSTGRES_DB: devdb + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 00000000..a67d3628 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,4 @@ +*.log +*.db +node_modules/* +build/* \ No newline at end of file diff --git a/web/Containerfile b/web/Containerfile new file mode 100644 index 00000000..f797cb7f --- /dev/null +++ b/web/Containerfile @@ -0,0 +1,13 @@ +FROM node:20 + +WORKDIR /web + +COPY tsconfig.json package.json package-lock.json ./ +COPY ./public ./public +COPY .eslintrc.json .prettierignore next.config.js ./ + +RUN npm i + +COPY ./src ./src + +CMD [ "npm", "run", "dev" ] \ No newline at end of file diff --git a/web/src/context/RoomContext.tsx b/web/src/context/RoomContext.tsx index 80aa6bd7..d663253e 100644 --- a/web/src/context/RoomContext.tsx +++ b/web/src/context/RoomContext.tsx @@ -22,10 +22,7 @@ import { RoomData } from '../types/RoomData'; import { ChatMessage, Player, ServerMessage } from '../types/ServerMessage'; import { alertError } from '../lib/Utils'; -const websocketBase = (process.env.NEXT_PUBLIC_API_PATH ?? '').replace( - 'http', - 'ws', -); +const websocketBase = (process.env.NEXT_PUBLIC_SOCKET_PATH ?? '') export enum ConnectionStatus { UNINITIALIZED, // the room connection is uninitialized and there is no authentication data present @@ -76,17 +73,17 @@ export const RoomContext = createContext({ async connect() { return { success: false }; }, - sendChatMessage(message) {}, - markGoal(row, col) {}, - unmarkGoal(row, col) {}, - changeColor() {}, - regenerateCard() {}, - disconnect() {}, - createRacetimeRoom() {}, - updateRacetimeRoom() {}, - joinRacetimeRoom() {}, - racetimeReady() {}, - racetimeUnready() {}, + sendChatMessage(message) { }, + markGoal(row, col) { }, + unmarkGoal(row, col) { }, + changeColor() { }, + regenerateCard() { }, + disconnect() { }, + createRacetimeRoom() { }, + updateRacetimeRoom() { }, + joinRacetimeRoom() { }, + racetimeReady() { }, + racetimeUnready() { }, }); interface RoomContextProps { @@ -239,7 +236,7 @@ export function RoomContextProvider({ slug, children }: RoomContextProps) { }, }, connectionStatus === ConnectionStatus.CONNECTING || - connectionStatus === ConnectionStatus.CONNECTED, + connectionStatus === ConnectionStatus.CONNECTED, ); // actions