From 9aa8aacc796e6b2559d5281ca6d0086384151b4e Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Fri, 8 May 2026 03:58:47 +0000 Subject: [PATCH] Add VPS deployment documentation and Docker setup --- .dockerignore | 9 +- .env.example | 11 ++- Dockerfile | 5 ++ docker-compose.yml | 45 ++++++++++ docs/DATA_COLLECTION.md | 141 +++++++++++++++++++++++++++++ docs/DEPLOYMENT.md | 193 ++++++++++++++++++++++++++++++++++++++++ docs/RESEARCH_NOTES.md | 60 +++++++++++++ next.config.mjs | 33 ++++++- 8 files changed, 491 insertions(+), 6 deletions(-) create mode 100644 docker-compose.yml create mode 100644 docs/DATA_COLLECTION.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/RESEARCH_NOTES.md diff --git a/.dockerignore b/.dockerignore index 2fe81c0..39d17f6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,13 @@ +.git +.gitignore .next node_modules +exports +coverage +.env +.env.* +!.env.example npm-debug.log* Dockerfile .dockerignore -.git -.gitignore README.md diff --git a/.env.example b/.env.example index f3d3427..e522d0a 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ -DATABASE_URL="postgresql://hcg:hcg_password@localhost:5432/hidden_cost_game?schema=public" +DATABASE_URL="postgresql://hcg:hcg_password_change_me@localhost:5432/hidden_cost_game?schema=public" APP_BASE_URL="http://localhost:3000" ENABLE_SERVER_SUBMISSION="false" NEXT_PUBLIC_ENABLE_SERVER_SUBMISSION="false" -ADMIN_EXPORT_TOKEN="replace-with-a-long-random-secret" -ADMIN_DASHBOARD_PASSWORD="replace-with-a-long-random-password" +ADMIN_EXPORT_TOKEN="change-me-before-production" +ADMIN_DASHBOARD_PASSWORD="change-me-before-production" SUBMISSION_RATE_LIMIT_WINDOW_MS="60000" SUBMISSION_RATE_LIMIT_MAX="20" @@ -13,3 +13,8 @@ MAX_SUBMISSION_BODY_BYTES="250000" CONSENT_VERSION="pilot-consent-v1" SCHEMA_VERSION="research-export-v1" + +# Docker Compose local defaults. Change all secrets before production. +POSTGRES_USER="hcg" +POSTGRES_PASSWORD="hcg_password_change_me" +POSTGRES_DB="hidden_cost_game" diff --git a/Dockerfile b/Dockerfile index 070a467..3ba82a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,15 @@ FROM node:20-alpine WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 COPY package*.json ./ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +COPY prisma ./prisma +RUN npm run db:generate + COPY . . RUN npm run build diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ef5320 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + app: + build: . + env_file: + - path: .env + required: false + environment: + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: "1" + APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000} + DATABASE_URL: postgresql://${POSTGRES_USER:-hcg}:${POSTGRES_PASSWORD:-hcg_password_change_me}@postgres:5432/${POSTGRES_DB:-hidden_cost_game}?schema=public + ENABLE_SERVER_SUBMISSION: ${ENABLE_SERVER_SUBMISSION:-true} + NEXT_PUBLIC_ENABLE_SERVER_SUBMISSION: ${NEXT_PUBLIC_ENABLE_SERVER_SUBMISSION:-true} + ADMIN_EXPORT_TOKEN: ${ADMIN_EXPORT_TOKEN:-change-me-before-production} + ADMIN_DASHBOARD_PASSWORD: ${ADMIN_DASHBOARD_PASSWORD:-change-me-before-production} + SUBMISSION_RATE_LIMIT_WINDOW_MS: ${SUBMISSION_RATE_LIMIT_WINDOW_MS:-60000} + SUBMISSION_RATE_LIMIT_MAX: ${SUBMISSION_RATE_LIMIT_MAX:-20} + MAX_SUBMISSION_BODY_BYTES: ${MAX_SUBMISSION_BODY_BYTES:-250000} + CONSENT_VERSION: ${CONSENT_VERSION:-pilot-consent-v1} + SCHEMA_VERSION: ${SCHEMA_VERSION:-research-export-v1} + ports: + - "127.0.0.1:3000:3000" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-hcg} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-hcg_password_change_me} + POSTGRES_DB: ${POSTGRES_DB:-hidden_cost_game} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + # Intentionally no ports: entry. Keep Postgres private to the Docker network in production. + +volumes: + postgres_data: diff --git a/docs/DATA_COLLECTION.md b/docs/DATA_COLLECTION.md new file mode 100644 index 0000000..79fae00 --- /dev/null +++ b/docs/DATA_COLLECTION.md @@ -0,0 +1,141 @@ +# Data Collection Guide + +This document answers the operational question: **How do I actually get the data from participants?** + +## End-to-end flow + +1. A participant opens the game URL, gives consent, completes the game, and completes the pre/post reveal measures. +2. At the end, the participant can optionally submit an anonymous session export. +3. The browser sends the export JSON to `POST /api/submissions` when server submissions are enabled. +4. The API validates the payload and stores it in the `ResearchSubmission` table in Postgres. +5. The researcher opens `/admin` or uses `curl` with `ADMIN_EXPORT_TOKEN`. +6. The researcher downloads CSV or JSON for analysis. + +`POST /api/research-submissions` is an alias for the same submission handler. + +## Before collecting real data + +Set these values in `.env` and restart the app: + +```bash +ENABLE_SERVER_SUBMISSION="true" +NEXT_PUBLIC_ENABLE_SERVER_SUBMISSION="true" +ADMIN_EXPORT_TOKEN="replace-with-a-long-random-secret" +ADMIN_DASHBOARD_PASSWORD="replace-with-a-long-random-password" +APP_BASE_URL="https://your-domain.com" +``` + +Run migrations before collecting data: + +```bash +docker compose exec app npm run db:migrate +``` + +## Confirm submissions are stored + +Health check: + +```bash +curl http://127.0.0.1:3000/api/health +``` + +Admin stats: + +```bash +curl -H "Authorization: Bearer $ADMIN_EXPORT_TOKEN" \ + "$APP_BASE_URL/api/admin/stats" +``` + +The stats response includes `totalSubmissions`, `completedSubmissions`, treatment-condition counts, and averaged computed metrics. + +You can also inspect the database directly from Docker: + +```bash +docker compose exec postgres psql -U hcg -d hidden_cost_game -c 'SELECT id, "sessionId", "submittedAt", "completedGameRounds" FROM "ResearchSubmission" ORDER BY submittedAt DESC LIMIT 10;' +``` + +If you changed `POSTGRES_USER` or `POSTGRES_DB`, update the command. + +## Export CSV + +From a browser, sign in to `/admin` with `ADMIN_DASHBOARD_PASSWORD` and use the CSV download control. + +From the command line: + +```bash +curl -H "Authorization: Bearer $ADMIN_EXPORT_TOKEN" \ + "$APP_BASE_URL/api/admin/submissions.csv" \ + -o submissions.csv +``` + +The CSV flattens key fields such as assignment condition, game outcomes, computed metrics, pre/post survey values, and participant background fields. + +## Export JSON + +Use the built-in script: + +```bash +docker compose exec app npm run export:submissions +``` + +Or use `curl`: + +```bash +curl -H "Authorization: Bearer $ADMIN_EXPORT_TOKEN" \ + "$APP_BASE_URL/api/admin/submissions?limit=500" \ + -o submissions.json +``` + +The JSON export includes database metadata plus each original submission payload. If the response includes `nextCursor`, request the next page with `?cursor=`. + +## Interpret completeness flags + +Each stored payload includes a `completeness` object. The most important fields are: + +- `isComplete` — `true` means the participant reached the expected complete export state. +- `completedGameRounds` — number of game rounds completed. +- Additional missing-section flags, if present in the payload, identify which survey or game sections were incomplete. + +For primary analyses, start with `completeness.isComplete === true`. Keep incomplete submissions for audit and attrition checks unless your study protocol says otherwise. + +## Remove test submissions manually + +Back up first. Then delete only the rows you have identified as test data. + +Create a backup: + +```bash +mkdir -p backups +docker compose exec -T postgres pg_dump -U hcg -d hidden_cost_game > backups/before-test-delete-$(date +%Y%m%d-%H%M%S).sql +``` + +List recent rows: + +```bash +docker compose exec postgres psql -U hcg -d hidden_cost_game -c 'SELECT id, "sessionId", "submittedAt" FROM "ResearchSubmission" ORDER BY submittedAt DESC LIMIT 20;' +``` + +Delete one known test row by id: + +```bash +docker compose exec postgres psql -U hcg -d hidden_cost_game -c 'DELETE FROM "ResearchSubmission" WHERE id = '\''paste-test-submission-id-here'\'';' +``` + +Delete multiple known test rows by id: + +```bash +docker compose exec postgres psql -U hcg -d hidden_cost_game -c 'DELETE FROM "ResearchSubmission" WHERE id IN ('\''id-one'\'', '\''id-two'\'');' +``` + +Avoid broad date-range deletes unless you have already exported and verified the affected rows. + +## Backup before deleting anything + +Use `pg_dump` before any manual deletion: + +```bash +mkdir -p backups +docker compose exec -T postgres pg_dump -U hcg -d hidden_cost_game > backups/hidden_cost_game-$(date +%Y%m%d-%H%M%S).sql +``` + +Store important backups somewhere other than the VPS, such as encrypted object storage or an institutional backup location. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..0721698 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,193 @@ +# Deployment Guide + +This guide covers practical VPS deployment for the Hidden Cost Game with Docker Compose or with a native Node/Postgres installation. + +## A. VPS assumptions + +- Ubuntu server, preferably an LTS release. +- Either: + - Docker Engine with the Docker Compose plugin installed, or + - Node 20, npm, and PostgreSQL installed directly on the server. +- A domain name is strongly recommended so participants can use a stable URL. +- HTTPS is strongly recommended. Use Caddy for automatic HTTPS, or Nginx with Certbot/Let's Encrypt. +- Keep Postgres private. Do not expose port `5432` to the public internet. + +## Required production environment variables + +Start from the example file, then change all secrets before production: + +```bash +cp .env.example .env +nano .env +``` + +At minimum review: + +- `APP_BASE_URL` — set this to `https://your-domain.com` in production. +- `ENABLE_SERVER_SUBMISSION` and `NEXT_PUBLIC_ENABLE_SERVER_SUBMISSION` — set both to `true` when you want participant submissions stored on the server. +- `ADMIN_EXPORT_TOKEN` — use a long random value for API exports. +- `ADMIN_DASHBOARD_PASSWORD` — use a long random password for `/admin`. +- `POSTGRES_PASSWORD` — change the local Docker default before real data collection. +- `DATABASE_URL` — for native Node deployments, point this at the local/native Postgres database. + +Generate secrets with a command such as: + +```bash +openssl rand -base64 32 +``` + +## B. Docker deployment + +The Docker setup includes an `app` service and a private `postgres` service with a persistent `postgres_data` volume. The Postgres service has no public `ports:` mapping by default. + +```bash +git clone hidden-cost-game +cd hidden-cost-game +cp .env.example .env +nano .env +``` + +Start the app and database: + +```bash +docker compose up --build -d +``` + +Run database migrations after the containers are up: + +```bash +docker compose exec app npm run db:migrate +``` + +This project does **not** run destructive reset commands at container startup. Migrations are explicit so the app does not silently lose data. + +Check logs: + +```bash +docker compose logs -f app +``` + +Check the health endpoint from the VPS: + +```bash +curl http://127.0.0.1:3000/api/health +``` + +Expected output is JSON with `"ok": true`. If server submissions are enabled and the database URL is configured, the response should also show `serverSubmissionEnabled: true` and `databaseConfigured: true`. + +## Native Node 20 + Postgres deployment + +Install Node 20, npm, and Postgres with your preferred Ubuntu package workflow. Create a Postgres user and database matching your `.env`, for example: + +```bash +sudo -u postgres psql +``` + +```sql +CREATE USER hcg WITH PASSWORD 'replace-with-a-long-random-password'; +CREATE DATABASE hidden_cost_game OWNER hcg; +\q +``` + +Then deploy the app: + +```bash +git clone hidden-cost-game +cd hidden-cost-game +cp .env.example .env +nano .env +npm install +npm run db:generate +npm run build +npm run db:migrate +npm run start +``` + +For production, run the Node process under a supervisor such as systemd or pm2 and place Nginx or Caddy in front of it. + +## C. Nginx reverse proxy example + +Bind Docker Compose to localhost and proxy public traffic through Nginx: + +```nginx +server { + server_name your-domain.com; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +After this, add HTTPS with Certbot or your preferred ACME client. + +## D. Caddy example + +Caddy can manage HTTPS automatically when DNS points to the VPS: + +```caddyfile +your-domain.com { + reverse_proxy localhost:3000 +} +``` + +## E. Firewall + +Allow only SSH and web traffic from the public internet: + +```bash +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +sudo ufw status +``` + +Do not allow public access to Postgres (`5432`). The Docker Compose file intentionally keeps Postgres private on the Compose network. + +## F. Backups + +Back up before migrations, updates, or deletion. This example matches the Docker Compose service/user/database defaults: + +```bash +mkdir -p backups +docker compose exec -T postgres pg_dump -U hcg -d hidden_cost_game > backups/hidden_cost_game-$(date +%Y%m%d-%H%M%S).sql +``` + +If you changed `POSTGRES_USER` or `POSTGRES_DB`, update the `pg_dump` command accordingly. + +Restore only after verifying you are targeting the correct environment: + +```bash +cat backups/hidden_cost_game-YYYYMMDD-HHMMSS.sql | docker compose exec -T postgres psql -U hcg -d hidden_cost_game +``` + +## G. Updating deployment + +```bash +git pull +docker compose up --build -d +docker compose exec app npm run db:migrate +docker compose logs -f app +curl http://127.0.0.1:3000/api/health +``` + +Run a backup before updating if the database contains real participant submissions. + +## H. Rollback notes + +- Keep a copy of the previous Git commit SHA before updating: `git rev-parse HEAD`. +- If a new build fails before migrations are run, return to the previous commit and rebuild: + +```bash +git checkout +docker compose up --build -d +``` + +- Database migrations are harder to roll back than app code. Prisma deploy migrations are intended to move forward. Always back up before running migrations against production data. +- If a migration has already changed production data or schema, restore from a verified backup or create a forward-fix migration rather than running destructive reset commands. diff --git a/docs/RESEARCH_NOTES.md b/docs/RESEARCH_NOTES.md new file mode 100644 index 0000000..04997d2 --- /dev/null +++ b/docs/RESEARCH_NOTES.md @@ -0,0 +1,60 @@ +# Research Notes + +## Motivation + +The Hidden Cost Game is designed to study how people judge individual choices when important structural constraints are initially hidden. Participants first observe outcomes through an apparently individual-choice frame, then learn that the game assigned unequal hidden healthcare cost conditions. + +## Hidden cost manipulation + +Participants are assigned to displayed and hidden coverage profiles. The visible experience invites interpretation of treatment choices and outcomes, while the hidden rule reveal later clarifies that structural cost conditions shaped what choices were realistically available. + +## Pre/post reveal logic + +The study captures judgments before and after the hidden rule reveal: + +- Pre-reveal measures record initial attributions, confidence, perceived responsibility, suspicion of constraints, and policy attitudes. +- The reveal explains the hidden cost rule and unequal structural conditions. +- Post-reveal measures record revised attributions, perceived structural impact, perspective change, judgment accuracy, and policy attitudes. + +The central comparison is the within-participant shift from pre-reveal to post-reveal judgments. + +## Measures + +Core measure families include: + +- Game behavior: treatment choices, skipped/partial/full care choices, final financial score, final health score, total treatment costs, and total income. +- Attribution and responsibility: initial primary attribution, revised attribution, individual responsibility ratings, and responsibility shift. +- Constraint recognition: suspicion before reveal and perceived structural impact after reveal. +- Protest and policy attitudes: protest legitimacy, rule correction support, redistribution support, and changes in those attitudes. +- Participant background: demographic and contextual variables such as age group, gender, subjective economic status, medical cost pressure, healthcare coverage, and institutional trust. + +## Computed metrics + +Computed metrics summarize analysis-ready outcomes, including: + +- `burden` — experienced financial/health burden from the game. +- `careAvoidance` — tendency to skip or reduce care. +- `responsibilityShift` — change in responsibility judgment after the reveal. +- `constraintRecognitionShift` — change in recognition of structural constraints. +- `protestLegitimacyShift` — change in perceived legitimacy of protest. +- `ruleCorrectionSupportShift` — change in support for correcting the rules. +- `redistributionSupportShift` — change in redistributive support. +- `certaintyCorrection`, `informationCaution`, and `perspectiveChange` — additional post-reveal interpretation and reflection measures. + +## Ethics and limitations + +- Participation should be voluntary and based on clear consent language. +- Avoid collecting directly identifying information unless your approved protocol requires it. +- Explain that the scenario is a simplified simulation and not medical or financial advice. +- The manipulation can reveal unequal constraints after participants have already made judgments; debriefing language should be clear and non-shaming. +- Online samples may not represent the target population, and game behavior may not generalize to real healthcare decisions. +- Treat incomplete sessions carefully. They may reflect attrition, confusion, technical issues, or refusal to submit. + +## Recommended pilot procedure + +1. Run an internal technical pilot with test submissions enabled. +2. Verify consent flow, completion flow, `/api/health`, `/admin`, CSV export, JSON export, and backups. +3. Remove test submissions only after backing up and verifying ids. +4. Run a small participant pilot to estimate completion time, attrition, item clarity, and whether the hidden rule reveal is understandable. +5. Inspect distributions for assignment balance, incomplete submissions, extreme response patterns, and missing data. +6. Freeze the consent version, schema version, and analysis plan before collecting the main sample. diff --git a/next.config.mjs b/next.config.mjs index 4678774..6a92d6f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,35 @@ +const isProduction = process.env.NODE_ENV === "production"; + +const contentSecurityPolicy = [ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + `script-src 'self' 'unsafe-inline'${isProduction ? "" : " 'unsafe-eval'"}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + `connect-src 'self'${isProduction ? "" : " ws: wss:"}`, + ...(isProduction ? ["upgrade-insecure-requests"] : []), +].join("; "); + /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Content-Security-Policy", value: contentSecurityPolicy }, + ], + }, + ]; + }, +}; export default nextConfig;