Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions app/api/submissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,7 +25,7 @@ const rateLimitBuckets = new Map<string, RateLimitEntry>();
type ResearchSubmissionCreateData = Parameters<PrismaClient["researchSubmission"]["create"]>[0]["data"];
type ResearchSubmissionPayloadInput = ResearchSubmissionCreateData["payload"];

type ResearchSubmissionPayload = Record<string, unknown> & {
type ResearchSubmissionPayload = ValidResearchExport & {
serverSubmissionId: string;
submittedAt: string;
};
Expand Down Expand Up @@ -102,6 +103,11 @@ export async function POST(request: NextRequest) {
},
});

await sendSubmissionToGoogleSheets(payload, {
serverSubmissionId,
submittedAt,
});

return NextResponse.json(
{
ok: true,
Expand Down
129 changes: 129 additions & 0 deletions lib/googleSheetsWebhook.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
}