[BACKEND] Initialize Go backend structure and dependencies (#23)

This commit is contained in:
2026-02-15 22:50:06 +00:00
parent 7157877f0b
commit c75f976a2d
29 changed files with 1857 additions and 253 deletions

355
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,355 @@
# .gitea/workflows/ci.yaml
# ============================================
# Chat Switchboard - CI/CD Pipeline
# ============================================
# Cluster deployments use SEPARATE FE + BE images.
# Unified image is for Docker Hub only (Docker Desktop use).
#
# Deployment mapping:
# PR → FE + BE :dev → wipe/migrate → dev.DOMAIN
# Push to main → FE + BE :test → migrate → test.DOMAIN
# Push to main → FE-only :lite → (no DB) → lite.DOMAIN
# Tag v* → FE + BE :latest → migrate → www.DOMAIN
# → Unified :latest → Docker Hub only (not deployed)
#
# Required Gitea Variables:
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
#
# Required Gitea Secrets:
# POSTGRES_USER, POSTGRES_PASSWORD
# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
#
# Global Variables (Gitea org-level):
# CERT_ISSUER_PROD
# ============================================
name: CI/CD
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main]
tags: ['v*']
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
env:
REGISTRY: ${{ vars.REGISTRY }}
NAMESPACE: ${{ vars.NAMESPACE || 'gobha-ai' }}
DOMAIN: ${{ vars.DOMAIN || 'gobha.ai' }}
CERT_ISSUER: ${{ vars.CERT_ISSUER_PROD || 'letsencrypt-prod' }}
POSTGRES_HOST: ${{ vars.POSTGRES_HOST || 'postgres-primary.postgres.svc.cluster.local' }}
POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }}
FE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-fe
BE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-be
UNIFIED_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }}
jobs:
# ── Stage 1: Go Build & Test ─────────────────
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Download and tidy
working-directory: server
run: |
go mod download
go mod tidy
- name: Run tests
working-directory: server
run: go test -v -race ./...
- name: Build check
working-directory: server
run: CGO_ENABLED=0 go build -o /dev/null .
# ── Stage 2: Build, Database, Deploy ─────────
build-and-deploy:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Add /tools to PATH
run: echo "/tools" >> $GITHUB_PATH
- name: Install tools
run: |
apt-get update -qq && apt-get install -y -qq gettext-base postgresql-client > /dev/null
# ── Determine environment ──────────────────
- name: Determine environment
id: env
run: |
if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then
echo "ENVIRONMENT=dev" >> "$GITHUB_OUTPUT"
echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT"
echo "DEPLOY_HOST=dev.${DOMAIN}" >> "$GITHUB_OUTPUT"
echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT"
echo "DB_NAME=chat_switchboard_dev" >> "$GITHUB_OUTPUT"
echo "DB_WIPE=true" >> "$GITHUB_OUTPUT"
echo "FE_REPLICAS=1" >> "$GITHUB_OUTPUT"
echo "BE_REPLICAS=1" >> "$GITHUB_OUTPUT"
echo "FE_MEMORY_REQUEST=64Mi" >> "$GITHUB_OUTPUT"
echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT"
echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT"
echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT"
echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT"
echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT"
echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
VERSION="${{ gitea.ref_name }}"
echo "ENVIRONMENT=production" >> "$GITHUB_OUTPUT"
echo "IMAGE_TAG=latest" >> "$GITHUB_OUTPUT"
echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT"
echo "DEPLOY_HOST=www.${DOMAIN}" >> "$GITHUB_OUTPUT"
echo "DEPLOY_SUFFIX=" >> "$GITHUB_OUTPUT"
echo "DB_NAME=chat_switchboard" >> "$GITHUB_OUTPUT"
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
echo "FE_REPLICAS=2" >> "$GITHUB_OUTPUT"
echo "BE_REPLICAS=2" >> "$GITHUB_OUTPUT"
echo "FE_MEMORY_REQUEST=64Mi" >> "$GITHUB_OUTPUT"
echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT"
echo "FE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
echo "FE_CPU_LIMIT=200m" >> "$GITHUB_OUTPUT"
echo "BE_MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
echo "BE_MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "BE_CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
echo "BE_CPU_LIMIT=500m" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
else
echo "ENVIRONMENT=test" >> "$GITHUB_OUTPUT"
echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT"
echo "DEPLOY_HOST=test.${DOMAIN}" >> "$GITHUB_OUTPUT"
echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT"
echo "DB_NAME=chat_switchboard_test" >> "$GITHUB_OUTPUT"
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
echo "FE_REPLICAS=1" >> "$GITHUB_OUTPUT"
echo "BE_REPLICAS=1" >> "$GITHUB_OUTPUT"
echo "FE_MEMORY_REQUEST=64Mi" >> "$GITHUB_OUTPUT"
echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT"
echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT"
echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT"
echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT"
echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT"
echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
fi
# ── Database Bootstrap ───────────────────────
- name: Bootstrap database
env:
PGHOST: ${{ env.POSTGRES_HOST }}
PGPORT: ${{ env.POSTGRES_PORT }}
PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }}
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
APP_USER: ${{ secrets.POSTGRES_USER }}
APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
DB_NAME: ${{ steps.env.outputs.DB_NAME }}
run: |
chmod +x scripts/db-bootstrap.sh
scripts/db-bootstrap.sh
# ── Database Migration ───────────────────────
- name: Run migrations
env:
PGHOST: ${{ env.POSTGRES_HOST }}
PGPORT: ${{ env.POSTGRES_PORT }}
PGUSER: ${{ secrets.POSTGRES_USER }}
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
DB_NAME: ${{ steps.env.outputs.DB_NAME }}
DB_WIPE: ${{ steps.env.outputs.DB_WIPE }}
run: |
chmod +x scripts/db-migrate.sh
scripts/db-migrate.sh
# ── Build Backend Image ──────────────────────
- name: Build backend image
run: |
docker build -f server/Dockerfile \
-t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
./server
# ── Build Frontend Image (cluster mode) ──────
- name: Build frontend image
run: |
docker build -f Dockerfile.frontend \
-t ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
.
# ── Tag release versions ─────────────────────
- name: Tag release versions
if: steps.env.outputs.EXTRA_TAG != ''
run: |
docker tag ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} ${BE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
docker tag ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
# ── Push to Internal Registry ────────────────
- name: Push images
run: |
docker push ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}
docker push ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}
if [[ -n "${{ steps.env.outputs.EXTRA_TAG }}" ]]; then
docker push ${BE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
docker push ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
fi
# ── Build + Push Unified (release only) ──────
- name: Build unified image
if: steps.env.outputs.is_release == 'true'
run: |
docker build -f Dockerfile.unified \
-t ${UNIFIED_IMAGE}:latest \
-t ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \
.
docker push ${UNIFIED_IMAGE}:latest
docker push ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
- name: Push unified to Docker Hub
if: steps.env.outputs.is_release == 'true' && secrets.DOCKERHUB_TOKEN != ''
run: |
echo "${{ secrets.DOCKERHUB_TOKEN }}" | \
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
docker tag ${UNIFIED_IMAGE}:latest ${DOCKERHUB_IMAGE}:latest
docker tag ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
docker push ${DOCKERHUB_IMAGE}:latest
docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
# ── Deploy to Kubernetes ─────────────────────
- name: Ensure namespace
run: |
kubectl create namespace ${NAMESPACE} 2>/dev/null || true
- name: Sync secrets
run: |
kubectl create secret generic switchboard-db-credentials \
--namespace=${NAMESPACE} \
--from-literal=POSTGRES_USER=${{ secrets.POSTGRES_USER }} \
--from-literal=POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \
--dry-run=client -o yaml | kubectl apply -f -
- name: Render and apply manifests
env:
ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }}
IMAGE_TAG: ${{ steps.env.outputs.IMAGE_TAG }}
DEPLOY_SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }}
DEPLOY_HOST: ${{ steps.env.outputs.DEPLOY_HOST }}
DB_NAME: ${{ steps.env.outputs.DB_NAME }}
FE_REPLICAS: ${{ steps.env.outputs.FE_REPLICAS }}
BE_REPLICAS: ${{ steps.env.outputs.BE_REPLICAS }}
FE_MEMORY_REQUEST: ${{ steps.env.outputs.FE_MEMORY_REQUEST }}
FE_MEMORY_LIMIT: ${{ steps.env.outputs.FE_MEMORY_LIMIT }}
FE_CPU_REQUEST: ${{ steps.env.outputs.FE_CPU_REQUEST }}
FE_CPU_LIMIT: ${{ steps.env.outputs.FE_CPU_LIMIT }}
BE_MEMORY_REQUEST: ${{ steps.env.outputs.BE_MEMORY_REQUEST }}
BE_MEMORY_LIMIT: ${{ steps.env.outputs.BE_MEMORY_LIMIT }}
BE_CPU_REQUEST: ${{ steps.env.outputs.BE_CPU_REQUEST }}
BE_CPU_LIMIT: ${{ steps.env.outputs.BE_CPU_LIMIT }}
run: |
envsubst < k8s/backend.yaml > /tmp/backend.yaml
envsubst < k8s/frontend.yaml > /tmp/frontend.yaml
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━"
kubectl apply -f /tmp/backend.yaml
kubectl apply -f /tmp/frontend.yaml
kubectl apply -f /tmp/ingress.yaml
- name: Rollout and verify
run: |
SUFFIX="${{ steps.env.outputs.DEPLOY_SUFFIX }}"
ENV="${{ steps.env.outputs.ENVIRONMENT }}"
kubectl rollout restart deployment/switchboard-be${SUFFIX} -n ${NAMESPACE}
kubectl rollout restart deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE}
kubectl rollout status deployment/switchboard-be${SUFFIX} -n ${NAMESPACE} --timeout=180s
kubectl rollout status deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE} --timeout=180s
echo ""
echo "━━━ Pod Status ━━━"
kubectl get pods -n ${NAMESPACE} -l app=switchboard,env=${ENV}
BE_READY=$(kubectl get deployment switchboard-be${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}')
FE_READY=$(kubectl get deployment switchboard-fe${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}')
if [[ "${BE_READY:-0}" -gt 0 ]] && [[ "${FE_READY:-0}" -gt 0 ]]; then
echo "✅ Healthy: BE=${BE_READY} FE=${FE_READY} pods ready"
else
echo "❌ Unhealthy: BE=${BE_READY:-0} FE=${FE_READY:-0}"
kubectl logs -n ${NAMESPACE} -l app=switchboard,env=${ENV} --tail=30
exit 1
fi
- name: Summary
run: |
HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
echo "## ✅ Deployed to ${{ steps.env.outputs.env_label }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| | |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
echo "| **URL** | https://${HOST} |" >> $GITHUB_STEP_SUMMARY
echo "| **Backend** | \`${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then
echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY
fi
# ── Stage 3: FE-Only Lite Deploy (main only) ─
deploy-lite:
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Add /tools to PATH
run: echo "/tools" >> $GITHUB_PATH
- name: Install tools
run: |
apt-get update -qq && apt-get install -y -qq gettext-base > /dev/null
- name: Build lite image
run: |
docker build -f Dockerfile.frontend \
-t ${FE_IMAGE}:lite \
.
- name: Push lite image
run: docker push ${FE_IMAGE}:lite
- name: Deploy lite
env:
ENVIRONMENT: lite
IMAGE_TAG: lite
DEPLOY_HOST: "lite.${{ vars.DOMAIN || 'gobha.ai' }}"
run: |
export NAMESPACE="${NAMESPACE}"
export FE_IMAGE="${FE_IMAGE}"
export CERT_ISSUER="${CERT_ISSUER}"
envsubst < k8s/lite.yaml > /tmp/lite.yaml
kubectl create namespace ${NAMESPACE} 2>/dev/null || true
kubectl apply -f /tmp/lite.yaml
kubectl rollout restart deployment/switchboard-lite -n ${NAMESPACE}
kubectl rollout status deployment/switchboard-lite -n ${NAMESPACE} --timeout=120s
echo "✅ Lite deployed to https://${DEPLOY_HOST}"

View File

@@ -1,37 +1,33 @@
# ==========================================
# Chat Switchboard Frontend Dockerfile
# Chat Switchboard - Frontend Dockerfile
# ==========================================
# Static SPA served by nginx. No API proxy —
# in cluster mode, the Ingress routes /api/ to
# the backend service directly.
# Also used for the lite (unmanaged) deployment.
# ==========================================
# Build stage - using alpine, no Node.js needed (build.sh is just bash)
# Build stage
FROM alpine:3.19 AS builder
WORKDIR /app
# Install bash and required tools
RUN apk add --no-cache bash coreutils
# Copy build script and source
COPY build.sh ./
COPY src ./src
# Make build script executable and run it
RUN chmod +x build.sh && ./build.sh
# Production stage
FROM nginx:alpine AS production
FROM nginx:alpine
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Static-only nginx config (no /api/ proxy)
COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf
# Copy built standalone files
# Built SPA files
COPY --from=builder /app/standalone /usr/share/nginx/html
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
# Run nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,25 +1,28 @@
# ==========================================
# Chat Switchboard Unified Dockerfile
# Option B: Multi-stage build
# ==========================================
# ============================================
# Chat Switchboard - Unified Dockerfile
# ============================================
# Stage 1: Build Go backend
# Stage 2: Build standalone frontend
# Stage 3: Production image (nginx + backend)
#
# nginx serves frontend on :80, proxies /api/ to Go on :8080
# ============================================
# Stage 1: Backend build
# ── Stage 1: Go backend build ────────────────
FROM golang:1.22-bookworm AS backend
WORKDIR /app
COPY server/go.mod ./
COPY server/go.sum ./
RUN go mod download
COPY server/go.mod server/go.sum* ./
RUN go mod download && go mod verify
COPY server/ .
RUN CGO_ENABLED=1 go build -o /backend chat-switchboard-api
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/switchboard .
# Stage 2: Frontend build
# ── Stage 2: Frontend build ──────────────────
FROM alpine:3.19 AS frontend
WORKDIR /app
RUN apk add --no-cache bash coreutils
COPY build.sh ./
@@ -27,24 +30,22 @@ COPY src ./src
RUN chmod +x build.sh && ./build.sh
# Stage 3: Production
FROM nginx:alpine AS production
# ── Stage 3: Production ─────────────────────
FROM nginx:1-alpine
# Copy backend binary
COPY --from=backend /backend /usr/local/bin/
RUN apk add --no-cache bash
# Copy built frontend files
# Copy Go backend
COPY --from=backend /bin/switchboard /usr/local/bin/switchboard
# Copy built frontend
COPY --from=frontend /app/standalone /usr/share/nginx/html
# Copy custom nginx config for reverse proxy
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Create startup script
RUN echo '#!/bin/sh\n\
mkdir -p /app/data\n\
/usr/local/bin/backend serve &\n\
nginx -g "daemon off;"\n' > /start.sh && chmod +x /start.sh
# Startup script: launches Go backend before nginx starts
COPY scripts/docker-entrypoint.sh /docker-entrypoint.d/90-start-backend.sh
RUN chmod +x /docker-entrypoint.d/90-start-backend.sh
EXPOSE 80
CMD ["/start.sh"]

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.0.1

100
k8s/backend.yaml Normal file
View File

@@ -0,0 +1,100 @@
# k8s/backend.yaml
# ============================================
# Chat Switchboard - Backend Deployment
# ============================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: switchboard-be${DEPLOY_SUFFIX}
namespace: ${NAMESPACE}
labels:
app: switchboard
component: backend
env: ${ENVIRONMENT}
spec:
replicas: ${BE_REPLICAS}
selector:
matchLabels:
app: switchboard
component: backend
env: ${ENVIRONMENT}
template:
metadata:
labels:
app: switchboard
component: backend
env: ${ENVIRONMENT}
spec:
containers:
- name: backend
image: ${BE_IMAGE}:${IMAGE_TAG}
imagePullPolicy: Always
ports:
- containerPort: 8080
name: http
env:
- name: ENVIRONMENT
value: "${ENVIRONMENT}"
- name: PORT
value: "8080"
- name: POSTGRES_HOST
value: "${POSTGRES_HOST}"
- name: POSTGRES_PORT
value: "${POSTGRES_PORT}"
- name: POSTGRES_DB
value: "${DB_NAME}"
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: switchboard-db-credentials
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: switchboard-db-credentials
key: POSTGRES_PASSWORD
- name: DATABASE_URL
value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@${POSTGRES_HOST}:${POSTGRES_PORT}/${DB_NAME}?sslmode=disable"
resources:
requests:
memory: "${BE_MEMORY_REQUEST}"
cpu: "${BE_CPU_REQUEST}"
limits:
memory: "${BE_MEMORY_LIMIT}"
cpu: "${BE_CPU_LIMIT}"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: switchboard-be${DEPLOY_SUFFIX}
namespace: ${NAMESPACE}
labels:
app: switchboard
component: backend
env: ${ENVIRONMENT}
spec:
selector:
app: switchboard
component: backend
env: ${ENVIRONMENT}
ports:
- protocol: TCP
port: 8080
targetPort: 8080
name: http

135
k8s/deployment.yaml Normal file
View File

@@ -0,0 +1,135 @@
# k8s/deployment.yaml
# ============================================
# Chat Switchboard - Kubernetes Deployment Template
# ============================================
# Variables substituted by CI/CD via envsubst:
# DEPLOY_NAME, ENVIRONMENT, IMAGE_TAG, BASE_PATH,
# DB_NAME, REPLICAS, NAMESPACE, REGISTRY, IMAGE_NAME,
# DOMAIN, CERT_ISSUER, POSTGRES_HOST, POSTGRES_PORT,
# MEMORY_REQUEST, MEMORY_LIMIT, CPU_REQUEST, CPU_LIMIT
# ============================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${DEPLOY_NAME}
namespace: ${NAMESPACE}
labels:
app: chat-switchboard
env: ${ENVIRONMENT}
service: chat-switchboard
spec:
replicas: ${REPLICAS}
selector:
matchLabels:
app: chat-switchboard
env: ${ENVIRONMENT}
template:
metadata:
labels:
app: chat-switchboard
env: ${ENVIRONMENT}
service: chat-switchboard
spec:
containers:
- name: switchboard
image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
env:
- name: ENVIRONMENT
value: "${ENVIRONMENT}"
- name: BASE_PATH
value: "${BASE_PATH}"
- name: PORT
value: "8080"
# ── Database connection ──
- name: POSTGRES_HOST
value: "${POSTGRES_HOST}"
- name: POSTGRES_PORT
value: "${POSTGRES_PORT}"
- name: POSTGRES_DB
value: "${DB_NAME}"
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: switchboard-db-credentials
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: switchboard-db-credentials
key: POSTGRES_PASSWORD
- name: DATABASE_URL
value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@${POSTGRES_HOST}:${POSTGRES_PORT}/${DB_NAME}?sslmode=disable"
resources:
requests:
memory: "${MEMORY_REQUEST}"
cpu: "${CPU_REQUEST}"
limits:
memory: "${MEMORY_LIMIT}"
cpu: "${CPU_LIMIT}"
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: ${DEPLOY_NAME}
namespace: ${NAMESPACE}
labels:
app: chat-switchboard
env: ${ENVIRONMENT}
spec:
selector:
app: chat-switchboard
env: ${ENVIRONMENT}
ports:
- protocol: TCP
port: 80
targetPort: 80
name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ${DEPLOY_NAME}
namespace: ${NAMESPACE}
annotations:
cert-manager.io/cluster-issuer: "${CERT_ISSUER}"
traefik.ingress.kubernetes.io/router.entrypoints: websecure
labels:
app: chat-switchboard
env: ${ENVIRONMENT}
spec:
ingressClassName: "traefik"
tls:
- hosts:
- ${DOMAIN}
secretName: chat-switchboard-tls
rules:
- host: "${DOMAIN}"
http:
paths:
- path: ${BASE_PATH}
pathType: Prefix
backend:
service:
name: ${DEPLOY_NAME}
port:
number: 80

80
k8s/frontend.yaml Normal file
View File

@@ -0,0 +1,80 @@
# k8s/frontend.yaml
# ============================================
# Chat Switchboard - Frontend Deployment
# ============================================
# Static nginx serving the SPA. No /api/ proxy here —
# the Ingress routes /api/ directly to the backend service.
# ============================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: switchboard-fe${DEPLOY_SUFFIX}
namespace: ${NAMESPACE}
labels:
app: switchboard
component: frontend
env: ${ENVIRONMENT}
spec:
replicas: ${FE_REPLICAS}
selector:
matchLabels:
app: switchboard
component: frontend
env: ${ENVIRONMENT}
template:
metadata:
labels:
app: switchboard
component: frontend
env: ${ENVIRONMENT}
spec:
containers:
- name: frontend
image: ${FE_IMAGE}:${IMAGE_TAG}
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
resources:
requests:
memory: "${FE_MEMORY_REQUEST}"
cpu: "${FE_CPU_REQUEST}"
limits:
memory: "${FE_MEMORY_LIMIT}"
cpu: "${FE_CPU_LIMIT}"
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: switchboard-fe${DEPLOY_SUFFIX}
namespace: ${NAMESPACE}
labels:
app: switchboard
component: frontend
env: ${ENVIRONMENT}
spec:
selector:
app: switchboard
component: frontend
env: ${ENVIRONMENT}
ports:
- protocol: TCP
port: 80
targetPort: 80
name: http

45
k8s/ingress.yaml Normal file
View File

@@ -0,0 +1,45 @@
# k8s/ingress.yaml
# ============================================
# Chat Switchboard - Ingress
# ============================================
# Routes for a single subdomain:
# /api/* → backend service (Go API on :8080)
# /* → frontend service (nginx SPA on :80)
# ============================================
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: switchboard${DEPLOY_SUFFIX}
namespace: ${NAMESPACE}
annotations:
cert-manager.io/cluster-issuer: "${CERT_ISSUER}"
traefik.ingress.kubernetes.io/router.entrypoints: websecure
labels:
app: switchboard
env: ${ENVIRONMENT}
spec:
ingressClassName: "traefik"
tls:
- hosts:
- ${DEPLOY_HOST}
secretName: switchboard${DEPLOY_SUFFIX}-tls
rules:
- host: "${DEPLOY_HOST}"
http:
paths:
# API routes → backend
- path: /api
pathType: Prefix
backend:
service:
name: switchboard-be${DEPLOY_SUFFIX}
port:
number: 8080
# Everything else → frontend
- path: /
pathType: Prefix
backend:
service:
name: switchboard-fe${DEPLOY_SUFFIX}
port:
number: 80

104
k8s/lite.yaml Normal file
View File

@@ -0,0 +1,104 @@
# k8s/lite.yaml
# ============================================
# Chat Switchboard - Lite (FE-Only)
# ============================================
# Static SPA with no backend. All data in localStorage.
# Deployed to lite.DOMAIN on push to main.
# ============================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: switchboard-lite
namespace: ${NAMESPACE}
labels:
app: switchboard
component: frontend
env: lite
spec:
replicas: 1
selector:
matchLabels:
app: switchboard
component: frontend
env: lite
template:
metadata:
labels:
app: switchboard
component: frontend
env: lite
spec:
containers:
- name: frontend
image: ${FE_IMAGE}:${IMAGE_TAG}
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "64Mi"
cpu: "50m"
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: switchboard-lite
namespace: ${NAMESPACE}
labels:
app: switchboard
env: lite
spec:
selector:
app: switchboard
component: frontend
env: lite
ports:
- protocol: TCP
port: 80
targetPort: 80
name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: switchboard-lite
namespace: ${NAMESPACE}
annotations:
cert-manager.io/cluster-issuer: "${CERT_ISSUER}"
traefik.ingress.kubernetes.io/router.entrypoints: websecure
labels:
app: switchboard
env: lite
spec:
ingressClassName: "traefik"
tls:
- hosts:
- ${DEPLOY_HOST}
secretName: switchboard-lite-tls
rules:
- host: "${DEPLOY_HOST}"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: switchboard-lite
port:
number: 80

View File

@@ -1,125 +0,0 @@
-- ==========================================
-- Chat Switchboard - Initial Schema
-- ==========================================
-- PostgreSQL migration for backend support
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table (optional, for multi-user support)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE,
password_hash VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- API configurations per user
CREATE TABLE api_configs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL DEFAULT 'default',
endpoint VARCHAR(500) NOT NULL,
api_key_encrypted TEXT NOT NULL,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, name)
);
-- User settings
CREATE TABLE user_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID UNIQUE REFERENCES users(id) ON DELETE CASCADE,
default_model VARCHAR(100) DEFAULT 'gpt-3.5-turbo',
stream_responses BOOLEAN DEFAULT TRUE,
show_thinking BOOLEAN DEFAULT TRUE,
system_prompt TEXT,
max_tokens INTEGER DEFAULT 4096,
temperature DECIMAL(3,2) DEFAULT 0.7,
top_p DECIMAL(3,2) DEFAULT 1.0,
presence_penalty DECIMAL(3,2) DEFAULT 0.0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Chats
CREATE TABLE chats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
model VARCHAR(100),
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
is_archived BOOLEAN DEFAULT FALSE,
is_pinned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Messages
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('system', 'user', 'assistant')),
content TEXT NOT NULL,
model VARCHAR(100),
tokens_used INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Cached models per API config
CREATE TABLE cached_models (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
api_config_id UUID NOT NULL REFERENCES api_configs(id) ON DELETE CASCADE,
model_id VARCHAR(100) NOT NULL,
owned_by VARCHAR(100),
cached_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(api_config_id, model_id)
);
-- Indexes for performance
CREATE INDEX idx_chats_user_id ON chats(user_id);
CREATE INDEX idx_chats_updated_at ON chats(updated_at DESC);
CREATE INDEX idx_messages_chat_id ON messages(chat_id);
CREATE INDEX idx_messages_created_at ON messages(created_at);
CREATE INDEX idx_api_configs_user_id ON api_configs(user_id);
-- Updated at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply updated_at triggers
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_api_configs_updated_at BEFORE UPDATE ON api_configs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_user_settings_updated_at BEFORE UPDATE ON user_settings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_chats_updated_at BEFORE UPDATE ON chats
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- View for chat list with message count
CREATE VIEW chat_list AS
SELECT
c.id,
c.user_id,
c.title,
c.model,
c.is_archived,
c.is_pinned,
c.created_at,
c.updated_at,
COUNT(m.id) as message_count,
MAX(m.created_at) as last_message_at
FROM chats c
LEFT JOIN messages m ON m.chat_id = c.id
GROUP BY c.id;

