Skip to content
Merged
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ data/db/*
# Local Netlify folder
.netlify
/.env

# Qualtrics survey-definition dumps (for local diffing)
.qualtrics-dumps/
41 changes: 36 additions & 5 deletions packages/survey/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,42 @@ The schemas live alongside what they validate:

## Qualtrics API

Final output of the survey is done via the [Qualtrics API](https://api.qualtrics.com/5d17de1a27084-example-use-cases-walkthrough) which has [MCP](https://api.qualtrics.com/55231bbc7cd3c-mcp-overview#qualtrics-mcp-servers) support.
Final output of the survey is done via the [Qualtrics API](https://api.qualtrics.com/5d17de1a27084-example-use-cases-walkthrough).

### Sync

`packages/survey/scripts/qualtrics-sync.ts` pushes this YAML into an **existing** Qualtrics survey via the [Survey Definitions API](https://api.qualtrics.com/). It transforms each question with the [type mapping](../../questions/README.md#type-mapping) and reconciles **idempotently keyed on `DataExportTag`** (which equals `question.id` / the filename):

- in YAML, not in the survey → **created**,
- in both → **updated only when the content changed**,
- in the survey, not in YAML → reported as an **orphan**, deleted only with `--prune`.

Qualtrics `QID`s are preserved across runs, so a re-run with no edits is a no-op. New questions are created with a minimal payload and then immediately updated with the full payload (`DataExportTag`, recodes, randomization) — the create-then-update sequence avoids the API's create-time validation errors.

Configure via environment variables (set in a `.env` file at root):

```sh
QUALTRICS_API_TOKEN=... # X-API-TOKEN
QUALTRICS_DATACENTER=... # e.g. iad1, fra1, syd1
QUALTRICS_SURVEY_ID=SV_... # the target existing survey
```

Then, from the repo root:

```sh
npm run sync:qualtrics:dry -w survey # read-only GET + reconcile, prints WOULD-write actions
npm run sync:qualtrics -w survey # apply changes
npm run sync:qualtrics -w survey -- --prune # also delete orphaned questions/blocks
npm run sync:qualtrics -w survey -- --verbose # per-question decisions
```

With no `QUALTRICS_API_TOKEN`, the script runs **offline** and just prints the transformed payloads — handy for eyeballing the transforms without credentials.

**Phase 1 scope:** questions, blocks, page breaks, and ensuring every block is reachable from the flow (new blocks are appended; existing flow is never rewritten). The flow-level `if/then` branching and the `randomize: N` BlockRandomizer are printed as "not synced yet" and left for a later phase.

### MCP server

[See Qualtrics docs](https://api.qualtrics.com/55231bbc7cd3c-mcp-overview#qualtrics-mcp-servers) for more info.

1. Visit the [Oauth credentials page from the dashboard](https://stackoverflow.pdx1.qualtrics.com/admin/oauth-client-manager)
2. Create client
Expand All @@ -49,7 +84,3 @@ claude mcp add-json qualtrics \
'{"type":"http","url":"https://stackoverflow.pdx1.qualtrics.com/API/mcp/survey-definitions","oauth":{"clientId":"your-client-id","callbackPort":8080}}' \
--client-secret
```

## Qualtrics sync

Coming soon…
7 changes: 6 additions & 1 deletion packages/survey/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@
"validate:questions": "tsx scripts/validate-schema.ts questions",
"validate:survey": "tsx scripts/validate-schema.ts survey",
"validate:cross": "tsx scripts/validate.ts",
"validate": "npm run validate:questions && npm run validate:survey && npm run validate:cross"
"validate": "npm run validate:questions && npm run validate:survey && npm run validate:cross",
"sync:qualtrics": "tsx --env-file-if-exists=../../.env scripts/qualtrics-sync.ts",
"sync:qualtrics:dry": "tsx --env-file-if-exists=../../.env scripts/qualtrics-sync.ts --dry-run",
"sync:qualtrics:dump": "tsx --env-file-if-exists=../../.env scripts/qualtrics-dump.ts"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tsconfig/svelte": "^5.0.8",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.9.1",
"ajv": "^8.20.0",
"json-schema-to-typescript": "^15.0.4",
Expand All @@ -29,6 +33,7 @@
},
"dependencies": {
"@dnd-kit/svelte": "^0.4.0",
"lodash-es": "^4.18.1",
"marked": "^18.0.4",
"yaml": "^2.9.0"
}
Expand Down
139 changes: 139 additions & 0 deletions packages/survey/scripts/qualtrics-dump.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env tsx
// Dump one or more Qualtrics survey definitions to normalized JSON for local
// diffing. A raw GET differs in incidental ways between surveys (QIDs,
// BlockIDs, FlowIDs, *_Unsafe, timestamps), so this keys questions by
// DataExportTag, drops server-assigned ids, rewrites DisplayLogic locators
// QID -> tag, and sorts keys — so a plain `diff` shows real content
// differences, not noise.
//
// Usage (from repo root):
// npm run sync:qualtrics:dump -w survey # dumps QUALTRICS_SURVEY_ID
// npm run sync:qualtrics:dump -w survey -- SV_a SV_b # dumps the given ids
// Writes .qualtrics-dumps/<surveyId>.json (git-ignored).

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { QualtricsClient } from './qualtrics.ts'
import type { SurveyDefinition } from './qualtrics.ts'

const here = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(here, '../../..')
const outDir = path.join(repoRoot, '.qualtrics-dumps')

const token = process.env.QUALTRICS_API_TOKEN
const datacenter = process.env.QUALTRICS_DATACENTER
if (!token || !datacenter) {
console.error('QUALTRICS_API_TOKEN and QUALTRICS_DATACENTER must be set.')
process.exit(2)
}

const ids = process.argv.slice(2).filter((a) => !a.startsWith('-'))
if (ids.length === 0) {
const fallback = process.env.QUALTRICS_SURVEY_ID
if (!fallback) {
console.error('Pass one or more survey ids, or set QUALTRICS_SURVEY_ID.')
process.exit(2)
}
ids.push(fallback)
}

// Deep-normalize: drop human-readable Description fields and rewrite any QIDxx
// reference (in DisplayLogic locators, QuestionID, etc.) to its DataExportTag.
function deepNorm(value: unknown, qidToTag: Map<string, string>): unknown {
if (Array.isArray(value)) return value.map((v) => deepNorm(v, qidToTag))
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) {
if (k === 'Description') continue
out[k] = deepNorm(v, qidToTag)
}
return out
}
if (typeof value === 'string') return value.replace(/QID\d+/g, (m) => qidToTag.get(m) ?? m)
return value
}

// Recursively sort object keys so output ordering is stable across runs.
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys)
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const k of Object.keys(value as Record<string, unknown>).sort()) out[k] = sortKeys((value as Record<string, unknown>)[k])
return out
}
return value
}

// Server-assigned / volatile fields not worth diffing.
const DROP_QUESTION_FIELDS = new Set([
'QuestionID',
'QuestionText_Unsafe',
'NextChoiceId',
'NextAnswerId',
'DataVisibility',
'GradingData',
'Language',
'DefaultChoices',
])

function normalize(def: SurveyDefinition): unknown {
const qidToTag = new Map<string, string>()
for (const [qid, q] of Object.entries(def.Questions ?? {})) if (q.DataExportTag) qidToTag.set(qid, q.DataExportTag)

const blockNameById = new Map<string, string>()
for (const [blockId, block] of Object.entries(def.Blocks ?? {})) blockNameById.set(block.ID ?? blockId, block.Description ?? blockId)

// Questions keyed by DataExportTag.
const questions: Record<string, unknown> = {}
for (const [qid, q] of Object.entries(def.Questions ?? {})) {
const tag = q.DataExportTag ?? qid
const picked: Record<string, unknown> = {}
for (const [k, v] of Object.entries(q)) if (!DROP_QUESTION_FIELDS.has(k)) picked[k] = v
questions[tag] = deepNorm(picked, qidToTag)
}

// Blocks keyed by name; elements as question tags / page breaks.
const blocks: Record<string, string[]> = {}
for (const [blockId, block] of Object.entries(def.Blocks ?? {})) {
const name = block.Description ?? blockId
blocks[name] = (block.BlockElements ?? []).map((e) =>
e.Type === 'Question' && e.QuestionID ? `Q:${qidToTag.get(e.QuestionID) ?? e.QuestionID}` : e.Type
)
}

// Flow: block ids -> names, drop FlowIDs, keep randomizer + embedded data shape.
interface RawFlowEl {
Type: string
ID?: string
Flow?: RawFlowEl[]
SubSet?: number
EvenPresentation?: boolean
EmbeddedData?: { Field?: string }[]
}
const normFlow = (els: RawFlowEl[] | undefined): unknown[] =>
(els ?? []).map((el) => {
if (el.Type === 'BlockRandomizer')
return { Type: 'BlockRandomizer', SubSet: el.SubSet, Even: el.EvenPresentation, Flow: normFlow(el.Flow) }
if (el.Type === 'EmbeddedData') return { Type: 'EmbeddedData', Fields: (el.EmbeddedData ?? []).map((d) => d.Field) }
if (el.ID) return { Type: el.Type, Block: blockNameById.get(el.ID) ?? el.ID }
return { Type: el.Type }
})

return sortKeys({
questionCount: Object.keys(def.Questions ?? {}).length,
flow: normFlow((def.SurveyFlow?.Flow as RawFlowEl[] | undefined) ?? []),
blocks,
questions,
})
}

fs.mkdirSync(outDir, { recursive: true })

for (const surveyId of ids) {
const client = new QualtricsClient({ token, datacenter, surveyId })
const def = await client.getDefinition()
const file = path.join(outDir, `${surveyId}.json`)
fs.writeFileSync(file, JSON.stringify(normalize(def), null, 2) + '\n')
console.error(`wrote ${path.relative(repoRoot, file)} (${Object.keys(def.Questions ?? {}).length} questions)`)
}
Loading