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 9012367..0000000 Binary files a/Teams-2.xlsx and /dev/null differ diff --git a/k8s/base/app-deployment.yaml b/k8s/base/app-deployment.yaml new file mode 100644 index 0000000..455e093 --- /dev/null +++ b/k8s/base/app-deployment.yaml @@ -0,0 +1,124 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: flxn-app + labels: + app: flxn + component: app +spec: + replicas: 1 + selector: + matchLabels: + app: flxn + component: app + template: + metadata: + labels: + app: flxn + component: app + spec: + containers: + - name: flxn-app + image: git.yohler.net/kyle/flxn-app:latest + imagePullPolicy: Always + ports: + - containerPort: 3000 + name: http + protocol: TCP + env: + - name: VITE_API_DOMAIN + valueFrom: + configMapKeyRef: + name: flxn-config + key: vite_api_domain + - name: VITE_WEBSITE_DOMAIN + valueFrom: + configMapKeyRef: + name: flxn-config + key: vite_website_domain + - name: SUPERTOKENS_URI + valueFrom: + configMapKeyRef: + name: flxn-config + key: supertokens_uri + - name: POCKETBASE_URL + valueFrom: + configMapKeyRef: + name: flxn-config + key: pocketbase_url + - name: SUPERTOKENS_API_KEY + valueFrom: + secretKeyRef: + name: flxn-secrets + key: supertokens_api_key + - name: PORT + value: "3000" + - name: NODE_ENV + value: "production" + + - name: TWILIO_ACCOUNT_SID + valueFrom: + secretKeyRef: + name: flxn-secrets + key: twilio_account_sid + - name: TWILIO_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: flxn-secrets + key: twilio_auth_token + - name: TWILIO_SERVICE_SID + valueFrom: + secretKeyRef: + name: flxn-secrets + key: twilio_service_sid + - name: POCKETBASE_ADMIN_EMAIL + valueFrom: + secretKeyRef: + name: flxn-secrets + key: pocketbase_admin_email + - name: POCKETBASE_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: flxn-secrets + key: pocketbase_admin_password + - name: VITE_SPOTIFY_CLIENT_ID + valueFrom: + configMapKeyRef: + name: flxn-config + key: vite_spotify_client_id + - name: SPOTIFY_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: flxn-secrets + key: spotify_client_secret + - name: VITE_SPOTIFY_REDIRECT_URI + valueFrom: + configMapKeyRef: + name: flxn-config + key: vite_spotify_redirect_uri + + resources: + requests: + memory: "768Mi" + cpu: "250m" + limits: + memory: "1536Mi" + cpu: "1000m" + + livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/k8s/base/app-service.yaml b/k8s/base/app-service.yaml new file mode 100644 index 0000000..c8c6ac5 --- /dev/null +++ b/k8s/base/app-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: flxn-app + labels: + app: flxn + component: app +spec: + type: ClusterIP + ports: + - port: 3000 + targetPort: 3000 + protocol: TCP + name: http + selector: + app: flxn + component: app diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..4d6a6db --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- app-deployment.yaml +- app-service.yaml +- pocketbase-deployment.yaml +- pocketbase-service.yaml +- pb-data-pvc.yaml + +commonLabels: + app: flxn \ No newline at end of file diff --git a/k8s/base/pb-data-pvc.yaml b/k8s/base/pb-data-pvc.yaml new file mode 100644 index 0000000..b42d45b --- /dev/null +++ b/k8s/base/pb-data-pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: flxn-pb-data + labels: + app: flxn + component: pocketbase +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi diff --git a/k8s/base/pocketbase-deployment.yaml b/k8s/base/pocketbase-deployment.yaml new file mode 100644 index 0000000..769ef44 --- /dev/null +++ b/k8s/base/pocketbase-deployment.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: flxn-pocketbase + labels: + app: flxn + component: pocketbase +spec: + replicas: 1 + selector: + matchLabels: + app: flxn + component: pocketbase + template: + metadata: + labels: + app: flxn + component: pocketbase + spec: + containers: + - name: pocketbase + image: git.yohler.net/kyle/flxn-pocketbase:latest + imagePullPolicy: Always + ports: + - containerPort: 8090 + name: http + protocol: TCP + volumeMounts: + - name: pb-data + mountPath: /pb/pb_data + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /api/health + port: 8090 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: 8090 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: pb-data + persistentVolumeClaim: + claimName: flxn-pb-data diff --git a/k8s/base/pocketbase-service.yaml b/k8s/base/pocketbase-service.yaml new file mode 100644 index 0000000..095041c --- /dev/null +++ b/k8s/base/pocketbase-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: flxn-pocketbase + labels: + app: flxn + component: pocketbase +spec: + type: NodePort + ports: + - port: 8090 + targetPort: 8090 + nodePort: 30090 + protocol: TCP + name: http + selector: + app: flxn + component: pocketbase diff --git a/k8s/overlays/dev/configmap.yaml b/k8s/overlays/dev/configmap.yaml new file mode 100644 index 0000000..4a45b99 --- /dev/null +++ b/k8s/overlays/dev/configmap.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: flxn-config + namespace: flxn-dev +data: + vite_api_domain: "https://dev.flexxon.app" + vite_website_domain: "https://dev.flexxon.app" + supertokens_uri: "http://192.168.0.50:30568" + pocketbase_url: "http://192.168.0.50:30096" + vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277" + vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback" + s3_endpoint: "https://s3.yohler.net" + s3_bucket: "flxn-dev" diff --git a/k8s/overlays/dev/ingress.yaml b/k8s/overlays/dev/ingress.yaml new file mode 100644 index 0000000..56600e4 --- /dev/null +++ b/k8s/overlays/dev/ingress.yaml @@ -0,0 +1,17 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: flxn-app + namespace: flxn-dev +spec: + rules: + - host: dev.flexxon.app + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: flxn-app + port: + number: 3000 diff --git a/k8s/overlays/dev/kustomization.yaml b/k8s/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..52f045b --- /dev/null +++ b/k8s/overlays/dev/kustomization.yaml @@ -0,0 +1,50 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: flxn-dev + +resources: +- namespace.yaml +- ../../base +- configmap.yaml +- ingress.yaml + +images: +- name: git.yohler.net/kyle/flxn-app + newTag: dev +- name: git.yohler.net/kyle/flxn-pocketbase + newTag: latest + +commonLabels: + environment: dev + +patches: +- patch: |- + - op: replace + path: /spec/template/spec/containers/0/resources/requests/memory + value: "768Mi" + - op: replace + path: /spec/template/spec/containers/0/resources/limits/memory + value: "1536Mi" + target: + kind: Deployment + name: flxn-app + +- patch: |- + - op: replace + path: /spec/type + value: NodePort + - op: add + path: /spec/ports/0/nodePort + value: 30083 + target: + kind: Service + name: flxn-app + +- patch: |- + - op: replace + path: /spec/ports/0/nodePort + value: 30096 + target: + kind: Service + name: flxn-pocketbase diff --git a/k8s/overlays/dev/namespace.yaml b/k8s/overlays/dev/namespace.yaml new file mode 100644 index 0000000..ede3fa5 --- /dev/null +++ b/k8s/overlays/dev/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: flxn-dev diff --git a/k8s/overlays/prod/configmap.yaml b/k8s/overlays/prod/configmap.yaml new file mode 100644 index 0000000..faf5cb5 --- /dev/null +++ b/k8s/overlays/prod/configmap.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: flxn-config + namespace: flxn-prod +data: + vite_api_domain: "https://flexxon.app" + vite_website_domain: "https://flexxon.app" + supertokens_uri: "http://192.168.0.50:30568" + pocketbase_url: "http://192.168.0.50:30097" + vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277" + vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback" + s3_endpoint: "https://s3.yohler.net" + s3_bucket: "flxn-prod" diff --git a/k8s/overlays/prod/ingress.yaml b/k8s/overlays/prod/ingress.yaml new file mode 100644 index 0000000..d280d7e --- /dev/null +++ b/k8s/overlays/prod/ingress.yaml @@ -0,0 +1,17 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: flxn-app + namespace: flxn-prod +spec: + rules: + - host: flexxon.app + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: flxn-app + port: + number: 3000 diff --git a/k8s/overlays/prod/kustomization.yaml b/k8s/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..e0d6c75 --- /dev/null +++ b/k8s/overlays/prod/kustomization.yaml @@ -0,0 +1,50 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: flxn-prod + +resources: +- namespace.yaml +- ../../base +- configmap.yaml +- ingress.yaml + +images: +- name: git.yohler.net/kyle/flxn-app + newTag: latest +- name: git.yohler.net/kyle/flxn-pocketbase + newTag: latest + +commonLabels: + environment: prod + +patches: +- patch: |- + - op: replace + path: /spec/template/spec/containers/0/resources/requests/memory + value: "1536Mi" + - op: replace + path: /spec/template/spec/containers/0/resources/limits/memory + value: "3Gi" + target: + kind: Deployment + name: flxn-app + +- patch: |- + - op: replace + path: /spec/type + value: NodePort + - op: add + path: /spec/ports/0/nodePort + value: 30084 + target: + kind: Service + name: flxn-app + +- patch: |- + - op: replace + path: /spec/ports/0/nodePort + value: 30097 + target: + kind: Service + name: flxn-pocketbase diff --git a/k8s/overlays/prod/namespace.yaml b/k8s/overlays/prod/namespace.yaml new file mode 100644 index 0000000..57e37b1 --- /dev/null +++ b/k8s/overlays/prod/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: flxn-prod diff --git a/k8s/overlays/shared/configmap.yaml b/k8s/overlays/shared/configmap.yaml new file mode 100644 index 0000000..ee4bd45 --- /dev/null +++ b/k8s/overlays/shared/configmap.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: supertokens-config + namespace: flxn-shared diff --git a/k8s/overlays/shared/kustomization.yaml b/k8s/overlays/shared/kustomization.yaml new file mode 100644 index 0000000..f4b22c4 --- /dev/null +++ b/k8s/overlays/shared/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: flxn-shared + +resources: +- namespace.yaml +- supertokens-deployment.yaml +- supertokens-service.yaml +- supertokens-postgres-deployment.yaml +- supertokens-postgres-service.yaml +- supertokens-db-pvc.yaml + +labels: +- pairs: + environment: shared diff --git a/k8s/overlays/shared/namespace.yaml b/k8s/overlays/shared/namespace.yaml new file mode 100644 index 0000000..07a4239 --- /dev/null +++ b/k8s/overlays/shared/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: flxn-shared diff --git a/k8s/overlays/shared/supertokens-db-pvc.yaml b/k8s/overlays/shared/supertokens-db-pvc.yaml new file mode 100644 index 0000000..f2a9a23 --- /dev/null +++ b/k8s/overlays/shared/supertokens-db-pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: supertokens-db-data + labels: + app: flxn + component: supertokens-db +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/k8s/overlays/shared/supertokens-deployment.yaml b/k8s/overlays/shared/supertokens-deployment.yaml new file mode 100644 index 0000000..879abce --- /dev/null +++ b/k8s/overlays/shared/supertokens-deployment.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: supertokens + labels: + app: flxn + component: supertokens +spec: + replicas: 1 + selector: + matchLabels: + app: flxn + component: supertokens + template: + metadata: + labels: + app: flxn + component: supertokens + spec: + enableServiceLinks: false + containers: + - name: supertokens + image: registry.supertokens.io/supertokens/supertokens-postgresql:latest + ports: + - containerPort: 3567 + name: http + protocol: TCP + env: + - name: PORT + value: "3567" + - name: POSTGRESQL_USER + value: supertokens + - name: POSTGRESQL_PASSWORD + valueFrom: + secretKeyRef: + name: supertokens-secrets + key: postgres_password + - name: POSTGRESQL_HOST + value: supertokens-db + - name: POSTGRESQL_PORT + value: "5432" + - name: POSTGRESQL_DATABASE_NAME + value: supertokens + - name: API_KEYS + valueFrom: + secretKeyRef: + name: supertokens-secrets + key: api_keys + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /hello + port: 3567 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /hello + port: 3567 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/k8s/overlays/shared/supertokens-postgres-deployment.yaml b/k8s/overlays/shared/supertokens-postgres-deployment.yaml new file mode 100644 index 0000000..5167d9e --- /dev/null +++ b/k8s/overlays/shared/supertokens-postgres-deployment.yaml @@ -0,0 +1,68 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: supertokens-db + labels: + app: flxn + component: supertokens-db +spec: + replicas: 1 + selector: + matchLabels: + app: flxn + component: supertokens-db + template: + metadata: + labels: + app: flxn + component: supertokens-db + spec: + containers: + - name: postgres + image: postgres:16-alpine + ports: + - containerPort: 5432 + name: postgres + protocol: TCP + env: + - name: POSTGRES_USER + value: "supertokens" + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: supertokens-secrets + key: postgres_password + - name: POSTGRES_DB + value: "supertokens" + - name: PGDATA + value: "/var/lib/postgresql/data/pgdata" + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + exec: + command: + - pg_isready + - -U + - supertokens + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + exec: + command: + - pg_isready + - -U + - supertokens + initialDelaySeconds: 5 + periodSeconds: 5 + volumes: + - name: postgres-data + persistentVolumeClaim: + claimName: supertokens-db-data diff --git a/k8s/overlays/shared/supertokens-postgres-service.yaml b/k8s/overlays/shared/supertokens-postgres-service.yaml new file mode 100644 index 0000000..d51ff9c --- /dev/null +++ b/k8s/overlays/shared/supertokens-postgres-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: supertokens-db + labels: + app: flxn + component: supertokens-db +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + protocol: TCP + name: postgres + selector: + app: flxn + component: supertokens-db diff --git a/k8s/overlays/shared/supertokens-service.yaml b/k8s/overlays/shared/supertokens-service.yaml new file mode 100644 index 0000000..ebb973a --- /dev/null +++ b/k8s/overlays/shared/supertokens-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: supertokens + labels: + app: flxn + component: supertokens +spec: + type: NodePort + ports: + - port: 3567 + targetPort: 3567 + nodePort: 30568 + protocol: TCP + name: http + selector: + app: flxn + component: supertokens diff --git a/server.ts b/server.ts deleted file mode 100644 index b11b142..0000000 --- a/server.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * TanStack Start Production Server with Bun - * - * A high-performance production server for TanStack Start applications that - * implements intelligent static asset loading with configurable memory management. - * - * Features: - * - Hybrid loading strategy (preload small files, serve large files on-demand) - * - Configurable file filtering with include/exclude patterns - * - Memory-efficient response generation - * - Production-ready caching headers - * - * Environment Variables: - * - * PORT (number) - * - Server port number - * - Default: 3000 - * - * STATIC_PRELOAD_MAX_BYTES (number) - * - Maximum file size in bytes to preload into memory - * - Files larger than this will be served on-demand from disk - * - Default: 5242880 (5MB) - * - Example: STATIC_PRELOAD_MAX_BYTES=5242880 (5MB) - * - * STATIC_PRELOAD_INCLUDE (string) - * - Comma-separated list of glob patterns for files to include - * - If specified, only matching files are eligible for preloading - * - Patterns are matched against filenames only, not full paths - * - Example: STATIC_PRELOAD_INCLUDE="*.js,*.css,*.woff2" - * - * STATIC_PRELOAD_EXCLUDE (string) - * - Comma-separated list of glob patterns for files to exclude - * - Applied after include patterns - * - Patterns are matched against filenames only, not full paths - * - Example: STATIC_PRELOAD_EXCLUDE="*.map,*.txt" - * - * STATIC_PRELOAD_VERBOSE (boolean) - * - Enable detailed logging of loaded and skipped files - * - Default: false - * - Set to "true" to enable verbose output - * - * Usage: - * bun run server.ts - */ - -import { readdir } from 'node:fs/promises' -import { join } from 'node:path' - -// Configuration -const PORT = Number(process.env.PORT ?? 3000) -const CLIENT_DIR = './dist/client' -const SERVER_ENTRY = './dist/server/server.js' - -// Preloading configuration from environment variables -const MAX_PRELOAD_BYTES = Number( - process.env.STATIC_PRELOAD_MAX_BYTES ?? 5 * 1024 * 1024, // 5MB default -) - -// Parse comma-separated include patterns (no defaults) -const INCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_INCLUDE ?? '') - .split(',') - .map((s) => 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