29
nginx.frontend.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

61
scripts/db-bootstrap.sh Normal file
View File

@@ -0,0 +1,61 @@
#!/bin/bash
# ============================================
# Chat Switchboard - Database Bootstrap
# ============================================
# Idempotent: safe to run on every CI build.
# Creates the app role and database if they
# don't exist. Runs as the Postgres superuser.
#
# Required env vars:
# PGHOST, PGPORT, PGUSER, PGPASSWORD — admin connection
# APP_USER, APP_PASSWORD — app-level credentials
# DB_NAME — target database name
# ============================================
set -euo pipefail
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Database bootstrap: ${DB_NAME}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ── 1. Create role if not exists ─────────────
ROLE_EXISTS=$(psql -tAc "SELECT 1 FROM pg_roles WHERE rolname = '${APP_USER}';" postgres)
if [[ "${ROLE_EXISTS}" == "1" ]]; then
echo "✓ Role '${APP_USER}' already exists"
# Update password in case it changed
psql -c "ALTER ROLE ${APP_USER} WITH PASSWORD '${APP_PASSWORD}';" postgres
echo "✓ Password synced"
else
psql -c "CREATE ROLE ${APP_USER} WITH LOGIN PASSWORD '${APP_PASSWORD}';" postgres
echo "✓ Role '${APP_USER}' created"
fi
# ── 2. Create database if not exists ─────────
DB_EXISTS=$(psql -tAc "SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}';" postgres)
if [[ "${DB_EXISTS}" == "1" ]]; then
echo "✓ Database '${DB_NAME}' already exists"
else
psql -c "CREATE DATABASE ${DB_NAME} OWNER ${APP_USER};" postgres
echo "✓ Database '${DB_NAME}' created"
fi
# ── 3. Ensure extensions (requires superuser) ─
psql -d "${DB_NAME}" <<'SQL'
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "vector";
SQL
echo "✓ Extensions verified (uuid-ossp, pgcrypto)"
# ── 4. Grant privileges ─────────────────────
psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${APP_USER};"
psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${APP_USER};"
psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER};"
psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER};"
echo "✓ Privileges granted"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Bootstrap complete: ${DB_NAME}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

