Skip to content

Commit 10c942b

Browse files
y4nderclaude
andauthored
FAC-104 to FAC-106 + VPS deployment infrastructure (#243)
* chore(docs): update roadmap * FAC-104 feat: expose semester context for dean faculty evaluation flow (#233) * feat: add GET /semesters/current endpoint for semester context Expose a dedicated authenticated endpoint that returns the latest active semester, unblocking the dean faculty evaluation flow on the frontend. Any authenticated user can call this endpoint. Closes #231 https://claude.ai/code/session_015zAeByh3G1YxLc6cEtniGn * feat: rework to campus-aware GET /semesters listing endpoint Replace GET /semesters/current (single semester) with GET /semesters (full listing). Each semester now includes its campus (id, code, name) so the frontend can resolve the correct semester for the user's campus in the dean faculty evaluation flow. https://claude.ai/code/session_015zAeByh3G1YxLc6cEtniGn * feat: add optional campusId filter to GET /semesters Allow filtering semesters by campus via ?campusId= query parameter. When omitted, all semesters are returned. https://claude.ai/code/session_015zAeByh3G1YxLc6cEtniGn --------- Co-authored-by: Claude <noreply@anthropic.com> * FAC-105 feat: report generation infrastructure — faculty evaluation PDF (#234) * FAC-105 feat: report generation infrastructure — faculty evaluation PDF Async PDF report generation via BullMQ + Puppeteer/Handlebars with Cloudflare R2 storage and presigned download URLs. Supports single and batch generation with scope-aware authorization, dedup guards, and orphan protection. Closes #232 * fix: resolve runtime issues in report generation pipeline - Switch all raw SQL from $1 params to ? placeholders (em.execute() routes through Knex which only supports ? bindings) - Wrap JS arrays with pgArray() helper for ANY(?) PostgreSQL queries - Fix template path resolution (process.cwd() + dist/modules/ instead of __dirname which resolves to dist/src/) - Add User entity to ReportsModule for RolesGuard DI resolution - Include tech-spec artifact and MikroORM schema snapshot * FAC-106 fix: resolve scope by category code instead of moodleCategoryId (#236) The scope resolver matched departments/programs by moodleCategoryId, which is semester-specific in Moodle's category tree. A Dean assigned to a program-level category (depth 4) would never match any department (depth 3), and even at the correct depth, querying a different semester would fail because each semester creates new category IDs. Fix scope resolution to navigate depth 4→3 via parentMoodleCategoryId and match by code (consistent across semesters) instead of moodleCategoryId. Add DEAN depth auto-resolution to the admin assignment endpoint so future assignments always land at depth 3. Closes #235 * feat: VPS deployment infrastructure Add production Dockerfile (multi-stage), Docker Compose for VPS deployment with staging/production profiles, Nginx reverse proxy config with SSL, GitHub Actions CI/CD deploy workflow, environment templates, Postgres init script with pgvector, and database backup script. * fix: skip lifecycle scripts in production Docker stage Husky prepare script fails in production stage since it's a dev dependency. Using --ignore-scripts for the production npm ci. * fix: correct dist entry point path in Dockerfile NestJS build outputs to dist/src/main.js, not dist/main.js. * fix: install Chromium in production Docker image for Puppeteer PDF generation * fix: use runtime DNS resolution in Nginx for optional API containers Nginx fails at startup when an upstream host doesn't exist. Using variables with Docker's embedded DNS resolver (127.0.0.11) defers resolution to request time, allowing staging and production to run independently. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c44e651 commit 10c942b

67 files changed

Lines changed: 16240 additions & 4998 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
node_modules
2+
dist
3+
.git
4+
.gitignore
5+
.env*
6+
!.env.*.sample
7+
test/
8+
coverage/
9+
*.md
10+
!package.json
11+
_bmad*/
12+
_bmad-output/
13+
docs/
14+
.github/
15+
mock-worker/
16+
.vscode/
17+
.idea/

.env.production.sample

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# ──────────────────────────────────────────────
2+
# Faculytics API — Production Environment
3+
# ──────────────────────────────────────────────
4+
# Copy to .env.production and fill in real values.
5+
# DATABASE_URL and REDIS_URL are set in docker-compose.deploy.yml
6+
# via environment overrides — do NOT set them here.
7+
# ──────────────────────────────────────────────
8+
9+
# Server (PORT set via Compose — do not override here)
10+
# NODE_ENV set via Compose — do not override here
11+
12+
# Moodle LMS
13+
MOODLE_BASE_URL=
14+
MOODLE_MASTER_KEY=
15+
16+
# Authentication
17+
JWT_SECRET=
18+
REFRESH_SECRET=
19+
# JWT_ACCESS_TOKEN_EXPIRY=300s
20+
# JWT_REFRESH_TOKEN_EXPIRY=30d
21+
# JWT_BCRYPT_ROUNDS=10
22+
23+
# CORS — production frontend URL
24+
CORS_ORIGINS=["https://faculytics.ctr3.org"]
25+
26+
# OpenAI
27+
OPENAI_API_KEY=
28+
29+
# ──────────────────────────────────────────────
30+
# Optional: Admin
31+
# ──────────────────────────────────────────────
32+
33+
SUPER_ADMIN_USERNAME=superadmin
34+
SUPER_ADMIN_PASSWORD=changeme-use-a-strong-password
35+
36+
# ──────────────────────────────────────────────
37+
# Optional: Moodle sync
38+
# ──────────────────────────────────────────────
39+
40+
SYNC_ON_STARTUP=true
41+
# DISABLE_SYNC_CATEGORY_ON_STARTUP=false
42+
# MOODLE_SYNC_CONCURRENCY=3
43+
44+
# ──────────────────────────────────────────────
45+
# Optional: Rate Limiting
46+
# ──────────────────────────────────────────────
47+
48+
# THROTTLE_TTL_SECONDS=60
49+
# THROTTLE_LIMIT=60
50+
51+
# ──────────────────────────────────────────────
52+
# Optional: Analysis workers
53+
# ──────────────────────────────────────────────
54+
55+
# SENTIMENT_WORKER_URL=
56+
# BULLMQ_SENTIMENT_CONCURRENCY=3
57+
# EMBEDDINGS_WORKER_URL=
58+
# EMBEDDINGS_CONCURRENCY=3
59+
# TOPIC_MODEL_WORKER_URL=
60+
# TOPIC_MODEL_CONCURRENCY=1
61+
# RECOMMENDATIONS_CONCURRENCY=1
62+
# RECOMMENDATIONS_MODEL=gpt-4o-mini
63+
64+
# ──────────────────────────────────────────────
65+
# Optional: Report Generation (R2 Storage)
66+
# ──────────────────────────────────────────────
67+
68+
# CF_ACCOUNT_ID=
69+
# R2_ACCESS_KEY_ID=
70+
# R2_SECRET_ACCESS_KEY=
71+
# R2_BUCKET_NAME=faculytics-reports
72+
# REPORT_GENERATION_CONCURRENCY=2

.env.sample

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,17 @@ OPENAI_API_KEY=
9393
# TOPIC_MODEL_CONCURRENCY=1
9494
# RECOMMENDATIONS_CONCURRENCY=1
9595
# RECOMMENDATIONS_MODEL=gpt-4o-mini
96+
97+
# ──────────────────────────────────────────────
98+
# Optional: Report Generation (R2 Storage)
99+
# App starts without these — report endpoints return 503
100+
# ──────────────────────────────────────────────
101+
102+
# CF_ACCOUNT_ID=
103+
# R2_ACCESS_KEY_ID=
104+
# R2_SECRET_ACCESS_KEY=
105+
# R2_BUCKET_NAME=faculytics-reports
106+
# REPORT_GENERATION_CONCURRENCY=2
107+
# REPORT_PRESIGNED_URL_EXPIRY_SECONDS=3600
108+
# REPORT_BATCH_MAX_SIZE=100
109+
# REPORT_RETENTION_DAYS=7

.env.staging.sample

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# ──────────────────────────────────────────────
2+
# Faculytics API — Staging Environment
3+
# ──────────────────────────────────────────────
4+
# Copy to .env.staging and fill in real values.
5+
# DATABASE_URL and REDIS_URL are set in docker-compose.deploy.yml
6+
# via environment overrides — do NOT set them here.
7+
# ──────────────────────────────────────────────
8+
9+
# Server (PORT set via Compose — do not override here)
10+
# NODE_ENV set via Compose — do not override here
11+
12+
# Moodle LMS
13+
MOODLE_BASE_URL=
14+
MOODLE_MASTER_KEY=
15+
16+
# Authentication
17+
JWT_SECRET=
18+
REFRESH_SECRET=
19+
# JWT_ACCESS_TOKEN_EXPIRY=300s
20+
# JWT_REFRESH_TOKEN_EXPIRY=30d
21+
# JWT_BCRYPT_ROUNDS=10
22+
23+
# CORS — staging frontend URL
24+
CORS_ORIGINS=["https://staging.faculytics.ctr3.org"]
25+
26+
# OpenAI
27+
OPENAI_API_KEY=
28+
29+
# ──────────────────────────────────────────────
30+
# Optional: Admin
31+
# ──────────────────────────────────────────────
32+
33+
SUPER_ADMIN_USERNAME=superadmin
34+
SUPER_ADMIN_PASSWORD=changeme-staging
35+
36+
# ──────────────────────────────────────────────
37+
# Optional: Moodle sync
38+
# ──────────────────────────────────────────────
39+
40+
SYNC_ON_STARTUP=true
41+
# DISABLE_SYNC_CATEGORY_ON_STARTUP=false
42+
# MOODLE_SYNC_CONCURRENCY=3
43+
44+
# ──────────────────────────────────────────────
45+
# Optional: Rate Limiting
46+
# ──────────────────────────────────────────────
47+
48+
# THROTTLE_TTL_SECONDS=60
49+
# THROTTLE_LIMIT=60
50+
51+
# ──────────────────────────────────────────────
52+
# Optional: Analysis workers
53+
# ──────────────────────────────────────────────
54+
55+
# SENTIMENT_WORKER_URL=
56+
# BULLMQ_SENTIMENT_CONCURRENCY=3
57+
# EMBEDDINGS_WORKER_URL=
58+
# EMBEDDINGS_CONCURRENCY=3
59+
# TOPIC_MODEL_WORKER_URL=
60+
# TOPIC_MODEL_CONCURRENCY=1
61+
# RECOMMENDATIONS_CONCURRENCY=1
62+
# RECOMMENDATIONS_MODEL=gpt-4o-mini
63+
64+
# ──────────────────────────────────────────────
65+
# Optional: Report Generation (R2 Storage)
66+
# ──────────────────────────────────────────────
67+
68+
# CF_ACCOUNT_ID=
69+
# R2_ACCESS_KEY_ID=
70+
# R2_SECRET_ACCESS_KEY=
71+
# R2_BUCKET_NAME=faculytics-reports
72+
# REPORT_GENERATION_CONCURRENCY=2

.github/workflows/deploy.yml

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
name: Deploy
2+
3+
on:
4+
push:
5+
branches:
6+
- staging
7+
- master
8+
9+
jobs:
10+
deploy-staging:
11+
name: Deploy to Staging
12+
runs-on: ubuntu-latest
13+
if: github.ref == 'refs/heads/staging'
14+
steps:
15+
- name: Deploy via SSH
16+
uses: appleboy/ssh-action@v1
17+
with:
18+
host: ${{ secrets.VPS_HOST }}
19+
username: ${{ secrets.VPS_DEPLOY_USER }}
20+
key: ${{ secrets.VPS_SSH_KEY }}
21+
script: |
22+
set -e
23+
cd /opt/faculytics
24+
25+
echo "Fetching latest staging..."
26+
git fetch origin staging
27+
28+
echo "Building staging image..."
29+
mkdir -p /tmp/faculytics-build
30+
git archive origin/staging | tar -x -C /tmp/faculytics-build
31+
docker build -t faculytics-api:staging -f /tmp/faculytics-build/Dockerfile /tmp/faculytics-build
32+
rm -rf /tmp/faculytics-build
33+
34+
echo "Deploying staging..."
35+
docker compose -f docker-compose.deploy.yml --profile staging up -d api-staging
36+
37+
echo "Waiting for health check..."
38+
sleep 15
39+
if docker compose -f docker-compose.deploy.yml --profile staging ps api-staging | grep -q "(healthy)"; then
40+
echo "Staging deploy successful!"
41+
else
42+
echo "Health check - checking API response..."
43+
docker exec $(docker compose -f docker-compose.deploy.yml ps -q api-staging) node -e "fetch('http://localhost:5201/api/v1/health').then(r=>r.json()).then(console.log).catch(e=>{console.error(e);process.exit(1)})" || true
44+
docker compose -f docker-compose.deploy.yml --profile staging logs --tail=50 api-staging
45+
echo "WARNING: Health check inconclusive - check logs above"
46+
fi
47+
48+
echo "Cleaning old images..."
49+
docker system prune -f --filter "until=168h" || true
50+
51+
- name: Discord Notification
52+
if: always()
53+
uses: sarisia/actions-status-discord@v1
54+
with:
55+
webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
56+
title: 'Staging Deploy'
57+
description: '${{ job.status == ''success'' && ''Staging deployment succeeded'' || ''Staging deployment failed'' }}'
58+
color: '${{ job.status == ''success'' && ''0x00ff00'' || ''0xff0000'' }}'
59+
60+
deploy-production:
61+
name: Deploy to Production
62+
runs-on: ubuntu-latest
63+
if: github.ref == 'refs/heads/master'
64+
steps:
65+
- name: Deploy via SSH
66+
uses: appleboy/ssh-action@v1
67+
with:
68+
host: ${{ secrets.VPS_HOST }}
69+
username: ${{ secrets.VPS_DEPLOY_USER }}
70+
key: ${{ secrets.VPS_SSH_KEY }}
71+
script: |
72+
set -e
73+
cd /opt/faculytics
74+
75+
echo "Fetching latest master..."
76+
git fetch origin master
77+
78+
echo "Building production image..."
79+
mkdir -p /tmp/faculytics-build
80+
git archive origin/master | tar -x -C /tmp/faculytics-build
81+
docker build -t faculytics-api:production -f /tmp/faculytics-build/Dockerfile /tmp/faculytics-build
82+
rm -rf /tmp/faculytics-build
83+
84+
echo "Deploying production..."
85+
docker compose -f docker-compose.deploy.yml --profile production up -d api-production
86+
87+
echo "Waiting for health check..."
88+
sleep 15
89+
if docker compose -f docker-compose.deploy.yml --profile staging ps api-production | grep -q "(healthy)"; then
90+
echo "Production deploy successful!"
91+
else
92+
echo "Health check - checking API response..."
93+
docker exec $(docker compose -f docker-compose.deploy.yml ps -q api-production) node -e "fetch('http://localhost:5200/api/v1/health').then(r=>r.json()).then(console.log).catch(e=>{console.error(e);process.exit(1)})" || true
94+
docker compose -f docker-compose.deploy.yml --profile production logs --tail=50 api-production
95+
echo "WARNING: Health check inconclusive - check logs above"
96+
fi
97+
98+
echo "Cleaning old images..."
99+
docker system prune -f --filter "until=168h" || true
100+
101+
- name: Discord Notification
102+
if: always()
103+
uses: sarisia/actions-status-discord@v1
104+
with:
105+
webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
106+
title: 'Production Deploy'
107+
description: '${{ job.status == ''success'' && ''Production deployment succeeded'' || ''Production deployment failed'' }}'
108+
color: '${{ job.status == ''success'' && ''0x00ff00'' || ''0xff0000'' }}'

Dockerfile

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Stage 1: Build
2+
FROM node:24-alpine AS build
3+
4+
WORKDIR /app
5+
6+
COPY package.json package-lock.json ./
7+
RUN npm ci
8+
9+
COPY . .
10+
RUN npm run build
11+
12+
# Stage 2: Production
13+
FROM node:24-alpine
14+
15+
# Install Chromium for Puppeteer PDF generation
16+
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
17+
18+
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
19+
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
20+
21+
WORKDIR /app
22+
23+
COPY package.json package-lock.json ./
24+
RUN npm ci --omit=dev --ignore-scripts
25+
26+
COPY --from=build /app/dist ./dist
27+
28+
ENV NODE_ENV=production
29+
30+
EXPOSE 5200
31+
32+
CMD ["node", "dist/src/main"]

0 commit comments

Comments
 (0)