diff --git a/.env.example b/.env.example index d20bfa4..694568f 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ SUBMISSION_RATE_LIMIT_WINDOW_MS="60000" SUBMISSION_RATE_LIMIT_MAX="20" MAX_SUBMISSION_BODY_BYTES="250000" +GOOGLE_SHEETS_WEBHOOK_URL="" +GOOGLE_SHEETS_WEBHOOK_SECRET="" + CONSENT_VERSION="pilot-consent-v1" SCHEMA_VERSION="research-export-v1" diff --git a/README.md b/README.md index d464c38..8000ec5 100644 --- a/README.md +++ b/README.md @@ -162,11 +162,81 @@ Start from `.env.example` for local or server configuration. | `SUBMISSION_RATE_LIMIT_WINDOW_MS` | Optional | `60000` | Submission API | In-memory rate-limit window for submission attempts. Defaults to 60000 ms. | | `SUBMISSION_RATE_LIMIT_MAX` | Optional | `20` | Submission API | Maximum submissions per client key per window. Defaults to 20. | | `MAX_SUBMISSION_BODY_BYTES` | Optional | `250000` | Submission API | Maximum accepted submission body size. Defaults to 250000 bytes. | +| `GOOGLE_SHEETS_WEBHOOK_URL` | Optional | `https://script.google.com/macros/s/.../exec` | Submission API | Optional Google Apps Script webhook URL. When set, successfully stored submissions are mirrored to Google Sheets after PostgreSQL save. | +| `GOOGLE_SHEETS_WEBHOOK_SECRET` | Recommended with Sheets mirror | `replace-with-a-long-random-secret` | Submission API, Apps Script | Shared secret sent to the webhook for verification. Keep it private and do not append it to sheet rows. | | `CONSENT_VERSION` | Optional server metadata fallback | `pilot-consent-v1` | Submission API | Stored as a fallback if a submitted payload lacks `consentVersion`. The client export currently uses the version constant in `utils/researchMetrics.ts`. | | `SCHEMA_VERSION` | Present in `.env.example`; not currently read by app code | `research-export-v1` | Deployment convention only | Reserved/configuration note for schema versioning. The client export currently uses the schema version constant in `utils/researchMetrics.ts`. | For Docker Compose, `.env.example` also includes `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` for the bundled PostgreSQL service. + +## Optional Google Sheets mirror + +PostgreSQL remains the primary source of truth for completed research submissions. You can also mirror a flattened summary row to a Google Sheet by deploying a Google Apps Script web app and setting `GOOGLE_SHEETS_WEBHOOK_URL`. If the Sheets webhook is missing, slow, invalid, or returns an error, `POST /api/submissions` still returns success to the participant after the PostgreSQL write succeeds; the webhook result is only logged server-side. + +Setup steps: + +1. Create a Google Sheet and add a sheet tab named `Submissions`. +2. Open **Extensions → Apps Script**, create a web app script, and set a script property named `WEBHOOK_SECRET`. +3. Deploy the Apps Script as a web app and copy its `/exec` URL into `.env` as `GOOGLE_SHEETS_WEBHOOK_URL`; set the same secret in `GOOGLE_SHEETS_WEBHOOK_SECRET`. +4. Restart the app so the server process reads the new environment variables. +5. Submit one complete test session and verify both the app logs and the new Google Sheet row. + +A minimal Apps Script receiver that checks the shared secret from the JSON body is shown below. The app also sends an `Authorization: Bearer ...` header, but Apps Script web apps do not always expose request headers consistently, so this example validates `data.secret` and deliberately does not append the secret to the sheet row. + +```javascript +function doPost(e) { + const expectedSecret = PropertiesService.getScriptProperties().getProperty("WEBHOOK_SECRET"); + const data = JSON.parse(e.postData.contents || "{}"); + + if (!expectedSecret || data.secret !== expectedSecret) { + return ContentService + .createTextOutput(JSON.stringify({ ok: false, error: "unauthorized" })) + .setMimeType(ContentService.MimeType.JSON); + } + + const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Submissions"); + if (!sheet) { + return ContentService + .createTextOutput(JSON.stringify({ ok: false, error: "missing Submissions sheet" })) + .setMimeType(ContentService.MimeType.JSON); + } + + sheet.appendRow([ + new Date(), + data.serverSubmissionId, + data.submittedAt, + data.sessionId, + data.schemaVersion, + data.exportVersion, + data.assignedDisplayedProfile, + data.assignedHiddenProfile, + data.finalFinancialScore, + data.finalHealthScore, + data.fullTreatmentChoices, + data.partialTreatmentChoices, + data.skippedTreatmentChoices, + data.responsibilityShift, + data.constraintRecognitionShift, + data.protestLegitimacyShift, + data.ruleCorrectionSupportShift, + data.redistributionSupportShift, + data.revisionCondition, + data.attemptedPreRevealRevision, + data.usedRevisionOpportunity, + data.revealTimingCondition, + data.costVisibilityCondition, + data.explanationFrameCondition, + data.replayCompleted, + data.replayAssignmentCondition + ]); + + return ContentService + .createTextOutput(JSON.stringify({ ok: true })) + .setMimeType(ContentService.MimeType.JSON); +} +``` + ## Server pilot mode with Docker The Docker Compose setup runs the Next.js app and a private PostgreSQL service. Postgres is not exposed with a public host port by default. diff --git a/app/api/submissions/route.ts b/app/api/submissions/route.ts index b2a31b7..53a000b 100644 --- a/app/api/submissions/route.ts +++ b/app/api/submissions/route.ts @@ -3,7 +3,8 @@ import type { PrismaClient } from "@prisma/client"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getPrismaClient } from "@/lib/prisma"; -import { researchExportSchema } from "@/lib/researchExportSchema"; +import { sendSubmissionToGoogleSheets } from "@/lib/googleSheetsWebhook"; +import { researchExportSchema, type ValidResearchExport } from "@/lib/researchExportSchema"; import { getMaxSubmissionBodyBytes, getSubmissionRateLimitMax, @@ -24,7 +25,7 @@ const rateLimitBuckets = new Map(); type ResearchSubmissionCreateData = Parameters[0]["data"]; type ResearchSubmissionPayloadInput = ResearchSubmissionCreateData["payload"]; -type ResearchSubmissionPayload = Record & { +type ResearchSubmissionPayload = ValidResearchExport & { serverSubmissionId: string; submittedAt: string; }; @@ -102,6 +103,11 @@ export async function POST(request: NextRequest) { }, }); + await sendSubmissionToGoogleSheets(payload, { + serverSubmissionId, + submittedAt, + }); + return NextResponse.json( { ok: true, diff --git a/lib/googleSheetsWebhook.ts b/lib/googleSheetsWebhook.ts new file mode 100644 index 0000000..7c3dd78 --- /dev/null +++ b/lib/googleSheetsWebhook.ts @@ -0,0 +1,129 @@ +import type { ValidResearchExport } from "@/lib/researchExportSchema"; + +const WEBHOOK_TIMEOUT_MS = 5_000; +const MAX_PAYLOAD_JSON_BYTES = 100_000; + +type SubmissionWebhookMetadata = { + serverSubmissionId: string; + submittedAt: Date | string; +}; + +type GoogleSheetsSubmissionRow = { + secret?: string; + type: "hidden-cost-game-submission"; + serverSubmissionId: string; + submittedAt: string; + sessionId: string; + schemaVersion: string; + exportVersion: string; + assignedDisplayedProfile: string; + assignedHiddenProfile: string; + finalFinancialScore: number; + finalHealthScore: number; + fullTreatmentChoices: number; + partialTreatmentChoices: number; + skippedTreatmentChoices: number; + responsibilityShift: number; + constraintRecognitionShift: number; + protestLegitimacyShift: number; + ruleCorrectionSupportShift: number; + redistributionSupportShift: number; + revisionCondition: string | null; + attemptedPreRevealRevision: boolean | null; + usedRevisionOpportunity: boolean | null; + revealTimingCondition: string | null; + costVisibilityCondition: string | null; + explanationFrameCondition: string | null; + replayCompleted: boolean; + replayAssignmentCondition: string | null; + payloadJson?: string; +}; + +export async function sendSubmissionToGoogleSheets( + payload: ValidResearchExport, + metadata: SubmissionWebhookMetadata, +): Promise { + const webhookUrl = process.env.GOOGLE_SHEETS_WEBHOOK_URL?.trim(); + if (!webhookUrl) { + return; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS); + + try { + const row = buildSubmissionRow(payload, metadata); + const headers = new Headers({ + "content-type": "application/json", + }); + + const secret = process.env.GOOGLE_SHEETS_WEBHOOK_SECRET?.trim(); + if (secret) { + headers.set("authorization", `Bearer ${secret}`); + } + + const response = await fetch(webhookUrl, { + method: "POST", + headers, + body: JSON.stringify(row), + signal: controller.signal, + }); + + if (!response.ok) { + console.warn( + `[google-sheets-webhook] Submission mirror failed for ${metadata.serverSubmissionId}: ${response.status} ${response.statusText}`, + ); + return; + } + + console.info(`[google-sheets-webhook] Mirrored submission ${metadata.serverSubmissionId}.`); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown webhook error"; + console.warn(`[google-sheets-webhook] Submission mirror failed for ${metadata.serverSubmissionId}: ${message}`); + } finally { + clearTimeout(timeout); + } +} + +function buildSubmissionRow( + payload: ValidResearchExport, + metadata: SubmissionWebhookMetadata, +): GoogleSheetsSubmissionRow { + const row: GoogleSheetsSubmissionRow = { + secret: process.env.GOOGLE_SHEETS_WEBHOOK_SECRET?.trim() || undefined, + type: "hidden-cost-game-submission", + serverSubmissionId: metadata.serverSubmissionId, + submittedAt: metadata.submittedAt instanceof Date ? metadata.submittedAt.toISOString() : metadata.submittedAt, + sessionId: payload.sessionId, + schemaVersion: payload.schemaVersion, + exportVersion: payload.exportVersion, + assignedDisplayedProfile: payload.assignedProfile.displayedProfile, + assignedHiddenProfile: payload.assignedProfile.hiddenProfile, + finalFinancialScore: payload.gameSummary.finalFinancialScore, + finalHealthScore: payload.gameSummary.finalHealthScore, + fullTreatmentChoices: payload.gameSummary.fullTreatmentChoices, + partialTreatmentChoices: payload.gameSummary.partialTreatmentChoices, + skippedTreatmentChoices: payload.gameSummary.skippedTreatmentChoices, + responsibilityShift: payload.computedMetrics.responsibilityShift, + constraintRecognitionShift: payload.computedMetrics.constraintRecognitionShift, + protestLegitimacyShift: payload.computedMetrics.protestLegitimacyShift, + ruleCorrectionSupportShift: payload.computedMetrics.ruleCorrectionSupportShift, + redistributionSupportShift: payload.computedMetrics.redistributionSupportShift, + revisionCondition: payload.revisionAccess?.condition ?? null, + attemptedPreRevealRevision: + payload.computedMetrics.attemptedPreRevealRevision ?? payload.preRevealRevision?.attempted ?? null, + usedRevisionOpportunity: payload.computedMetrics.usedRevisionOpportunity ?? payload.preRevealRevision?.used ?? null, + revealTimingCondition: payload.revealTimingCondition?.condition ?? null, + costVisibilityCondition: payload.costVisibilityCondition?.condition ?? null, + explanationFrameCondition: payload.explanationFrameCondition?.condition ?? null, + replayCompleted: payload.computedMetrics.replayCompleted, + replayAssignmentCondition: payload.computedMetrics.replayAssignmentCondition ?? payload.replayGame?.assignmentCondition ?? null, + }; + + const payloadJson = JSON.stringify(payload); + if (Buffer.byteLength(payloadJson, "utf8") <= MAX_PAYLOAD_JSON_BYTES) { + row.payloadJson = payloadJson; + } + + return row; +}