From d1951afb3cc3b51731388df0b6c9246b96ae333f Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 8 Feb 2026 16:01:21 -0600 Subject: [PATCH] testing cicd --- .gitea/workflows/ci-cd.yaml | 143 +++++++ Dockerfile | 28 ++ Dockerfile.pocketbase | 17 +- Teams-2.xlsx | Bin 13520 -> 0 bytes k8s/base/app-deployment.yaml | 124 ++++++ k8s/base/app-service.yaml | 17 + k8s/base/kustomization.yaml | 12 + k8s/base/pb-data-pvc.yaml | 13 + k8s/base/pocketbase-deployment.yaml | 57 +++ k8s/base/pocketbase-service.yaml | 18 + k8s/overlays/dev/configmap.yaml | 14 + k8s/overlays/dev/ingress.yaml | 17 + k8s/overlays/dev/kustomization.yaml | 50 +++ k8s/overlays/dev/namespace.yaml | 4 + k8s/overlays/prod/configmap.yaml | 14 + k8s/overlays/prod/ingress.yaml | 17 + k8s/overlays/prod/kustomization.yaml | 50 +++ k8s/overlays/prod/namespace.yaml | 4 + k8s/overlays/shared/configmap.yaml | 5 + k8s/overlays/shared/kustomization.yaml | 16 + k8s/overlays/shared/namespace.yaml | 4 + k8s/overlays/shared/supertokens-db-pvc.yaml | 13 + .../shared/supertokens-deployment.yaml | 71 ++++ .../supertokens-postgres-deployment.yaml | 68 ++++ .../shared/supertokens-postgres-service.yaml | 17 + k8s/overlays/shared/supertokens-service.yaml | 18 + server.ts | 366 ------------------ src/app/routeTree.gen.ts | 21 + src/app/routes/api/health.ts | 22 ++ test.js | 269 ------------- 30 files changed, 849 insertions(+), 640 deletions(-) create mode 100644 .gitea/workflows/ci-cd.yaml create mode 100644 Dockerfile delete mode 100644 Teams-2.xlsx create mode 100644 k8s/base/app-deployment.yaml create mode 100644 k8s/base/app-service.yaml create mode 100644 k8s/base/kustomization.yaml create mode 100644 k8s/base/pb-data-pvc.yaml create mode 100644 k8s/base/pocketbase-deployment.yaml create mode 100644 k8s/base/pocketbase-service.yaml create mode 100644 k8s/overlays/dev/configmap.yaml create mode 100644 k8s/overlays/dev/ingress.yaml create mode 100644 k8s/overlays/dev/kustomization.yaml create mode 100644 k8s/overlays/dev/namespace.yaml create mode 100644 k8s/overlays/prod/configmap.yaml create mode 100644 k8s/overlays/prod/ingress.yaml create mode 100644 k8s/overlays/prod/kustomization.yaml create mode 100644 k8s/overlays/prod/namespace.yaml create mode 100644 k8s/overlays/shared/configmap.yaml create mode 100644 k8s/overlays/shared/kustomization.yaml create mode 100644 k8s/overlays/shared/namespace.yaml create mode 100644 k8s/overlays/shared/supertokens-db-pvc.yaml create mode 100644 k8s/overlays/shared/supertokens-deployment.yaml create mode 100644 k8s/overlays/shared/supertokens-postgres-deployment.yaml create mode 100644 k8s/overlays/shared/supertokens-postgres-service.yaml create mode 100644 k8s/overlays/shared/supertokens-service.yaml delete mode 100644 server.ts create mode 100644 src/app/routes/api/health.ts delete mode 100644 test.js diff --git a/.gitea/workflows/ci-cd.yaml b/.gitea/workflows/ci-cd.yaml new file mode 100644 index 0000000..b32033e --- /dev/null +++ b/.gitea/workflows/ci-cd.yaml @@ -0,0 +1,143 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - development + - main + +jobs: + build-app: + name: Build and Push App Docker Image + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set environment variables + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "DOCKER_TAG=latest" >> $GITHUB_ENV + echo "ENVIRONMENT=prod" >> $GITHUB_ENV + else + echo "DOCKER_TAG=dev" >> $GITHUB_ENV + echo "ENVIRONMENT=dev" >> $GITHUB_ENV + fi + + - name: Build and push app Docker image + run: | + docker login git.yohler.net -u ${{ github.actor }} -p ${{ secrets.PACKAGES_TOKEN }} + + docker build \ + -f Dockerfile \ + -t git.yohler.net/kyle/flxn-app:${{ env.DOCKER_TAG }} \ + -t git.yohler.net/kyle/flxn-app:${{ github.sha }} \ + . + + docker push git.yohler.net/kyle/flxn-app:${{ env.DOCKER_TAG }} + docker push git.yohler.net/kyle/flxn-app:${{ github.sha }} + + build-pocketbase: + name: Build and Push PocketBase Docker Image + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check if PocketBase Dockerfile or migrations changed + id: check_changes + run: | + if [ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ] || ! git cat-file -e ${{ github.event.before }} 2>/dev/null; then + echo "changed=true" >> $GITHUB_OUTPUT + elif git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -qE "(Dockerfile.pocketbase|pb_migrations/)"; then + echo "changed=true" >> $GITHUB_OUTPUT + else + echo "changed=false" >> $GITHUB_OUTPUT + fi + + - name: Build and push PocketBase Docker image + if: steps.check_changes.outputs.changed == 'true' || github.event.before == '0000000000000000000000000000000000000000' + run: | + docker login git.yohler.net -u ${{ github.actor }} -p ${{ secrets.PACKAGES_TOKEN }} + + docker build \ + -f Dockerfile.pocketbase \ + -t git.yohler.net/kyle/flxn-pocketbase:latest \ + -t git.yohler.net/kyle/flxn-pocketbase:${{ github.sha }} \ + . + + docker push git.yohler.net/kyle/flxn-pocketbase:latest + docker push git.yohler.net/kyle/flxn-pocketbase:${{ github.sha }} + + deploy: + name: Deploy to Kubernetes + needs: [build-app, build-pocketbase] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set environment variables + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "ENVIRONMENT=prod" >> $GITHUB_ENV + echo "NAMESPACE=flxn-prod" >> $GITHUB_ENV + else + echo "ENVIRONMENT=dev" >> $GITHUB_ENV + echo "NAMESPACE=flxn-dev" >> $GITHUB_ENV + fi + + - name: Install kubectl + run: | + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x kubectl + mv kubectl /usr/local/bin/ + + - name: Configure kubectl + run: | + mkdir -p $HOME/.kube + echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config + chmod 600 $HOME/.kube/config + kubectl config set-cluster local --insecure-skip-tls-verify=true + + - name: Verify kubectl access + run: | + kubectl version --client + kubectl get nodes + + - name: Deploy shared services (SuperTokens) + run: | + kubectl apply -k k8s/overlays/shared/ + + - name: Deploy to ${{ env.ENVIRONMENT }} + run: | + kubectl apply -k k8s/overlays/${{ env.ENVIRONMENT }}/ + + - name: Force rollout to pull latest image + run: | + kubectl rollout restart deployment/flxn-app -n ${{ env.NAMESPACE }} + kubectl rollout restart deployment/flxn-pocketbase -n ${{ env.NAMESPACE }} + + - name: Wait for rollout + run: | + kubectl rollout status deployment/flxn-app -n ${{ env.NAMESPACE }} --timeout=5m + kubectl rollout status deployment/flxn-pocketbase -n ${{ env.NAMESPACE }} --timeout=5m + + - name: Verify deployment + run: | + kubectl get pods -n ${{ env.NAMESPACE }} -l app=flxn + kubectl get svc -n ${{ env.NAMESPACE }} -l app=flxn + kubectl get ingress -n ${{ env.NAMESPACE }} + + - name: Check app health + run: | + sleep 15 + APP_POD=$(kubectl get pod -n ${{ env.NAMESPACE }} -l component=app -o jsonpath='{.items[0].metadata.name}') + kubectl exec -n ${{ env.NAMESPACE }} $APP_POD -- wget -O- http://localhost:3000/api/health || echo "Health check failed (endpoint may not exist yet)" + + - name: Check PocketBase health + run: | + PB_POD=$(kubectl get pod -n ${{ env.NAMESPACE }} -l component=pocketbase -o jsonpath='{.items[0].metadata.name}') + kubectl exec -n ${{ env.NAMESPACE }} $PB_POD -- wget -O- http://localhost:8090/api/health || echo "PocketBase health check completed" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..76e977d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM oven/bun:1 AS builder + +WORKDIR /app + +COPY package.json bun.lockb* ./ + +RUN bun install --frozen-lockfile + +COPY . . + +RUN bun run build + +FROM oven/bun:1-alpine AS runtime + +WORKDIR /app + +COPY --from=builder /app/.output ./.output + +EXPOSE 3000 + +ENV NODE_ENV=production +ENV PORT=3000 +ENV NITRO_PORT=3000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" + +CMD ["bun", "run", "./.output/server/index.mjs"] diff --git a/Dockerfile.pocketbase b/Dockerfile.pocketbase index e45081b..3d7bc33 100644 --- a/Dockerfile.pocketbase +++ b/Dockerfile.pocketbase @@ -1,16 +1,23 @@ FROM alpine:latest -ARG PB_VERSION=0.29.2 +ARG PB_VERSION=0.26.5 RUN apk add --no-cache \ unzip \ ca-certificates -# download and unzip PocketBase ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip -RUN unzip /tmp/pb.zip -d /pb/ +RUN unzip /tmp/pb.zip -d /pb/ && \ + rm /tmp/pb.zip && \ + chmod +x /pb/pocketbase + +RUN mkdir -p /pb/pb_data + +COPY pb_migrations /pb/pb_migrations EXPOSE 8090 -# start PocketBase -CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1 + +CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"] diff --git a/Teams-2.xlsx b/Teams-2.xlsx deleted file mode 100644 index 9012367332a95dd6ce18e6c0e1bfe5a2cfc57095..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13520 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 s.trim()) - .filter(Boolean) - .map(globToRegExp) - -// Parse comma-separated exclude patterns (no defaults) -const EXCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_EXCLUDE ?? '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - .map(globToRegExp) - -// Verbose logging flag -const VERBOSE = process.env.STATIC_PRELOAD_VERBOSE === 'true' - -/** - * Convert a simple glob pattern to a regular expression - * Supports * wildcard for matching any characters - */ -function globToRegExp(glob: string): RegExp { - // Escape regex special chars except *, then replace * with .* - const escaped = glob - .replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&') - .replace(/\*/g, '.*') - return new RegExp(`^${escaped}$`, 'i') -} - -/** - * Metadata for preloaded static assets - */ -interface AssetMetadata { - route: string - size: number - type: string -} - -/** - * Result of static asset preloading process - */ -interface PreloadResult { - routes: Record Response> - loaded: Array - skipped: Array -} - -/** - * Check if a file should be included based on configured patterns - */ -function shouldInclude(relativePath: string): boolean { - const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath - - // If include patterns are specified, file must match at least one - if (INCLUDE_PATTERNS.length > 0) { - if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) { - return false - } - } - - // If exclude patterns are specified, file must not match any - if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) { - return false - } - - return true -} - -/** - * Build static routes with intelligent preloading strategy - * Small files are loaded into memory, large files are served on-demand - */ -async function buildStaticRoutes(clientDir: string): Promise { - const routes: Record Response> = {} - const loaded: Array = [] - const skipped: Array = [] - - console.log(`šŸ“¦ Loading static assets from ${clientDir}...`) - console.log( - ` Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`, - ) - if (INCLUDE_PATTERNS.length > 0) { - console.log( - ` Include patterns: ${process.env.STATIC_PRELOAD_INCLUDE ?? ''}`, - ) - } - if (EXCLUDE_PATTERNS.length > 0) { - console.log( - ` Exclude patterns: ${process.env.STATIC_PRELOAD_EXCLUDE ?? ''}`, - ) - } - - let totalPreloadedBytes = 0 - - try { - // Read all files recursively - const files = await readdir(clientDir, { recursive: true }) - - for (const relativePath of files) { - const filepath = join(clientDir, relativePath) - const route = '/' + relativePath.replace(/\\/g, '/') // Handle Windows paths - - try { - // Get file metadata - const file = Bun.file(filepath) - - // Skip if file doesn't exist or is empty - if (!(await file.exists()) || file.size === 0) { - continue - } - - const metadata: AssetMetadata = { - route, - size: file.size, - type: file.type || 'application/octet-stream', - } - - // Determine if file should be preloaded - const matchesPattern = shouldInclude(relativePath) - const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES - - if (matchesPattern && withinSizeLimit) { - // Preload small files into memory - const bytes = await file.bytes() - - routes[route] = () => - new Response(bytes, { - headers: { - 'Content-Type': metadata.type, - 'Cache-Control': 'public, max-age=31536000, immutable', - }, - }) - - loaded.push({ ...metadata, size: bytes.byteLength }) - totalPreloadedBytes += bytes.byteLength - } else { - // Serve large or filtered files on-demand - routes[route] = () => { - const fileOnDemand = Bun.file(filepath) - return new Response(fileOnDemand, { - headers: { - 'Content-Type': metadata.type, - 'Cache-Control': 'public, max-age=3600', - }, - }) - } - - skipped.push(metadata) - } - } catch (error: unknown) { - if (error instanceof Error && error.name !== 'EISDIR') { - console.error(`āŒ Failed to load ${filepath}:`, error) - } - } - } - - // Always show file overview in Vite-like format first - if (loaded.length > 0 || skipped.length > 0) { - const allFiles = [...loaded, ...skipped].sort((a, b) => - a.route.localeCompare(b.route), - ) - - // Calculate max path length for alignment - const maxPathLength = Math.min( - Math.max(...allFiles.map((f) => f.route.length)), - 60, - ) - - // Format file size with KB and gzip estimation - const formatFileSize = (bytes: number) => { - const kb = bytes / 1024 - // Rough gzip estimation (typically 30-70% compression) - const gzipKb = kb * 0.35 - return { - size: kb < 100 ? kb.toFixed(2) : kb.toFixed(1), - gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1), - } - } - - if (loaded.length > 0) { - console.log('\nšŸ“ Preloaded into memory:') - loaded - .sort((a, b) => a.route.localeCompare(b.route)) - .forEach((file) => { - const { size, gzip } = formatFileSize(file.size) - const paddedPath = file.route.padEnd(maxPathLength) - const sizeStr = `${size.padStart(7)} kB` - const gzipStr = `gzip: ${gzip.padStart(6)} kB` - console.log(` ${paddedPath} ${sizeStr} │ ${gzipStr}`) - }) - } - - if (skipped.length > 0) { - console.log('\nšŸ’¾ Served on-demand:') - skipped - .sort((a, b) => a.route.localeCompare(b.route)) - .forEach((file) => { - const { size, gzip } = formatFileSize(file.size) - const paddedPath = file.route.padEnd(maxPathLength) - const sizeStr = `${size.padStart(7)} kB` - const gzipStr = `gzip: ${gzip.padStart(6)} kB` - console.log(` ${paddedPath} ${sizeStr} │ ${gzipStr}`) - }) - } - - // Show detailed verbose info if enabled - if (VERBOSE) { - console.log('\nšŸ“Š Detailed file information:') - allFiles.forEach((file) => { - const isPreloaded = loaded.includes(file) - const status = isPreloaded ? '[MEMORY]' : '[ON-DEMAND]' - const reason = - !isPreloaded && file.size > MAX_PRELOAD_BYTES - ? ' (too large)' - : !isPreloaded - ? ' (filtered)' - : '' - console.log( - ` ${status.padEnd(12)} ${file.route} - ${file.type}${reason}`, - ) - }) - } - } - - // Log summary after the file list - console.log() // Empty line for separation - if (loaded.length > 0) { - console.log( - `āœ… Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`, - ) - } else { - console.log('ā„¹ļø No files preloaded into memory') - } - - if (skipped.length > 0) { - const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length - const filtered = skipped.length - tooLarge - console.log( - `ā„¹ļø ${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`, - ) - } - } catch (error) { - console.error(`āŒ Failed to load static files from ${clientDir}:`, error) - } - - return { routes, loaded, skipped } -} - -/** - * Start the production server - */ -async function startServer() { - console.log('šŸš€ Starting production server...') - - // Load TanStack Start server handler - let handler: { fetch: (request: Request) => Response | Promise } - try { - const serverModule = (await import(SERVER_ENTRY)) as { - default: { fetch: (request: Request) => Response | Promise } - } - handler = serverModule.default - console.log('āœ… TanStack Start handler loaded') - } catch (error) { - console.error('āŒ Failed to load server handler:', error) - process.exit(1) - } - - // Build static routes with intelligent preloading - const { routes } = await buildStaticRoutes(CLIENT_DIR) - - // Create Bun server - const server = Bun.serve({ - port: PORT, - - idleTimeout: 255, - - routes: { - // Serve static assets (preloaded or on-demand) - ...routes, - - // Fallback to TanStack Start handler for all other routes - '/*': (request) => { - try { - return handler.fetch(request) - } catch (error) { - console.error('Server handler error:', error) - return new Response('Internal Server Error', { status: 500 }) - } - }, - }, - - // Global error handler - error(error) { - console.error('Uncaught server error:', error) - return new Response('Internal Server Error', { status: 500 }) - }, - }) - - console.log( - `\nšŸš€ Server running at http://localhost:${String(server.port)}\n`, - ) -} - -// Start the server -startServer().catch((error: unknown) => { - console.error('Failed to start server:', error) - process.exit(1) -}) \ No newline at end of file diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 553c25a..e6b39b8 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as LogoutRouteImport } from './routes/logout' import { Route as LoginRouteImport } from './routes/login' import { Route as AuthedRouteImport } from './routes/_authed' import { Route as AuthedIndexRouteImport } from './routes/_authed/index' +import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as AuthedStatsRouteImport } from './routes/_authed/stats' import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings' import { Route as AuthedBadgesRouteImport } from './routes/_authed/badges' @@ -67,6 +68,11 @@ const AuthedIndexRoute = AuthedIndexRouteImport.update({ path: '/', getParentRoute: () => AuthedRoute, } as any) +const ApiHealthRoute = ApiHealthRouteImport.update({ + id: '/api/health', + path: '/api/health', + getParentRoute: () => rootRouteImport, +} as any) const AuthedStatsRoute = AuthedStatsRouteImport.update({ id: '/stats', path: '/stats', @@ -224,6 +230,7 @@ export interface FileRoutesByFullPath { '/badges': typeof AuthedBadgesRoute '/settings': typeof AuthedSettingsRoute '/stats': typeof AuthedStatsRoute + '/api/health': typeof ApiHealthRoute '/': typeof AuthedIndexRoute '/admin/activities': typeof AuthedAdminActivitiesRoute '/admin/badges': typeof AuthedAdminBadgesRoute @@ -257,6 +264,7 @@ export interface FileRoutesByTo { '/badges': typeof AuthedBadgesRoute '/settings': typeof AuthedSettingsRoute '/stats': typeof AuthedStatsRoute + '/api/health': typeof ApiHealthRoute '/': typeof AuthedIndexRoute '/admin/activities': typeof AuthedAdminActivitiesRoute '/admin/badges': typeof AuthedAdminBadgesRoute @@ -293,6 +301,7 @@ export interface FileRoutesById { '/_authed/badges': typeof AuthedBadgesRoute '/_authed/settings': typeof AuthedSettingsRoute '/_authed/stats': typeof AuthedStatsRoute + '/api/health': typeof ApiHealthRoute '/_authed/': typeof AuthedIndexRoute '/_authed/admin/activities': typeof AuthedAdminActivitiesRoute '/_authed/admin/badges': typeof AuthedAdminBadgesRoute @@ -329,6 +338,7 @@ export interface FileRouteTypes { | '/badges' | '/settings' | '/stats' + | '/api/health' | '/' | '/admin/activities' | '/admin/badges' @@ -362,6 +372,7 @@ export interface FileRouteTypes { | '/badges' | '/settings' | '/stats' + | '/api/health' | '/' | '/admin/activities' | '/admin/badges' @@ -397,6 +408,7 @@ export interface FileRouteTypes { | '/_authed/badges' | '/_authed/settings' | '/_authed/stats' + | '/api/health' | '/_authed/' | '/_authed/admin/activities' | '/_authed/admin/badges' @@ -429,6 +441,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute LogoutRoute: typeof LogoutRoute RefreshSessionRoute: typeof RefreshSessionRoute + ApiHealthRoute: typeof ApiHealthRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiEventsSplatRoute: typeof ApiEventsSplatRoute ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute @@ -479,6 +492,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedIndexRouteImport parentRoute: typeof AuthedRoute } + '/api/health': { + id: '/api/health' + path: '/api/health' + fullPath: '/api/health' + preLoaderRoute: typeof ApiHealthRouteImport + parentRoute: typeof rootRouteImport + } '/_authed/stats': { id: '/_authed/stats' path: '/stats' @@ -738,6 +758,7 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, LogoutRoute: LogoutRoute, RefreshSessionRoute: RefreshSessionRoute, + ApiHealthRoute: ApiHealthRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiEventsSplatRoute: ApiEventsSplatRoute, ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute, diff --git a/src/app/routes/api/health.ts b/src/app/routes/api/health.ts new file mode 100644 index 0000000..38c7e5a --- /dev/null +++ b/src/app/routes/api/health.ts @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/api/health")({ + server: { + handlers: { + GET: () => { + return new Response( + JSON.stringify({ + status: "ok", + timestamp: new Date().toISOString(), + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + } + ); + }, + }, + }, +}); diff --git a/test.js b/test.js deleted file mode 100644 index 739a63c..0000000 --- a/test.js +++ /dev/null @@ -1,269 +0,0 @@ -import PocketBase from "pocketbase"; -import * as xlsx from "xlsx"; -import { nanoid } from "nanoid"; - -import { createTeamsService } from "./src/lib/pocketbase/services/teams.ts"; -import { createPlayersService } from "./src/lib/pocketbase/services/players.ts"; -import { createMatchesService } from "./src/lib/pocketbase/services/matches.ts"; -import { createTournamentsService } from "./src/lib/pocketbase/services/tournaments.ts"; - -const POCKETBASE_URL = "http://127.0.0.1:8090"; -const EXCEL_FILE_PATH = "./Teams-2.xlsx"; - -const ADMIN_EMAIL = "kyle.yohler@gmail.com"; -const ADMIN_PASSWORD = "xj44aqz9CWrNNM0o"; - -// --- Helpers --- -async function createPlayerIfMissing(playersService, nameColumn, idColumn) { - const playerId = idColumn?.trim(); - if (playerId) return playerId; - - let firstName, lastName; - if (!nameColumn || !nameColumn.trim()) { - firstName = `Player_${nanoid(4)}`; - lastName = "(Regional)"; - } else { - const parts = nameColumn.trim().split(" "); - firstName = parts[0]; - lastName = parts[1] || "(Regional)"; - } - - const newPlayer = await playersService.createPlayer({ first_name: firstName, last_name: lastName }); - return newPlayer.id; -} - -async function handleTeamsSheet(rows, teamsService, playersService, pb, tournamentIdMap = {}) { - console.log(`šŸ“„ Importing ${rows.length} teams...`); - const teamIdMap = {}; // spreadsheet ID -> PocketBase ID - - for (const [i, row] of rows.entries()) { - try { - const spreadsheetTeamId = row["ID"]?.toString().trim(); - if (!spreadsheetTeamId) { - console.warn(`āš ļø [${i + 1}] Team row missing spreadsheet ID, skipping.`); - continue; - } - - const p1Id = await createPlayerIfMissing(playersService, row["P1 Name"], row["P1 ID"]); - const p2Id = await createPlayerIfMissing(playersService, row["P2 Name"], row["P2 ID"]); - - let name = row["Name"]?.trim(); - if (!name) { - const p1First = row["P1 Name"]?.split(" ")[0] || "Player1"; - const p2First = row["P2 Name"]?.split(" ")[0] || "Player2"; - name = `${p1First} and ${p2First}`; - console.warn(`āš ļø [${i + 1}] No team name found. Using generated name: ${name}`); - } - - const existing = await pb.collection("teams").getFullList({ - filter: `name = "${name}"`, - fields: "id", - }); - - if (existing.length > 0) { - console.log(`ā„¹ļø [${i + 1}] Team "${name}" already exists, skipping.`); - teamIdMap[spreadsheetTeamId] = existing[0].id; - continue; - } - - // If there's a tournament for this team, get its PB ID - const tournamentSpreadsheetId = row["Tournament ID"]?.toString().trim(); - const tournamentId = tournamentSpreadsheetId ? tournamentIdMap[tournamentSpreadsheetId] : undefined; - - const teamInput = { - name, - primary_color: row.primary_color || "", - accent_color: row.accent_color || "", - logo: row.logo || "", - players: [p1Id, p2Id], - tournament: tournamentId, // single tournament relation, - private: true - }; - - const team = await teamsService.createTeam(teamInput); - teamIdMap[spreadsheetTeamId] = team.id; - - console.log(`āœ… [${i + 1}] Created team: ${team.name} with players: ${[p1Id, p2Id].join(", ")}`); - - // Add the team to the tournament's "teams" relation - if (tournamentId) { - await pb.collection("tournaments").update(tournamentId, { - "teams+": [team.id], - }); - console.log(`āœ… Added team "${team.name}" to tournament ${tournamentId}`); - } - } catch (err) { - console.error(`āŒ [${i + 1}] Failed to create team: ${err.message}`); - } - } - - return teamIdMap; -} - - -async function handleTournamentSheet(rows, tournamentsService, teamIdMap, pb) { - console.log(`šŸ“„ Importing ${rows.length} tournaments...`); - const tournamentIdMap = {}; - const validFormats = ["double_elim", "single_elim", "groups", "swiss", "swiss_bracket"]; - - for (const [i, row] of rows.entries()) { - try { - const spreadsheetId = row["ID"]?.toString().trim(); - if (!spreadsheetId) { - console.warn(`āš ļø [${i + 1}] Tournament missing spreadsheet ID, skipping.`); - continue; - } - - if (!row["Name"]) { - console.warn(`āš ļø [${i + 1}] Tournament name missing, skipping.`); - continue; - } - - const format = validFormats.includes(row["Format"]) ? row["Format"] : "double_elim"; - - // Convert start_time to ISO datetime string - let startTime = null; - if (row["Start Time"]) { - try { - startTime = new Date(row["Start Time"]).toISOString(); - } catch (e) { - console.warn(`āš ļø [${i + 1}] Invalid start time format, using null`); - } - } - - const tournamentInput = { - name: row["Name"], - start_time: startTime, - format, - regional: true, - teams: Object.values(teamIdMap), // Add all created teams - }; - - const tournament = await tournamentsService.createTournament(tournamentInput); - tournamentIdMap[spreadsheetId] = tournament.id; - - console.log(`āœ… [${i + 1}] Created tournament: ${tournament.name} with ${Object.values(teamIdMap).length} teams`); - } catch (err) { - console.error(`āŒ [${i + 1}] Failed to create tournament: ${err.message}`); - } - } - - return tournamentIdMap; -} - - -async function handleMatchesSheet(rows, matchesService, teamIdMap, tournamentIdMap, pb) { - console.log(`šŸ“„ Importing ${rows.length} matches...`); - - const tournamentMatchesMap = {}; - - for (const [i, row] of rows.entries()) { - try { - const homeId = teamIdMap[row["Home ID"]]; - const awayId = teamIdMap[row["Away ID"]]; - const tournamentId = tournamentIdMap[row["Tournament ID"]]; - - if (!homeId || !awayId || !tournamentId) { - console.warn(`āš ļø [${i + 1}] Could not find mapping for Home, Away, or Tournament, skipping.`); - continue; - } - - // --- Ensure the teams are linked to the tournament --- - for (const teamId of [homeId, awayId]) { - const team = await pb.collection("teams").getOne(teamId, { fields: "tournaments" }); - const tournaments = team.tournaments || []; - if (!tournaments.includes(tournamentId)) { - // Add tournament to team - await pb.collection("teams").update(teamId, { "tournaments+": [tournamentId] }); - // Add team to tournament - await pb.collection("tournaments").update(tournamentId, { "teams+": [teamId] }); - console.log(`āœ… Linked team ${team.name} to tournament ${tournamentId}`); - } - } - - // --- Create match --- - const data = { - tournament: tournamentId, - home: homeId, - away: awayId, - home_cups: Number(row["Home cups"] || 0), - away_cups: Number(row["Away cups"] || 0), - status: "ended", - lid: i+1 - }; - - const match = await matchesService.createMatch(data); - console.log(`āœ… [${i + 1}] Created match ID: ${match.id}`); - - if (!tournamentMatchesMap[tournamentId]) tournamentMatchesMap[tournamentId] = []; - tournamentMatchesMap[tournamentId].push(match.id); - } catch (err) { - console.error(`āŒ [${i + 1}] Failed to create match: ${err.message}`); - } - } - - // Update each tournament with the created match IDs - for (const [tournamentId, matchIds] of Object.entries(tournamentMatchesMap)) { - try { - await pb.collection("tournaments").update(tournamentId, { "matches+": matchIds }); - console.log(`āœ… Updated tournament ${tournamentId} with ${matchIds.length} matches`); - } catch (err) { - console.error(`āŒ Failed to update tournament ${tournamentId} with matches: ${err.message}`); - } - } -} - - -// --- Main Import --- -export async function importExcel() { - const pb = new PocketBase(POCKETBASE_URL); - await pb.admins.authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); - - const teamsService = createTeamsService(pb); - const playersService = createPlayersService(pb); - const tournamentsService = createTournamentsService(pb); - const matchesService = createMatchesService(pb); - - const workbook = xlsx.readFile(EXCEL_FILE_PATH); - - let teamIdMap = {}; - let tournamentIdMap = {}; - - // Process sheets in correct order: Tournaments -> Teams -> Matches - const sheetOrder = ["tournament", "tournaments", "teams", "matches"]; - const processedSheets = new Set(); - - for (const sheetNamePattern of sheetOrder) { - for (const sheetName of workbook.SheetNames) { - if (processedSheets.has(sheetName)) continue; - if (sheetName.toLowerCase() !== sheetNamePattern) continue; - - const worksheet = workbook.Sheets[sheetName]; - const rows = xlsx.utils.sheet_to_json(worksheet); - - console.log(`\nšŸ“˜ Processing sheet: ${sheetName}`); - - switch (sheetName.toLowerCase()) { - case "teams": - teamIdMap = await handleTeamsSheet(rows, teamsService, playersService, pb, tournamentIdMap); - break; - case "tournament": - case "tournaments": - tournamentIdMap = await handleTournamentSheet(rows, tournamentsService, teamIdMap, pb); - break; - case "matches": - await handleMatchesSheet(rows, matchesService, teamIdMap, tournamentIdMap, pb); - break; - default: - console.log(`āš ļø No handler found for sheet '${sheetName}', skipping.`); - } - - processedSheets.add(sheetName); - } - } - - console.log("\nšŸŽ‰ All sheets imported successfully!"); -} - -// --- Run --- -importExcel().catch(console.error); \ No newline at end of file