91
scripts/db-migrate.sh Normal file
View File

@@ -0,0 +1,91 @@
#!/bin/bash
# ============================================
# Chat Switchboard - Database Migration Runner
# ============================================
# Tracks applied migrations in schema_migrations.
# In dev (DB_WIPE=true): drops all tables, re-runs
# all migrations for a clean slate every PR build.
#
# Required env vars:
# PGHOST, PGPORT, PGUSER, PGPASSWORD — app-level connection
# DB_NAME — target database
# DB_WIPE — "true" to wipe first (dev only)
# ============================================
set -euo pipefail
MIGRATIONS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../migrations" && pwd)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Migration runner: ${DB_NAME}"
echo " Wipe mode: ${DB_WIPE}"
echo " Migrations: ${MIGRATIONS_DIR}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
export PGDATABASE="${DB_NAME}"
# ── Dev wipe: drop everything and start fresh ─
if [[ "${DB_WIPE}" == "true" ]]; then
echo "⚠ Dev wipe: dropping all objects..."
psql <<'SQL'
DO $$ DECLARE
r RECORD;
BEGIN
-- Drop all views
FOR r IN (SELECT viewname FROM pg_views WHERE schemaname = 'public') LOOP
EXECUTE 'DROP VIEW IF EXISTS public.' || quote_ident(r.viewname) || ' CASCADE';
END LOOP;
-- Drop all tables (including schema_migrations for clean slate)
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
-- Drop all functions (skip extension-owned like uuid_nil etc)
FOR r IN (SELECT ns.nspname || '.' || p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' AS func
FROM pg_proc p JOIN pg_namespace ns ON p.pronamespace = ns.oid
WHERE ns.nspname = 'public'
AND NOT EXISTS (
SELECT 1 FROM pg_depend d
WHERE d.objid = p.oid AND d.deptype = 'e'
)) LOOP
EXECUTE 'DROP FUNCTION IF EXISTS ' || r.func || ' CASCADE';
END LOOP;
END $$;
SQL
echo "✓ All public schema objects dropped"
fi
# ── Ensure tracking table exists ──────────────
psql <<'SQL'
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
SQL
# ── Apply pending migrations ──────────────────
APPLIED=0
SKIPPED=0
for migration in "${MIGRATIONS_DIR}"/*.sql; do
[[ -f "${migration}" ]] || continue
VERSION=$(basename "${migration}")
ALREADY=$(psql -tAc "SELECT 1 FROM schema_migrations WHERE version = '${VERSION}';")
if [[ "${ALREADY}" == "1" ]]; then
echo " skip: ${VERSION} (already applied)"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo " apply: ${VERSION}..."
psql -v ON_ERROR_STOP=1 -f "${migration}"
psql -c "INSERT INTO schema_migrations (version) VALUES ('${VERSION}');"
APPLIED=$((APPLIED + 1))
echo "${VERSION} applied"
done
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Migrations complete: ${APPLIED} applied, ${SKIPPED} skipped"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

View File

@@ -0,0 +1,28 @@
#!/bin/bash
# ============================================
# Chat Switchboard - Backend Launcher
# ============================================
# Runs as an nginx entrypoint.d hook.
# Starts the Go backend in the background
# before nginx begins accepting connections.
# ============================================
set -e
echo "🔀 Starting Chat Switchboard backend..."
# Launch Go backend in background
/usr/local/bin/switchboard &
BACKEND_PID=$!
# Wait for backend to be ready (max 10s)
for i in $(seq 1 20); do
if wget -q --spider http://localhost:${PORT:-8080}/health 2>/dev/null; then
echo "✅ Backend ready (PID ${BACKEND_PID})"
exit 0
fi
sleep 0.5
done
echo "❌ Backend failed to start within 10s"
exit 1

44
server/.env.example Normal file
View File

@@ -0,0 +1,44 @@
# Chat Switchboard - Server Environment Variables
# Copy this file to .env and fill in the values
# Server settings
PORT=8080
ENVIRONMENT=development
DEBUG=true
# Database settings (PostgreSQL)
DB_HOST=localhost
DB_PORT=5432
DB_USER=chat_switchboard
DB_PASSWORD=your-secure-password-here
DB_NAME=chat_switchboard
DB_SSL_MODE=disable
DB_MAX_CONNS=25
# JWT settings
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRATION=24h
JWT_ISSUER=chat-switchboard
# CORS settings (comma-separated for multiple origins)
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS,PATCH
CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With,Accept,Origin
# Rate limiting
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=1m
# Logging
LOG_LEVEL=info
# Optional: Redis configuration (for WebSocket pub/sub and session cache)
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_DB=0
# REDIS_PASSWORD=
# Optional: External services
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# GOOGLE_API_KEY=

View File

@@ -1,5 +1,8 @@
# ==========================================
# Chat Switchboard Backend Dockerfile
# Chat Switchboard - Backend Dockerfile
# ==========================================
# Standalone Go API server for cluster deployment.
# Build context is ./server (not repo root).
# ==========================================
# Build stage
@@ -7,16 +10,14 @@ FROM golang:1.22-bookworm AS builder
WORKDIR /app
# Copy go mod files first for caching
# Build context is ./server, so paths are relative to that
COPY go.mod go.sum ./
# Copy module files and download deps
COPY go.mod go.sum* ./
RUN go mod download
# Copy source code from server directory
# Copy source and tidy (resolves any go.sum gaps)
COPY . .
# Build the application
RUN CGO_ENABLED=1 go build -o /bin/engine .
RUN go mod tidy
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/switchboard .
# Runtime stage
FROM debian:bookworm-slim
@@ -25,11 +26,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /bin/engine /usr/local/bin/engine
COPY --from=builder /bin/switchboard /usr/local/bin/switchboard
WORKDIR /app
EXPOSE 8080
VOLUME ["/app/data"]
ENTRYPOINT ["engine"]
CMD ["serve"]
ENTRYPOINT ["switchboard"]

36
server/config/config.go Normal file
View File

@@ -0,0 +1,36 @@
package config
import (
"os"
"github.com/joho/godotenv"
)
// Config holds all application configuration.
type Config struct {
Port string
DatabaseURL string
JWTSecret string
Environment string
}
// Load reads configuration from environment variables.
// A .env file is loaded if present but not required.
func Load() *Config {
// Best-effort .env load (not required in production)
_ = godotenv.Load()
return &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
Environment: getEnv("ENVIRONMENT", "development"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -0,0 +1,55 @@
package database
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/config"
)
// DB is the application-wide database connection pool.
var DB *sql.DB
// Connect opens a connection pool to PostgreSQL using the
// DATABASE_URL from config. It pings to verify connectivity.
func Connect(cfg *config.Config) error {
if cfg.DatabaseURL == "" {
log.Println("⚠ DATABASE_URL not set — running without database")
return nil
}
var err error
DB, err = sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("database open: %w", err)
}
// Connection pool tuning
DB.SetMaxOpenConns(25)
DB.SetMaxIdleConns(5)
if err := DB.Ping(); err != nil {
return fmt.Errorf("database ping: %w", err)
}
log.Println("✅ Database connected")
return nil
}
// Close gracefully shuts down the connection pool.
func Close() {
if DB != nil {
DB.Close()
}
}
// IsConnected returns true if a database connection is available.
func IsConnected() bool {
if DB == nil {
return false
}
return DB.Ping() == nil
}

View File

@@ -1,10 +1,12 @@
module git.gobha.me/xcaliber/chat-switchboard
go 1.21
go 1.22
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
)
require (

View File

@@ -2,54 +2,48 @@ package main
import (
"log"
"os"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
)
func main() {
// Load .env file if present
if err := godotenv.Load(); err != nil {
log.Printf("Warning: could not load .env file: %v", err)
}
// Load configuration from environment
cfg := config.Load()
// Get port from environment
port := os.Getenv("PORT")
if port == "" {
port = "8080"
// Connect to database (optional — runs without it in unmanaged mode)
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
log.Println(" Running in unmanaged mode (no persistence)")
}
defer database.Close()
// Initialize router
r := gin.Default()
// CORS middleware
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
r.Use(middleware.CORS())
// Health check
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
status := gin.H{
"status": "ok",
"database": database.IsConnected(),
}
c.JSON(200, status)
})
// API routes
api := r.Group("/api/v1")
{
// Auth routes
// Public auth routes
api.POST("/auth/register", handleRegister)
api.POST("/auth/login", handleLogin)
// Protected routes
protected := api.Group("")
protected.Use(authMiddleware())
protected.Use(middleware.Auth(cfg))
{
// Chats
protected.GET("/chats", handleListChats)
@@ -76,36 +70,25 @@ func main() {
}
}
log.Printf("🔀 Chat Switchboard API starting on port %s", port)
if err := r.Run(":" + port); err != nil {
log.Printf("🔀 Chat Switchboard API starting on port %s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
// Placeholder handlers - implement in handlers/ package
func handleRegister(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleLogin(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleCreateAPIConfig(c *gin.Context) {
c.JSON(501, gin.H{"error": "not implemented"})
}
func handleDeleteAPIConfig(c *gin.Context) {
c.JSON(501, gin.H{"error": "not implemented"})
}
func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// TODO: Implement JWT validation
c.Next()
}
}
// Placeholder handlers — will be moved to handlers/ package
func handleRegister(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleLogin(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleCreateAPIConfig(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleDeleteAPIConfig(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }

View File

@@ -6,13 +6,16 @@ import (
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
)
func TestHealthEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
c.JSON(200, gin.H{"status": "ok", "database": false})
})
w := httptest.NewRecorder()
@@ -22,29 +25,18 @@ func TestHealthEndpoint(t *testing.T) {
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
expectedBody := `{"status":"ok"}`
if w.Body.String() != expectedBody {
t.Errorf("Expected body %s, got %s", expectedBody, w.Body.String())
}
}
func TestCORSHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
r.Use(middleware.CORS())
r.GET("/test", func(c *gin.Context) {
c.Status(200)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("OPTIONS", "/api/v1/test", nil)
req, _ := http.NewRequest("OPTIONS", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
@@ -60,7 +52,6 @@ func TestRouterSetup(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
// Setup routes like in main.go
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
@@ -73,7 +64,6 @@ func TestRouterSetup(t *testing.T) {
api.POST("/chats", handleCreateChat)
}
// Verify routes are registered
routes := r.Routes()
routePaths := make(map[string]bool)
for _, route := range routes {
@@ -94,3 +84,23 @@ func TestRouterSetup(t *testing.T) {
}
}
}
func TestAuthMiddlewareNoDatabase(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
cfg := &config.Config{JWTSecret: "test-secret"}
// No database connected — middleware should pass through
r.Use(middleware.Auth(cfg))
r.GET("/protected", func(c *gin.Context) {
c.JSON(200, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/protected", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected pass-through with no DB, got status %d", w.Code)
}
}

68
server/middleware/auth.go Normal file
View File

@@ -0,0 +1,68 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// Claims represents the JWT payload.
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
// Auth returns a Gin middleware that validates JWT bearer tokens.
// When no database is connected (unmanaged mode), all requests
// are allowed through without authentication.
func Auth(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip auth when running without a database (unmanaged mode)
if !database.IsConnected() {
c.Next()
return
}
header := c.GetHeader("Authorization")
if header == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing authorization header",
})
return
}
tokenString := strings.TrimPrefix(header, "Bearer ")
if tokenString == header {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid authorization format",
})
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
// Store claims in context for downstream handlers
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Next()
}
}

100
server/middleware/cors.go Normal file
View File

@@ -0,0 +1,100 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// CORSConfig holds the configuration for the CORS middleware
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
AllowCredentials bool
MaxAge int
}
// DefaultCORSConfig returns the default CORS configuration
func DefaultCORSConfig() *CORSConfig {
return &CORSConfig{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "Accept", "Origin"},
AllowCredentials: false,
MaxAge: 86400, // 24 hours
}
}
// CORS returns a Gin middleware handler for CORS (Cross-Origin Resource Sharing)
func CORS() gin.HandlerFunc {
return CORSWithConfig(DefaultCORSConfig())
}
// CORSWithConfig returns a Gin middleware handler for CORS with custom configuration
func CORSWithConfig(config *CORSConfig) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
// Check if the origin is allowed
if len(config.AllowedOrigins) > 0 && config.AllowedOrigins[0] != "*" {
allowed := false
for _, allowedOrigin := range config.AllowedOrigins {
if origin == allowedOrigin {
allowed = true
break
}
}
if !allowed {
c.AbortWithStatusJSON(403, gin.H{
"error": "origin not allowed",
})
return
}
}
// Set CORS headers
if len(config.AllowedOrigins) > 0 {
if config.AllowedOrigins[0] == "*" {
c.Header("Access-Control-Allow-Origin", "*")
} else {
c.Header("Access-Control-Allow-Origin", origin)
}
}
c.Header("Access-Control-Allow-Methods", joinStringSlice(config.AllowedMethods, ", "))
c.Header("Access-Control-Allow-Headers", joinStringSlice(config.AllowedHeaders, ", "))
if config.AllowCredentials {
c.Header("Access-Control-Allow-Credentials", "true")
}
c.Header("Access-Control-Max-Age", stringFromInt(config.MaxAge))
// Handle preflight requests
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
// Helper function to join a slice of strings
func joinStringSlice(slice []string, sep string) string {
if len(slice) == 0 {
return ""
}
result := slice[0]
for i := 1; i < len(slice); i++ {
result += sep + slice[i]
}
return result
}
// Helper function to convert int to string
func stringFromInt(n int) string {
if n == 0 {
return ""
}
return string(rune('0'+n%10)) + stringFromInt(n/10)
}

114
server/middleware/errors.go Normal file
View File

@@ -0,0 +1,114 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// ErrorHandlerConfig holds the configuration for the error handler middleware
type ErrorHandlerConfig struct {
TimeFormat string
TimeZone string
}
// DefaultErrorHandlerConfig returns the default error handler configuration
func DefaultErrorHandlerConfig() *ErrorHandlerConfig {
return &ErrorHandlerConfig{
TimeFormat: "2006/01/02 - 15:04:05",
TimeZone: "UTC",
}
}
// ErrorHandler returns a Gin middleware handler for centralized error handling
func ErrorHandler() gin.HandlerFunc {
return ErrorHandlerWithConfig(DefaultErrorHandlerConfig())
}
// ErrorHandlerWithConfig returns a Gin middleware handler for centralized error handling with custom configuration
func ErrorHandlerWithConfig(config *ErrorHandlerConfig) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// Check if there were any errors
if len(c.Errors) > 0 {
err := c.Errors.Last()
// Determine the status code
statusCode := http.StatusInternalServerError
if gin.Mode() != gin.ReleaseMode {
// In development mode, return detailed error information
c.JSON(http.StatusInternalServerError, gin.H{
"error": "internal server error",
"message": err.Error(),
"details": c.Errors,
})
return
}
// In production mode, return a generic error message
c.JSON(statusCode, gin.H{
"error": "internal server error",
})
}
}
}
// ErrorResponse represents a standard error response
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code,omitempty"`
Details map[string]string `json:"details,omitempty"`
}
// NewErrorResponse creates a new error response
func NewErrorResponse(error string, code string, details map[string]string) *ErrorResponse {
return &ErrorResponse{
Error: error,
Code: code,
Details: details,
}
}
// BadRequest creates a 400 Bad Request response
func BadRequest(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, gin.H{
"error": message,
})
}
// Unauthorized creates a 401 Unauthorized response
func Unauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, gin.H{
"error": message,
})
}
// Forbidden creates a 403 Forbidden response
func Forbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, gin.H{
"error": message,
})
}
// NotFound creates a 404 Not Found response
func NotFound(c *gin.Context, message string) {
c.JSON(http.StatusNotFound, gin.H{
"error": message,
})
}
// InternalServerError creates a 500 Internal Server Error response
func InternalServerError(c *gin.Context, message string) {
c.JSON(http.StatusInternalServerError, gin.H{
"error": message,
})
}
// ValidationError creates a response for validation errors
func ValidationError(c *gin.Context, errors map[string]string) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "validation failed",
"details": errors,
})
}

View File

@@ -0,0 +1,130 @@
package middleware
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// RequestLoggerConfig holds the configuration for the request logger
type RequestLoggerConfig struct {
SkipPaths []string
Logger *log.Logger
TimeFormat string
TimeZone string
}
// DefaultRequestLoggerConfig returns the default request logger configuration
func DefaultRequestLoggerConfig() *RequestLoggerConfig {
return &RequestLoggerConfig{
SkipPaths: []string{"/health", "/ready", "/metrics"},
Logger: log.Default(),
TimeFormat: "2006/01/02 - 15:04:05",
TimeZone: "UTC",
}
}
// Logger returns a Gin middleware handler for logging requests
func Logger() gin.HandlerFunc {
return LoggerWithConfig(DefaultRequestLoggerConfig())
}
// LoggerWithConfig returns a Gin middleware handler for logging requests with custom configuration
func LoggerWithConfig(config *RequestLoggerConfig) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip logging for certain paths
path := c.Request.URL.Path
skip := false
for _, skipPath := range config.SkipPaths {
if path == skipPath {
skip = true
break
}
}
start := time.Now()
raw := c.Request.URL.RawQuery
c.Next()
if skip {
return
}
// Calculate latency
latency := time.Since(start)
// Get the client IP
clientIP := c.ClientIP()
// Get the request method
method := c.Request.Method
// Get the status code
statusCode := c.Writer.Status()
// Log the request
if raw != "" {
raw = "?" + raw
}
config.Logger.Printf(
"[%s] %s %s%s | %d | %v | %s",
clientIP,
method,
path,
raw,
statusCode,
latency,
c.Errors.ByType(gin.ErrorTypePrivate).String(),
)
}
}
// Recovery returns a Gin middleware handler for recovering from panics
func Recovery() gin.HandlerFunc {
return RecoveryWithConfig(DefaultRecoveryConfig())
}
// RecoveryConfig holds the configuration for the recovery middleware
type RecoveryConfig struct {
Logger *log.Logger
TimeFormat string
TimeZone string
SkipStackTrace bool
StackTraceSize int
StackAll bool
}
// DefaultRecoveryConfig returns the default recovery configuration
func DefaultRecoveryConfig() *RecoveryConfig {
return &RecoveryConfig{
Logger: log.Default(),
TimeFormat: "2006/01/02 - 15:04:05",
TimeZone: "UTC",
SkipStackTrace: false,
StackTraceSize: 1024,
StackAll: false,
}
}
// RecoveryWithConfig returns a Gin middleware handler for recovering from panics with custom configuration
func RecoveryWithConfig(config *RecoveryConfig) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// Log the panic
config.Logger.Printf("[PANIC] %v", err)
// Abort with a 500 error
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": "internal server error",
})
}
}()
c.Next()
}
}

148
server/models/models.go Normal file
View File

@@ -0,0 +1,148 @@
package models
import (
"time"
)
// BaseModel contains fields common to all models
type BaseModel struct {
ID string `json:"id" db:"id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// User represents a user in the system
type User struct {
BaseModel
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
Name string `json:"name" db:"name"`
Role string `json:"role" db:"role"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
IsActive bool `json:"is_active" db:"is_active"`
}
// UserRole constants
const (
UserRoleUser = "user"
UserRoleAdmin = "admin"
)
// Chat represents a chat conversation
type Chat struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Model string `json:"model" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
IsArchived bool `json:"is_archived" db:"is_archived"`
}
// Message represents a chat message
type Message struct {
BaseModel
ChatID string `json:"chat_id" db:"chat_id"`
Role string `json:"role" db:"role"` // "user", "assistant", "system"
Content string `json:"content" db:"content"`
Tokens int `json:"tokens,omitempty" db:"tokens"`
Model string `json:"model,omitempty" db:"model"`
FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"`
}
// MessageRole constants
const (
MessageRoleUser = "user"
MessageRoleAssistant = "assistant"
MessageRoleSystem = "system"
)
// APIConfig represents a configured API provider
type APIConfig struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"` // "openai", "anthropic", "google", etc.
APIKey string `json:"-" db:"api_key"`
BaseURL string `json:"base_url,omitempty" db:"base_url"`
Model string `json:"model" db:"model"`
IsDefault bool `json:"is_default" db:"is_default"`
}
// Channel represents a chat channel
type Channel struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
IsPrivate bool `json:"is_private" db:"is_private"`
OwnerID string `json:"owner_id" db:"owner_id"`
}
// ChannelMember represents a member of a channel
type ChannelMember struct {
BaseModel
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"` // "member", "moderator", "admin"
}
// ChannelMessage represents a message in a channel
type ChannelMessage struct {
BaseModel
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
Content string `json:"content" db:"content"`
ParentID string `json:"parent_id,omitempty" db:"parent_id"`
}
// Note represents a user note
type Note struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
Tags []string `json:"tags,omitempty" db:"tags"`
FolderID string `json:"folder_id,omitempty" db:"folder_id"`
IsShared bool `json:"is_shared" db:"is_shared"`
}
// KnowledgeBase represents a knowledge base collection
type KnowledgeBase struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Source string `json:"source" db:"source"` // "url", "file", "text"
Settings string `json:"settings,omitempty" db:"settings"` // JSON settings
}
// Workflow represents a workflow definition
type Workflow struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Steps string `json:"steps" db:"steps"` // JSON array of steps
Settings string `json:"settings,omitempty" db:"settings"`
IsPublic bool `json:"is_public" db:"is_public"`
}
// Settings represents user settings
type Settings struct {
UserID string `json:"user_id" db:"user_id"`
Theme string `json:"theme" db:"theme"`
Language string `json:"language" db:"language"`
Model string `json:"model" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
MaxTokens int `json:"max_tokens" db:"max_tokens"`
Temperature float64 `json:"temperature" db:"temperature"`
DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"`
}
// APIToken represents an API token for external access
type APIToken struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Token string `json:"-" db:"token"`
ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
IsActive bool `json:"is_active" db:"is_active"`
}

8
server/version.go Normal file
View File

@@ -0,0 +1,8 @@
package main
// Version is set at build time via:
//
// go build -ldflags "-X main.Version=$(cat VERSION)"
//
// Falls back to "dev" for local builds without ldflags.
var Version = "dev"

7
src/js/version.js Normal file
View File

@@ -0,0 +1,7 @@
/**
* Chat Switchboard - Version Configuration
* Single source of truth: VERSION file at repo root
* Injected by build.sh at build time
*/
const SWITCHBOARD_VERSION = '__VERSION__';
const APP_NAME = 'Chat Switchboard';