diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb8da64..d9acc33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,89 @@ jobs: --searchPath=/db \ update + - name: Verify production-shaped upgrade and migration rerun + run: | + PGPASSWORD=postgres createdb -h 127.0.0.1 -U postgres ONA_upgrade + rm -rf /tmp/db-pre-lifecycle + cp -a db /tmp/db-pre-lifecycle + sed -i '/v1_6_survey_lifecycle_email_delivery.sql/d' /tmp/db-pre-lifecycle/changelogs/master-changelog.xml + docker run --rm --network host \ + -v /tmp/db-pre-lifecycle:/db \ + liquibase/liquibase:4.29 \ + --url=jdbc:postgresql://127.0.0.1:5432/ONA_upgrade \ + --username=postgres --password=postgres \ + --changeLogFile=changelogs/master-changelog.xml --searchPath=/db update + PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -d ONA_upgrade -v ON_ERROR_STOP=1 <<'SQL' + INSERT INTO survey(name,title,creation_date,questions,organization_id,archived_at) + SELECT 'Upgrade Active','Active',now(),'{"elements":[{"type":"text","name":"question_1"}]}'::jsonb,id,NULL FROM organizations WHERE slug='default-imported'; + INSERT INTO survey(name,title,creation_date,questions,organization_id,archived_at) + SELECT 'Upgrade Draft','Draft',now(),'{"elements":[{"type":"text","name":"question_1"}]}'::jsonb,id,NULL FROM organizations WHERE slug='default-imported'; + INSERT INTO survey(name,title,creation_date,questions,organization_id,archived_at) + SELECT 'Upgrade Archived','Archived',now(),'{"elements":[]}'::jsonb,id,now() FROM organizations WHERE slug='default-imported'; + INSERT INTO respondent(name,contact_info,survey_name,can_respond,uuid,lang,response,email_sent,survey_id) + SELECT 'Active Person','active@example.test',name,true,'upgrade-active-token','English','{"question_1":"yes"}'::jsonb,true,id FROM survey WHERE name='Upgrade Active'; + INSERT INTO email(survey_name,lang,text,survey_id) + SELECT name,'English','Please participate',id FROM survey WHERE name IN ('Upgrade Active','Upgrade Draft'); + SQL + PGPASSWORD=postgres createdb -h 127.0.0.1 -U postgres -T ONA_upgrade ONA_conflict + PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -d ONA_conflict -v ON_ERROR_STOP=1 \ + -c "UPDATE respondent SET survey_id=gen_random_uuid() WHERE survey_name='Upgrade Active'" + if docker run --rm --network host -v "$PWD/db:/db" liquibase/liquibase:4.29 \ + --url=jdbc:postgresql://127.0.0.1:5432/ONA_conflict --username=postgres --password=postgres \ + --changeLogFile=changelogs/master-changelog.xml --searchPath=/db update; then + echo 'Expected orphaned stable-ID preflight to fail' >&2 + exit 1 + fi + PGPASSWORD=postgres createdb -h 127.0.0.1 -U postgres -T ONA_upgrade ONA_lock_timeout + (PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -d ONA_lock_timeout -v ON_ERROR_STOP=1 -c 'BEGIN; LOCK TABLE survey IN ACCESS EXCLUSIVE MODE; SELECT pg_sleep(20); COMMIT') & + LOCK_PID=$! + sleep 1 + if docker run --rm --network host -v "$PWD/db:/db" liquibase/liquibase:4.29 \ + --url=jdbc:postgresql://127.0.0.1:5432/ONA_lock_timeout --username=postgres --password=postgres \ + --changeLogFile=changelogs/master-changelog.xml --searchPath=/db update; then + echo 'Expected lifecycle migration lock timeout' >&2 + exit 1 + fi + wait "$LOCK_PID" + docker run --rm --network host -v "$PWD/db:/db" liquibase/liquibase:4.29 \ + --url=jdbc:postgresql://127.0.0.1:5432/ONA_lock_timeout --username=postgres --password=postgres \ + --changeLogFile=changelogs/master-changelog.xml --searchPath=/db update + if PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -d ONA_upgrade -v ON_ERROR_STOP=1 \ + -c 'CREATE UNIQUE INDEX CONCURRENTLY idx_survey_id_org_unique ON survey ((1))'; then + echo 'Expected duplicate-key concurrent index build to leave a recoverable invalid index' >&2 + exit 1 + fi + trap 'kill "${WRITER_PID:-}" 2>/dev/null || true' EXIT + ( + while true; do + PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -d ONA_upgrade -c "UPDATE survey SET title=title WHERE name='Upgrade Draft'" >/dev/null + sleep 0.1 + done + ) & + WRITER_PID=$! + docker run --rm --network host \ + -v "$PWD/db:/db" \ + liquibase/liquibase:4.29 \ + --url=jdbc:postgresql://127.0.0.1:5432/ONA_upgrade \ + --username=postgres --password=postgres \ + --changeLogFile=changelogs/master-changelog.xml --searchPath=/db update + kill "$WRITER_PID" 2>/dev/null || true + wait "$WRITER_PID" 2>/dev/null || true + unset WRITER_PID + trap - EXIT + docker run --rm --network host \ + -v "$PWD/db:/db" \ + liquibase/liquibase:4.29 \ + --url=jdbc:postgresql://127.0.0.1:5432/ONA_upgrade \ + --username=postgres --password=postgres \ + --changeLogFile=changelogs/master-changelog.xml --searchPath=/db update + PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -d ONA_upgrade -v ON_ERROR_STOP=1 \ + -c "SELECT lifecycle_status,count(*) FROM survey WHERE name LIKE 'Upgrade %' GROUP BY lifecycle_status ORDER BY lifecycle_status" \ + -c "SELECT c.relname,i.indisvalid,i.indisready FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid WHERE c.relname IN ('survey_id_key','idx_survey_id_org_unique','idx_respondent_id_survey_unique') AND (NOT i.indisvalid OR NOT i.indisready)" \ + -c 'DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid WHERE c.relname IN ('\''survey_id_key'\'','\''idx_survey_id_org_unique'\'','\''idx_respondent_id_survey_unique'\'') AND (NOT i.indisvalid OR NOT i.indisready)) THEN RAISE EXCEPTION '\''invalid lifecycle index remains'\''; END IF; IF (SELECT count(*) FROM email_worker_control) <> 4 THEN RAISE EXCEPTION '\''worker controls missing'\''; END IF; IF (SELECT count(*) FROM survey WHERE name LIKE '\''Upgrade %'\'' AND lifecycle_status='\''active'\'') <> 1 THEN RAISE EXCEPTION '\''active backfill mismatch'\''; END IF; IF (SELECT count(*) FROM survey WHERE name LIKE '\''Upgrade %'\'' AND lifecycle_status='\''draft'\'') <> 1 THEN RAISE EXCEPTION '\''draft backfill mismatch'\''; END IF; IF (SELECT count(*) FROM survey WHERE name LIKE '\''Upgrade %'\'' AND lifecycle_status='\''closed'\'') <> 1 THEN RAISE EXCEPTION '\''closed backfill mismatch'\''; END IF; END $$;' + - name: Install API dependencies - run: npm ci + run: npm ci --workspaces=false working-directory: api - name: Run API unit/security tests @@ -78,9 +159,8 @@ jobs: cache: npm cache-dependency-path: ${{ matrix.app }}/package-lock.json - - name: Install dependencies + - name: Install workspace dependencies run: npm ci - working-directory: ${{ matrix.app }} - name: Test run: npm test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0fddb82..df011d5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -135,7 +135,12 @@ jobs: mkdir -p "$STAGE_DIR/deploy" "$STAGE_DIR/db" rsync -a --exclude node_modules api "$STAGE_DIR/" cp -a db/changelogs "$STAGE_DIR/db/" - cp scripts/deploy/remote-deploy.sh scripts/deploy/bootstrap-admin.js scripts/deploy/finalize-legacy-accounts.js "$STAGE_DIR/deploy/" + cp scripts/deploy/remote-deploy.sh \ + scripts/deploy/bootstrap-admin.js \ + scripts/deploy/finalize-legacy-accounts.js \ + scripts/deploy/ecosystem.config.js \ + scripts/deploy/set-email-claiming.js \ + "$STAGE_DIR/deploy/" echo "$GITHUB_SHA" > "$STAGE_DIR/REVISION" tar -czf api-release.tar.gz -C "$STAGE_DIR" . diff --git a/.github/workflows/rollback-api.yml b/.github/workflows/rollback-api.yml index eaefcef..ea7d50c 100644 --- a/.github/workflows/rollback-api.yml +++ b/.github/workflows/rollback-api.yml @@ -130,6 +130,7 @@ jobs: "rm -rf /tmp/ona-deploy && mkdir -p /tmp/ona-deploy", "aws s3 cp s3://\($bucket)/api/\($sha).tar.gz /tmp/ona-deploy/release.tar.gz", "tar -xzf /tmp/ona-deploy/release.tar.gz -C /tmp/ona-deploy", + "test -f /tmp/ona-deploy/api/lifecycle.js && test -f /tmp/ona-deploy/api/email-worker.js && test -f /tmp/ona-deploy/deploy/ecosystem.config.js && test -f /tmp/ona-deploy/deploy/set-email-claiming.js || { echo Refusing rollback to an invalid or pre-lifecycle artifact >&2; exit 1; }", "bash /tmp/ona-deploy/deploy/remote-deploy.sh /tmp/ona-deploy", "rm -rf /tmp/ona-deploy" ] diff --git a/api/.env.local.example b/api/.env.local.example index 270c683..d60a93f 100644 --- a/api/.env.local.example +++ b/api/.env.local.example @@ -17,6 +17,17 @@ DEMO_EMAIL_RATE_LIMIT_MAX=10 # DEMO_TOKEN_SECRET=replace-me # Optional for email sending features. RESEND_API_KEY=replace-me +# Durable lifecycle launch/worker settings. +SURVEY_DELIVERY_V2_ENABLED=true +LEGACY_START_ENABLED=true +EMAIL_WORKER_ENV=local +EMAIL_WORKER_HEARTBEAT_MAX_AGE_SECONDS=45 +EMAIL_LEASE_SECONDS=60 +EMAIL_PROVIDER_TIMEOUT_MS=15000 +EMAIL_MAX_ATTEMPTS=6 +EMAIL_MAX_AGE_HOURS=72 +EMAIL_RATE_PER_SECOND=5 +SURVEY_EMAIL_SENDER=CLA Survey # Optional for local DB bootstrap admin created by `npm run db:setup`. LOCAL_ADMIN_USERNAME=admin LOCAL_ADMIN_PASSWORD=admin123 diff --git a/api/email-worker.js b/api/email-worker.js new file mode 100644 index 0000000..85c0aec --- /dev/null +++ b/api/email-worker.js @@ -0,0 +1,92 @@ +'use strict'; + +const os = require('os'); +const crypto = require('crypto'); +const fs = require('fs'); +const dotenvFlow = require('dotenv-flow'); +const { Pool } = require('pg'); +const { ResendProvider, buildInvitationPayload, payloadHash, classifyProviderError, sanitizeProviderMessage, reserveProviderRate } = require('./email'); +const { environmentName } = require('./lifecycle'); + +dotenvFlow.config(); + +function createPool(env=process.env){return new Pool({user:env.DB_USER,password:env.DB_PASSWORD,host:env.DB_HOST,port:env.DB_PORT,database:env.DB_NAME||'ONA',ssl:env.DB_SSL==='true'?{ca:env.DB_SSL_CA?fs.readFileSync(env.DB_SSL_CA,'utf8'):undefined,rejectUnauthorized:Boolean(env.DB_SSL_CA)}:undefined});} +const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms)); +const isOutsideProviderIdempotencyWindow=(startedAt,now,hours)=>Boolean(startedAt)&&new Date(startedAt).getTime()<=new Date(now).getTime()-hours*3600000; +const canRetryAmbiguous=({firstProviderStartedAt,providerAttemptCount,createdAt,now,idempotencyHours,maxAttempts,maxAgeHours})=>Boolean(firstProviderStartedAt)&&!isOutsideProviderIdempotencyWindow(firstProviderStartedAt,now,idempotencyHours)&&Number(providerAttemptCount)new Date(now).getTime()-maxAgeHours*3600000; +const bounded=(value,max=500)=>value===null||value===undefined?null:sanitizeProviderMessage(value).slice(0,max); + +class DeliveryWorker { + constructor({pool,provider,env=process.env,clock=()=>new Date(),random=Math.random,sleepFn=sleep,instanceId}={}){ + this.pool=pool;this.env=env;this.environment=environmentName(env);this.rateBudgetEnvironment=env.EMAIL_RATE_BUDGET_ENV||this.environment;this.provider=provider;this.clock=clock;this.random=random;this.sleep=sleepFn; + this.instanceId=instanceId||`${env.DEPLOYMENT_ID||'local'}/${os.hostname()}/${process.pid}/${crypto.randomUUID()}`;this.release=env.RELEASE_REVISION||env.REVISION||'local'; + this.leaseSeconds=Math.max(20,Number(env.EMAIL_LEASE_SECONDS||60));this.maxAttempts=Math.max(1,Number(env.EMAIL_MAX_ATTEMPTS||6));this.maxAgeHours=Math.max(1,Number(env.EMAIL_MAX_AGE_HOURS||72));this.idempotencyHours=Math.min(23,Math.max(1,Number(env.EMAIL_PROVIDER_IDEMPOTENCY_HOURS||23)));this.rate=Math.max(1,Number(env.EMAIL_RATE_PER_SECOND||5));this.stopped=false;this.claiming=false;this.lastError=null; + } + async heartbeat(){await this.pool.query(`INSERT INTO email_worker_heartbeats(environment,worker_instance,release_revision,enabled,claiming,heartbeat_at,last_error,started_at) VALUES($1,$2,$3,true,$4,now(),$5,now()) ON CONFLICT(environment,worker_instance) DO UPDATE SET release_revision=excluded.release_revision,enabled=true,claiming=excluded.claiming,heartbeat_at=now(),last_error=excluded.last_error`,[this.environment,this.instanceId,this.release,this.claiming,this.lastError?bounded(this.lastError):null]);} + async control(){const result=await this.pool.query('SELECT claiming_enabled,minimum_release FROM email_worker_control WHERE environment=$1',[this.environment]);const row=result.rows[0];return Boolean(row?.claiming_enabled&&(!row.minimum_release||this.release===row.minimum_release));} + async claim(){const client=await this.pool.connect();try{await client.query('BEGIN');const control=await client.query('SELECT claiming_enabled,minimum_release FROM email_worker_control WHERE environment=$1 FOR SHARE',[this.environment]);if(!control.rows[0]?.claiming_enabled||control.rows[0].minimum_release&&this.release!==control.rows[0].minimum_release){await client.query('COMMIT');return null;} + const selected=await client.query(`SELECT id,status,attempt_count,created_at FROM survey_email_deliveries WHERE ((status IN ('pending','retry_wait') AND next_attempt_at<=now()) OR (status='leased' AND lease_expires_at<=now())) ORDER BY next_attempt_at,created_at FOR UPDATE SKIP LOCKED LIMIT 1`); + if(!selected.rows[0]){await client.query('COMMIT');return null;}const previous=selected.rows[0]; + if(previous.status==='leased'){ + const boundary=await client.query(`SELECT MIN(provider_started_at) AS first_provider_started_at,COUNT(provider_started_at)::int AS provider_attempt_count FROM survey_email_attempts WHERE delivery_id=$1 AND outcome IN ('in_progress','uncertain')`,[previous.id]); + const crossed=Number(boundary.rows[0]?.provider_attempt_count||0)>0; + const outsideWindow=isOutsideProviderIdempotencyWindow(boundary.rows[0]?.first_provider_started_at,this.clock(),this.idempotencyHours); + const exhausted=!canRetryAmbiguous({firstProviderStartedAt:boundary.rows[0]?.first_provider_started_at,providerAttemptCount:boundary.rows[0]?.provider_attempt_count,createdAt:previous.created_at,now:this.clock(),idempotencyHours:this.idempotencyHours,maxAttempts:this.maxAttempts,maxAgeHours:this.maxAgeHours}); + await client.query(`UPDATE survey_email_attempts SET finished_at=COALESCE(finished_at,now()),outcome=CASE WHEN outcome='in_progress' THEN $2 ELSE outcome END,error_message=CASE WHEN outcome='in_progress' THEN $3 ELSE error_message END WHERE delivery_id=$1 AND outcome='in_progress'`,[previous.id,crossed?'uncertain':'cancelled',crossed?'lease expired after provider boundary':'lease expired before provider boundary']); + if(crossed&&(outsideWindow||exhausted)){ + await client.query(`UPDATE survey_email_deliveries SET status='uncertain',dispatch_failed_at=now(),lease_owner=NULL,lease_token=NULL,lease_expires_at=NULL,updated_at=now(),last_error_code=$2,last_error_message='Provider acceptance could not be reconciled safely' WHERE id=$1`,[previous.id,outsideWindow?'idempotency_window_expired':'ambiguous_attempts_exhausted']); + await client.query('COMMIT');return null; + } + } + const token=crypto.randomUUID();const updated=await client.query(`UPDATE survey_email_deliveries SET status='leased',lease_owner=$2,lease_token=$3,lease_expires_at=now()+($4::text||' seconds')::interval,attempt_count=attempt_count+1,updated_at=now() WHERE id=$1 RETURNING *`,[previous.id,this.instanceId,token,this.leaseSeconds]);const delivery=updated.rows[0]; + await client.query(`INSERT INTO survey_email_attempts(delivery_id,attempt_number,lease_token,outcome) VALUES($1,$2,$3,'in_progress')`,[delivery.id,delivery.attempt_count,token]);await client.query('COMMIT');return delivery; + }catch(error){await client.query('ROLLBACK').catch(()=>{});throw error;}finally{client.release();}} + async startProviderRequest(delivery){const client=await this.pool.connect();let globalBoundaryLock=false;let surveyBoundaryLock=false;try{await client.query('BEGIN'); + await client.query(`SELECT pg_advisory_lock(hashtextextended($1,0))`,[`email-provider-boundary:${this.environment}`]);globalBoundaryLock=true; + const control=(await client.query(`SELECT claiming_enabled,minimum_release FROM email_worker_control WHERE environment=$1 FOR SHARE`,[this.environment])).rows[0]; + await client.query(`SELECT pg_advisory_lock(hashtextextended($1,0))`,[`survey-provider-boundary:${delivery.survey_id}`]);surveyBoundaryLock=true; + const survey=(await client.query(`SELECT id,name,lifecycle_status,archived_at FROM survey WHERE id=$1 FOR SHARE`,[delivery.survey_id])).rows[0]; + const result=await client.query(`SELECT d.*,r.uuid,t.body_text,l.cancelled_at FROM survey_email_deliveries d JOIN respondent r ON r.respondent_id=d.respondent_id AND r.survey_id=d.survey_id JOIN survey_launches l ON l.id=d.launch_id JOIN survey_launch_templates t ON t.launch_id=d.launch_id AND t.language=d.language WHERE d.id=$1 AND d.status='leased' AND d.lease_token=$2 FOR UPDATE OF d`,[delivery.id,delivery.lease_token]); + const row=result.rows[0];if(!row||!survey){await client.query('COMMIT');return {action:'stale'};} + row.survey_name=survey.name; + if(!control?.claiming_enabled||(control.minimum_release&&this.release!==control.minimum_release)){await this.releaseWithoutSend(client,row,'worker_disabled_before_send');await client.query('COMMIT');return {action:'disabled'};} + if(row.cancellation_requested_at||row.cancelled_at||survey.lifecycle_status!=='active'||survey.archived_at){await this.cancel(client,row,'survey_inactive');await client.query('COMMIT');return {action:'cancelled'};} + const unresolved=await client.query(`SELECT MIN(provider_started_at) AS first_provider_started_at,COUNT(provider_started_at)::int AS provider_attempt_count FROM survey_email_attempts WHERE delivery_id=$1 AND outcome='uncertain'`,[row.id]); + const unresolvedBoundary=unresolved.rows[0]?.first_provider_started_at; + if(unresolvedBoundary&&!canRetryAmbiguous({firstProviderStartedAt:unresolvedBoundary,providerAttemptCount:unresolved.rows[0]?.provider_attempt_count,createdAt:row.created_at,now:this.clock(),idempotencyHours:this.idempotencyHours,maxAttempts:this.maxAttempts,maxAgeHours:this.maxAgeHours})){await this.finalizeTerminal(client,row,'uncertain','ambiguous_retry_window_exhausted','Provider acceptance could not be reconciled safely before retry');await client.query('COMMIT');return {action:'uncertain'};} + const payload=buildInvitationPayload({to:row.to_address,sender:row.sender,subject:row.subject,bodyText:row.body_text,surveyBaseUrl:row.survey_base_url,surveyName:survey.name,token:row.uuid,language:row.language}); + if(payloadHash(payload)!==row.expected_payload_hash){await this.finalizeTerminal(client,row,'uncertain','payload_hash_mismatch','Rendered provider payload no longer matches launch snapshot');await client.query('COMMIT');return {action:'mismatch'};} + const remaining=new Date(row.lease_expires_at).getTime()-this.clock().getTime()-2000; + if(remaining<=1000){await this.releaseWithoutSend(client,row,'lease_too_short_before_provider');await client.query('COMMIT');return {action:'short_lease'};} + if(!await reserveProviderRate(this.pool,this.rateBudgetEnvironment,this.rate)){await this.releaseWithoutSend(client,row,'provider_rate_wait');await client.query('COMMIT');return {action:'rate_wait'};} + await client.query(`UPDATE survey_email_attempts SET provider_started_at=now() WHERE delivery_id=$1 AND lease_token=$2 AND outcome='in_progress'`,[row.id,row.lease_token]); + await client.query('COMMIT'); + const providerResult=this.provider.send(payload,{idempotencyKey:delivery.provider_idempotency_key,timeoutMs:Math.min(Number(this.env.EMAIL_PROVIDER_TIMEOUT_MS||15000),remaining)}).then((result)=>({result}),(error)=>({error})); + return {action:'send',row,providerResult}; + }catch(error){await client.query('ROLLBACK').catch(()=>{});throw error;}finally{ + if(surveyBoundaryLock)await client.query(`SELECT pg_advisory_unlock(hashtextextended($1,0))`,[`survey-provider-boundary:${delivery.survey_id}`]).catch(()=>{}); + if(globalBoundaryLock)await client.query(`SELECT pg_advisory_unlock(hashtextextended($1,0))`,[`email-provider-boundary:${this.environment}`]).catch(()=>{}); + client.release(); + }} + async releaseWithoutSend(client,row,reason){await client.query(`UPDATE survey_email_attempts SET outcome='cancelled',finished_at=now(),error_message=$3 WHERE delivery_id=$1 AND lease_token=$2 AND outcome='in_progress'`,[row.id,row.lease_token,reason]);await client.query(`UPDATE survey_email_deliveries SET status='pending',lease_owner=NULL,lease_token=NULL,lease_expires_at=NULL,next_attempt_at=CASE WHEN $3='provider_rate_wait' THEN now()+interval '100 milliseconds' ELSE now() END,updated_at=now(),last_error_code=$3 WHERE id=$1 AND status='leased' AND lease_token=$2`,[row.id,row.lease_token,reason]);} + async cancel(client,row,reason){await client.query(`UPDATE survey_email_attempts SET outcome='cancelled',finished_at=now(),error_message=$3 WHERE delivery_id=$1 AND lease_token=$2 AND outcome='in_progress'`,[row.id,row.lease_token,reason]);await client.query(`UPDATE survey_email_deliveries SET status='cancelled',lease_owner=NULL,lease_token=NULL,lease_expires_at=NULL,updated_at=now(),last_error_code=$3 WHERE id=$1 AND status='leased' AND lease_token=$2`,[row.id,row.lease_token,reason]);} + async finalizeTerminal(client,row,status,code,message,providerId=null){const outcome=status==='failed'?'permanent_failure':status;await client.query(`UPDATE survey_email_attempts SET outcome=$3,finished_at=now(),provider_code=$4,error_message=$5,provider_message_id=$6 WHERE delivery_id=$1 AND lease_token=$2 AND outcome='in_progress'`,[row.id,row.lease_token,outcome,code,bounded(message),providerId]);return client.query(`UPDATE survey_email_deliveries SET status=$3,provider_message_id=COALESCE($6,provider_message_id),dispatch_accepted_at=CASE WHEN $3='accepted' THEN now() ELSE dispatch_accepted_at END,dispatch_failed_at=CASE WHEN $3 IN ('failed','uncertain') THEN now() ELSE dispatch_failed_at END,last_error_code=$4,last_error_message=$5,lease_owner=NULL,lease_token=NULL,lease_expires_at=NULL,updated_at=now() WHERE id=$1 AND status='leased' AND lease_token=$2`,[row.id,row.lease_token,status,code,bounded(message),providerId]);} + backoff(attempt,retryAfter){const seconds=Number.parseInt(retryAfter,10);if(Number.isFinite(seconds)&&seconds>0)return Math.min(seconds*1000,3600000);const cap=Math.min(3600000,1000*(2**Math.min(attempt,12)));return Math.floor(this.random()*cap);} + async finalizeFailure(row,error){const classification=classifyProviderError(error);const client=await this.pool.connect();try{await client.query('BEGIN');if(classification==='quota'){await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`,[`email-provider-boundary:${this.environment}`]);await client.query(`UPDATE email_worker_control SET claiming_enabled=false,updated_at=now(),reason='provider quota requires operator action' WHERE environment=$1`,[this.environment]);}const locked=await client.query(`SELECT * FROM survey_email_deliveries WHERE id=$1 AND status='leased' AND lease_token=$2 FOR UPDATE`,[row.id,row.lease_token]);const current=locked.rows[0];if(!current){await client.query('COMMIT');return;}const providerStats=(await client.query(`SELECT COUNT(provider_started_at)::int AS provider_attempt_count,MIN(provider_started_at) AS first_provider_started_at FROM survey_email_attempts WHERE delivery_id=$1`,[current.id])).rows[0];if(current.cancellation_requested_at){await this.cancel(client,current,'cancelled_after_provider_failure');} + else if(classification==='ambiguous'){ + const retryAllowed=canRetryAmbiguous({firstProviderStartedAt:providerStats.first_provider_started_at,providerAttemptCount:providerStats.provider_attempt_count,createdAt:current.created_at,now:this.clock(),idempotencyHours:this.idempotencyHours,maxAttempts:this.maxAttempts,maxAgeHours:this.maxAgeHours}); + if(retryAllowed){const delay=this.backoff(current.attempt_count,error.retryAfter);await client.query(`UPDATE survey_email_attempts SET outcome='uncertain',finished_at=now(),provider_code=$3,error_message=$4 WHERE delivery_id=$1 AND lease_token=$2 AND outcome='in_progress'`,[current.id,current.lease_token,error.code,bounded(error.message)]);await client.query(`UPDATE survey_email_deliveries SET status='retry_wait',next_attempt_at=now()+($3::text||' milliseconds')::interval,lease_owner=NULL,lease_token=NULL,lease_expires_at=NULL,last_error_code=$4,last_error_message=$5,updated_at=now() WHERE id=$1 AND status='leased' AND lease_token=$2`,[current.id,current.lease_token,delay,error.code,bounded(error.message)]);} + else await this.finalizeTerminal(client,current,'uncertain',error.code||'ambiguous_provider_result',error.message); + } + else if(classification==='quota'){await client.query(`UPDATE survey_email_attempts SET outcome='transient_failure',finished_at=now(),provider_code=$3,error_message=$4 WHERE delivery_id=$1 AND lease_token=$2 AND outcome='in_progress'`,[current.id,current.lease_token,error.code,bounded(error.message)]);await client.query(`UPDATE survey_email_deliveries SET status='retry_wait',next_attempt_at=now()+interval '1 hour',lease_owner=NULL,lease_token=NULL,lease_expires_at=NULL,last_error_code=$3,last_error_message=$4,updated_at=now() WHERE id=$1 AND status='leased' AND lease_token=$2`,[current.id,current.lease_token,error.code,bounded(error.message)]);} + else if(classification==='transient'&&Number(providerStats.provider_attempt_count)this.clock().getTime()-this.maxAgeHours*3600000){const delay=this.backoff(current.attempt_count,error.retryAfter);await client.query(`UPDATE survey_email_attempts SET outcome='transient_failure',finished_at=now(),provider_code=$3,error_message=$4 WHERE delivery_id=$1 AND lease_token=$2 AND outcome='in_progress'`,[current.id,current.lease_token,error.code,bounded(error.message)]);await client.query(`UPDATE survey_email_deliveries SET status='retry_wait',next_attempt_at=now()+($3::text||' milliseconds')::interval,lease_owner=NULL,lease_token=NULL,lease_expires_at=NULL,last_error_code=$4,last_error_message=$5,updated_at=now() WHERE id=$1 AND status='leased' AND lease_token=$2`,[current.id,current.lease_token,delay,error.code,bounded(error.message)]);} + else await this.finalizeTerminal(client,current,'failed',error.code||'provider_error',error.message);await client.query('COMMIT');}catch(e){await client.query('ROLLBACK').catch(()=>{});throw e;}finally{client.release();}} + async finalizeAccepted(row,providerId){const client=await this.pool.connect();try{await client.query('BEGIN');const result=await this.finalizeTerminal(client,row,'accepted',null,null,providerId);if(result.rowCount)await client.query(`UPDATE respondent SET email_sent=true WHERE respondent_id=$1 AND survey_id=$2`,[row.respondent_id,row.survey_id]);await client.query('COMMIT');}catch(error){await client.query('ROLLBACK').catch(()=>{});throw error;}finally{client.release();}} + async processOne(){const delivery=await this.claim();if(!delivery)return false;const started=await this.startProviderRequest(delivery);if(started.action!=='send')return true;const outcome=await started.providerResult;if(outcome.error)await this.finalizeFailure(started.row,outcome.error);else await this.finalizeAccepted(started.row,outcome.result.id);return true;} + async run(){const heartbeatMs=Math.max(3000,Number(this.env.EMAIL_HEARTBEAT_MS||10000));const timer=setInterval(()=>this.heartbeat().catch((e)=>{this.lastError=e.message;}),heartbeatMs);try{while(!this.stopped){this.claiming=await this.control();await this.heartbeat();if(!this.claiming){await this.sleep(1000);continue;}const didWork=await this.processOne();if(!didWork)await this.sleep(Number(this.env.EMAIL_IDLE_MS||750));}}finally{clearInterval(timer);this.claiming=false;await this.heartbeat().catch(()=>{});}} + stop(){this.stopped=true;} +} + +async function main(){const pool=createPool();const provider=new ResendProvider({apiKey:process.env.RESEND_API_KEY||process.env.RESEND_KEY,timeoutMs:Number(process.env.EMAIL_PROVIDER_TIMEOUT_MS||15000)});const worker=new DeliveryWorker({pool,provider});const stop=()=>worker.stop();process.on('SIGTERM',stop);process.on('SIGINT',stop);try{await worker.run();}finally{await pool.end();}} +if(require.main===module)main().catch((error)=>{console.error('Email worker failed:',bounded(error.message));process.exit(1);}); +module.exports={DeliveryWorker,createPool,isOutsideProviderIdempotencyWindow,canRetryAmbiguous}; diff --git a/api/email.js b/api/email.js new file mode 100644 index 0000000..8f51834 --- /dev/null +++ b/api/email.js @@ -0,0 +1,155 @@ +'use strict'; + +const crypto = require('crypto'); + +const DEFAULT_SENDER = 'CLA Survey '; +const RENDERER_VERSION = 'survey-invitation-v1'; + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (character) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[character]); +} + +function normalizeTemplateText(value) { + return String(value ?? '').replace(/\r\n?/g, '\n').trim(); +} + +function buildSurveyLink(baseUrl, surveyName, token) { + if (!baseUrl) throw new Error('Survey base URL is required'); + const url = new URL(baseUrl); + url.searchParams.set('surveyName', surveyName); + url.searchParams.set('userId', token); + return url.toString(); +} + +function documentLanguage(language) { + const normalized = String(language || 'en').trim().toLowerCase(); + const codes = { + english: 'en', spanish: 'es', french: 'fr', german: 'de', italian: 'it', + portuguese: 'pt', dutch: 'nl', polish: 'pl', russian: 'ru', japanese: 'ja', + chinese: 'zh', korean: 'ko', + }; + return codes[normalized] || (/^[a-z]{2}(?:-[a-z0-9]{2,8})*$/i.test(normalized) ? normalized : 'en'); +} + +function renderInvitation({ bodyText, link, language = 'en' }) { + const normalized = normalizeTemplateText(bodyText); + const lang = documentLanguage(language); + const paragraphs = normalized.split(/\n{2,}/).map((paragraph) => + `

${escapeHtml(paragraph).replace(/\n/g, '
')}

` + ).join(''); + const safeLink = escapeHtml(link); + const html = `CLA Network Survey
Contemporary Leadership Advisors

CLA Network Survey

${paragraphs}

Open your CLA Network Survey

This invitation link is unique to you. Please do not forward it.

For privacy questions or help, contact your survey administrator or survey@cladvisors.com.

— The CLA team


Contemporary Leadership Advisors, 299 Park Ave, New York, NY 10171

`; + const text = `CLA Network Survey\n\n${normalized}\n\nOpen your CLA Network Survey:\n${link}\n\nThis invitation link is unique to you. Please do not forward it.\n\nFor privacy questions or help, contact your survey administrator or survey@cladvisors.com.\n\n— The CLA team\nContemporary Leadership Advisors, 299 Park Ave, New York, NY 10171`; + return { html, text }; +} + +function buildInvitationPayload({ to, sender = DEFAULT_SENDER, subject = 'CLA Network Survey', bodyText, surveyBaseUrl, surveyName, token, language }) { + const link = buildSurveyLink(surveyBaseUrl, surveyName, token); + const rendered = renderInvitation({ bodyText, link, language }); + return { from: sender, to, subject, html: rendered.html, text: rendered.text }; +} + +function payloadHash(payload) { + return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); +} + +function sanitizeProviderMessage(value) { + return String(value || 'Email provider request failed').replace(/[\r\n\t]+/g, ' ').slice(0, 500); +} + +class ProviderError extends Error { + constructor(message, { code = 'provider_error', status, retryAfter, uncertain = false } = {}) { + super(sanitizeProviderMessage(message)); + this.name = 'ProviderError'; + this.code = String(code).slice(0, 100); + this.status = status; + this.retryAfter = retryAfter; + this.uncertain = uncertain; + } +} + +class ResendProvider { + constructor({ apiKey, fetchImpl = global.fetch, endpoint = 'https://api.resend.com/emails', timeoutMs = 15000 } = {}) { + if (!apiKey) throw new Error('Resend API key is required'); + if (!fetchImpl) throw new Error('A fetch implementation is required'); + this.apiKey = apiKey; + this.fetchImpl = fetchImpl; + this.endpoint = endpoint; + this.timeoutMs = timeoutMs; + } + + async send(payload, { idempotencyKey, timeoutMs = this.timeoutMs } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + let response; + try { + response = await this.fetchImpl(this.endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + } catch (error) { + const cause = error?.cause; + const detail = [error?.message, cause?.message, cause?.code].filter(Boolean).join(' '); + // fetch rejections cannot prove whether bytes crossed the provider boundary. + throw new ProviderError(error?.name === 'AbortError' ? 'Provider request timed out' : detail || 'Provider network request failed', { + code: error?.name === 'AbortError' ? 'timeout' : String(cause?.code || 'network_error').toLowerCase(), + uncertain: true, + }); + } finally { + clearTimeout(timeout); + } + + let result = {}; + try { result = await response.json(); } catch { /* sanitized generic error below */ } + if (!response.ok || result?.error) { + const providerError = result?.error || result; + throw new ProviderError(providerError?.message || `Provider returned HTTP ${response.status}`, { + code: providerError?.name || providerError?.code || `http_${response.status}`, + status: response.status, + retryAfter: response.headers?.get?.('retry-after') || null, + }); + } + if (!result?.id) throw new ProviderError('Provider response did not include a message ID', { code: 'missing_provider_id', uncertain: true }); + return { id: result.id }; + } +} + +async function reserveProviderRate(pool, environment, rate) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, [`email-rate-budget:${environment}`]); + await client.query(`DELETE FROM email_rate_reservations WHERE environment=$1 AND reserved_atclock_timestamp()-interval '1 second'`, [environment]); + if (Number(used.rows[0]?.count || 0) >= rate) { + await client.query('COMMIT'); + return false; + } + await client.query(`INSERT INTO email_rate_reservations(environment,reserved_at) VALUES($1,clock_timestamp())`, [environment]); + await client.query('COMMIT'); + return true; + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + throw error; + } finally { + client.release(); + } +} + +function classifyProviderError(error) { + const status = Number(error?.status || 0); + if (error?.uncertain || error?.code === 'concurrent_idempotent_requests' || status >= 500) return 'ambiguous'; + if (/quota|plan_limit/i.test(String(error?.code || ''))) return 'quota'; + if (status === 429 || ['network_error', 'timeout'].includes(error?.code)) return 'transient'; + return 'permanent'; +} + +module.exports = { DEFAULT_SENDER, RENDERER_VERSION, escapeHtml, normalizeTemplateText, documentLanguage, buildSurveyLink, renderInvitation, buildInvitationPayload, payloadHash, ResendProvider, ProviderError, classifyProviderError, sanitizeProviderMessage, reserveProviderRate }; diff --git a/api/lifecycle.js b/api/lifecycle.js new file mode 100644 index 0000000..63ce9d9 --- /dev/null +++ b/api/lifecycle.js @@ -0,0 +1,261 @@ +'use strict'; + +const crypto = require('crypto'); +const { DEFAULT_SENDER, RENDERER_VERSION, normalizeTemplateText, buildInvitationPayload, payloadHash } = require('./email'); + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MAX_LAUNCH_RECIPIENTS = 1000; +const MAX_LAUNCH_TEMPLATES = 100; +const ROLE_RANK = { viewer: 10, analyst: 20, editor: 30, admin: 40, owner: 50 }; +let authoritativeSurveyValidator = null; +const setSurveyDefinitionValidator = (validator) => { authoritativeSurveyValidator = validator; }; +const SUPPORTED_LANGUAGES = new Set([ + 'english', 'spanish', 'french', 'german', 'italian', 'portuguese', + 'dutch', 'polish', 'russian', 'japanese', 'chinese', 'korean', +]); + +class LifecycleError extends Error { + constructor(status, code, message, details) { super(message); this.status = status; this.code = code; this.details = details; } +} + +function environmentName(env = process.env) { + const value = env.EMAIL_WORKER_ENV || env.NODE_ENV || 'local'; + return value === 'production' ? 'prod' : value === 'development' || value === 'dev' ? 'local' : value; +} +function normalizeLanguage(value) { return String(value || '').trim().toLowerCase(); } +function fingerprint(input) { return crypto.createHash('sha256').update(JSON.stringify(input)).digest('hex'); } +function canRole(role, minimum) { return (ROLE_RANK[role] || 0) >= ROLE_RANK[minimum]; } +function publicError(error) { return { error: error.code || 'internal_error', message: error.message, ...(error.details ? { details: error.details } : {}) }; } + +async function strictAudit(client, { organizationId, actorUserId, surveyId, eventType, metadata = {} }) { + await client.query(`INSERT INTO audit_events(organization_id,actor_user_id,survey_id,event_type,metadata) VALUES($1,$2,$3,$4,$5::jsonb)`, + [organizationId, actorUserId, surveyId, eventType, JSON.stringify(metadata)]); +} + +async function loadAuthorizedSurvey(client, user, surveyId, minimumRole, lock = '') { + if (!UUID_RE.test(String(surveyId || ''))) throw new LifecycleError(404, 'survey_not_found', 'Survey not found.'); + const result = await client.query( + `SELECT s.*, om.role FROM survey s LEFT JOIN organization_memberships om ON om.organization_id=s.organization_id AND om.user_id=$1 WHERE s.id=$2 ${lock ? `FOR ${lock} OF s` : ''}`, + [user.id, surveyId]); + const survey = result.rows[0]; + const role = user.isPlatformAdmin || user.is_platform_admin ? 'owner' : survey?.role; + if (!survey || !canRole(role, minimumRole)) throw new LifecycleError(404, 'survey_not_found', 'Survey not found.'); + return { ...survey, role }; +} + +async function loadReadinessData(client, survey) { + // A pg Client executes one query at a time. Keep this sequential so launch + // transactions remain compatible with pg 9 rather than relying on the + // deprecated behavior of queueing concurrent client.query() calls. + const recipientResult = await client.query( + `SELECT respondent_id,name,contact_info,uuid,lang FROM respondent WHERE survey_id=$1 AND can_respond=true ORDER BY respondent_id LIMIT ${MAX_LAUNCH_RECIPIENTS + 1}`, + [survey.id] + ); + const templateResult = await client.query( + `SELECT lang,text FROM email WHERE survey_id=$1 ORDER BY lang LIMIT ${MAX_LAUNCH_TEMPLATES + 1}`, + [survey.id] + ); + const excludedResult = await client.query( + `SELECT count(*)::int AS count FROM respondent WHERE survey_id=$1 AND can_respond IS NOT TRUE`, + [survey.id] + ); + return { recipients: recipientResult.rows, templates: templateResult.rows, excludedCount: Number(excludedResult.rows[0]?.count || 0) }; +} + +function evaluateReadiness(survey, data, config = process.env) { + const blockers = []; + const warnings = []; + if (survey.archived_at) blockers.push({ code: 'survey_archived', message: 'The survey is archived.' }); + if (survey.lifecycle_status !== 'draft') blockers.push({ code: 'survey_not_draft', message: 'Only a draft survey can be launched.' }); + if (!Array.isArray(survey.questions?.elements) || survey.questions.elements.length === 0) blockers.push({ code: 'questions_missing', message: 'At least one survey question is required.' }); + else if (authoritativeSurveyValidator) { + try { authoritativeSurveyValidator(survey.questions); } + catch (error) { blockers.push({ code: 'questions_invalid', message: error.message || 'Survey questions are invalid.' }); } + } else if (survey.questions.elements.some((question) => !question || typeof question !== 'object' || !String(question.name || '').trim() || !String(question.type || '').trim())) blockers.push({ code: 'questions_invalid', message: 'Every survey question requires a stable name and type.' }); + if (data.recipients.length === 0) blockers.push({ code: 'recipients_missing', message: 'At least one eligible respondent is required.' }); + if (data.recipients.length > MAX_LAUNCH_RECIPIENTS) blockers.push({ code: 'recipients_limit_exceeded', message: `A launch may contain at most ${MAX_LAUNCH_RECIPIENTS} eligible respondents.` }); + if (data.templates.length > MAX_LAUNCH_TEMPLATES) blockers.push({ code: 'templates_limit_exceeded', message: `A survey may contain at most ${MAX_LAUNCH_TEMPLATES} notification templates.` }); + + const languages = new Set(); + const addresses = new Map(); + const excludedCount = Number(data.excludedCount || 0); + for (const recipient of data.recipients) { + const language = normalizeLanguage(recipient.lang); + const address = String(recipient.contact_info || '').trim().toLowerCase(); + if (!EMAIL_RE.test(address)) blockers.push({ code: 'recipient_email_invalid', respondentId: recipient.respondent_id, message: 'An eligible respondent has an invalid email address.' }); + if (!recipient.uuid) blockers.push({ code: 'recipient_token_missing', respondentId: recipient.respondent_id, message: 'An eligible respondent has no invitation token.' }); + if (!language) blockers.push({ code: 'recipient_language_missing', respondentId: recipient.respondent_id, message: 'An eligible respondent has no language.' }); + else if (!SUPPORTED_LANGUAGES.has(language)) blockers.push({ code: 'recipient_language_unsupported', respondentId: recipient.respondent_id, language, message: `An eligible respondent uses unsupported language ${language}.` }); + else languages.add(language); + if (addresses.has(address)) blockers.push({ code: 'recipient_email_duplicate', respondentId: recipient.respondent_id, message: 'Eligible respondent email addresses must be unique.' }); + addresses.set(address, recipient.respondent_id); + } + const templateMap = new Map(); + const templateCounts = new Map(); + for (const template of data.templates) { + const language = normalizeLanguage(template.lang); + if (!language) continue; + templateCounts.set(language, (templateCounts.get(language) || 0) + 1); + if (templateCounts.get(language) > 1) blockers.push({ code: 'template_duplicate', language, message: `More than one ${language} template is configured.` }); + const text = normalizeTemplateText(template.text); + if (text) templateMap.set(language, text); + } + for (const language of languages) if (!templateMap.has(language)) blockers.push({ code: 'template_missing', language, message: `A nonempty ${language} template is required.` }); + if (!config.SURVEY_URL) blockers.push({ code: 'survey_url_missing', message: 'Survey URL is not configured.' }); + if (!(config.RESEND_API_KEY || config.RESEND_KEY)) blockers.push({ code: 'provider_key_missing', message: 'Email provider is not configured.' }); + if (!(config.SURVEY_EMAIL_SENDER || DEFAULT_SENDER)) blockers.push({ code: 'sender_missing', message: 'Survey sender is not configured.' }); + const blockerCount = blockers.length; + const publicBlockers = blockers.slice(0, 100); + if (blockerCount > publicBlockers.length) publicBlockers.push({ code: 'blockers_truncated', message: `${blockerCount - publicBlockers.length} additional readiness blockers were omitted.` }); + const sortedLanguages = [...languages].sort(); + return { + lifecycleStatus: survey.lifecycle_status, + archived: Boolean(survey.archived_at), + eligibleCount: data.recipients.length, + excludedCount, + languages: sortedLanguages, + templateLanguages: [...templateMap.keys()].sort(), + templateCoverage: sortedLanguages.map((language) => ({ language, covered: templateMap.has(language) })), + blockers: publicBlockers, + blockerCount, + warnings, + canLaunch: blockerCount === 0, + templateMap, + }; +} + +async function getReadiness(pool, user, surveyId, config = process.env) { + const client = await pool.connect(); + try { + const survey = await loadAuthorizedSurvey(client, user, surveyId, 'editor'); + const data = await loadReadinessData(client, survey); + const readiness = evaluateReadiness(survey, data, config); + const env = environmentName(config); + const maxAge = Math.max(5, Number(config.EMAIL_WORKER_HEARTBEAT_MAX_AGE_SECONDS || 45)); + const worker = await client.query(`SELECT 1 FROM email_worker_control c WHERE c.environment=$1 AND c.claiming_enabled=true AND EXISTS(SELECT 1 FROM email_worker_heartbeats h WHERE h.environment=c.environment AND h.enabled=true AND h.claiming=true AND h.heartbeat_at>now()-($2::text||' seconds')::interval AND (c.minimum_release='' OR h.release_revision=c.minimum_release))`, [env,maxAge]); + if (!worker.rowCount) readiness.blockers.push({code:'worker_unavailable',message:'No fresh compatible email worker is available.'}); + readiness.canLaunch = readiness.blockers.length === 0; + return readiness; + } finally { client.release(); } +} + +function aggregateSelect(whereSql) { + return `SELECT l.id,l.survey_id,l.kind,l.parent_launch_id,l.created_at,l.cancelled_at, + count(DISTINCT d.id)::int AS target_count, + count(DISTINCT d.id) FILTER(WHERE d.status='pending')::int AS pending_count, + count(DISTINCT d.id) FILTER(WHERE d.status='leased')::int AS leased_count, + count(DISTINCT d.id) FILTER(WHERE d.status='retry_wait')::int AS retry_wait_count, + count(DISTINCT d.id) FILTER(WHERE d.status='accepted')::int AS accepted_count, + count(DISTINCT d.id) FILTER(WHERE d.status='failed')::int AS failed_count, + count(DISTINCT d.id) FILTER(WHERE d.status='uncertain')::int AS uncertain_count, + count(DISTINCT d.id) FILTER(WHERE d.status='cancelled')::int AS cancelled_count, + min(a.started_at) AS started_at,max(a.finished_at) FILTER(WHERE d.status IN ('accepted','failed','uncertain','cancelled')) AS finished_at, + CASE + WHEN count(DISTINCT d.id)>0 AND count(DISTINCT d.id) FILTER(WHERE d.status='pending')=count(DISTINCT d.id) AND count(a.id)=0 THEN 'queued' + WHEN count(DISTINCT d.id) FILTER(WHERE d.status IN ('pending','leased','retry_wait'))>0 THEN 'processing' + WHEN count(DISTINCT d.id)>0 AND count(DISTINCT d.id) FILTER(WHERE d.status='cancelled')=count(DISTINCT d.id) THEN 'cancelled' + WHEN count(DISTINCT d.id)>0 AND count(DISTINCT d.id) FILTER(WHERE d.status='accepted')=count(DISTINCT d.id) THEN 'completed' + WHEN count(DISTINCT d.id)>0 AND count(DISTINCT d.id) FILTER(WHERE d.status='accepted')=0 AND count(DISTINCT d.id) FILTER(WHERE d.status IN ('failed','uncertain'))>0 THEN 'failed' + ELSE 'completed_with_errors' END AS status + FROM survey_launches l JOIN survey_email_deliveries d ON d.launch_id=l.id LEFT JOIN survey_email_attempts a ON a.delivery_id=d.id + ${whereSql} GROUP BY l.id ORDER BY l.created_at DESC`; +} + +async function launchSurvey(pool, user, surveyId, { kind = 'initial', idempotencyKey, legacy = false } = {}, config = process.env) { + if (config.SURVEY_DELIVERY_V2_ENABLED !== 'true') throw new LifecycleError(503, 'launch_disabled', 'Durable survey launch is not enabled.'); + if (kind !== 'initial') throw new LifecycleError(400, 'launch_kind_invalid', 'Only initial launches are available.'); + if (!legacy && !UUID_RE.test(String(idempotencyKey || ''))) throw new LifecycleError(400, 'idempotency_key_invalid', 'Idempotency-Key must be a UUID.'); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const env = environmentName(config); + const controlResult = await client.query('SELECT * FROM email_worker_control WHERE environment=$1 FOR SHARE', [env]); + if (!controlResult.rows[0]) throw new LifecycleError(503, 'worker_unavailable', 'Email worker control is not configured.'); + const survey = await loadAuthorizedSurvey(client, user, surveyId, 'editor', 'UPDATE'); + const data = await loadReadinessData(client, survey); + const targetIds = data.recipients.map((row) => Number(row.respondent_id)).sort((a,b) => a-b); + const requestFingerprint = fingerprint({ organizationId: survey.organization_id, surveyId: survey.id, kind, parentLaunchId: null, targetIds }); + const effectiveKey = legacy ? `initial/${survey.id}` : idempotencyKey; + const replay = await client.query('SELECT id,request_fingerprint FROM survey_launches WHERE organization_id=$1 AND idempotency_key=$2', [survey.organization_id, effectiveKey]); + if (replay.rows[0]) { + if (replay.rows[0].request_fingerprint !== requestFingerprint) throw new LifecycleError(409, 'idempotency_conflict', 'Idempotency-Key was already used for different launch inputs.'); + const result = await client.query(aggregateSelect('WHERE l.id=$1'), [replay.rows[0].id]); + await client.query('COMMIT'); + return { ...result.rows[0], lifecycleStatus: survey.lifecycle_status, replayed: true }; + } + const existingInitial = await client.query("SELECT id FROM survey_launches WHERE survey_id=$1 AND kind='initial'", [survey.id]); + if (existingInitial.rows[0]) { + const result = await client.query(aggregateSelect('WHERE l.id=$1'), [existingInitial.rows[0].id]); + await client.query('COMMIT'); + return { ...result.rows[0], lifecycleStatus: survey.lifecycle_status, replayed: true }; + } + const heartbeatSeconds = Math.max(5, Number(config.EMAIL_WORKER_HEARTBEAT_MAX_AGE_SECONDS || 45)); + const heartbeat = await client.query(`SELECT 1 FROM email_worker_heartbeats WHERE environment=$1 AND enabled=true AND claiming=true AND heartbeat_at > now()-($2::text||' seconds')::interval AND ($3='' OR release_revision = $3) LIMIT 1`, [env, heartbeatSeconds, controlResult.rows[0].minimum_release || '']); + if (!controlResult.rows[0].claiming_enabled || heartbeat.rowCount === 0) throw new LifecycleError(503, 'worker_unavailable', 'No fresh compatible email worker is available.'); + const readiness = evaluateReadiness(survey, data, config); + if (!readiness.canLaunch) throw new LifecycleError(422, 'survey_not_ready', 'Survey is not ready to launch.', readiness); + + const launchResult = await client.query(`INSERT INTO survey_launches(survey_id,organization_id,kind,idempotency_key,request_fingerprint,requested_by_user_id) VALUES($1,$2,'initial',$3,$4,$5) RETURNING id,created_at`, [survey.id,survey.organization_id,effectiveKey,requestFingerprint,user.id]); + const launch = launchResult.rows[0]; + const sender = config.SURVEY_EMAIL_SENDER || DEFAULT_SENDER; + const subject = 'CLA Network Survey'; + for (const [language, bodyText] of readiness.templateMap) { + await client.query('INSERT INTO survey_launch_templates(launch_id,language,subject,body_text,template_hash) VALUES($1,$2,$3,$4,$5)', [launch.id,language,subject,bodyText,fingerprint(bodyText)]); + } + for (const recipient of data.recipients) { + const language = normalizeLanguage(recipient.lang); + const bodyText = readiness.templateMap.get(language); + const payload = buildInvitationPayload({ to:String(recipient.contact_info).trim().toLowerCase(),sender,subject,bodyText,surveyBaseUrl:config.SURVEY_URL,surveyName:survey.name,token:recipient.uuid,language }); + const deliveryId = crypto.randomUUID(); + await client.query(`INSERT INTO survey_email_deliveries(id,launch_id,survey_id,organization_id,respondent_id,to_address,recipient_display_name,language,sender,subject,template_hash,survey_base_url,renderer_version,render_inputs,expected_payload_hash,provider_idempotency_key) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16)`, [deliveryId,launch.id,survey.id,survey.organization_id,recipient.respondent_id,String(recipient.contact_info).trim().toLowerCase(),recipient.name,language,sender,subject,fingerprint(bodyText),config.SURVEY_URL,RENDERER_VERSION,JSON.stringify({surveyName:survey.name}),payloadHash(payload),`survey-delivery-${deliveryId}`]); + } + await client.query(`UPDATE survey SET lifecycle_status='active',started_at=now(),started_by_user_id=$1,closed_at=NULL,closed_by_user_id=NULL,lifecycle_version=lifecycle_version+1 WHERE id=$2`, [user.id,survey.id]); + await strictAudit(client,{organizationId:survey.organization_id,actorUserId:user.id,surveyId:survey.id,eventType:'survey.launch_requested',metadata:{launchId:launch.id,targetCount:data.recipients.length}}); + await strictAudit(client,{organizationId:survey.organization_id,actorUserId:user.id,surveyId:survey.id,eventType:'survey.lifecycle_changed',metadata:{from:'draft',to:'active',launchId:launch.id}}); + await client.query('COMMIT'); + return { id:launch.id,survey_id:survey.id,kind,status:'queued',target_count:data.recipients.length,pending_count:data.recipients.length,leased_count:0,retry_wait_count:0,accepted_count:0,failed_count:0,uncertain_count:0,cancelled_count:0,created_at:launch.created_at,lifecycleStatus:'active',replayed:false }; + } catch (error) { await client.query('ROLLBACK').catch(()=>{}); if (error.code === '23505') throw new LifecycleError(409,'launch_conflict','An initial launch already exists.'); throw error; } + finally { client.release(); } +} + +async function listLaunches(pool,user,surveyId,launchId) { + const client=await pool.connect(); + try { await loadAuthorizedSurvey(client,user,surveyId,'viewer'); const params=[surveyId]; let where='WHERE l.survey_id=$1'; if(launchId){params.push(launchId);where+=' AND l.id=$2';} const result=await client.query(aggregateSelect(where),params); if(launchId&&!result.rows[0]) throw new LifecycleError(404,'launch_not_found','Launch not found.'); return launchId?result.rows[0]:result.rows; } finally { client.release(); } +} + +async function listDeliveries(pool,user,surveyId,{status,cursor,limit=50}={}) { + const client=await pool.connect(); + try { await loadAuthorizedSurvey(client,user,surveyId,'analyst'); const values=[surveyId]; const clauses=['d.survey_id=$1']; + if(status){values.push(status);clauses.push(`d.status=$${values.length}`);} if(cursor){values.push(cursor);clauses.push(`d.id < $${values.length}`);} values.push(Math.min(100,Math.max(1,Number(limit)||50))); + const result=await client.query(`SELECT d.id,d.launch_id,d.respondent_id,d.recipient_display_name,d.to_address,d.language,d.status,d.attempt_count,d.dispatch_accepted_at,d.dispatch_failed_at,d.last_error_code,d.last_error_message,d.created_at,d.updated_at,(SELECT max(started_at) FROM survey_email_attempts WHERE delivery_id=d.id) AS last_attempt_at FROM survey_email_deliveries d WHERE ${clauses.join(' AND ')} ORDER BY d.id DESC LIMIT $${values.length}`,values); + return {deliveries:result.rows,nextCursor:result.rows.length===values[values.length-1]?result.rows.at(-1).id:null}; + } finally {client.release();} +} + +async function transitionSurvey(pool,user,surveyId,action) { + const minimum=action==='reopen'?'admin':action==='archive'?'admin':'editor'; const client=await pool.connect(); + try { await client.query('BEGIN'); await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`,[`survey-provider-boundary:${surveyId}`]); const survey=await loadAuthorizedSurvey(client,user,surveyId,minimum,'UPDATE'); + if(action!=='archive'&&survey.archived_at) throw new LifecycleError(404,'survey_not_found','Survey not found.'); + if(action==='close'&&survey.lifecycle_status!=='active') throw new LifecycleError(409,'lifecycle_conflict','Only an active survey can be closed.'); + if(action==='reopen'&&survey.lifecycle_status!=='closed') throw new LifecycleError(409,'lifecycle_conflict','Only a closed survey can be reopened.'); + if(action==='archive'&&survey.archived_at) throw new LifecycleError(404,'survey_not_found','Survey not found.'); + if(action==='close'||action==='archive') { + await client.query(`UPDATE survey_email_deliveries SET status=CASE WHEN status IN ('pending','retry_wait') THEN 'cancelled' ELSE status END,cancellation_requested_at=CASE WHEN status='leased' THEN now() ELSE cancellation_requested_at END,updated_at=now(),last_error_code=CASE WHEN status IN ('pending','retry_wait') THEN $2 ELSE last_error_code END WHERE survey_id=$1 AND status IN ('pending','retry_wait','leased')`,[survey.id,action==='close'?'survey_closed':'survey_archived']); + await client.query('UPDATE survey_launches SET cancelled_at=COALESCE(cancelled_at,now()) WHERE survey_id=$1 AND cancelled_at IS NULL',[survey.id]); + } + let next; + if(action==='close'){next='closed';await client.query(`UPDATE survey SET lifecycle_status='closed',closed_at=now(),closed_by_user_id=$1,lifecycle_version=lifecycle_version+1 WHERE id=$2`,[user.id,survey.id]);} + else if(action==='reopen'){next='active';await client.query(`UPDATE survey SET lifecycle_status='active',closed_at=NULL,closed_by_user_id=NULL,lifecycle_version=lifecycle_version+1 WHERE id=$1`,[survey.id]);} + else {next=survey.lifecycle_status;await client.query(`UPDATE survey SET archived_at=now(),archived_by_user_id=$1,lifecycle_version=lifecycle_version+1 WHERE id=$2`,[user.id,survey.id]);} + await strictAudit(client,{organizationId:survey.organization_id,actorUserId:user.id,surveyId:survey.id,eventType:action==='archive'?'survey.archived':'survey.lifecycle_changed',metadata:action==='archive'?{lifecycleStatus:survey.lifecycle_status}:{from:survey.lifecycle_status,to:next}}); + await client.query('COMMIT'); return {surveyId:survey.id,lifecycleStatus:next,archived:action==='archive'}; + }catch(error){await client.query('ROLLBACK').catch(()=>{});throw error;}finally{client.release();} +} + +async function withEditableSurvey(pool,user,surveyId,mutation) { + const client=await pool.connect(); + try {await client.query('BEGIN');const survey=await loadAuthorizedSurvey(client,user,surveyId,'editor','UPDATE');if(survey.archived_at||survey.lifecycle_status!=='draft')throw new LifecycleError(409,'survey_not_editable','Survey configuration is locked after launch.');const value=await mutation(client,survey);await client.query('COMMIT');return value;}catch(error){await client.query('ROLLBACK').catch(()=>{});throw error;}finally{client.release();} +} + +module.exports={LifecycleError,publicError,environmentName,normalizeLanguage,fingerprint,strictAudit,loadAuthorizedSurvey,evaluateReadiness,getReadiness,launchSurvey,listLaunches,listDeliveries,transitionSurvey,withEditableSurvey,aggregateSelect,setSurveyDefinitionValidator}; diff --git a/api/package-lock.json b/api/package-lock.json index dd84782..653c33a 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -21,7 +21,7 @@ "nanoid": "^3.3.4", "papaparse": "^5.4.1", "pg": "^8.13.1", - "resend": "^0.16.0", + "resend": "6.18.1", "sqlite3": "^5.1.6", "survey-core": "2.5.35" }, @@ -41,6 +41,8 @@ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -58,6 +60,8 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" }, @@ -69,13 +73,17 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -93,6 +101,8 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-regex": "^6.2.2" }, @@ -163,7 +173,9 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", @@ -181,6 +193,7 @@ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=14" } @@ -190,6 +203,8 @@ "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.7.tgz", "integrity": "sha512-hMMhxk6TpOcDC5qnKzXPVJoVGEwfm+U5bGOPH+MyTTlx0F02RLQygcATBKsbP7aI/mvkmBAZoFbgPIHop7ovug==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "html-to-text": "9.0.3", "pretty": "2.0.0", @@ -205,6 +220,8 @@ "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.10.0.tgz", "integrity": "sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.10.0" @@ -213,6 +230,12 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -334,6 +357,8 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" }, @@ -388,19 +413,9 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, - "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -595,6 +610,8 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -606,7 +623,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/color-support": { "version": "1.1.3", @@ -620,6 +639,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -633,6 +653,8 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=14" } @@ -657,6 +679,8 @@ "resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz", "integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", @@ -671,6 +695,8 @@ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" @@ -748,6 +774,8 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -770,6 +798,8 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -778,6 +808,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -829,6 +860,8 @@ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -848,13 +881,17 @@ "url": "https://github.com/sponsors/fb55" } ], - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "optional": true, + "peer": true }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "license": "BSD-2-Clause", + "optional": true, + "peer": true, "dependencies": { "domelementtype": "^2.3.0" }, @@ -870,6 +907,8 @@ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "license": "BSD-2-Clause", + "optional": true, + "peer": true, "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -920,13 +959,17 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/editorconfig": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", @@ -945,6 +988,8 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -954,6 +999,8 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^2.0.2" }, @@ -969,6 +1016,8 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", + "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -1020,6 +1069,8 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -1075,6 +1126,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1198,6 +1250,8 @@ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "is-extendable": "^0.1.0" }, @@ -1212,6 +1266,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1242,31 +1302,13 @@ "node": ">= 0.8" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" @@ -1283,6 +1325,8 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">=14" }, @@ -1294,6 +1338,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -1516,6 +1561,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -1548,6 +1594,8 @@ "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.3.tgz", "integrity": "sha512-hxDF1kVCF2uw4VUJ3vr2doc91pXf2D5ngKcNviSitNkhP9OMOaJkDrFIFL6RMvko7NisWTEiqGpQ9LAxcVok1w==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@selderee/plugin-htmlparser2": "^0.10.0", "deepmerge": "^4.2.2", @@ -1571,6 +1619,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", @@ -1738,7 +1788,9 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/ip": { "version": "2.0.0", @@ -1780,13 +1832,17 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1843,6 +1899,8 @@ "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz", "integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1850,13 +1908,16 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -1872,6 +1933,8 @@ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", @@ -1893,6 +1956,8 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -1902,6 +1967,8 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -1912,6 +1979,8 @@ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -1932,6 +2001,8 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^2.0.2" }, @@ -1947,6 +2018,8 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -1956,6 +2029,8 @@ "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "abbrev": "^2.0.0" }, @@ -1971,6 +2046,8 @@ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=14" } @@ -1979,13 +2056,17 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "is-buffer": "^1.1.5" }, @@ -1998,6 +2079,8 @@ "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://ko-fi.com/killymxi" } @@ -2007,6 +2090,8 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -2523,7 +2608,9 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true }, "node_modules/papaparse": { "version": "5.4.1", @@ -2535,6 +2622,8 @@ "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.11.0.tgz", "integrity": "sha512-VfcwXlBWgTF+unPcr7yu3HSSA6QUdDaDnrHcytVfj5Z8azAyKBDrYnSIfeSxlrEayndNcLmrXzg+Vxbo6DWRXQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "leac": "^0.6.0", "peberminta": "^0.8.0" @@ -2564,6 +2653,8 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -2573,6 +2664,8 @@ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -2588,13 +2681,17 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/path-scurry/node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -2609,6 +2706,8 @@ "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.8.0.tgz", "integrity": "sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://ko-fi.com/killymxi" } @@ -2707,6 +2806,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==", + "license": "MIT-0" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -2747,6 +2852,8 @@ "resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz", "integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", @@ -2779,7 +2886,9 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -2793,12 +2902,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -2855,6 +2958,8 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2867,6 +2972,8 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -2902,13 +3009,24 @@ } }, "node_modules/resend": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/resend/-/resend-0.16.0.tgz", - "integrity": "sha512-6NnrtEapGD6FtnhRUrtDdfgQFH2av5cTkrnElUh6AjTzGrHXiMpD/GRwT46pqpA6i/AXSDXKV7Wh/hHQJLKFew==", + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.18.1.tgz", + "integrity": "sha512-XN8XIaDdKF+ziSQ3K23ndUcyhP7U3ze2gky6SPgYkuAOq54mH4Wdhwm7QylEQ3zlz0NzdX7/l1AgmJUZbdPI/Q==", "license": "MIT", "dependencies": { - "@react-email/render": "0.0.7", - "axios": "1.4.0" + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } } }, "node_modules/retry": { @@ -2963,6 +3081,8 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -2972,6 +3092,8 @@ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.10.0.tgz", "integrity": "sha512-DEL/RW/f4qLw/NrVg97xKaEBC8IpzIG2fvxnzCp3Z4yk4jQ3MXom+Imav9wApjxX2dfS3eW7x0DXafJr85i39A==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "parseley": "^0.11.0" }, @@ -3058,6 +3180,8 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -3070,6 +3194,8 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -3277,6 +3403,16 @@ "node": ">= 8" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -3312,6 +3448,8 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -3338,6 +3476,8 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -3620,6 +3760,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, "dependencies": { "isexe": "^2.0.0" }, @@ -3643,6 +3784,8 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -3661,6 +3804,8 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -3678,6 +3823,8 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3693,6 +3840,8 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" }, @@ -3704,13 +3853,17 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -3728,6 +3881,8 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-regex": "^6.2.2" }, @@ -3768,6 +3923,8 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "optional": true, + "peer": true, "requires": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -3780,17 +3937,23 @@ "ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "optional": true, + "peer": true }, "emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "optional": true, + "peer": true }, "string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "optional": true, + "peer": true, "requires": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -3801,6 +3964,8 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "optional": true, + "peer": true, "requires": { "ansi-regex": "^6.2.2" } @@ -3852,7 +4017,9 @@ "@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "optional": true, + "peer": true }, "@paralleldrive/cuid2": { "version": "2.3.1", @@ -3867,12 +4034,15 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true + "optional": true, + "peer": true }, "@react-email/render": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.7.tgz", "integrity": "sha512-hMMhxk6TpOcDC5qnKzXPVJoVGEwfm+U5bGOPH+MyTTlx0F02RLQygcATBKsbP7aI/mvkmBAZoFbgPIHop7ovug==", + "optional": true, + "peer": true, "requires": { "html-to-text": "9.0.3", "pretty": "2.0.0", @@ -3884,11 +4054,18 @@ "version": "0.10.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.10.0.tgz", "integrity": "sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==", + "optional": true, + "peer": true, "requires": { "domhandler": "^5.0.3", "selderee": "^0.10.0" } }, + "@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -3978,7 +4155,9 @@ "ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "optional": true, + "peer": true }, "anymatch": { "version": "3.1.3", @@ -4018,17 +4197,8 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", - "requires": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, "balanced-match": { "version": "1.0.2", @@ -4174,6 +4344,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "optional": true, + "peer": true, "requires": { "color-name": "~1.1.4" } @@ -4181,7 +4353,9 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "optional": true, + "peer": true }, "color-support": { "version": "1.1.3", @@ -4192,6 +4366,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -4199,7 +4374,9 @@ "commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==" + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "optional": true, + "peer": true }, "component-emitter": { "version": "1.3.1", @@ -4216,6 +4393,8 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz", "integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==", + "optional": true, + "peer": true, "requires": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", @@ -4226,6 +4405,8 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "optional": true, + "peer": true, "requires": { "ini": "^1.3.4", "proto-list": "~1.2.1" @@ -4286,6 +4467,8 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "optional": true, + "peer": true, "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4303,12 +4486,15 @@ "deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "optional": true, + "peer": true }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true }, "delegates": { "version": "1.0.0", @@ -4344,6 +4530,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "optional": true, + "peer": true, "requires": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -4353,12 +4541,16 @@ "domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "optional": true, + "peer": true }, "domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "optional": true, + "peer": true, "requires": { "domelementtype": "^2.3.0" } @@ -4367,6 +4559,8 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "optional": true, + "peer": true, "requires": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -4399,12 +4593,16 @@ "eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "optional": true, + "peer": true }, "editorconfig": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "optional": true, + "peer": true, "requires": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", @@ -4416,6 +4614,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "optional": true, + "peer": true, "requires": { "balanced-match": "^1.0.0" } @@ -4424,6 +4624,8 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "optional": true, + "peer": true, "requires": { "brace-expansion": "^2.0.2" } @@ -4431,7 +4633,9 @@ "semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==" + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "optional": true, + "peer": true } } }, @@ -4473,7 +4677,9 @@ "entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "optional": true, + "peer": true }, "env-paths": { "version": "2.2.1", @@ -4509,6 +4715,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "requires": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -4603,6 +4810,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "optional": true, + "peer": true, "requires": { "is-extendable": "^0.1.0" } @@ -4613,6 +4822,11 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "dev": true }, + "fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" + }, "fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4636,15 +4850,12 @@ "unpipe": "~1.0.0" } }, - "follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" - }, "foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "optional": true, + "peer": true, "requires": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" @@ -4653,7 +4864,9 @@ "signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "optional": true, + "peer": true } } }, @@ -4661,6 +4874,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4810,6 +5024,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "requires": { "has-symbols": "^1.0.3" } @@ -4831,6 +5046,8 @@ "version": "9.0.3", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.3.tgz", "integrity": "sha512-hxDF1kVCF2uw4VUJ3vr2doc91pXf2D5ngKcNviSitNkhP9OMOaJkDrFIFL6RMvko7NisWTEiqGpQ9LAxcVok1w==", + "optional": true, + "peer": true, "requires": { "@selderee/plugin-htmlparser2": "^0.10.0", "deepmerge": "^4.2.2", @@ -4843,6 +5060,8 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "optional": true, + "peer": true, "requires": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", @@ -4978,7 +5197,9 @@ "ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "optional": true, + "peer": true }, "ip": { "version": "2.0.0", @@ -5008,12 +5229,16 @@ "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "optional": true, + "peer": true }, "is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==" + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "optional": true, + "peer": true }, "is-extglob": { "version": "2.1.1", @@ -5050,17 +5275,22 @@ "is-whitespace": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz", - "integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==" + "integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==", + "optional": true, + "peer": true }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true }, "jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "optional": true, + "peer": true, "requires": { "@isaacs/cliui": "^8.0.2", "@pkgjs/parseargs": "^0.11.0" @@ -5070,6 +5300,8 @@ "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "optional": true, + "peer": true, "requires": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", @@ -5081,12 +5313,16 @@ "abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==" + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "optional": true, + "peer": true }, "brace-expansion": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "optional": true, + "peer": true, "requires": { "balanced-match": "^1.0.0" } @@ -5095,6 +5331,8 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "optional": true, + "peer": true, "requires": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -5108,6 +5346,8 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "optional": true, + "peer": true, "requires": { "brace-expansion": "^2.0.2" } @@ -5115,12 +5355,16 @@ "minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "optional": true, + "peer": true }, "nopt": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "optional": true, + "peer": true, "requires": { "abbrev": "^2.0.0" } @@ -5130,17 +5374,23 @@ "js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==" + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "optional": true, + "peer": true }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "optional": true, + "peer": true }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "optional": true, + "peer": true, "requires": { "is-buffer": "^1.1.5" } @@ -5148,12 +5398,16 @@ "leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", - "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==" + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "optional": true, + "peer": true }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "optional": true, + "peer": true, "requires": { "js-tokens": "^3.0.0 || ^4.0.0" } @@ -5516,7 +5770,9 @@ "package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "optional": true, + "peer": true }, "papaparse": { "version": "5.4.1", @@ -5527,6 +5783,8 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.11.0.tgz", "integrity": "sha512-VfcwXlBWgTF+unPcr7yu3HSSA6QUdDaDnrHcytVfj5Z8azAyKBDrYnSIfeSxlrEayndNcLmrXzg+Vxbo6DWRXQ==", + "optional": true, + "peer": true, "requires": { "leac": "^0.6.0", "peberminta": "^0.8.0" @@ -5545,12 +5803,16 @@ "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "optional": true, + "peer": true }, "path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "optional": true, + "peer": true, "requires": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -5559,12 +5821,16 @@ "lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "optional": true, + "peer": true }, "minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "optional": true, + "peer": true } } }, @@ -5576,7 +5842,9 @@ "peberminta": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.8.0.tgz", - "integrity": "sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==" + "integrity": "sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==", + "optional": true, + "peer": true }, "pg": { "version": "8.13.1", @@ -5644,6 +5912,11 @@ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true }, + "postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==" + }, "postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -5671,6 +5944,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz", "integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==", + "optional": true, + "peer": true, "requires": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", @@ -5696,7 +5971,9 @@ "proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==" + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "optional": true, + "peer": true }, "proxy-addr": { "version": "2.0.7", @@ -5707,11 +5984,6 @@ "ipaddr.js": "1.9.1" } }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -5751,6 +6023,8 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "optional": true, + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -5759,6 +6033,8 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "optional": true, + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -5784,12 +6060,12 @@ } }, "resend": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/resend/-/resend-0.16.0.tgz", - "integrity": "sha512-6NnrtEapGD6FtnhRUrtDdfgQFH2av5cTkrnElUh6AjTzGrHXiMpD/GRwT46pqpA6i/AXSDXKV7Wh/hHQJLKFew==", + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.18.1.tgz", + "integrity": "sha512-XN8XIaDdKF+ziSQ3K23ndUcyhP7U3ze2gky6SPgYkuAOq54mH4Wdhwm7QylEQ3zlz0NzdX7/l1AgmJUZbdPI/Q==", "requires": { - "@react-email/render": "0.0.7", - "axios": "1.4.0" + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" } }, "retry": { @@ -5820,6 +6096,8 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "optional": true, + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -5828,6 +6106,8 @@ "version": "0.10.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.10.0.tgz", "integrity": "sha512-DEL/RW/f4qLw/NrVg97xKaEBC8IpzIG2fvxnzCp3Z4yk4jQ3MXom+Imav9wApjxX2dfS3eW7x0DXafJr85i39A==", + "optional": true, + "peer": true, "requires": { "parseley": "^0.11.0" } @@ -5897,6 +6177,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "optional": true, + "peer": true, "requires": { "shebang-regex": "^3.0.0" } @@ -5904,7 +6186,9 @@ "shebang-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "optional": true, + "peer": true }, "side-channel": { "version": "1.1.1", @@ -6041,6 +6325,15 @@ "minipass": "^3.1.1" } }, + "standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "requires": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -6068,6 +6361,8 @@ "version": "npm:string-width@4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "optional": true, + "peer": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -6086,6 +6381,8 @@ "version": "npm:strip-ansi@6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "optional": true, + "peer": true, "requires": { "ansi-regex": "^5.0.1" } @@ -6297,6 +6594,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -6313,6 +6611,8 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "optional": true, + "peer": true, "requires": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -6322,17 +6622,23 @@ "ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "optional": true, + "peer": true }, "emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "optional": true, + "peer": true }, "string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "optional": true, + "peer": true, "requires": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -6343,6 +6649,8 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "optional": true, + "peer": true, "requires": { "ansi-regex": "^6.2.2" } @@ -6353,6 +6661,8 @@ "version": "npm:wrap-ansi@7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "optional": true, + "peer": true, "requires": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -6363,6 +6673,8 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "optional": true, + "peer": true, "requires": { "color-convert": "^2.0.1" } diff --git a/api/package.json b/api/package.json index 530a3a5..d852335 100644 --- a/api/package.json +++ b/api/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "nodemon server.js", "start": "node server.js", + "worker": "node email-worker.js", + "worker:dev": "nodemon email-worker.js", "test": "node --test" }, "author": "", @@ -23,7 +25,7 @@ "nanoid": "^3.3.4", "papaparse": "^5.4.1", "pg": "^8.13.1", - "resend": "^0.16.0", + "resend": "6.18.1", "sqlite3": "^5.1.6", "survey-core": "2.5.35" }, diff --git a/api/server.js b/api/server.js index 6ff483b..2f18a6c 100644 --- a/api/server.js +++ b/api/server.js @@ -16,6 +16,8 @@ const { Model, Serializer, Question } = require('survey-core'); dotenvFlow.config(); +const { ResendProvider, reserveProviderRate } = require('./email'); +const lifecycle = require('./lifecycle'); const resendApiKey = process.env.RESEND_KEY || process.env.RESEND_API_KEY; // Keep server-side validation in step with the respondent's custom SurveyJS type. @@ -25,10 +27,8 @@ if (!Serializer.findClass('draggableranking')) { } Serializer.addClass('draggableranking', [], () => new QuestionDraggableRankingModel(''), 'question'); } +lifecycle.setSurveyDefinitionValidator(validateSurveyDefinition); -// Create a new instance of the Pool. -// DB_SSL enables TLS (RDS enforces it); DB_SSL_CA points at the RDS CA bundle -// so the server certificate is actually verified. const pool = new Pool({ user: process.env.DB_USER, password: process.env.DB_PASSWORD, @@ -36,288 +36,54 @@ const pool = new Pool({ port: process.env.DB_PORT, database: process.env.DB_NAME || 'ONA', ssl: process.env.DB_SSL === 'true' - ? { - ca: process.env.DB_SSL_CA ? fs.readFileSync(process.env.DB_SSL_CA, 'utf8') : undefined, - rejectUnauthorized: Boolean(process.env.DB_SSL_CA), - } + ? { ca: process.env.DB_SSL_CA ? fs.readFileSync(process.env.DB_SSL_CA, 'utf8') : undefined, + rejectUnauthorized: Boolean(process.env.DB_SSL_CA) } : undefined, }); - const resend = resendApiKey ? new Resend(resendApiKey) : null; +const directSurveyProvider = resendApiKey ? new ResendProvider({ apiKey: resendApiKey }) : null; -const EMAIL_HTML = [` - - - - - - - - - - - - - -
- - - - - - -
Logo -
`, - `Start your survey -
-

View our privacy policy .

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vel rhoncus lacus. Nulla facilisi. Donec turpis sem, dictum a sollicitudin a, faucibus ac sem. Morbi sed erat non ex mollis pulvinar ut eu nisi.

-

— The CLA team

-
-

Contemporary Leadership Advisors, 299 Park Ave, New York, NY 10171

-
-
- - -`]; - -const loremIpsum = `

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque vel rhoncus lacus. Nulla facilisi. Donec turpis sem, dictum a sollicitudin a, faucibus ac sem.

-

Morbi sed erat non ex mollis pulvinar ut eu nisi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed gravida cursus pellentesque. Aliquam in lectus et ex ultricies sodales a.

`; - -async function sendAccountEmail({ to, subject, html, text }) { - if (!resend) { - return { sent: false, message: 'Email delivery is not configured; deliver the returned link manually.' }; - } - - try { - await resend.emails.send({ - from: 'CLA Survey ', - to, - subject, - html, - text, - }); - return { sent: true }; - } catch (error) { - console.error(`Failed to send account email to ${to}:`, error.message); - return { sent: false, message: 'Email delivery failed; deliver the returned link manually.' }; +async function reserveSynchronousEmailRate() { + const environment = process.env.EMAIL_RATE_BUDGET_ENV || lifecycle.environmentName(process.env); + const rate = Math.max(1, Number(process.env.EMAIL_RATE_PER_SECOND || 5)); + const deadline = Date.now() + Math.max(1000, Number(process.env.SYNC_EMAIL_RATE_WAIT_MS || 10000)); + while (Date.now() < deadline) { + if (await reserveProviderRate(pool, environment, rate)) return; + await new Promise((resolve) => setTimeout(resolve, 100 + Math.floor(Math.random() * 150))); } + const error = new Error('Email provider rate budget is busy; retry shortly.'); + error.statusCode = 503; + throw error; } -function buildSurveyEmailHtml(text, link) { - const formattedText = (`

${text.replace(/"/g, '')}

`) - .replace(/

/g, '

'); - return EMAIL_HTML[0] + formattedText + EMAIL_HTML[1] + link + EMAIL_HTML[2]; -} - -async function sendMail(email, id, surveyName, text, subject = 'CLA Network Survey') { +// Account/demo mail remains bounded request work in Phase 1. Resend resolves +// provider errors in {error}; only a response containing an id is accepted. +async function sendAccountEmail({ to, subject, html, text }) { + if (!resend) return { sent: false, message: 'Email is not configured; deliver the returned link manually.' }; try { - if (!resend) { - throw new Error('Missing RESEND_KEY or RESEND_API_KEY environment variable'); - } - - const customLink = `${process.env.SURVEY_URL}/?surveyName=${encodeURIComponent(surveyName)}&userId=${encodeURIComponent(id)}`; - const emailData = { - from: 'CLA Survey ', - to: email, - subject, - html: buildSurveyEmailHtml(text, customLink), - surveyName - }; - - // Add delay to respect rate limit - await rateLimitedSend(emailData); - + await reserveSynchronousEmailRate(); + const result = await resend.emails.send({ from: 'CLA Survey ', to, subject, html, text }); + if (result?.error || !result?.data?.id) throw new Error(result?.error?.message || 'Provider response did not include a message ID'); + return { sent: true, providerMessageId: result.data.id }; } catch (error) { - console.error(`Failed to send email to ${email}:`, error); - throw error; + console.error('Account email provider request failed:', String(error.message || error).slice(0, 500)); + return { sent: false, message: 'Email provider request failed; deliver the returned link manually.' }; } } -async function sendDemoMail(email, survey, text, demoToken, subject = 'CLA Network Survey') { - if (!resend) { - throw new Error('Missing RESEND_KEY or RESEND_API_KEY environment variable'); - } - if (!process.env.SURVEY_URL) { - throw new Error('Missing SURVEY_URL environment variable'); - } - - const link = `${process.env.SURVEY_URL}/?surveyName=${encodeURIComponent(survey.name)}&demoToken=${encodeURIComponent(demoToken)}`; - const result = await resend.emails.send({ - from: 'CLA Survey ', - to: email, - subject: `[Demo] ${subject}`, - html: buildSurveyEmailHtml(text, link), - }); - if (result?.error) throw new Error(result.error.message || 'Email delivery failed'); +function buildSurveyEmailHtml(text, link, language = 'en') { + return require('./email').renderInvitation({ bodyText: text, link, language }).html; } -// Queue for managing email sending with rate limiting -const emailQueue = []; -let isProcessing = false; -const RATE_LIMIT = 10; // emails per second -const DELAY = 1000; // 1 second delay between batches - -async function rateLimitedSend(emailData) { - // Add email to queue - emailQueue.push(emailData); - - // Start processing if not already running - if (!isProcessing) { - isProcessing = true; - await processEmailQueue(); - } -} - -async function processEmailQueue() { - while (emailQueue.length > 0) { - // Process up to RATE_LIMIT emails at once - const batch = emailQueue.splice(0, RATE_LIMIT); - - // Send batch of emails and track successful sends - const results = await Promise.all(batch.map(async (emailData) => { - try { - await resend.emails.send(emailData); - // Extract recipient email from emailData - return { success: true, email: emailData.to }; - } catch (error) { - console.error(`Failed to send email to ${emailData.to}:`, error); - return { success: false, email: emailData.to }; - } - })); - - // Update email_sent status for successful sends - const successfulBySurvey = results.reduce((grouped, result) => { - if (!result.success) return grouped; - const surveyName = batch.find(emailData => emailData.to === result.email)?.surveyName; - if (!surveyName) return grouped; - grouped[surveyName] = grouped[surveyName] || []; - grouped[surveyName].push(result.email); - return grouped; - }, {}); - for (const [surveyName, successfulEmails] of Object.entries(successfulBySurvey)) { - if (successfulEmails.length > 0) { - try { - await pool.query( - 'UPDATE Respondent SET email_sent = true WHERE contact_info = ANY($1) AND survey_name = $2', - [successfulEmails, surveyName] - ); - } catch (error) { - console.error('Failed to update email_sent status:', error); - } - } - } - - // Wait for rate limit window if more emails remain - if (emailQueue.length > 0) { - await new Promise(resolve => setTimeout(resolve, DELAY)); - } - } - - isProcessing = false; -} - -// User test email function (allow admin user to send test email to themselves) -async function sendTestMail(email, survey, lang) { - const client = await pool.connect(); - try { - const query = `SELECT text, invitation_subject FROM email WHERE ${legacySurveyPredicate()} AND lang = $3`; - const values = [survey.id, survey.name, lang]; - const response = await client.query(query, values); - - if (!response.rows || response.rows.length === 0) { - throw new Error(`Email template not found for survey '${survey.name}' in language '${lang}'`); - } - - const text = response.rows[0].text; - if (text === undefined || text === null) { - throw new Error(`Email text is undefined for survey '${survey.name}'`); - } - - const respondentResult = await client.query( - `SELECT uuid FROM Respondent - WHERE ${legacySurveyPredicate()} - AND can_respond = true - AND uuid IS NOT NULL - AND lower(contact_info) = lower($3) - ORDER BY respondent_id - LIMIT 1`, - [survey.id, survey.name, email] - ); - const respondentToken = respondentResult.rows[0]?.uuid; - if (!respondentToken) { - const error = new Error(`No active respondent token found for '${email}' on survey '${survey.name}'. Reminders can only be sent to that respondent's own email address.`); - error.statusCode = 404; - throw error; - } - - await sendMail(email, respondentToken, survey.name, text, response.rows[0].invitation_subject); - } finally { - client.release(); - } +async function sendDemoMail(email, survey, text, demoToken, subject = 'CLA Network Survey', language = 'en') { + if (!directSurveyProvider) throw new Error('Missing RESEND_KEY or RESEND_API_KEY environment variable'); + await reserveSynchronousEmailRate(); + const link = `${process.env.SURVEY_URL}/?surveyName=${encodeURIComponent(survey.name)}&demoToken=${encodeURIComponent(demoToken)}`; + const rendered = require('./email').renderInvitation({ bodyText: text, link, language }); + return directSurveyProvider.send({ from: 'CLA Survey ', to: email, subject: `[Demo] ${subject}`, ...rendered }, + { idempotencyKey: `survey-demo/${crypto.randomUUID()}` }); } -async function startSurvey(survey){ - // Pull all users from the database - const client = await pool.connect(); - const query = `SELECT name, contact_info, uuid, lang FROM Respondent WHERE ${legacySurveyPredicate()} AND can_respond = true`; - const values = [survey.id, survey.name]; - let respondents = []; - let emails = []; - await client.query(query, values) - .then(response => { - respondents = response.rows.map(row => ({ - userName: row.name, - email: row.contact_info, - userId: row.uuid, - language: row.lang - })); - }); - - // Pull the email text from the database for each language - const emailQuery = `SELECT lang, text, invitation_subject FROM email WHERE ${legacySurveyPredicate()}`; - const emailValues = [survey.id, survey.name]; - await client.query(emailQuery, emailValues) - .then(response => { - emails = response.rows.map(row => ({ - language: row.lang, - text: row.text, - subject: row.invitation_subject - })); - }); - // Create a map from language to email text - const emailMap = emails.reduce((map, email) => { - map[email.language.replace(/"/g, "").replace(/'/g, "")] = { - text: '

' + email.text + '

', - subject: email.subject, - }; - return map; - }, {}); - - const missingLanguages = [...new Set( - respondents - .filter(respondent => !emailMap[respondent.language]) - .map(respondent => respondent.language) - )]; - if (missingLanguages.length > 0) { - throw new Error(`Invitation templates are missing for: ${missingLanguages.join(', ')}`); - } - - // Send the emails - respondents.forEach(respondent => { - const invitation = emailMap[respondent.language]; - sendMail( - respondent.email, - respondent.userId, - survey.name, - invitation.text.replace(/"/g, "").replace(/'/g, ""), - invitation.subject - ); - }); - } -// sendMail('bgarcia2324@gmail.com', 'byVHldRI2ZgaOXNhE-ih7', 'GEEEEEE'); - // Function to execute a query async function executeQuery(query, values = []) { const client = await pool.connect(); @@ -469,13 +235,15 @@ app.use(cors({ if (!normalizedOrigin || allowedOrigins.includes(normalizedOrigin)) { callback(null, true); } else { - console.warn(`CORS rejected origin: ${origin} (Allowed: ${allowedOrigins.join(', ')})`); - callback(new Error('Not allowed by CORS')); + console.warn(`CORS withheld for origin: ${origin} (Allowed: ${allowedOrigins.join(', ')})`); + // Continue without CORS headers so authenticated mutation middleware can + // return its stable 403 instead of Express converting a CORS error to 500. + callback(null, false); } }, credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Idempotency-Key'] })); app.set('trust proxy', 1); @@ -490,18 +258,44 @@ app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, - // Per-environment cookie name: staging and prod share the .bennetts.work - // cookie domain, so a shared name would let them clobber each other - name: process.env.SESSION_COOKIE_NAME || 'sessionId', + // Host-only v2 cookie: sibling static hosts must never receive API sessions. + name: process.env.SESSION_COOKIE_NAME || 'ona_session_v2', cookie: { secure: process.env.NODE_ENV === 'prod', // Only use secure in production httpOnly: true, maxAge: 24 * 60 * 60 * 1000, // 24 hours - sameSite: 'lax', // Changed from 'strict' to 'lax' for better compatibility - path: '/', - domain: process.env.NODE_ENV === 'prod' ? '.bennetts.work' : undefined + sameSite: 'lax', + path: '/' } })); +function isTrustedStateChangingOrigin({ stateChanging, userId, origin, dashboardOrigin, nodeEnv }) { + if (!stateChanging || !userId) return true; + const hosted = ['prod', 'production'].includes(String(nodeEnv || '').toLowerCase()); + if (hosted) return Boolean(dashboardOrigin) && origin === dashboardOrigin; + return !origin || (Boolean(dashboardOrigin) && origin === dashboardOrigin); +} + +// Explicitly retire the old parent-domain cookie during the controlled re-login rollout. +app.use((req, res, next) => { + if (process.env.NODE_ENV === 'prod') { + // Expire both legacy parent-domain names during the forced v2 re-login. + // New Terraform config uses a distinct host-only SESSION_COOKIE_NAME. + for (const legacyName of ['sessionId', 'sessionId-staging']) { + res.append('Set-Cookie', `${legacyName}=; Max-Age=0; Path=/; Domain=.bennetts.work; Secure; HttpOnly; SameSite=Lax`); + } + } + const trustedOrigin = isTrustedStateChangingOrigin({ + stateChanging: !['GET', 'HEAD', 'OPTIONS'].includes(req.method), + userId: req.session?.userId, + origin: req.get('Origin')?.replace(/\/$/, ''), + dashboardOrigin: process.env.FRONTEND_URL?.replace(/\/$/, ''), + nodeEnv: process.env.NODE_ENV, + }); + if (!trustedOrigin) { + return res.status(403).json({ error: 'csrf_origin_invalid', message: 'A trusted dashboard Origin is required.' }); + } + next(); +}); const isLocalEnvironment = ['development', 'dev', 'local', 'test'].includes(process.env.NODE_ENV || 'development'); const allowPublicSignup = process.env.ALLOW_PUBLIC_SIGNUP === 'true' || (isLocalEnvironment && process.env.ALLOW_PUBLIC_SIGNUP !== 'false'); @@ -655,7 +449,7 @@ async function updateLastLoginIfSupported(userId) { } } -async function validateRespondentToken(surveyName, userId) { +async function validateRespondentToken(surveyName, userId, queryable = pool, lockSurvey = false) { if (!surveyName || surveyName === 'undefined' || surveyName === 'null') { return { ok: false, status: 400, message: 'Survey name is required.' }; } @@ -664,13 +458,15 @@ async function validateRespondentToken(surveyName, userId) { return { ok: false, status: 400, message: 'User ID is required.' }; } - const result = await pool.query( - `SELECT r.respondent_id, r.response, r.can_respond, r.survey_id + const result = await queryable.query( + `SELECT r.respondent_id, r.response, r.can_respond, r.survey_id, s.lifecycle_status FROM Respondent r JOIN Survey s ON (r.survey_id = s.id OR (r.survey_id IS NULL AND r.survey_name = s.name)) WHERE r.uuid = $1 AND r.survey_name = $2 - AND s.archived_at IS NULL`, + AND s.archived_at IS NULL + AND s.lifecycle_status = 'active' + ${lockSurvey ? 'FOR SHARE OF s' : ''}`, [userId, surveyName] ); @@ -819,7 +615,12 @@ app.post('/api/logout', (req, res) => { console.error('Logout error:', err); return res.status(500).json({ error: 'Error during logout' }); } - res.clearCookie(process.env.SESSION_COOKIE_NAME || 'sessionId'); + res.clearCookie(process.env.SESSION_COOKIE_NAME || 'ona_session_v2', { + secure: process.env.NODE_ENV === 'prod', + httpOnly: true, + sameSite: 'lax', + path: '/', + }); res.json({ success: true }); }); }); @@ -1397,7 +1198,8 @@ async function resolveSurveyForUser(req, res, { surveyName, surveyId, allowedRol const result = await pool.query( `SELECT s.id, s.name, s.title, s.creation_date, s.questions, - s.organization_id, s.created_by_user_id, om.role + s.organization_id, s.created_by_user_id, s.lifecycle_status, s.lifecycle_version, + s.started_at, s.closed_at, s.archived_at, om.role FROM Survey s LEFT JOIN organization_memberships om ON om.organization_id = s.organization_id AND om.user_id = $1 @@ -1430,11 +1232,11 @@ async function insertSurvey(name, title, organizationId, createdByUserId) { console.log('Survey added successfully!'); return result.rows[0]; } -async function insertUsers(users, deleteRow = null, survey = null) { - const client = await pool.connect(); +async function insertUsers(users, deleteRow = null, survey = null, transactionClient = null) { + const client = transactionClient || await pool.connect(); try { - await client.query('BEGIN'); + if (!transactionClient) await client.query('BEGIN'); // If there's a row to delete, delete it first if (deleteRow) { @@ -1473,22 +1275,19 @@ async function insertUsers(users, deleteRow = null, survey = null) { await client.query(query, values); } - await client.query('COMMIT'); + if (!transactionClient) await client.query('COMMIT'); } catch (error) { - await client.query('ROLLBACK'); + if (!transactionClient) await client.query('ROLLBACK'); console.error('Error in database operation:', error); throw error; } finally { - client.release(); + if (!transactionClient) client.release(); } } -async function insertEmails(data, survey = null) { - // Start a PostgreSQL client from the pool - const client = await pool.connect(); - console.log(data); +async function insertEmails(data, survey = null, transactionClient = null) { + const client = transactionClient || await pool.connect(); try { - // Begin a transaction - await client.query('BEGIN'); + if (!transactionClient) await client.query('BEGIN'); // Iterate through the emails and insert or update them for (const email of data) { @@ -1513,19 +1312,18 @@ async function insertEmails(data, survey = null) { await client.query(query, values); } - await client.query('COMMIT'); - console.log('Email data inserted or updated successfully!'); + if (!transactionClient) await client.query('COMMIT'); } catch (error) { - await client.query('ROLLBACK'); + if (!transactionClient) await client.query('ROLLBACK'); console.error('Error inserting or updating emails:', error); throw error; } finally { - client.release(); + if (!transactionClient) client.release(); } } -async function insertQuestions(name, title, json, surveyId = null) { - const client = await pool.connect(); +async function insertQuestions(name, title, json, surveyId = null, transactionClient = null) { + const client = transactionClient || await pool.connect(); try { if (title === undefined || title === null || title === '') { @@ -1544,7 +1342,7 @@ async function insertQuestions(name, title, json, surveyId = null) { console.error('Error occurred:', error); throw error; } finally { - await client.release(); + if (!transactionClient) client.release(); } } async function insertResponses(responses, userId, surveyName, surveyId = null) { @@ -2212,34 +2010,13 @@ app.post('/api/surveys/:surveyId/copy', express.json(), requireAuth, async (req, } }); -app.post('/api/testEmail', express.json(), requireAuth, async (req, res) => { - const data = req.body; - const surveyName = data.surveyName; - const language = data.language; - const email = data.email; - - if (!surveyName) { - res.status(400).json({ message: 'Survey name is required.' }); - return; - } - if (!language) { - res.status(400).json({ message: 'Language name is required.' }); - return; - } - if (!email) { - res.status(400).json({ message: 'Email name is required.' }); - return; - } - - try { - const survey = await resolveSurveyForUser(req, res, { surveyName, allowedRoles: EDITOR_ROLES }); - if (!survey) return; - await sendTestMail(email, survey, language); - res.status(200).json({ message: 'Test email sent successfully!' }); - } catch (error) { - console.error(error); - res.status(error.statusCode || 500).json({ message: error.message || 'Error occurred while sending test email.' }); - } +app.post('/api/testEmail', express.json(), requireAuth, async (_req, res) => { + // Real-respondent reminders must not bypass durable delivery history. + // Phase 3 replaces this compatibility route with an audited reminder run. + res.status(410).json({ + error: 'reminders_not_available', + message: 'Respondent reminders are temporarily unavailable while durable reminder tracking is being introduced.', + }); }); app.post('/api/surveys/:surveyId/demo-email', express.json(), requireAuth, demoEmailRateLimiter, async (req, res) => { @@ -2278,24 +2055,68 @@ app.post('/api/surveys/:surveyId/demo-email', express.json(), requireAuth, demoE } }); -app.post('/api/startSurvey', express.json(), requireAuth, async (req, res) => { - const data = req.body; - const surveyName = data.surveyName; +function sendLifecycleError(res, error) { + if (error instanceof lifecycle.LifecycleError) return res.status(error.status).json(lifecycle.publicError(error)); + console.error('Lifecycle operation failed:', String(error.message || error).slice(0, 500)); + return res.status(500).json({ error: 'internal_error', message: 'Lifecycle operation failed.' }); +} +function launchResponse(res, launch) { + const location = `/api/surveys/${launch.survey_id}/launches/${launch.id}`; + return res.status(launch.replayed ? 200 : 202).location(location).json({ + launch, + lifecycleStatus: launch.lifecycleStatus, + message: launch.replayed ? 'Existing invitation launch returned; no new work was queued.' : 'Invitation launch queued.', + }); +} - if (!surveyName) { - res.status(400).json({ message: 'Survey name is required.' }); - return; +app.get('/api/surveys/:surveyId/launch-readiness', requireAuth, async (req, res) => { + try { + const readiness = await lifecycle.getReadiness(pool, req.user, req.params.surveyId); + if (process.env.SURVEY_DELIVERY_V2_ENABLED !== 'true') { + readiness.blockers.push({ code: 'launch_disabled', message: 'Durable survey launch is not enabled.' }); + readiness.blockerCount = Number(readiness.blockerCount || 0) + 1; + readiness.canLaunch = false; + } + res.json(readiness); } + catch (error) { sendLifecycleError(res, error); } +}); +app.post('/api/surveys/:surveyId/launches', express.json(), requireAuth, async (req, res) => { + if (process.env.SURVEY_DELIVERY_V2_ENABLED !== 'true') return res.status(503).json({ error: 'launch_disabled', message: 'Survey launch is temporarily disabled.' }); + try { launchResponse(res, await lifecycle.launchSurvey(pool, req.user, req.params.surveyId, { kind: req.body?.kind, idempotencyKey: req.get('Idempotency-Key') })); } + catch (error) { sendLifecycleError(res, error); } +}); +app.get('/api/surveys/:surveyId/launches', requireAuth, async (req, res) => { + try { res.json({ launches: await lifecycle.listLaunches(pool, req.user, req.params.surveyId) }); } + catch (error) { sendLifecycleError(res, error); } +}); +app.get('/api/surveys/:surveyId/launches/:launchId', requireAuth, async (req, res) => { + try { res.json({ launch: await lifecycle.listLaunches(pool, req.user, req.params.surveyId, req.params.launchId) }); } + catch (error) { sendLifecycleError(res, error); } +}); +app.get('/api/surveys/:surveyId/deliveries', requireAuth, async (req, res) => { + try { res.json(await lifecycle.listDeliveries(pool, req.user, req.params.surveyId, req.query)); } + catch (error) { sendLifecycleError(res, error); } +}); +app.post('/api/surveys/:surveyId/close', express.json(), requireAuth, async (req, res) => { + try { res.json(await lifecycle.transitionSurvey(pool, req.user, req.params.surveyId, 'close')); } + catch (error) { sendLifecycleError(res, error); } +}); +app.post('/api/surveys/:surveyId/reopen', express.json(), requireAuth, async (req, res) => { + try { res.json(await lifecycle.transitionSurvey(pool, req.user, req.params.surveyId, 'reopen')); } + catch (error) { sendLifecycleError(res, error); } +}); +// Deprecated compatibility adapter. It uses a stable server business key and +// never performs provider I/O in the request. +app.post('/api/startSurvey', express.json(), requireAuth, async (req, res) => { + if (process.env.LEGACY_START_ENABLED !== 'true' || process.env.SURVEY_DELIVERY_V2_ENABLED !== 'true') return res.status(503).json({ error: 'launch_disabled', message: 'Legacy survey launch is disabled.' }); + if (!req.body?.surveyName) return res.status(400).json({ message: 'Survey name is required.' }); try { - const survey = await resolveSurveyForUser(req, res, { surveyName, allowedRoles: EDITOR_ROLES }); + const survey = await resolveSurveyForUser(req, res, { surveyName: req.body.surveyName, allowedRoles: EDITOR_ROLES }); if (!survey) return; - await startSurvey(survey); - res.status(200).json({ message: 'Survey started successfully!' }); - } catch (error) { - console.error(error); - res.status(500).json({ message: 'Failed to start survey.' }); - } + launchResponse(res, await lifecycle.launchSurvey(pool, req.user, survey.id, { kind: 'initial', legacy: true })); + } catch (error) { sendLifecycleError(res, error); } }); app.post('/api/updateEmails', express.json(), requireAuth, async (req, res) => { @@ -2340,9 +2161,10 @@ app.post('/api/updateEmails', express.json(), requireAuth, async (req, res) => { try { const survey = await resolveSurveyForUser(req, res, { surveyName, allowedRoles: EDITOR_ROLES }); if (!survey) return; - await insertEmails(emailTemplates, survey); + await lifecycle.withEditableSurvey(pool, req.user, survey.id, (client, lockedSurvey) => insertEmails(emailTemplates, lockedSurvey, client)); res.status(200).json({ message: 'Email data updated successfully.' }); } catch (error) { + if (error instanceof lifecycle.LifecycleError) return sendLifecycleError(res, error); console.error('Error updating email templates:', error); res.status(500).json({ message: 'Failed to update email data.' }); } @@ -2415,7 +2237,7 @@ app.post('/api/updateTarget', requireAuth, async (req, res) => { if (!survey) return; // Handle the database operations with potential deletion - await insertUsers(surveyTargets, deleteRow, survey); + await lifecycle.withEditableSurvey(pool, req.user, survey.id, (client, lockedSurvey) => insertUsers(surveyTargets, deleteRow, lockedSurvey, client)); res.status(200).json({ message: 'Respondents updated successfully.', @@ -2423,6 +2245,7 @@ app.post('/api/updateTarget', requireAuth, async (req, res) => { }); } catch (error) { + if (error instanceof lifecycle.LifecycleError) return sendLifecycleError(res, error); console.error('Error updating respondents:', error); res.status(500).json({ message: 'Failed to update respondents', @@ -2575,7 +2398,7 @@ app.post('/api/updateTargets', express.json(), requireAuth, async (req, res) => if (!survey) return; // Insert the users into the database - await insertUsers(surveyTargets, null, survey); + await lifecycle.withEditableSurvey(pool, req.user, survey.id, (client, lockedSurvey) => insertUsers(surveyTargets, null, lockedSurvey, client)); res.status(200).json({ message: 'Survey created successfully.', @@ -2583,6 +2406,7 @@ app.post('/api/updateTargets', express.json(), requireAuth, async (req, res) => }); } catch (error) { + if (error instanceof lifecycle.LifecycleError) return sendLifecycleError(res, error); console.error('Error processing CSV:', error); res.status(500).json({ message: 'Failed to process CSV data', @@ -2722,52 +2546,48 @@ app.post('/api/updateQuestions', express.json(), requireAuth, async (req, res) = } else { return res.status(400).json({ message: 'Invalid questions format.' }); } + // Reject malformed definitions before taking the lifecycle write lock; the + // same definition is normalized again inside the transaction. + validateSurveyDefinition(submittedQuestions); - const historicalMaximumResult = await pool.query( - `SELECT COALESCE(MAX((matched.parts[1])::numeric), 0)::text AS max_question_number - FROM Respondent r - CROSS JOIN LATERAL jsonb_object_keys( - CASE WHEN jsonb_typeof(r.response) = 'object' THEN r.response ELSE '{}'::jsonb END - ) AS response_key(key) - CROSS JOIN LATERAL regexp_match(response_key.key, '^question_([1-9][0-9]*)$') AS matched(parts) - WHERE ${legacySurveyPredicate('r')}`, - [survey.id, survey.name] - ); - const historicalMaximum = BigInt(historicalMaximumResult.rows[0]?.max_question_number || 0); - const currentCanonicalNames = new Set( - (Array.isArray(survey.questions?.elements) ? survey.questions.elements : []) - .map((question) => question?.name) - .filter((name) => typeof name === 'string' && /^question_[1-9]\d*$/.test(name)) - ); - const savedQuestions = normalizeQuestionNames(submittedQuestions, { - minimumNextQuestionNumber: historicalMaximum + 1n, - currentCanonicalNames, - // Survey Creator may discard unknown top-level metadata. Never let its - // submitted copy reset the allocation watermark held by the database. - persistedNextQuestionNumber: Object.prototype.hasOwnProperty.call( - survey.questions || {}, 'claNextQuestionNumber' - ) ? survey.questions.claNextQuestionNumber : 1, + const savedQuestions = await lifecycle.withEditableSurvey(pool, req.user, survey.id, async (client, lockedSurvey) => { + const historicalMaximumResult = await client.query( + `SELECT COALESCE(MAX((matched.parts[1])::numeric), 0)::text AS max_question_number + FROM Respondent r + CROSS JOIN LATERAL jsonb_object_keys(CASE WHEN jsonb_typeof(r.response) = 'object' THEN r.response ELSE '{}'::jsonb END) AS response_key(key) + CROSS JOIN LATERAL regexp_match(response_key.key, '^question_([1-9][0-9]*)$') AS matched(parts) + WHERE ${legacySurveyPredicate('r')}`, + [lockedSurvey.id, lockedSurvey.name] + ); + const historicalMaximum = BigInt(historicalMaximumResult.rows[0]?.max_question_number || 0); + const currentCanonicalNames = new Set((lockedSurvey.questions?.elements || []).map((question) => question?.name).filter((name) => typeof name === 'string' && /^question_[1-9]\d*$/.test(name))); + const normalized = normalizeQuestionNames(submittedQuestions, { + minimumNextQuestionNumber: historicalMaximum + 1n, + currentCanonicalNames, + persistedNextQuestionNumber: Object.prototype.hasOwnProperty.call(lockedSurvey.questions || {}, 'claNextQuestionNumber') ? lockedSurvey.questions.claNextQuestionNumber : 1, + }); + await insertQuestions(lockedSurvey.name, title, normalized, lockedSurvey.id, client); + return normalized; }); - await insertQuestions(survey.name, title, savedQuestions, survey.id); - res.status(200).json({ message: 'Questions created successfully.', questions: savedQuestions }); } catch (error) { + if (error instanceof lifecycle.LifecycleError) return sendLifecycleError(res, error); res.status(400).json({ message: error.message || 'Invalid questions schema.' }); } }); // PUT API endpoint for answer submission app.post('/api/user', express.json(), respondentRateLimiter, async (req, res) => { + let client; + let committed = false; try { const data = req.body; const userId = data.userId; const surveyName = data.surveyName; - - const validation = await validateRespondentToken(surveyName, userId); - if (!validation.ok) { - return res.status(validation.status).json({ message: validation.message }); - } - + // Cheap denial avoids reserving a connection for invalid public traffic; + // authorization is repeated under the lifecycle lock before any write. + const preliminary = await validateRespondentToken(surveyName, userId); + if (!preliminary.ok) return res.status(preliminary.status).json({ message: preliminary.message }); let answers; try { answers = JSON.parse(data.answers); @@ -2775,13 +2595,14 @@ app.post('/api/user', express.json(), respondentRateLimiter, async (req, res) => return res.status(400).json({ message: 'Answers must be valid JSON.' }); } if (!answers || typeof answers !== 'object' || Array.isArray(answers)) { - return res.status(400).json({ - message: 'Invalid survey responses.', - errors: ['Answers must be an object.'] - }); + return res.status(400).json({ message: 'Invalid survey responses.', errors: ['Answers must be an object.'] }); } + client = await pool.connect(); + await client.query('BEGIN'); + const validation = await validateRespondentToken(surveyName, userId, client, true); + if (!validation.ok) return res.status(validation.status).json({ message: validation.message }); - const schemaResult = await pool.query( + const schemaResult = await client.query( validation.respondent.survey_id ? 'SELECT questions FROM Survey WHERE id = $1' : 'SELECT questions FROM Survey WHERE name = $1', @@ -2812,7 +2633,7 @@ app.post('/api/user', express.json(), respondentRateLimiter, async (req, res) => } if (requestedLazyTagboxValues.size > 0) { const requestedValues = [...requestedLazyTagboxValues]; - const choicesResult = await pool.query( + const choicesResult = await client.query( `SELECT r.name, r.contact_info FROM Respondent r WHERE ${legacySurveyPredicate('r')} @@ -2829,11 +2650,17 @@ app.post('/api/user', express.json(), respondentRateLimiter, async (req, res) => const answerTimeStamp = new Date().toLocaleString(); answers.timeStamp = answerTimeStamp; - await insertResponses(answers, userId, surveyName, validation.respondent.survey_id); + const updateResult = await client.query('UPDATE respondent SET response=$1 WHERE uuid=$2 AND survey_id=$3', [answers, userId, validation.respondent.survey_id]); + if (!updateResult.rowCount) throw new Error('No matching respondent found for survey.'); + await client.query('COMMIT'); + committed = true; res.status(200).json({ success: true }); } catch (error) { console.error('Error submitting response:', error); res.status(500).json({ message: 'Failed to submit response.' }); + } finally { + if (client && !committed) await client.query('ROLLBACK').catch(() => {}); + if (client) client.release(); } }); @@ -3094,14 +2921,22 @@ app.get('/api/results', requireAuth, async (req, res) => { app.get('/api/targets', requireAuth, async(req, res) => { const { surveyName = '' } = req.query; - const client = await pool.connect(); + let client; + try { client = await pool.connect(); } + catch (error) { + console.error(error); + return res.status(500).json({ message: 'Failed to retrieve survey targets.' }); + } const survey = await resolveSurveyForUser(req, res, { surveyName, allowedRoles: ANALYST_ROLES }); if (!survey) { client.release(); return; } - const query = `SELECT name, contact_info, respondent_id, can_respond, lang, response IS NULL AS response_status - FROM Respondent - WHERE ${legacySurveyPredicate()}`; + const query = `SELECT r.name, r.contact_info, r.respondent_id, r.can_respond, r.lang, r.response IS NULL AS response_status, + r.email_sent, d.status AS email_status, a.started_at AS last_email_attempt + FROM Respondent r + LEFT JOIN LATERAL (SELECT status,id FROM survey_email_deliveries WHERE respondent_id=r.respondent_id AND survey_id=r.survey_id ORDER BY created_at DESC LIMIT 1) d ON true + LEFT JOIN LATERAL (SELECT started_at FROM survey_email_attempts WHERE delivery_id=d.id ORDER BY attempt_number DESC LIMIT 1) a ON true + WHERE ${legacySurveyPredicate('r')}`; client.query(query, [survey.id, survey.name]) .then(response => { const respondents = response.rows.map((row, index) => ({ @@ -3110,43 +2945,78 @@ app.get('/api/targets', requireAuth, async(req, res) => { email: row.contact_info, language: row.lang, canRespond: row.can_respond, - status: row.response_status ? 'Incomplete' : 'Complete' + status: row.response_status ? 'Incomplete' : 'Complete', + responseStatus: row.response_status ? 'incomplete' : 'complete', + emailStatus: row.email_status || (row.email_sent ? 'legacy_assumed_accepted' : 'not_queued'), + lastEmailAttempt: row.last_email_attempt || null })); res.status(200).json(respondents); }) - .catch(e => console.error(e.stack)) + .catch((error) => { + console.error(error.stack); + if (!res.headersSent) res.status(500).json({ message: 'Failed to retrieve survey targets.' }); + }) .finally(() => client.release()); }); // GET API endpoint for a list of current surveys app.get('/api/surveys', requireAuth, async (req, res) => { // NEW DB CODE - const client = await pool.connect(); + let client; + try { client = await pool.connect(); } + catch (error) { + console.error(error); + return res.status(500).json({ message: 'Failed to retrieve surveys.' }); + } const query = isPlatformAdmin(req.user) ? ` SELECT s.id, s.name, s.organization_id, o.name AS organization_name, 'owner'::text AS role, - s.creation_date, + s.creation_date, s.lifecycle_status, s.started_at, s.closed_at, + COALESCE(starter.display_name, starter.username) AS started_by_name, + (SELECT jsonb_build_object( + 'id', l.id, 'targetCount', count(d.id), + 'pendingCount', count(*) FILTER (WHERE d.status='pending'), + 'leasedCount', count(*) FILTER (WHERE d.status='leased'), + 'retryWaitCount', count(*) FILTER (WHERE d.status='retry_wait'), + 'acceptedCount', count(*) FILTER (WHERE d.status='accepted'), + 'failedCount', count(*) FILTER (WHERE d.status='failed'), + 'uncertainCount', count(*) FILTER (WHERE d.status='uncertain'), + 'cancelledCount', count(*) FILTER (WHERE d.status='cancelled') + ) FROM survey_launches l JOIN survey_email_deliveries d ON d.launch_id=l.id WHERE l.survey_id=s.id GROUP BY l.id,l.created_at ORDER BY l.created_at DESC LIMIT 1) AS latest_launch, COUNT(r.respondent_id) AS number_of_respondents, COALESCE(jsonb_array_length(s.questions->'elements'), 0) AS number_of_questions FROM Survey s LEFT JOIN organizations o ON o.id = s.organization_id + LEFT JOIN users starter ON starter.id = s.started_by_user_id LEFT JOIN Respondent r ON (r.survey_id = s.id OR (r.survey_id IS NULL AND r.survey_name = s.name)) WHERE s.archived_at IS NULL - GROUP BY s.id, s.name, s.organization_id, o.name, s.creation_date, s.questions + GROUP BY s.id, s.name, s.organization_id, o.name, starter.display_name, starter.username, s.creation_date, s.questions, s.lifecycle_status, s.started_at, s.closed_at ORDER BY s.creation_date DESC NULLS LAST ` : ` SELECT s.id, s.name, s.organization_id, o.name AS organization_name, om.role, - s.creation_date, + s.creation_date, s.lifecycle_status, s.started_at, s.closed_at, + COALESCE(starter.display_name, starter.username) AS started_by_name, + (SELECT jsonb_build_object( + 'id', l.id, 'targetCount', count(d.id), + 'pendingCount', count(*) FILTER (WHERE d.status='pending'), + 'leasedCount', count(*) FILTER (WHERE d.status='leased'), + 'retryWaitCount', count(*) FILTER (WHERE d.status='retry_wait'), + 'acceptedCount', count(*) FILTER (WHERE d.status='accepted'), + 'failedCount', count(*) FILTER (WHERE d.status='failed'), + 'uncertainCount', count(*) FILTER (WHERE d.status='uncertain'), + 'cancelledCount', count(*) FILTER (WHERE d.status='cancelled') + ) FROM survey_launches l JOIN survey_email_deliveries d ON d.launch_id=l.id WHERE l.survey_id=s.id GROUP BY l.id,l.created_at ORDER BY l.created_at DESC LIMIT 1) AS latest_launch, COUNT(r.respondent_id) AS number_of_respondents, COALESCE(jsonb_array_length(s.questions->'elements'), 0) AS number_of_questions FROM Survey s JOIN organization_memberships om ON om.organization_id = s.organization_id AND om.user_id = $1 LEFT JOIN organizations o ON o.id = s.organization_id + LEFT JOIN users starter ON starter.id = s.started_by_user_id LEFT JOIN Respondent r ON (r.survey_id = s.id OR (r.survey_id IS NULL AND r.survey_name = s.name)) WHERE s.archived_at IS NULL - GROUP BY s.id, s.name, s.organization_id, o.name, om.role, s.creation_date, s.questions + GROUP BY s.id, s.name, s.organization_id, o.name, om.role, starter.display_name, starter.username, s.creation_date, s.questions, s.lifecycle_status, s.started_at, s.closed_at ORDER BY s.creation_date DESC NULLS LAST `; @@ -3161,13 +3031,18 @@ app.get('/api/surveys', requireAuth, async (req, res) => { respondents: Math.max(0, Number(row.number_of_respondents || 0) - 1) + "", questions: row.number_of_questions + "", date: row.creation_date, + lifecycleStatus: row.lifecycle_status, + startedAt: row.started_at, + startedByName: row.started_by_name, + closedAt: row.closed_at, + latestLaunch: row.latest_launch, })); // Process the returned JSON data res.status(200).json({ surveys }); }) .catch(error => { - // Handle the error console.error(error); + if (!res.headersSent) res.status(500).json({ message: 'Failed to retrieve surveys.' }); }) .finally(() => client.release()); }); @@ -3229,56 +3104,14 @@ app.get('/api/user/status', respondentRateLimiter, async (req, res) => { } }); -// Delete survey endpoint +// Delete survey endpoint (soft archive with atomic cancellation/audit). app.delete('/api/survey/:surveyName', requireAuth, async (req, res) => { - const surveyName = req.params.surveyName; - console.log("surveyName", surveyName); - if (!surveyName) { - return res.status(400).json({ message: 'Survey name is required.' }); - } - - const client = await pool.connect(); - try { - const survey = await resolveSurveyForUser(req, res, { surveyName, allowedRoles: ADMIN_ROLES }); + const survey = await resolveSurveyForUser(req, res, { surveyName: req.params.surveyName, allowedRoles: ADMIN_ROLES }); if (!survey) return; - await client.query('BEGIN'); - - // Archive survey; keep respondents and email templates for rollback/audit. - const result = await client.query( - 'UPDATE survey SET archived_at = CURRENT_TIMESTAMP, archived_by_user_id = $1 WHERE id = $2 AND archived_at IS NULL RETURNING name', - [req.user.id, survey.id] - ); - - await client.query('COMMIT'); - - await logAuditEvent({ - organizationId: survey.organization_id, - actorUserId: req.user.id, - surveyId: survey.id, - eventType: 'survey.archived', - metadata: { surveyName: survey.name } - }); - - if (result.rowCount === 0) { - return res.status(404).json({ message: 'Survey not found.' }); - } - - res.status(200).json({ - message: 'Survey archived successfully.', - archivedSurvey: result.rows[0].name - }); - - } catch (error) { - await client.query('ROLLBACK'); - console.error('Error deleting survey:', error); - res.status(500).json({ - message: 'Failed to delete survey', - error: error.message - }); - } finally { - client.release(); - } + const result = await lifecycle.transitionSurvey(pool, req.user, survey.id, 'archive'); + res.status(200).json({ message: 'Survey archived successfully.', archivedSurvey: survey.name, ...result }); + } catch (error) { sendLifecycleError(res, error); } }); // Delete user endpoint @@ -3298,10 +3131,10 @@ app.delete('/api/user', requireAuth, async (req, res) => { try { const survey = await resolveSurveyForUser(req, res, { surveyName, allowedRoles: EDITOR_ROLES }); if (!survey) return; - const result = await client.query( - 'DELETE FROM respondent WHERE name = $1 AND (survey_id = $2 OR (survey_id IS NULL AND survey_name = $3)) RETURNING name, survey_name', - [userName, survey.id, survey.name] - ); + const result = await lifecycle.withEditableSurvey(pool, req.user, survey.id, (transactionClient, lockedSurvey) => transactionClient.query( + 'DELETE FROM respondent WHERE name = $1 AND survey_id = $2 RETURNING name, survey_name', + [userName, lockedSurvey.id] + )); if (result.rowCount === 0) { return res.status(404).json({ @@ -3315,11 +3148,9 @@ app.delete('/api/user', requireAuth, async (req, res) => { }); } catch (error) { + if (error instanceof lifecycle.LifecycleError) return sendLifecycleError(res, error); console.error('Error deleting user:', error); - res.status(500).json({ - message: 'Failed to delete user', - error: error.message - }); + res.status(500).json({ message: 'Failed to delete user' }); } finally { client.release(); } @@ -3341,30 +3172,14 @@ app.delete('/api/question', requireAuth, async (req, res) => { // First, get the current questions const survey = await resolveSurveyForUser(req, res, { surveyName, allowedRoles: EDITOR_ROLES }); if (!survey) return; - const currentCanonicalNames = new Set( - (Array.isArray(survey.questions?.elements) ? survey.questions.elements : []) - .map((question) => question?.name) - .filter((name) => typeof name === 'string' && /^question_[1-9]\d*$/.test(name)) - ); - // Normalize before removal so the highest allocated identity remains in - // the persisted watermark even when it has never appeared in a response. - const questions = normalizeQuestionNames(survey.questions, { currentCanonicalNames }); - - // Find and remove the question - const questionIndex = questions.elements.findIndex(q => q.name === questionName); - - if (questionIndex === -1) { - return res.status(404).json({ message: 'Question not found in survey.' }); - } - - // Remove the question - questions.elements.splice(questionIndex, 1); - - // Update the survey with the modified questions - const updateResult = await client.query( - 'UPDATE survey SET questions = $1 WHERE id = $2 RETURNING name', - [questions, survey.id] - ); + const updateResult = await lifecycle.withEditableSurvey(pool, req.user, survey.id, async (transactionClient, lockedSurvey) => { + const currentCanonicalNames = new Set((lockedSurvey.questions?.elements || []).map((question) => question?.name).filter((name) => typeof name === 'string' && /^question_[1-9]\d*$/.test(name))); + const questions = normalizeQuestionNames(lockedSurvey.questions, { currentCanonicalNames }); + const questionIndex = questions.elements.findIndex((question) => question.name === questionName); + if (questionIndex === -1) throw new lifecycle.LifecycleError(404, 'question_not_found', 'Question not found in survey.'); + questions.elements.splice(questionIndex, 1); + return transactionClient.query('UPDATE survey SET questions=$1 WHERE id=$2 RETURNING name', [questions, lockedSurvey.id]); + }); res.status(200).json({ message: 'Question deleted successfully.', @@ -3373,11 +3188,9 @@ app.delete('/api/question', requireAuth, async (req, res) => { }); } catch (error) { + if (error instanceof lifecycle.LifecycleError) return sendLifecycleError(res, error); console.error('Error deleting question:', error); - res.status(500).json({ - message: 'Failed to delete question', - error: error.message - }); + res.status(500).json({ message: 'Failed to delete question' }); } finally { client.release(); } @@ -3442,4 +3255,5 @@ module.exports = { validateRequiredAnswers, normalizeQuestionNames, formatRespondentChoice, + isTrustedStateChangingOrigin, }; diff --git a/api/test/email-delivery.test.js b/api/test/email-delivery.test.js new file mode 100644 index 0000000..f260062 --- /dev/null +++ b/api/test/email-delivery.test.js @@ -0,0 +1,143 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { renderInvitation, buildInvitationPayload, payloadHash, ResendProvider, classifyProviderError, ProviderError } = require('../email'); +const { evaluateReadiness, aggregateSelect, fingerprint, launchSurvey, transitionSurvey } = require('../lifecycle'); +const { DeliveryWorker, isOutsideProviderIdempotencyWindow, canRetryAmbiguous } = require('../email-worker'); + +test('invitation rendering escapes templates and emits equivalent accessible HTML/text', () => { + const token = 'respondent-token-123'; + const payload = buildInvitationPayload({ + to: 'person@example.com', bodyText: 'Hello \n\nPlease participate.', + surveyBaseUrl: 'https://survey.example.test/form', surveyName: 'Leadership & Team', token, language: 'English', + }); + assert.match(payload.html, //); + assert.match(payload.html, /alt="Contemporary Leadership Advisors"/); + assert.match(payload.html, />Open your CLA Network Survey/); + assert.match(payload.html, /<script>/); + assert.match(payload.text, /Please participate/); + assert.match(payload.text, /privacy questions/i); + assert.equal((payload.html.match(new RegExp(token, 'g')) || []).length, 1); + assert.equal((payload.text.match(new RegExp(token, 'g')) || []).length, 1); + assert.doesNotMatch(payload.html, /stripe|lorem ipsum/i); +}); + +test('Resend HTTP provider transmits idempotency and requires an accepted message id', async () => { + let request; + const provider = new ResendProvider({ apiKey: 'secret', fetchImpl: async (url, options) => { + request = { url, options }; + return { ok: true, status: 200, json: async () => ({ id: 'provider-1' }), headers: { get: () => null } }; + }}); + assert.deepEqual(await provider.send({ from:'a',to:'b',subject:'s',html:'h',text:'t' }, { idempotencyKey:'survey-delivery/1' }), { id:'provider-1' }); + assert.equal(request.options.headers['Idempotency-Key'], 'survey-delivery/1'); + assert.equal(request.options.headers.Authorization, 'Bearer secret'); + + const missing = new ResendProvider({ apiKey:'secret', fetchImpl: async () => ({ ok:true,status:200,json:async()=>({}),headers:{get:()=>null} }) }); + await assert.rejects(() => missing.send({}, { idempotencyKey:'key' }), (error) => error.code === 'missing_provider_id' && error.uncertain); +}); + +test('resolved provider error objects fail and retry classification is conservative', async () => { + const provider = new ResendProvider({ apiKey:'secret', fetchImpl:async()=>({ok:true,status:200,json:async()=>({error:{name:'rate_limit_exceeded',message:'slow down'}}),headers:{get:()=> '2'}}) }); + await assert.rejects(() => provider.send({}, {idempotencyKey:'key'}), (error) => error instanceof ProviderError && error.status === 200); + assert.equal(classifyProviderError(new ProviderError('retry',{status:503})), 'ambiguous'); + assert.equal(classifyProviderError(new ProviderError('bad',{status:422})), 'permanent'); + assert.equal(classifyProviderError(new ProviderError('still processing',{status:409,code:'concurrent_idempotent_requests'})), 'ambiguous'); + assert.equal(classifyProviderError(new ProviderError('plan exhausted',{status:429,code:'monthly_quota_exceeded'})), 'quota'); + assert.equal(classifyProviderError(new ProviderError('timeout',{uncertain:true})), 'ambiguous'); +}); + +test('readiness validates the entire audience and exact normalized template coverage', () => { + const survey = { lifecycle_status:'draft', archived_at:null, questions:{elements:[{name:'q1',type:'text'}]} }; + const good = evaluateReadiness(survey, { recipients:[{respondent_id:1,contact_info:'A@example.com',uuid:'token',lang:'English'}], templates:[{lang:' english ',text:'Welcome'}] }, {SURVEY_URL:'https://survey.test',RESEND_API_KEY:'key'}); + assert.equal(good.canLaunch,true); + assert.deepEqual(good.languages,['english']); + assert.deepEqual(good.templateCoverage,[{language:'english',covered:true}]); + const bad = evaluateReadiness(survey, { recipients:[ + {respondent_id:1,contact_info:'same@example.com',uuid:'token',lang:'French'}, + {respondent_id:2,contact_info:'SAME@example.com',uuid:null,lang:'French'}, + ], templates:[{lang:'English',text:'Welcome'}] }, {SURVEY_URL:'https://survey.test',RESEND_API_KEY:'key'}); + assert.equal(bad.canLaunch,false); + assert.ok(bad.blockers.some(({code})=>code==='recipient_email_duplicate')); + assert.ok(bad.blockers.some(({code})=>code==='recipient_token_missing')); + assert.ok(bad.blockers.some(({code})=>code==='template_missing')); + const unsupported = evaluateReadiness(survey, { recipients:[{respondent_id:3,contact_info:'x@example.com',uuid:'token',lang:'Klingon'}], templates:[{lang:'Klingon',text:'Qapla'}] }, {SURVEY_URL:'https://survey.test',RESEND_API_KEY:'key'}); + assert.ok(unsupported.blockers.some(({code})=>code==='recipient_language_unsupported')); + const duplicate = evaluateReadiness(survey, { recipients:[{respondent_id:4,contact_info:'x@example.com',uuid:'token',lang:'English'}], templates:[{lang:'English',text:'Welcome'},{lang:' english ',text:' '}] }, {SURVEY_URL:'https://survey.test',RESEND_API_KEY:'key'}); + assert.ok(duplicate.blockers.some(({code})=>code==='template_duplicate')); + const manyInvalid = evaluateReadiness(survey, { recipients:Array.from({length:150},(_,index)=>({respondent_id:index,contact_info:'invalid',uuid:null,lang:''})), templates:[] }, {SURVEY_URL:'https://survey.test',RESEND_API_KEY:'key'}); + assert.ok(manyInvalid.blockerCount > 100); + assert.equal(manyInvalid.blockers.length, 101); + assert.equal(manyInvalid.blockers.at(-1).code, 'blockers_truncated'); +}); + +test('launch fingerprint is canonical and aggregate SQL derives mutually exclusive dispatch states', () => { + assert.equal(fingerprint({targets:[1,2]}), fingerprint({targets:[1,2]})); + assert.notEqual(fingerprint({targets:[1,2]}), fingerprint({targets:[2,1]})); + const sql = aggregateSelect('WHERE l.survey_id=$1'); + for (const state of ['pending','leased','retry_wait','accepted','failed','uncertain','cancelled']) assert.match(sql, new RegExp(state)); + assert.match(sql, /count\(DISTINCT d\.id\)/); + assert.doesNotMatch(sql, /UPDATE survey_launches/); +}); + +test('expired ambiguous attempts never cross the provider idempotency boundary', () => { + const now = new Date('2026-08-04T12:00:00Z'); + assert.equal(isOutsideProviderIdempotencyWindow('2026-08-03T13:00:01Z', now, 23), false); + assert.equal(isOutsideProviderIdempotencyWindow('2026-08-03T13:00:00Z', now, 23), true); + const base = { firstProviderStartedAt:'2026-08-03T13:00:01Z',providerAttemptCount:2,createdAt:'2026-08-03T12:00:00Z',now,idempotencyHours:23,maxAttempts:6,maxAgeHours:72 }; + assert.equal(canRetryAmbiguous(base), true); + assert.equal(canRetryAmbiguous({...base,now:new Date('2026-08-04T12:00:02Z')}), false, 'retry_wait cannot cross the anchored provider window'); + assert.equal(canRetryAmbiguous({...base,providerAttemptCount:6}), false); + assert.equal(canRetryAmbiguous({...base,firstProviderStartedAt:null}), false); +}); + +test('worker retry backoff is bounded and uses injected randomness', () => { + const worker = new DeliveryWorker({ pool:{}, provider:{}, random:()=>0.5, env:{NODE_ENV:'test',EMAIL_MAX_ATTEMPTS:'3'} }); + assert.equal(worker.backoff(1,null),1000); + assert.equal(worker.backoff(20,null),1800000); + assert.equal(worker.backoff(1,'7'),7000); + assert.equal(worker.maxAttempts,3); +}); + +test('transactional launch locks control then survey, snapshots all work, activates, and audits before commit', async () => { + const surveyId='11111111-1111-4111-8111-111111111111'; + const orgId='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const launchId='22222222-2222-4222-8222-222222222222'; + const calls=[]; + const client={release(){},async query(sql,values=[]){calls.push({sql,values}); + if (/SELECT \* FROM email_worker_control/.test(sql)) return {rows:[{claiming_enabled:true,minimum_release:''}]}; + if (/SELECT s\.\*, om\.role/.test(sql)) return {rows:[{id:surveyId,name:'Survey A',organization_id:orgId,role:'editor',lifecycle_status:'draft',archived_at:null,questions:{elements:[{name:'q1',type:'text'}]}}]}; + if (/SELECT respondent_id/.test(sql)) return {rows:[{respondent_id:7,name:'Person',contact_info:'person@example.com',uuid:'secret-token',lang:'English'}]}; + if (/SELECT lang,text FROM email/.test(sql)) return {rows:[{lang:'English',text:'Please participate'}]}; + if (/SELECT id,request_fingerprint/.test(sql)||/SELECT id FROM survey_launches/.test(sql)) return {rows:[],rowCount:0}; + if (/SELECT 1 FROM email_worker_heartbeats/.test(sql)) return {rows:[{}],rowCount:1}; + if (/INSERT INTO survey_launches/.test(sql)) return {rows:[{id:launchId,created_at:new Date()}],rowCount:1}; + return {rows:[],rowCount:1}; + }}; + const result=await launchSurvey({connect:async()=>client},{id:9,isPlatformAdmin:false},surveyId,{kind:'initial',idempotencyKey:'33333333-3333-4333-8333-333333333333'},{NODE_ENV:'test',SURVEY_URL:'https://survey.test',RESEND_API_KEY:'key',SURVEY_DELIVERY_V2_ENABLED:'true'}); + assert.equal(result.status,'queued');assert.equal(result.target_count,1); + assert.match(calls[1].sql,/FOR SHARE/);assert.match(calls[2].sql,/FOR UPDATE OF s/); + assert.equal(calls.some(({sql})=>/INSERT INTO survey_email_deliveries/.test(sql)),true); + assert.equal(calls.filter(({sql})=>/INSERT INTO audit_events/.test(sql)).length,2); + assert.match(calls.at(-1).sql,/COMMIT/); + const deliveryCall=calls.find(({sql})=>/INSERT INTO survey_email_deliveries/.test(sql)); + assert.equal(deliveryCall.values.includes('secret-token'),false,'raw bearer token is not persisted in outbox columns'); +}); + +test('close atomically cancels queued work, fences leased work, and writes strict audit', async()=>{ + const surveyId='11111111-1111-4111-8111-111111111111';const calls=[]; + const client={release(){},async query(sql,values=[]){calls.push({sql,values});if(/SELECT s\.\*, om\.role/.test(sql))return{rows:[{id:surveyId,organization_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',role:'editor',lifecycle_status:'active'}]};return{rows:[],rowCount:1};}}; + const result=await transitionSurvey({connect:async()=>client},{id:4},surveyId,'close'); + assert.equal(result.lifecycleStatus,'closed'); + assert.ok(calls.some(({sql})=>/status IN \('pending','retry_wait','leased'\)/.test(sql)&&/cancellation_requested_at/.test(sql))); + assert.ok(calls.some(({sql})=>/INSERT INTO audit_events/.test(sql))); + assert.match(calls.at(-1).sql,/COMMIT/); +}); + +test('payload hash changes for token, template, or address without persisting rendered token separately', () => { + const base={to:'a@example.com',bodyText:'Welcome',surveyBaseUrl:'https://survey.test',surveyName:'S',token:'one',language:'en'}; + const first=payloadHash(buildInvitationPayload(base)); + assert.notEqual(first,payloadHash(buildInvitationPayload({...base,token:'two'}))); + assert.notEqual(first,payloadHash(buildInvitationPayload({...base,bodyText:'Changed'}))); + assert.notEqual(first,payloadHash(buildInvitationPayload({...base,to:'b@example.com'}))); +}); diff --git a/api/test/security.test.js b/api/test/security.test.js index e2ac485..48e25a6 100644 --- a/api/test/security.test.js +++ b/api/test/security.test.js @@ -42,6 +42,7 @@ const { validateRequiredAnswers, normalizeQuestionNames, formatRespondentChoice, + isTrustedStateChangingOrigin, } = require('../server'); test('question schema requiredness is explicit, typed, and validates submitted answers', () => { @@ -771,13 +772,19 @@ test('authenticated question update rejects nested, unknown, and invalid-identit } if (/sessions/i.test(sql)) return { rows: [], rowCount: 1 }; if (/LEFT JOIN organization_memberships/.test(sql)) { - return { rows: [{ id: 'survey-id', name: 'Survey A', role: 'editor', questions: { elements: [] } }] }; + return { rows: [{ id: '11111111-1111-4111-8111-111111111111', name: 'Survey A', role: 'editor', questions: { elements: [] } }] }; } return { rows: [], rowCount: 0 }; }; let persisted = false; pool.connect = async () => ({ - query: async () => { persisted = true; return { rows: [], rowCount: 0 }; }, + query: async (sql) => { + if (/^\s*(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [], rowCount: 0 }; + if (/FOR UPDATE OF s/.test(sql)) return { rows: [{ id: '11111111-1111-4111-8111-111111111111', name: 'Survey A', role: 'editor', lifecycle_status: 'draft', questions: { elements: [] } }] }; + if (/jsonb_object_keys/.test(sql)) return { rows: [{ max_question_number: '0' }] }; + if (/UPDATE Survey/i.test(sql)) { persisted = true; return { rows: [], rowCount: 1 }; } + return { rows: [], rowCount: 0 }; + }, release() {} }); @@ -906,7 +913,7 @@ test('authenticated question updates allocate above historical response keys for if (/sessions/i.test(sql)) return { rows: [], rowCount: 1 }; if (/LEFT JOIN organization_memberships/.test(sql)) { return { rows: [{ - id: 'history-survey-id', name: 'History Survey', role: 'editor', questions: persistedQuestions, + id: '22222222-2222-4222-8222-222222222222', name: 'History Survey', role: 'editor', questions: persistedQuestions, }] }; } if (/jsonb_object_keys/.test(sql)) { @@ -919,9 +926,15 @@ test('authenticated question updates allocate above historical response keys for const updates = []; pool.connect = async () => ({ query: async (sql, values) => { - updates.push({ sql, values }); - persistedQuestions = /SET title =/.test(sql) ? values[1] : values[0]; - return { rows: [{ name: 'History Survey' }], rowCount: 1 }; + if (/^\s*(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [], rowCount: 0 }; + if (/FOR UPDATE OF s/.test(sql)) return { rows: [{ id: '22222222-2222-4222-8222-222222222222', name: 'History Survey', role: 'editor', lifecycle_status: 'draft', questions: persistedQuestions }] }; + if (/jsonb_object_keys/.test(sql)) { historicalQueries.push({ sql, values }); return { rows: [{ max_question_number: historicalMaximum }] }; } + if (/UPDATE (Survey|survey)/.test(sql)) { + updates.push({ sql, values }); + persistedQuestions = /SET title =/.test(sql) ? values[1] : values[0]; + return { rows: [{ name: 'History Survey' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; }, release() {} }); @@ -991,20 +1004,20 @@ test('authenticated question updates allocate above historical response keys for ['question_13', 'question_14', 'question_16']); assert.equal(addAfterDelete.body.questions.claNextQuestionNumber, 17); - assert.equal(historicalQueries.length, 4); + assert.equal(historicalQueries.length, 3, 'invalid client metadata is rejected before taking the lifecycle lock'); for (const { sql, values } of historicalQueries) { assert.match(sql, /jsonb_object_keys/); assert.match(sql, /\^question_\(\[1-9\]\[0-9\]\*\)\$/); assert.match(sql, /r\.survey_id = \$1 OR \(r\.survey_id IS NULL AND r\.survey_name = \$2\)/); - assert.deepEqual(values, ['history-survey-id', 'History Survey']); + assert.deepEqual(values, ['22222222-2222-4222-8222-222222222222', 'History Survey']); } - assert.deepEqual(updates[0].values, [reorderedAndImported.body.questions, 'history-survey-id']); - assert.deepEqual(updates[1].values, ['Imported', csvAdd.body.questions, 'history-survey-id']); + assert.deepEqual(updates[0].values, [reorderedAndImported.body.questions, '22222222-2222-4222-8222-222222222222']); + assert.deepEqual(updates[1].values, ['Imported', csvAdd.body.questions, '22222222-2222-4222-8222-222222222222']); assert.deepEqual(updates[2].values, [ { ...csvAdd.body.questions, elements: csvAdd.body.questions.elements.slice(0, 2) }, - 'history-survey-id', + '22222222-2222-4222-8222-222222222222', ]); - assert.deepEqual(updates[3].values, [addAfterDelete.body.questions, 'history-survey-id']); + assert.deepEqual(updates[3].values, [addAfterDelete.body.questions, '22222222-2222-4222-8222-222222222222']); }); test('/api/user enforces required answers and accepts omitted optional answers', async (t) => { @@ -1045,8 +1058,11 @@ test('/api/user enforces required answers and accepts omitted optional answers', const persisted = []; pool.connect = async () => ({ query: async (sql, values) => { - persisted.push({ sql, values }); - return { rowCount: 1, rows: [] }; + if (/^\s*(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [], rowCount: 0 }; + if (/FROM Respondent r[\s\S]+JOIN Survey s/.test(sql)) return { rows: [{ respondent_id:91,response:null,can_respond:true,survey_id:'survey-requiredness-id' }] }; + if (/SELECT questions FROM Survey/.test(sql)) return { rows: [{ questions: schema }] }; + if (/UPDATE respondent SET response/.test(sql)) { persisted.push({ sql, values }); return { rowCount: 1, rows: [] }; } + return { rowCount: 0, rows: [] }; }, release() {} }); @@ -1098,7 +1114,7 @@ test('/api/user enforces required answers and accepts omitted optional answers', assert.deepEqual(persisted[0].values[0].question_2, ['one', 'two']); assert.equal(persisted[0].values[0].question_5, undefined, 'hidden required answer may be omitted'); assert.equal(typeof persisted[0].values[0].timeStamp, 'string'); - assert.equal(queryCalls.filter(({ sql }) => /SELECT questions FROM Survey/.test(sql)).length, 3); + assert.equal(queryCalls.filter(({ sql }) => /SELECT questions FROM Survey/.test(sql)).length, 0, 'schema reads use the locked submission transaction, not the pool'); }); test('/api/user rejects nested required omissions and answer constraints before persistence', async (t) => { @@ -1128,7 +1144,13 @@ test('/api/user rejects nested required omissions and answer constraints before }; const persisted = []; pool.connect = async () => ({ - query: async (sql, values) => { persisted.push({ sql, values }); return { rowCount: 1, rows: [] }; }, + query: async (sql, values) => { + if (/^\s*(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [], rowCount: 0 }; + if (/FROM Respondent r[\s\S]+JOIN Survey s/.test(sql)) return { rows: [{ can_respond:true,survey_id:'survey-constraints-id' }] }; + if (/SELECT questions FROM Survey/.test(sql)) return { rows: [{ questions: schema }] }; + if (/UPDATE respondent SET response/.test(sql)) { persisted.push({ sql, values }); return { rowCount: 1, rows: [] }; } + return { rowCount: 0, rows: [] }; + }, release() {}, }); @@ -1197,7 +1219,14 @@ test('/api/user validates lazy tagbox answers against exact same-survey responde }; const persisted = []; pool.connect = async () => ({ - query: async (sql, values) => { persisted.push({ sql, values }); return { rows: [], rowCount: 1 }; }, + query: async (sql, values) => { + if (/^\s*(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [], rowCount: 0 }; + if (/FROM Respondent r[\s\S]+JOIN Survey s/.test(sql)) return { rows: [{ can_respond:true,survey_id:'survey-a-id' }] }; + if (/SELECT questions FROM Survey/.test(sql)) return { rows: [{ questions: schema }] }; + if (/SELECT r\.name, r\.contact_info/.test(sql)) return pool.query(sql, values); + if (/UPDATE respondent SET response/.test(sql)) { persisted.push({ sql, values }); return { rows: [], rowCount: 1 }; } + return { rows: [], rowCount: 0 }; + }, release() {}, }); @@ -1482,6 +1511,14 @@ test('signed demo links load configured questions and real respondents but canno assert.equal(queryCount, 2); }); +test('hosted cookie-authenticated mutations require the exact dashboard Origin', () => { + const base = { stateChanging: true, userId: 1, dashboardOrigin: 'https://dashboard.test', nodeEnv: 'prod' }; + assert.equal(isTrustedStateChangingOrigin({ ...base, origin: undefined }), false); + assert.equal(isTrustedStateChangingOrigin({ ...base, origin: 'https://evil.test' }), false); + assert.equal(isTrustedStateChangingOrigin({ ...base, origin: 'https://dashboard.test' }), true); + assert.equal(isTrustedStateChangingOrigin({ ...base, stateChanging: false, origin: undefined }), true); +}); + test('dashboard/admin endpoints require authentication', async () => { const endpoints = [ ['post', '/api/survey', { surveyName: 'S' }], @@ -1918,10 +1955,11 @@ test('member management, invite, reset, and audit routes are present with requir assert.match(serverSource, /app\.post\('\/api\/password-reset\/request'/); assert.match(serverSource, /app\.post\('\/api\/password-reset\/complete'/); assert.match(serverSource, /eventType: 'member\.updated'/); - assert.match(serverSource, /eventType: 'survey\.archived'/); + const lifecycleSource = fs.readFileSync(path.join(__dirname, '../lifecycle.js'), 'utf8'); + assert.match(lifecycleSource, /'survey\.archived'/); }); -test('/api/testEmail rejects arbitrary recipients instead of falling back to another respondent token', async (t) => { +test('/api/testEmail disables the legacy untracked respondent reminder path', async (t) => { const originalQuery = pool.query; const originalConnect = pool.connect; t.after(() => { @@ -1959,14 +1997,6 @@ test('/api/testEmail rejects arbitrary recipients instead of falling back to ano pool.connect = async () => ({ query: async (sql, values) => { sendTestQueries.push({ sql, values }); - if (/SELECT text, invitation_subject FROM email/.test(sql)) { - return { rows: [{ text: 'Hello {{link}}', invitation_subject: 'Invitation' }] }; - } - if (/SELECT uuid FROM Respondent/.test(sql)) { - assert.match(sql, /lower\(contact_info\) = lower\(\$3\)/); - assert.deepEqual(values, ['11111111-1111-4111-8111-111111111111', 'Survey A', 'attacker@example.com']); - return { rows: [] }; - } return { rows: [], rowCount: 0 }; }, release() {} @@ -1980,9 +2010,9 @@ test('/api/testEmail rejects arbitrary recipients instead of falling back to ano .post('/api/testEmail') .send({ surveyName: 'Survey A', language: 'English', email: 'attacker@example.com' }); - assert.equal(res.status, 404); - assert.match(res.body.message, /Reminders can only be sent/); - assert.equal(sendTestQueries.some((call) => /SELECT uuid FROM Respondent/.test(call.sql)), true); + assert.equal(res.status, 410); + assert.equal(res.body.error, 'reminders_not_available'); + assert.equal(sendTestQueries.length, 0); }); test('dashboard read-only tables hide edit controls and demo email avoids public demo token', () => { @@ -1999,8 +2029,8 @@ test('dashboard read-only tables hide edit controls and demo email avoids public assert.match(respondentTable, /surveyName,/); assert.doesNotMatch(respondentTable, /params\.row\.surveyName/); assert.doesNotMatch(serverSource, /sendMail\(email, 'demo'/); - assert.match(serverSource, /lower\(contact_info\) = lower\(\$3\)/); - assert.match(serverSource, /No active respondent token found/); + assert.match(serverSource, /app\.post\('\/api\/surveys\/:surveyId\/demo-email'/); + assert.match(serverSource, /createDemoToken\(survey\.id, survey\.name\)/); }); test('demo seed is local-guarded, idempotent, and uses real respondent tokens', () => { @@ -2288,11 +2318,13 @@ test('survey create organization defaulting handles none, one, multiple, and pla assert.equal(res.statusCode, 400); }); -test('startSurvey email_sent update and survey archive implementation are scoped and non-destructive', () => { - const serverSource = fs.readFileSync(path.join(__dirname, '../server.js'), 'utf8'); - assert.match(serverSource, /UPDATE Respondent SET email_sent = true WHERE contact_info = ANY\(\$1\) AND survey_name = \$2/); - assert.match(serverSource, /UPDATE survey SET archived_at = CURRENT_TIMESTAMP, archived_by_user_id = \$1 WHERE id = \$2/); - assert.doesNotMatch(serverSource, /DELETE FROM email WHERE survey_name[\s\S]+DELETE FROM respondent WHERE survey_name[\s\S]+DELETE FROM survey WHERE name/); +test('provider acceptance dual-write and survey archive are stable-ID scoped and non-destructive', () => { + const workerSource = fs.readFileSync(path.join(__dirname, '../email-worker.js'), 'utf8'); + const lifecycleSource = fs.readFileSync(path.join(__dirname, '../lifecycle.js'), 'utf8'); + assert.match(workerSource, /UPDATE respondent SET email_sent=true WHERE respondent_id=\$1 AND survey_id=\$2/); + assert.match(lifecycleSource, /UPDATE survey SET archived_at=now\(\),archived_by_user_id=\$1/); + assert.match(lifecycleSource, /cancellation_requested_at/); + assert.doesNotMatch(lifecycleSource, /DELETE FROM (email|respondent|survey)/i); }); test('/api/names rejects demo and does not query/return respondent names', async (t) => { diff --git a/dashboard/src/components/Dashboard.js b/dashboard/src/components/Dashboard.js index da75344..a5da2ce 100644 --- a/dashboard/src/components/Dashboard.js +++ b/dashboard/src/components/Dashboard.js @@ -11,6 +11,8 @@ import EmailNotificationEditor from "./EmailNotificationEditor"; import InvitationSubjectEditor from "./InvitationSubjectEditor"; import CollapsibleSection from "./CollapsibleSection"; import { useAuth } from "../context/AuthContext"; +import SurveyLifecyclePanel from "./SurveyLifecyclePanel"; +import { lifecycleStatus, surveyId } from "./surveyLifecycle"; const Dashboard = () => { const theme = useTheme(); @@ -20,74 +22,75 @@ const Dashboard = () => { const [respondentData, setRespondentData] = React.useState(null); const [createDialogOpen, setCreateDialogOpen] = React.useState(false); const [snackbar, setSnackbar] = React.useState(null); + const surveyRequest = React.useRef(0); + const relatedRequest = React.useRef(0); const { memberships, canViewSensitiveSurveyData, canEditSurvey } = useAuth(); - const fetchSurveyData = async () => { + const fetchSurveyData = React.useCallback(async () => { + const request = ++surveyRequest.current; try { const response = await api.get("/surveys"); - setSurveyData(response.data.surveys); - - // Update selected survey if it still exists - if (selectSurvey) { - const surveyStillExists = response.data.surveys.find( - survey => (survey.id || survey.name) === (selectSurvey.id || selectSurvey.name) - ); - if (!surveyStillExists) { - setSelectSurvey(null); - setQuestionData(null); - setRespondentData(null); - } - } + if (request !== surveyRequest.current) return response.data.surveys || []; + const surveys = response.data.surveys || []; + setSurveyData(surveys); + setSelectSurvey((current) => { + if (!current) return null; + return surveys.find((survey) => surveyId(survey) === surveyId(current)) || null; + }); + return surveys; } catch (err) { - console.log(err); + if (request === surveyRequest.current) { + setSnackbar({ severity: 'error', message: 'Unable to refresh surveys.' }); + } + return []; } - }; + }, []); React.useEffect(() => { fetchSurveyData(); - }, []); + const timer = setInterval(fetchSurveyData, 30000); + return () => clearInterval(timer); + }, [fetchSurveyData]); React.useEffect(() => { + const request = ++relatedRequest.current; + const controller = new AbortController(); const fetchRelatedData = async () => { - if (!selectSurvey) return; - + if (!selectSurvey) { + setQuestionData(null); + setRespondentData(null); + return; + } + const selectedId = surveyId(selectSurvey); try { - // Fetch question data; viewers are allowed to see question text. - const questionResponse = await api.get( - `/listQuestions?surveyName=${selectSurvey.id || selectSurvey.name}` - ); - setQuestionData(questionResponse.data.questions); + const questionResponse = await api.get(`/listQuestions?surveyName=${selectedId}`, { signal: controller.signal }); + if (request === relatedRequest.current) setQuestionData(questionResponse.data.questions); } catch (err) { - console.log(err); - setQuestionData(null); + if (!controller.signal.aborted && request === relatedRequest.current) setQuestionData(null); } if (!canViewSensitiveSurveyData(selectSurvey)) { - setRespondentData(null); + if (request === relatedRequest.current) setRespondentData(null); return; } - try { - // Fetch respondent data for analyst+ roles only because it includes PII. - const respondentResponse = await api.get( - `/targets?surveyName=${selectSurvey.id || selectSurvey.name}` - ); - - // Remove dummy user with name 'None' - const filteredRespondents = respondentResponse.data.filter( - (respondent) => respondent.name !== "None" - ); - setRespondentData(filteredRespondents); + const respondentResponse = await api.get(`/targets?surveyName=${selectedId}`, { signal: controller.signal }); + const filteredRespondents = respondentResponse.data.filter((respondent) => respondent.name !== "None"); + if (request === relatedRequest.current) setRespondentData(filteredRespondents); } catch (err) { - console.log(err); - setRespondentData(null); + if (!controller.signal.aborted && request === relatedRequest.current) setRespondentData(null); } }; - fetchRelatedData(); + return () => controller.abort(); }, [selectSurvey, canViewSensitiveSurveyData]); const handleSelectRow = (childData) => { + if (surveyId(childData) !== surveyId(selectSurvey)) { + relatedRequest.current += 1; + setQuestionData(null); + setRespondentData(null); + } setSelectSurvey(childData); }; @@ -120,13 +123,18 @@ const Dashboard = () => { } }; - const handleRespondentsUpdate = (updatedSurveys) => { + const replaceSurveys = (updatedSurveys) => { setSurveyData(updatedSurveys); + setSelectSurvey((current) => current && updatedSurveys.find((survey) => surveyId(survey) === surveyId(current)) || null); }; - const handleQuestionsUpdate = (updatedSurveys) => { - setSurveyData(updatedSurveys); - }; + const handlePanelSurveyRefresh = React.useCallback(async (selectedId) => { + const surveys = await fetchSurveyData(); + return surveys.find((survey) => surveyId(survey) === selectedId); + }, [fetchSurveyData]); + + const selectedIsLifecycleLocked = Boolean(selectSurvey) && lifecycleStatus(selectSurvey) !== 'draft'; + const selectedReadOnly = !canEditSurvey(selectSurvey) || selectedIsLifecycleLocked; return ( { onSurveyDeleted={handleSurveyDeleted} onSurveyCopied={handleSurveyCopied} selectedSurvey={selectSurvey} + onLifecycleChange={fetchSurveyData} /> @@ -190,29 +199,39 @@ const Dashboard = () => { memberships={memberships} /> + {selectSurvey && } + - Questions are read-only while this survey is {lifecycleStatus(selectSurvey)}.} + {canEditSurvey(selectSurvey) && ( - - + {selectedIsLifecycleLocked ? ( + Invitation subjects are read-only while this survey is {lifecycleStatus(selectSurvey)}. + ) : ( + + )} + )} {canViewSensitiveSurveyData(selectSurvey) && ( + {selectedIsLifecycleLocked && Respondent identities are read-only while this survey is {lifecycleStatus(selectSurvey)}.} )} diff --git a/dashboard/src/components/EmailNotificationEditor.js b/dashboard/src/components/EmailNotificationEditor.js index 6c4c97d..3af4e27 100644 --- a/dashboard/src/components/EmailNotificationEditor.js +++ b/dashboard/src/components/EmailNotificationEditor.js @@ -19,7 +19,7 @@ import api from "../api/axios"; import { LANGUAGES } from "@network-survey/frontend-shared"; const HEADER = "Language,Text\n"; -const EmailNotificationEditor = ({ surveyId }) => { +const EmailNotificationEditor = ({ surveyId, readOnly = false }) => { const theme = useTheme(); const [selectedLanguage, setSelectedLanguage] = useState(null); const [notificationText, setNotificationText] = useState(""); @@ -38,9 +38,16 @@ const EmailNotificationEditor = ({ surveyId }) => { // Fetch notifications for the survey useEffect(() => { + const controller = new AbortController(); + setNotifications({}); + setSelectedLanguage(null); + setNotificationText(''); + setOriginalText(''); + setHasChanges(false); const fetchNotifications = async () => { try { - const response = await api.get(`/survey-notifications/${surveyId}`); + const response = await api.get(`/survey-notifications/${surveyId}`, { signal: controller.signal }); + if (controller.signal.aborted) return; setNotifications(response.data.notifications); // set available languages to all languages @@ -66,6 +73,7 @@ const EmailNotificationEditor = ({ surveyId }) => { setOriginalText(""); } } catch (error) { + if (controller.signal.aborted) return; console.error("Failed to fetch notifications:", error); setAlert({ show: true, @@ -74,9 +82,8 @@ const EmailNotificationEditor = ({ surveyId }) => { }); } }; - if (surveyId) { - fetchNotifications(); - } + if (surveyId) fetchNotifications(); + return () => controller.abort(); }, [surveyId]); // Update the notification text and original text when the selected language changes @@ -98,12 +105,14 @@ const EmailNotificationEditor = ({ surveyId }) => { }; const handleTextChange = (event) => { + if (readOnly) return; const newText = event.target.value; setNotificationText(newText); setHasChanges(newText !== originalText); }; const handleSave = async () => { + if (readOnly) return; try { const csvData = HEADER + LANGUAGES.map(lang => { const text = lang.label === selectedLanguage.label ? `"${notificationText.replace(/"/g, '""')}"` : notifications[lang.label] ? `"${notifications[lang.label].replace(/"/g, '""')}"` : '""'; @@ -139,6 +148,13 @@ const EmailNotificationEditor = ({ surveyId }) => { } }; + useEffect(() => { + if (readOnly && hasChanges) { + setNotificationText(originalText.replace(/"/g, '')); + setHasChanges(false); + } + }, [readOnly, hasChanges, originalText]); + const handleFileUpload = async (event) => { const file = event.target.files?.[0]; if (!file) return; @@ -227,7 +243,8 @@ const EmailNotificationEditor = ({ surveyId }) => { }} > - + {readOnly && Notification templates are read-only after a survey has been launched.} + { component="label" startIcon={} size="small" + disabled={readOnly} > Upload { option?.label || ""} @@ -315,6 +334,7 @@ const EmailNotificationEditor = ({ surveyId }) => { label="Notification Text" value={notificationText} onChange={handleTextChange} + disabled={readOnly} variant="outlined" placeholder={ selectedLanguage diff --git a/dashboard/src/components/QuestionTable.js b/dashboard/src/components/QuestionTable.js index 2fa9639..2dce9b7 100644 --- a/dashboard/src/components/QuestionTable.js +++ b/dashboard/src/components/QuestionTable.js @@ -38,6 +38,9 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } })); setTableRows(updatedRows); setOriginalRows(JSON.parse(JSON.stringify(updatedRows))); + } else { + setTableRows([]); + setOriginalRows([]); } }, [rows]); diff --git a/dashboard/src/components/RespondentTable.js b/dashboard/src/components/RespondentTable.js index 905ff3d..ec78886 100644 --- a/dashboard/src/components/RespondentTable.js +++ b/dashboard/src/components/RespondentTable.js @@ -6,7 +6,6 @@ import api from '../api/axios'; import { Box, Paper, Typography, Button, Switch } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import SaveIcon from '@mui/icons-material/Save'; -import EmailIcon from '@mui/icons-material/Email'; import DeleteIcon from '@mui/icons-material/Delete'; import TableMenuCell from './TableMenuCell'; import { LANGUAGES } from '@network-survey/frontend-shared'; @@ -85,7 +84,29 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal /> ) }, - { field: 'status', headerName: 'Status', width: 120 }, + { + field: 'responseStatus', + headerName: 'Response status', + width: 145, + valueGetter: (_, row) => row.responseStatus || row.response_status || row.status || 'Not started' + }, + { + field: 'emailStatus', + headerName: 'Email status', + width: 140, + valueGetter: (_, row) => row.emailStatus || row.email_status || 'Not queued' + }, + { + field: 'lastEmailAttempt', + headerName: 'Last email attempt', + width: 190, + valueGetter: (_, row) => row.lastEmailAttempt || row.last_email_attempt || null, + valueFormatter: (value) => { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(); + } + }, { field: 'actions', headerName: 'Actions', @@ -96,24 +117,6 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal , - handler: async (row) => { - try { - const response = await api.post('/testEmail', { - email: row.email, - surveyName, - language: row.language - }); - alert(response.data?.message || 'Email sent successfully via test route!'); - } catch (error) { - const errorMsg = error.response?.data?.message || error.message || 'An unknown error occurred'; - console.error('Error sending reminder:', error); - alert('Failed to send reminder: ' + errorMsg); - } - } - }, { label: 'Delete Respondent', icon: , @@ -150,6 +153,9 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal })); setTableRows(updatedRows); setOriginalRows(JSON.parse(JSON.stringify(updatedRows))); + } else { + setTableRows([]); + setOriginalRows([]); } }, [rows]); diff --git a/dashboard/src/components/StartSurveyDialog.js b/dashboard/src/components/StartSurveyDialog.js new file mode 100644 index 0000000..bda3be8 --- /dev/null +++ b/dashboard/src/components/StartSurveyDialog.js @@ -0,0 +1,128 @@ +import React from 'react'; +import { + Alert, Box, Button, Chip, CircularProgress, Dialog, DialogActions, + DialogContent, DialogContentText, DialogTitle, Divider, List, ListItem, + ListItemText, Stack, Typography, +} from '@mui/material'; +import api from '../api/axios'; +import { errorMessage, lifecycleLabel, surveyId } from './surveyLifecycle'; + +const newIntentKey = () => { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + const bytes = new Uint8Array(16); + if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(bytes); + else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}; + +const items = (value) => Array.isArray(value) ? value : []; + +const StartSurveyDialog = ({ open, survey, onClose, onAccepted }) => { + const [readiness, setReadiness] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(''); + const [intentKey, setIntentKey] = React.useState(null); + const requestInFlight = React.useRef(false); + const id = surveyId(survey); + + React.useEffect(() => { + if (!open || !id) return undefined; + const controller = new AbortController(); + setIntentKey(newIntentKey()); + setReadiness(null); + setError(''); + setLoading(true); + api.get(`/surveys/${id}/launch-readiness`, { signal: controller.signal }) + .then((response) => setReadiness(response.data)) + .catch((err) => { + if (!controller.signal.aborted) setError(errorMessage(err, 'Unable to check launch readiness.')); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [open, id]); + + const blockers = items(readiness?.blockers); + const warnings = items(readiness?.warnings); + const rawCoverage = readiness?.templateCoverage || readiness?.template_coverage || readiness?.templates || []; + const coverage = Array.isArray(rawCoverage) ? rawCoverage : Object.entries(rawCoverage).map(([language, value]) => ({ language, ...(typeof value === 'object' ? value : { covered: Boolean(value) }) })); + const canLaunch = Boolean(readiness?.canLaunch) && blockers.length === 0; + + const submit = async () => { + if (!canLaunch || submitting || requestInFlight.current || !intentKey) return; + requestInFlight.current = true; + setSubmitting(true); + setError(''); + try { + const response = await api.post( + `/surveys/${id}/launches`, + { kind: 'initial' }, + { headers: { 'Idempotency-Key': intentKey } }, + ); + if (response.status === 202 || response.data?.launchId || response.data?.id || response.data?.launch?.id) { + onAccepted?.(response.data); + } else { + setError('The server did not confirm that the invitation launch was queued.'); + } + } catch (err) { + if (err.response?.status === 422 && err.response?.data?.details) { + setReadiness(err.response.data.details); + } + setError(errorMessage(err, 'Unable to queue the invitation launch. The same launch key will be reused.')); + } finally { + requestInFlight.current = false; + setSubmitting(false); + } + }; + + const count = (camel, snake) => readiness?.[camel] ?? readiness?.[snake] ?? readiness?.recipientCounts?.[camel.replace('Count', '')] ?? 0; + const messageText = (entry) => typeof entry === 'string' ? entry : entry.message || entry.code; + + return ( + + Launch {survey?.name || 'survey'} + + + This queues real invitation emails containing respondent links. Queued or accepted email is not proof of delivery. + + {loading && Checking readiness…} + {error && {error}} + {readiness && ( + + + + + + + {coverage.length > 0 && ( + Language and template coverage + {coverage.map((entry, index) => ( + + + + ))} + + )} + {blockers.length > 0 && Launch blockers{blockers.map((entry, index) =>
{messageText(entry)}
)}
} + {warnings.length > 0 && Warnings{warnings.map((entry, index) =>
{messageText(entry)}
)}
} + + Confirming will activate this survey when the durable launch is accepted. Delivery progress will appear on the dashboard. +
+ )} +
+ + + + +
+ ); +}; + +export default StartSurveyDialog; diff --git a/dashboard/src/components/StartSurveyDialog.test.js b/dashboard/src/components/StartSurveyDialog.test.js new file mode 100644 index 0000000..5a7a41e --- /dev/null +++ b/dashboard/src/components/StartSurveyDialog.test.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, vi } from 'vitest'; +import api from '../api/axios'; +import StartSurveyDialog from './StartSurveyDialog'; + +vi.mock('../api/axios', () => ({ + default: { get: vi.fn(), post: vi.fn() }, +})); + +const readiness = { + lifecycleStatus: 'draft', + eligibleCount: 3, + excludedCount: 1, + canLaunch: true, + blockers: [], + warnings: [{ code: 'REAL_EMAIL', message: 'Real email will be sent.' }], + templateCoverage: [{ language: 'English', covered: true }], +}; + +beforeEach(() => vi.clearAllMocks()); + +test('shows readiness and queues one truthful idempotent launch', async () => { + api.get.mockResolvedValue({ data: readiness }); + api.post.mockResolvedValue({ status: 202, data: { launchId: 'launch-1' } }); + const accepted = vi.fn(); + render( {}} onAccepted={accepted} />); + + expect(await screen.findByText('3 eligible')).toBeInTheDocument(); + expect(screen.getByText('1 excluded')).toBeInTheDocument(); + expect(screen.getByText(/not proof of delivery/i)).toBeInTheDocument(); + + await userEvent.dblClick(screen.getByRole('button', { name: 'Queue invitations' })); + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1)); + const [, body, config] = api.post.mock.calls[0]; + expect(body).toEqual({ kind: 'initial' }); + expect(config.headers['Idempotency-Key']).toMatch(/^[0-9a-f-]{36}$/i); + expect(accepted).toHaveBeenCalledWith({ launchId: 'launch-1' }); +}); + +test('replaces stale readiness with launch-time blockers after a 422', async () => { + api.get.mockResolvedValue({ data: readiness }); + api.post.mockRejectedValue({ response: { status: 422, data: { message: 'Survey is not ready to launch.', details: { + ...readiness, canLaunch: false, blockers: [{ code: 'template_missing', message: 'A French template is required.' }], + } } } }); + render( {}} onAccepted={() => {}} />); + + await screen.findByText('3 eligible'); + await userEvent.click(screen.getByRole('button', { name: 'Queue invitations' })); + expect(await screen.findByText('A French template is required.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Queue invitations' })).toBeDisabled(); +}); + +test('keeps the same idempotency key and accepts a 200 durable replay after an ambiguous error', async () => { + api.get.mockResolvedValue({ data: readiness }); + api.post + .mockRejectedValueOnce({ response: { status: 503 } }) + .mockResolvedValueOnce({ status: 200, data: { launch: { id: 'launch-1', replayed: true } } }); + render( {}} onAccepted={() => {}} />); + + await screen.findByText('3 eligible'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Queue invitations' })).toBeEnabled()); + await userEvent.click(screen.getByRole('button', { name: 'Queue invitations' })); + expect(await screen.findByText(/delivery is currently unavailable/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Queue invitations' })); + + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(2)); + expect(api.post.mock.calls[1][2].headers['Idempotency-Key']).toBe(api.post.mock.calls[0][2].headers['Idempotency-Key']); +}); diff --git a/dashboard/src/components/SurveyEditor.js b/dashboard/src/components/SurveyEditor.js index b910f55..aa6a04b 100644 --- a/dashboard/src/components/SurveyEditor.js +++ b/dashboard/src/components/SurveyEditor.js @@ -25,6 +25,8 @@ import { } from '../utils/surveyToolbox'; import { hideQuestionValueName } from '../utils/surveyCreatorMetadata'; import { serializeFlatSurveySchema } from '../utils/surveySchemaSerialization'; +import { lifecycleStatus, surveyId } from './surveyLifecycle'; +import { useAuth } from '../context/AuthContext'; // Define and register custom question class for draggableranking class QuestionDraggableRankingModel extends Question { @@ -223,6 +225,7 @@ Serializer.removeProperty('survey', 'logo'); hideQuestionValueName(); const SurveyEditor = () => { + const { canEditSurvey } = useAuth(); const [surveys, setSurveys] = useState([]); const [selectedSurvey, setSelectedSurvey] = useState(null); const [inputValue, setInputValue] = useState(''); @@ -235,21 +238,38 @@ const SurveyEditor = () => { const creatorRef = useRef(null); const surveyHooksRef = useRef(new Map()); const selectedSurveyRef = useRef(null); + const selectedSurveyRecord = surveys.find((survey) => surveyId(survey) === selectedSurvey || survey.name === selectedSurvey) || null; + const lifecycleLocked = Boolean(selectedSurveyRecord) && lifecycleStatus(selectedSurveyRecord) !== 'draft'; + const roleLocked = Boolean(selectedSurveyRecord) && !canEditSurvey(selectedSurveyRecord); + const editorReadOnly = lifecycleLocked || roleLocked; - // Fetch surveys on mount + // Refresh lifecycle/role state while the editor remains open. useEffect(() => { + const controller = new AbortController(); + let first = true; + let inFlight = false; const fetchSurveys = async () => { - setLoading(true); + if (inFlight) return; + inFlight = true; + if (first) setLoading(true); try { - const response = await api.get('/surveys'); - setSurveys(response.data.surveys || []); + const response = await api.get('/surveys', { signal: controller.signal }); + if (!controller.signal.aborted) { + const nextSurveys = response.data.surveys || []; + setSurveys(nextSurveys); + setSelectedSurvey((current) => current && !nextSurveys.some((survey) => surveyId(survey) === current || survey.name === current) ? null : current); + } } catch (err) { - setSurveys([]); + if (first && !controller.signal.aborted) setSurveys([]); } finally { - setLoading(false); + if (first && !controller.signal.aborted) setLoading(false); + first = false; + inFlight = false; } }; fetchSurveys(); + const timer = setInterval(fetchSurveys, 30000); + return () => { clearInterval(timer); controller.abort(); }; }, []); useEffect(() => { @@ -499,6 +519,8 @@ const SurveyEditor = () => { // Normalize the one logical editor page while rejecting unsupported layouts // before Survey Creator can silently discard them in single-page mode. useEffect(() => { + const controller = new AbortController(); + creator.readOnly = editorReadOnly; if (!selectedSurvey) { creator.JSON = {}; return; @@ -507,7 +529,8 @@ const SurveyEditor = () => { setLoading(true); try { // Use the full survey JSON endpoint - const response = await api.get(`/admin/questions?surveyName=${selectedSurvey}`); + const response = await api.get(`/admin/questions?surveyName=${selectedSurvey}`, { signal: controller.signal }); + if (controller.signal.aborted) return; const json = response.data.questions || {}; const flatSchema = serializeFlatSurveySchema(json); creator.JSON = { @@ -519,17 +542,21 @@ const SurveyEditor = () => { configureSurveyModel(creator.survey, 'designer'); } } catch (err) { - creator.JSON = {}; - setSaveError(err.response?.data?.message || err.message || 'Unable to load survey. Please try again.'); + if (!controller.signal.aborted) { + creator.JSON = {}; + setSaveError(err.response?.data?.message || err.message || 'Unable to load survey. Please try again.'); + } } finally { - setLoading(false); + if (!controller.signal.aborted) setLoading(false); } }; loadSurvey(); - }, [selectedSurvey, creator, configureSurveyModel]); + return () => controller.abort(); + }, [selectedSurvey, creator, configureSurveyModel, editorReadOnly]); const handleSaveSurvey = async () => { - if (!selectedSurvey) return; + if (!selectedSurvey || editorReadOnly) return; + const savingSurveyName = selectedSurvey; setSaving(true); setSaveError(null); try { @@ -542,6 +569,7 @@ const SurveyEditor = () => { }); // Adopt the API's canonical names immediately. Otherwise Survey Creator // retains temporary names and a second save allocates fresh identities. + if (selectedSurveyRef.current !== savingSurveyName) return; const savedSchema = serializeFlatSurveySchema(response.data?.questions || questions); creator.JSON = { ...savedSchema, @@ -551,7 +579,7 @@ const SurveyEditor = () => { configureSurveyModel(creator.survey, 'designer'); } } catch (err) { - setSaveError(err.response?.data?.message || err.message || 'Unable to save survey. Please try again.'); + if (selectedSurveyRef.current === savingSurveyName) setSaveError(err.response?.data?.message || err.message || 'Unable to save survey. Please try again.'); } finally { setSaving(false); } @@ -601,9 +629,10 @@ const SurveyEditor = () => { // Handle survey selection or creation const handleSurveyChange = (event, newValue) => { if (typeof newValue === 'string') { - setSelectedSurvey(newValue); + const matchingSurvey = surveys.find((survey) => survey.name === newValue); + setSelectedSurvey(matchingSurvey ? surveyId(matchingSurvey) : null); } else if (newValue && newValue.name) { - setSelectedSurvey(newValue.name); + setSelectedSurvey(surveyId(newValue)); } else { setSelectedSurvey(null); } @@ -613,14 +642,16 @@ const SurveyEditor = () => { s.name)} - value={selectedSurvey || ''} + options={surveys} + disabled={saving} + getOptionLabel={(option) => typeof option === 'string' ? option : option.name || ''} + isOptionEqualToValue={(option, value) => surveyId(option) === surveyId(value)} + value={selectedSurveyRecord} onChange={handleSurveyChange} inputValue={inputValue} onInputChange={(e, v) => setInputValue(v)} renderInput={(params) => ( - + )} sx={{ minWidth: 300 }} /> @@ -628,7 +659,7 @@ const SurveyEditor = () => { @@ -640,6 +671,8 @@ const SurveyEditor = () => { Demo Survey + {lifecycleLocked && Survey design is read-only while this survey is {lifecycleStatus(selectedSurveyRecord)}. You can still preview it.} + {roleLocked && Your role has read-only access to this survey design. You can still preview it.} {saveError && {saveError}} { + const normalized = String(status || 'draft').toLowerCase(); + const color = normalized === 'active' ? 'success' : normalized === 'closed' ? 'default' : 'warning'; + return ; +}; + +const statusText = (status) => String(status || 'queued').replaceAll('_', ' ').replace(/^./, (letter) => letter.toUpperCase()); + +const SurveyLifecyclePanel = ({ survey, onSurveyRefresh, refreshToken = 0 }) => { + const [launches, setLaunches] = React.useState([]); + const [launchSurveyId, setLaunchSurveyId] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(''); + const [manualRefreshToken, setManualRefreshToken] = React.useState(0); + const generation = React.useRef(0); + const id = surveyId(survey); + + const load = React.useCallback(async (signal, expectedGeneration) => { + if (!id) return []; + setLoading(true); + try { + const response = await api.get(`/surveys/${id}/launches`, { signal }); + const next = Array.isArray(response.data) ? response.data : response.data?.launches || []; + if (!signal.aborted && generation.current === expectedGeneration) { + setLaunches(next); + setLaunchSurveyId(id); + setError(''); + await onSurveyRefresh?.(id, expectedGeneration); + } + return next; + } catch (err) { + if (!signal.aborted && generation.current === expectedGeneration) { + setError(err.response?.data?.message || 'Unable to load invitation delivery history.'); + if ([401, 403, 404].includes(err.response?.status)) await onSurveyRefresh?.(id, expectedGeneration); + } + return null; + } finally { + if (!signal.aborted && generation.current === expectedGeneration) setLoading(false); + } + }, [id, onSurveyRefresh]); + + React.useEffect(() => { + if (!id) return undefined; + const expectedGeneration = ++generation.current; + const controller = new AbortController(); + let timer; + let stopped = false; + let failures = 0; + const poll = async () => { + const next = await load(controller.signal, expectedGeneration); + if (stopped || generation.current !== expectedGeneration) return; + if (next === null) { + failures += 1; + timer = setTimeout(poll, Math.min(30000, 3000 * (2 ** Math.min(failures - 1, 4)))); + } else { + failures = 0; + timer = setTimeout(poll, next.some(isLaunchRunning) ? 3000 : 30000); + } + }; + poll(); + return () => { + stopped = true; + clearTimeout(timer); + controller.abort(); + }; + }, [id, load, refreshToken, manualRefreshToken]); + + if (!survey) return null; + const visibleLaunches = launchSurveyId === id ? launches : []; + const latest = visibleLaunches[0] || survey.latestLaunch || survey.latest_launch; + const counts = launchCounts(latest); + const terminal = counts.accepted + counts.failed + counts.uncertain + counts.cancelled; + const progress = counts.target ? Math.min(100, (terminal / counts.target) * 100) : 0; + const hasIssue = counts.failed + counts.uncertain + counts.cancelled > 0; + const summary = latest + ? `${statusText(launchStatus(latest))}: ${terminal} of ${counts.target} finished; ${counts.pending} pending, ${counts.leased} sending, ${counts.retryWait} waiting to retry, ${counts.accepted} accepted, ${counts.failed} failed, ${counts.uncertain} uncertain, ${counts.cancelled} cancelled.` + : 'No invitation launch history.'; + + const manualRefresh = () => setManualRefreshToken((value) => value + 1); + + return ( + + + + + Survey lifecycle + + + + Started {formatDateTime(survey.startedAt || survey.started_at)}{survey.startedByName ? ` by ${survey.startedByName}` : ''} + + + + + + {error && {error}} + {latest && + Latest invitation dispatch + + {summary} + Email status reflects dispatch acceptance, not mailbox delivery. + } + {hasIssue && + Some invitations were not accepted. Failed, uncertain, and cancelled messages remain visible in history. + } + {lifecycleStatus(survey) !== 'draft' && } sx={{ mt: 2 }}> + Questions, respondents, notification templates, and survey design are read-only while this survey is {lifecycleStatus(survey)}. + } + + {visibleLaunches.length > 0 && + Launch history + + CreatedStatusIn progressAccepted / targetFailedUncertainCancelled + {visibleLaunches.map((launch, index) => { const rowCounts = launchCounts(launch); return ( + {formatDateTime(launch.createdAt || launch.created_at)}{statusText(launchStatus(launch))}{rowCounts.pending} pending Ā· {rowCounts.leased} sending Ā· {rowCounts.retryWait} retrying{rowCounts.accepted} / {rowCounts.target}{rowCounts.failed}{rowCounts.uncertain}{rowCounts.cancelled} + ); })} +
+
} +
+ ); +}; + +export default SurveyLifecyclePanel; diff --git a/dashboard/src/components/SurveyLifecyclePanel.test.js b/dashboard/src/components/SurveyLifecyclePanel.test.js new file mode 100644 index 0000000..f54571f --- /dev/null +++ b/dashboard/src/components/SurveyLifecyclePanel.test.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import api from '../api/axios'; +import SurveyLifecyclePanel from './SurveyLifecyclePanel'; +import { launchCounts, launchStatus } from './surveyLifecycle'; + +vi.mock('../api/axios', () => ({ default: { get: vi.fn() } })); + +beforeEach(() => api.get.mockReset()); +afterEach(() => vi.useRealTimers()); + +const deferred = () => { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +}; + +test('normalizes the real snake_case launch aggregate contract', () => { + expect(launchCounts({ + target_count: 5, pending_count: 1, leased_count: 1, retry_wait_count: 1, + accepted_count: 1, failed_count: 0, uncertain_count: 1, cancelled_count: 0, + })).toEqual({ target: 5, pending: 1, leased: 1, retryWait: 1, accepted: 1, failed: 0, uncertain: 1, cancelled: 0 }); + expect(launchStatus({ targetCount: 42, acceptedCount: 42 })).toBe('completed'); +}); + +test('does not render prior survey history while the next survey is loading', async () => { + const second = deferred(); + api.get + .mockResolvedValueOnce({ data: { launches: [{ id: 'old', status: 'failed', counts: { target: 99, failed: 99 } }] } }) + .mockReturnValueOnce(second.promise); + const { rerender } = render(); + expect(await screen.findByText(/99 of 99 finished/)).toBeInTheDocument(); + rerender(); + expect(screen.queryByText(/99 of 99 finished/)).not.toBeInTheDocument(); + await act(async () => { + second.resolve({ data: { launches: [] } }); + await second.promise; + }); +}); + +test('retries launch history with backoff after a transient failure', async () => { + vi.useFakeTimers(); + api.get.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ data: { launches: [] } }); + render(); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(api.get).toHaveBeenCalledTimes(1); + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); + expect(api.get).toHaveBeenCalledTimes(2); +}); + +test('ignores launch history that resolves after the selected survey changes', async () => { + const first = deferred(); + const second = deferred(); + api.get.mockImplementation((url) => String(url || '').includes('survey-1') ? first.promise : second.promise); + const { rerender } = render(); + + rerender(); + await act(async () => { + second.resolve({ data: { launches: [{ id: 'new', status: 'completed', counts: { target: 2, accepted: 2 } }] } }); + await second.promise; + }); + expect(await screen.findByText(/2 of 2 finished/)).toBeInTheDocument(); + + await act(async () => { + first.resolve({ data: { launches: [{ id: 'old', status: 'failed', counts: { target: 99, failed: 99 } }] } }); + await first.promise; + }); + expect(screen.queryByText(/99 of 99 finished/)).not.toBeInTheDocument(); + expect(screen.getByLabelText('Invitation launch history')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('2 accepted'); +}); diff --git a/dashboard/src/components/SurveyTable.js b/dashboard/src/components/SurveyTable.js index 1331a48..dc4598c 100644 --- a/dashboard/src/components/SurveyTable.js +++ b/dashboard/src/components/SurveyTable.js @@ -1,80 +1,87 @@ -import React, { useState, useEffect } from 'react'; +import React, { useMemo } from 'react'; import { DataGrid, GridToolbar } from '@mui/x-data-grid'; import MenuCell from './SurveyTableMenuCell'; +import { LifecycleChip } from './SurveyLifecyclePanel'; +import { launchCounts, lifecycleStatus } from './surveyLifecycle'; -const columns = [ - { field: 'id', headerName: 'ID', width: 90, hidden: true }, - { field: 'name', headerName: 'Survey Name', width: 150 }, - { field: 'respondents', headerName: 'Respondents', width: 200 }, - { field: 'questions', headerName: 'Questions', width: 200 }, - { field: 'date', headerName: 'Creation Date', width: 200 }, - { - field: 'actions', - headerName: 'Actions', - width: 100, - sortable: false, - filterable: false, - renderCell: (params) => ( - - ) - }, -]; - -const SurveyTable = ({ rows, selectRow, onSurveyDeleted, onSurveyCopied, selectedSurvey }) => { - const [tableRows, setTableRows] = useState([]); +const SurveyTable = ({ + rows, + selectRow, + onSurveyDeleted, + onSurveyCopied, + selectedSurvey, + onLifecycleChange, +}) => { + const tableRows = useMemo(() => (rows || []).map((row) => ({ + ...row, + questions: row.questions === 'null' ? '0' : row.questions, + })), [rows]); - useEffect(() => { - if (rows) { - const processedRows = rows.map(row => ({ - ...row, - questions: row.questions === "null" ? "0" : row.questions, - onSurveyDeleted, - onSurveyCopied, - })); - setTableRows(processedRows); - } - }, [rows, onSurveyDeleted, onSurveyCopied]); - - const handleRowClick = (params) => { - selectRow(params.row); - }; + const columns = useMemo(() => [ + { field: 'id', headerName: 'ID', width: 90 }, + { field: 'name', headerName: 'Survey Name', minWidth: 170, flex: 1 }, + { + field: 'lifecycle', + headerName: 'Lifecycle', + width: 110, + sortable: false, + renderCell: ({ row }) => , + }, + { field: 'respondents', headerName: 'Respondents', width: 125 }, + { field: 'questions', headerName: 'Questions', width: 110 }, + { + field: 'invitationSummary', + headerName: 'Invitation dispatch', + width: 210, + sortable: false, + renderCell: ({ row }) => { + const latest = row.latestLaunch || row.latest_launch; + if (!latest) return 'Not launched'; + const counts = launchCounts(latest); + return `${counts.accepted} accepted / ${counts.target}${counts.failed ? ` Ā· ${counts.failed} failed` : ''}${counts.uncertain ? ` Ā· ${counts.uncertain} uncertain` : ''}`; + }, + }, + { field: 'date', headerName: 'Creation Date', width: 170 }, + { + field: 'actions', + headerName: 'Actions', + width: 90, + sortable: false, + filterable: false, + renderCell: ({ row }) => ( + + ), + }, + ], [onSurveyDeleted, onSurveyCopied, onLifecycleChange, selectRow]); return (
row.id || row.name} initialState={{ pagination: { paginationModel: { pageSize: 10 } }, - columns: { - columnVisibilityModel: { - // Hide columns id and lastname. - // Other columns will remain visible - id: false, - }, - } + columns: { columnVisibilityModel: { id: false } }, }} pageSizeOptions={[5, 10, 25, 50, { value: -1, label: 'All' }]} - disableSelectionOnClick - onRowClick={handleRowClick} - components={{ - Toolbar: GridToolbar, - }} + disableRowSelectionOnClick + onRowClick={(params) => selectRow(params.row)} + rowSelectionModel={selectedSurvey ? [selectedSurvey.id || selectedSurvey.name] : []} + slots={{ toolbar: GridToolbar }} sx={{ - '& .MuiDataGrid-columnHeader:hover': { - backgroundColor: 'rgba(66, 179, 175, 0.3)', - }, - '& .MuiDataGrid-row:hover': { - backgroundColor: 'rgba(0, 178, 140, 0.2)', - }, + '& .MuiDataGrid-columnHeader:hover': { backgroundColor: 'rgba(66, 179, 175, 0.3)' }, + '& .MuiDataGrid-row:hover': { backgroundColor: 'rgba(0, 178, 140, 0.2)' }, }} />
); }; -export default SurveyTable; \ No newline at end of file +export default SurveyTable; diff --git a/dashboard/src/components/SurveyTableMenuCell.js b/dashboard/src/components/SurveyTableMenuCell.js index 299ce6a..121fe96 100644 --- a/dashboard/src/components/SurveyTableMenuCell.js +++ b/dashboard/src/components/SurveyTableMenuCell.js @@ -1,27 +1,31 @@ import React, { useState } from 'react'; import { - IconButton, - Menu, - MenuItem, + Alert, + Box, + Button, Dialog, - DialogTitle, + DialogActions, DialogContent, DialogContentText, - DialogActions, - Button, - TextField, - Typography, + DialogTitle, + IconButton, + Menu, + MenuItem, Snackbar, - Alert, - Box + TextField, } from '@mui/material'; import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; import DeleteIcon from '@mui/icons-material/Delete'; import PlayCircle from '@mui/icons-material/PlayCircle'; import EmailIcon from '@mui/icons-material/Email'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import HistoryIcon from '@mui/icons-material/History'; +import StopCircleIcon from '@mui/icons-material/StopCircle'; +import ReplayIcon from '@mui/icons-material/Replay'; import api from '../api/axios'; import SendDemoDialog from './SendDemoDialog'; +import StartSurveyDialog from './StartSurveyDialog'; +import { capability, errorMessage, lifecycleStatus, surveyId } from './surveyLifecycle'; import { useAuth } from '../context/AuthContext'; const buildDefaultCopiedName = (name) => { @@ -31,84 +35,40 @@ const buildDefaultCopiedName = (name) => { return candidate === sourceName ? `${sourceName.slice(0, 250)}Copy2` : candidate; }; -const MenuCell = ({ row, onSurveyDeleted, onSurveyCopied }) => { +const MenuCell = ({ row, onSurveyDeleted, onSurveyCopied, onLifecycleChange, onViewLifecycle }) => { const [anchorEl, setAnchorEl] = useState(null); - const [startConfirmOpen, setStartConfirmOpen] = useState(false); + const [startOpen, setStartOpen] = useState(false); const [copyDialogOpen, setCopyDialogOpen] = useState(false); const [copiedName, setCopiedName] = useState(buildDefaultCopiedName(row.name)); const [copying, setCopying] = useState(false); const [copyError, setCopyError] = useState(''); + const [transition, setTransition] = useState(null); + const [transitioning, setTransitioning] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [archiving, setArchiving] = useState(false); const [demoDialogOpen, setDemoDialogOpen] = useState(false); const [demoSending, setDemoSending] = useState(false); - const [snackbar, setSnackbar] = useState({ - open: false, - message: '', - severity: 'success' - }); - const open = Boolean(anchorEl); - const { canEditSurvey, canArchiveSurvey } = useAuth(); - - // Add handler for closing snackbar - const handleCloseSnackbar = (event, reason) => { - if (reason === 'clickaway') { - return; - } - setSnackbar(prev => ({ ...prev, open: false })); - }; - - - const handleClick = (event) => { - event.stopPropagation(); - setAnchorEl(event.currentTarget); - }; - - const handleClose = (event) => { - if (event) { - event.stopPropagation(); - } - setAnchorEl(null); - }; - - const handleStartClick = (event) => { - event.stopPropagation(); - setStartConfirmOpen(true); - handleClose(); - }; - - // Modify handleStartConfirm - const handleStartConfirm = async () => { - try { - await api.post('/startSurvey', { surveyName: row.id || row.name }); - setStartConfirmOpen(false); - setSnackbar({ - open: true, - message: 'Survey started successfully', - severity: 'success' - }); - } catch (error) { - console.error('Error starting survey:', error); - setSnackbar({ - open: true, - message: 'Failed to start survey. Please try again.', - severity: 'error' - }); - } - }; - - const handleStartCancel = (event) => { - if (event) { - event.stopPropagation(); - } - setStartConfirmOpen(false); - }; + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }); + const { canEditSurvey, canArchiveSurvey, hasSurveyRole } = useAuth(); + const status = lifecycleStatus(row); + const id = surveyId(row); + const canEdit = canEditSurvey(row); + const canLaunch = status === 'draft' && capability(row, 'canLaunch', canEdit); + const canClose = status === 'active' && capability(row, 'canClose', canEdit); + const canReopen = status === 'closed' && capability(row, 'canReopen', hasSurveyRole(row, 'admin')); + + const notify = (message, severity = 'success') => setSnackbar({ open: true, message, severity }); + const handleCloseSnackbar = (_, reason) => reason !== 'clickaway' && setSnackbar((value) => ({ ...value, open: false })); + const stop = (event) => event?.stopPropagation(); + const closeMenu = (event) => { stop(event); setAnchorEl(null); }; + const openAction = (setter) => (event) => { stop(event); setter(true); setAnchorEl(null); }; const handleCopyClick = (event) => { - event.stopPropagation(); + stop(event); setCopiedName(buildDefaultCopiedName(row.name)); setCopyError(''); setCopyDialogOpen(true); - handleClose(); + setAnchorEl(null); }; const handleCopyClose = () => { @@ -131,13 +91,9 @@ const MenuCell = ({ row, onSurveyDeleted, onSurveyCopied }) => { setCopying(true); setCopyError(''); try { - const response = await api.post(`/surveys/${row.id || row.name}/copy`, { name }); + const response = await api.post(`/surveys/${id}/copy`, { name }); setCopyDialogOpen(false); - setSnackbar({ - open: true, - message: response.data?.message || `Survey copied successfully as "${name}".`, - severity: 'success' - }); + notify(response.data?.message || `Survey copied successfully as "${name}".`); await onSurveyCopied?.(response.data?.survey); } catch (error) { setCopyError(error.response?.data?.message || 'Failed to copy survey. Please try again.'); @@ -146,166 +102,87 @@ const MenuCell = ({ row, onSurveyDeleted, onSurveyCopied }) => { } }; - const handleDemoClick = (event) => { - event.stopPropagation(); - setDemoDialogOpen(true); - handleClose(); - }; - const handleDemoSubmit = async (email, language) => { setDemoSending(true); try { - const response = await api.post(`/surveys/${row.id || row.name}/demo-email`, { email, language }); + const response = await api.post(`/surveys/${id}/demo-email`, { email, language }); setDemoDialogOpen(false); - setSnackbar({ - open: true, - message: response.data?.message || 'Demo survey email sent successfully', - severity: 'success' - }); + notify(response.data?.message || 'Demo survey email sent successfully'); } catch (error) { - setSnackbar({ - open: true, - message: error.response?.data?.message || 'Failed to send demo survey email. Please try again.', - severity: 'error' - }); + notify(error.response?.data?.message || 'Failed to send demo survey email. Please try again.', 'error'); } finally { setDemoSending(false); } }; - const handleDeleteClick = (event) => { - event.stopPropagation(); - setDeleteConfirmOpen(true); - handleClose(); + const handleLaunchAccepted = (payload) => { + setStartOpen(false); + notify('Invitation launch queued. Track acceptance and failures in delivery status.'); + onLifecycleChange?.(id, payload); + onViewLifecycle?.(row); }; - const handleDeleteConfirm = async () => { + const handleTransition = async () => { + if (!transition || transitioning) return; + setTransitioning(true); try { - const response = await api.delete(`/survey/${row.id || row.name}`); - if (response.status === 200) { - onSurveyDeleted(row.name); - } + await api.post(`/surveys/${id}/${transition}`); + notify(transition === 'close' + ? 'Survey closed. Unsent invitations are being cancelled.' + : 'Survey reopened. Cancelled invitations were not resumed.'); + setTransition(null); + onLifecycleChange?.(id); } catch (error) { - console.error('Error deleting survey:', error); + notify(errorMessage(error, `Unable to ${transition} this survey.`), 'error'); + } finally { + setTransitioning(false); } - setDeleteConfirmOpen(false); }; - const handleDeleteCancel = (event) => { - if (event) { - event.stopPropagation(); + const handleDeleteConfirm = async () => { + if (archiving) return; + setArchiving(true); + try { + const response = await api.delete(`/survey/${id}`); + if (response.status === 200) onSurveyDeleted?.(row.name); + setDeleteConfirmOpen(false); + } catch (error) { + notify(errorMessage(error, 'Unable to archive this survey.'), 'error'); + } finally { + setArchiving(false); } - setDeleteConfirmOpen(false); }; return ( <> { stop(event); setAnchorEl(event.currentTarget); }} size="small" - aria-label={`Survey actions for ${row.name}`} - sx={{ - '&:hover': { - backgroundColor: 'rgba(66, 179, 175, 0.1)', - } - }} + aria-label={`${status === 'draft' ? 'Survey actions' : 'Actions'} for ${row.name}`} > - - - - {snackbar.message} - + + {snackbar.message} - - - {canEditSurvey(row) && ( - - - Start Survey - - )} - {canEditSurvey(row) && ( - - - Copy Survey - - )} - {canEditSurvey(row) && ( - - - Send Email Demo - - )} - {canArchiveSurvey(row) && ( - - - Archive Survey - - )} + + {canLaunch && Launch Survey} + {status !== 'draft' && { closeMenu(event); onViewLifecycle?.(row); }}>{status === 'closed' ? 'View History' : 'View Delivery Status'}} + {canClose && { closeMenu(event); setTransition('close'); }}>Close Survey} + {canReopen && { closeMenu(event); setTransition('reopen'); }}>Reopen Survey} + {canEdit && Copy Survey} + {canEdit && Send Email Demo} + {canArchiveSurvey(row) && Archive Survey} - e.stopPropagation()} - > - Start Survey - - - Are you sure you want to start the survey "{row.name}"? This will initiate the survey process for all respondents. - - - - - - - + setStartOpen(false)} onAccepted={handleLaunchAccepted} /> - event.stopPropagation()} - fullWidth - maxWidth="sm" - > + Copy survey - { event.preventDefault(); handleCopyConfirm(); }}> + { event.preventDefault(); handleCopyConfirm(); }}> - Copy the complete configuration and respondent roster from ā€œ{row.name}ā€. - Responses, invitation delivery history, completion state, and access links will be reset. + Copy the complete configuration and respondent roster from ā€œ{row.name}ā€. Responses, invitation delivery history, completion state, and access links will be reset. { onChange={(event) => { const nextName = event.target.value; setCopiedName(nextName); - setCopyError(nextName && !/^[A-Za-z0-9]*$/.test(nextName) - ? 'Only letters and numbers are allowed.' - : ''); + setCopyError(nextName && !/^[A-Za-z0-9]*$/.test(nextName) ? 'Only letters and numbers are allowed.' : ''); }} error={Boolean(copyError)} helperText={copyError || 'The survey title and invitation templates will be preserved.'} @@ -327,42 +202,40 @@ const MenuCell = ({ row, onSurveyDeleted, onSurveyCopied }) => { - + - setDemoDialogOpen(false)} - onSubmit={handleDemoSubmit} - surveyName={row.name} - loading={demoSending} - /> + setDemoDialogOpen(false)} onSubmit={handleDemoSubmit} surveyName={row.name} loading={demoSending} /> - e.stopPropagation()} - > - Delete Survey + !transitioning && setTransition(null)} aria-describedby="transition-description"> + {transition === 'close' ? 'Close survey' : 'Reopen survey'} - - Are you sure you want to archive the survey "{row.name}"? Respondents and email templates will be preserved. - + + {transition === 'close' + ? `Close ā€œ${row.name}ā€? Respondents will no longer be able to load or submit it, and unsent invitations will be cancelled.` + : `Reopen ā€œ${row.name}ā€? Respondents can use existing links again. Cancelled invitations will not resume.`} + - - + + !archiving && setDeleteConfirmOpen(false)} aria-describedby="archive-description" aria-busy={archiving}> + Archive Survey + Archive ā€œ{row.name}ā€? Respondents and delivery history will be preserved. + + + + + ); }; -export default MenuCell; \ No newline at end of file +export default MenuCell; diff --git a/dashboard/src/components/SurveyTableMenuCell.test.js b/dashboard/src/components/SurveyTableMenuCell.test.js index 681fb67..b5ca9fc 100644 --- a/dashboard/src/components/SurveyTableMenuCell.test.js +++ b/dashboard/src/components/SurveyTableMenuCell.test.js @@ -15,6 +15,7 @@ vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ canEditSurvey: () => canEdit, canArchiveSurvey: () => false, + hasSurveyRole: () => false, }), })); @@ -118,3 +119,20 @@ test('users without edit access do not see copy or email demo actions', async () expect(screen.queryByText('Copy Survey')).not.toBeInTheDocument(); expect(screen.queryByText('Send Email Demo')).not.toBeInTheDocument(); }); + +test('an active survey offers status and close, but no launch or reminder bypass', async () => { + api.post.mockResolvedValue({ status: 200, data: {} }); + const changed = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Actions for Leadership Survey' })); + expect(screen.getByText('View Delivery Status')).toBeInTheDocument(); + expect(screen.getByText('Close Survey')).toBeInTheDocument(); + expect(screen.queryByText('Launch Survey')).not.toBeInTheDocument(); + expect(screen.queryByText(/reminder/i)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Close Survey')); + await userEvent.click(screen.getByRole('button', { name: 'Close survey' })); + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/surveys/survey-1/close')); + expect(changed).toHaveBeenCalledWith('survey-1'); +}); diff --git a/dashboard/src/components/surveyLifecycle.js b/dashboard/src/components/surveyLifecycle.js new file mode 100644 index 0000000..a63faa6 --- /dev/null +++ b/dashboard/src/components/surveyLifecycle.js @@ -0,0 +1,65 @@ +export const surveyId = (survey) => survey?.id || survey?.surveyId || survey?.name; + +export const lifecycleStatus = (survey) => + String(survey?.lifecycleStatus || survey?.lifecycle_status || 'draft').toLowerCase(); + +export const lifecycleLabel = (status) => ({ + draft: 'Draft', + active: 'Active', + closed: 'Closed', +}[String(status || '').toLowerCase()] || 'Draft'); + +export const capability = (survey, name, fallback = false) => { + const capabilities = survey?.capabilities || {}; + if (typeof capabilities[name] === 'boolean') return capabilities[name]; + return fallback; +}; + +export const launchCounts = (launch) => { + const source = launch?.counts || launch?.dispatchCounts || launch?.dispatch_counts || launch || {}; + const number = (...keys) => { + const value = keys.map((key) => source?.[key]).find((item) => item !== undefined); + const parsed = Number(value || 0); + return Number.isFinite(parsed) ? parsed : 0; + }; + return { + target: number('target', 'targetCount', 'target_count', 'total'), + pending: number('pending', 'pendingCount', 'pending_count'), + leased: number('leased', 'leasedCount', 'leased_count', 'sending'), + retryWait: number('retryWait', 'retry_wait', 'retryWaitCount', 'retry_wait_count', 'retrying'), + accepted: number('accepted', 'acceptedCount', 'accepted_count'), + failed: number('failed', 'failedCount', 'failed_count'), + uncertain: number('uncertain', 'uncertainCount', 'uncertain_count'), + cancelled: number('cancelled', 'cancelledCount', 'cancelled_count'), + }; +}; + +export const launchStatus = (launch) => { + const explicit = launch?.status || launch?.launchStatus; + if (explicit) return String(explicit).toLowerCase(); + const counts = launchCounts(launch); + if (counts.pending + counts.leased + counts.retryWait > 0) return 'processing'; + if (counts.target > 0 && counts.accepted + counts.failed + counts.uncertain + counts.cancelled >= counts.target) { + return counts.failed + counts.uncertain + counts.cancelled > 0 ? 'completed_with_issues' : 'completed'; + } + return 'queued'; +}; +export const isLaunchRunning = (launch) => ['queued', 'processing'].includes(launchStatus(launch)); + +export const formatDateTime = (value) => { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(); +}; + +export const errorMessage = (error, fallback) => { + const status = error?.response?.status; + const detail = error?.response?.data?.message || error?.response?.data?.error; + if (detail) return detail; + if (status === 409) return 'This survey was already launched or changed by another user. Refresh and try again.'; + if (status === 422) return 'The survey is not ready to launch. Review the blockers below.'; + if (status === 429) return 'Launch requests are temporarily limited. Keep this dialog open and try again shortly.'; + if (status === 503) return 'Email delivery is currently unavailable. Keep this dialog open and try again later.'; + if (status >= 500) return 'The service could not accept the request. The same launch key will be reused when you retry.'; + return fallback; +}; diff --git a/db/changelogs/master-changelog.xml b/db/changelogs/master-changelog.xml index 0cc45ee..bcf25a1 100644 --- a/db/changelogs/master-changelog.xml +++ b/db/changelogs/master-changelog.xml @@ -13,4 +13,5 @@ + diff --git a/db/changelogs/v1_6_survey_lifecycle_email_delivery.sql b/db/changelogs/v1_6_survey_lifecycle_email_delivery.sql new file mode 100644 index 0000000..d744944 --- /dev/null +++ b/db/changelogs/v1_6_survey_lifecycle_email_delivery.sql @@ -0,0 +1,227 @@ +--liquibase formatted sql + +--changeset cladvisors:survey-lifecycle-preflight-1 splitStatements:false +--comment Preflight stable IDs and tenant ownership before adding durable lifecycle history. +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '5min'; +DO $$ +BEGIN + IF EXISTS (SELECT id FROM survey GROUP BY id HAVING id IS NULL OR count(*) > 1) THEN + RAISE EXCEPTION 'Survey.id preflight failed: null or duplicate IDs'; + END IF; + IF EXISTS (SELECT 1 FROM survey WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'Survey.organization_id preflight failed: null tenant'; + END IF; + IF EXISTS (SELECT 1 FROM respondent r LEFT JOIN survey s ON s.id = r.survey_id WHERE r.survey_id IS NULL OR s.id IS NULL) THEN + RAISE EXCEPTION 'Respondent.survey_id preflight failed: null/orphan rows'; + END IF; + IF EXISTS (SELECT 1 FROM email e LEFT JOIN survey s ON s.id = e.survey_id WHERE e.survey_id IS NULL OR s.id IS NULL) THEN + RAISE EXCEPTION 'Email.survey_id preflight failed: null/orphan rows'; + END IF; + IF EXISTS (SELECT 1 FROM respondent r JOIN survey s ON s.id=r.survey_id WHERE r.survey_name IS DISTINCT FROM s.name) THEN + RAISE WARNING 'Legacy respondent survey names differ from authoritative stable IDs'; + END IF; + IF EXISTS (SELECT 1 FROM email e JOIN survey s ON s.id=e.survey_id WHERE e.survey_name IS DISTINCT FROM s.name) THEN + RAISE WARNING 'Legacy email survey names differ from authoritative stable IDs'; + END IF; +END $$; +ALTER TABLE survey ADD CONSTRAINT survey_id_not_null_check CHECK (id IS NOT NULL) NOT VALID; +ALTER TABLE survey VALIDATE CONSTRAINT survey_id_not_null_check; +ALTER TABLE survey ADD CONSTRAINT survey_organization_not_null_check CHECK (organization_id IS NOT NULL) NOT VALID; +ALTER TABLE survey VALIDATE CONSTRAINT survey_organization_not_null_check; +ALTER TABLE respondent ADD CONSTRAINT respondent_survey_not_null_check CHECK (survey_id IS NOT NULL) NOT VALID; +ALTER TABLE respondent VALIDATE CONSTRAINT respondent_survey_not_null_check; + +--changeset cladvisors:survey-stable-id-index-1 runInTransaction:false +--comment Drop any valid or invalid leftover build before recreating the stable-ID index; reruns after a failed concurrent build are self-healing. +SET lock_timeout = '5s'; +SET statement_timeout = '5min'; +DROP INDEX CONCURRENTLY IF EXISTS idx_survey_id_full; +CREATE UNIQUE INDEX CONCURRENTLY idx_survey_id_full ON survey(id); +RESET lock_timeout; +RESET statement_timeout; + +--changeset cladvisors:survey-tenant-key-index-1 runInTransaction:false +SET lock_timeout = '5s'; +SET statement_timeout = '5min'; +DROP INDEX CONCURRENTLY IF EXISTS idx_survey_id_org_unique; +CREATE UNIQUE INDEX CONCURRENTLY idx_survey_id_org_unique ON survey(id, organization_id); +RESET lock_timeout; +RESET statement_timeout; + +--changeset cladvisors:respondent-survey-key-index-1 runInTransaction:false +SET lock_timeout = '5s'; +SET statement_timeout = '5min'; +DROP INDEX CONCURRENTLY IF EXISTS idx_respondent_id_survey_unique; +CREATE UNIQUE INDEX CONCURRENTLY idx_respondent_id_survey_unique ON respondent(respondent_id, survey_id); +RESET lock_timeout; +RESET statement_timeout; + +--changeset cladvisors:survey-stable-id-constraints-1 splitStatements:false +--comment Brief-lock promotion of stable IDs and tenant candidate keys. +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; +ALTER TABLE survey ALTER COLUMN id SET NOT NULL; +ALTER TABLE survey ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE respondent ALTER COLUMN survey_id SET NOT NULL; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='survey_id_key' AND conrelid='survey'::regclass) THEN + ALTER TABLE survey ADD CONSTRAINT survey_id_key UNIQUE USING INDEX idx_survey_id_full; + END IF; +END $$; +ALTER TABLE survey DROP CONSTRAINT IF EXISTS survey_id_not_null_check; +ALTER TABLE survey DROP CONSTRAINT IF EXISTS survey_organization_not_null_check; +ALTER TABLE respondent DROP CONSTRAINT IF EXISTS respondent_survey_not_null_check; +DROP INDEX IF EXISTS idx_survey_id_unique; + +--changeset cladvisors:survey-lifecycle-columns-1 splitStatements:false +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '5min'; +ALTER TABLE survey ADD COLUMN IF NOT EXISTS lifecycle_status TEXT NOT NULL DEFAULT 'draft'; +ALTER TABLE survey ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ; +ALTER TABLE survey ADD COLUMN IF NOT EXISTS started_by_user_id INTEGER REFERENCES users(id); +ALTER TABLE survey ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; +ALTER TABLE survey ADD COLUMN IF NOT EXISTS closed_by_user_id INTEGER REFERENCES users(id); +ALTER TABLE survey ADD COLUMN IF NOT EXISTS lifecycle_version INTEGER NOT NULL DEFAULT 0; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='survey_lifecycle_status_check' AND conrelid='survey'::regclass) THEN + ALTER TABLE survey ADD CONSTRAINT survey_lifecycle_status_check CHECK (lifecycle_status IN ('draft','active','closed')); + END IF; +END $$; +-- Preserve known live legacy links without inventing launch history. +UPDATE survey s SET lifecycle_status = CASE + WHEN s.archived_at IS NOT NULL THEN 'closed' + WHEN EXISTS (SELECT 1 FROM respondent r WHERE r.survey_id=s.id AND (r.response IS NOT NULL OR r.email_sent=true)) THEN 'active' + ELSE 'draft' END; +CREATE INDEX IF NOT EXISTS idx_survey_org_lifecycle ON survey(organization_id, lifecycle_status) WHERE archived_at IS NULL; + +--changeset cladvisors:survey-delivery-tables-1 splitStatements:false +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '5min'; +CREATE TABLE survey_launches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + survey_id UUID NOT NULL, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE RESTRICT, + kind TEXT NOT NULL CHECK (kind IN ('initial','reminder','retry_failed')), + parent_launch_id UUID, + idempotency_key TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + requested_by_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + cancelled_at TIMESTAMPTZ, + CONSTRAINT survey_launch_survey_fk FOREIGN KEY (survey_id,organization_id) REFERENCES survey(id,organization_id) ON DELETE RESTRICT, + CONSTRAINT survey_launch_id_scope_unique UNIQUE(id,survey_id,organization_id), + CONSTRAINT survey_launch_idempotency_unique UNIQUE(organization_id,idempotency_key) +); +ALTER TABLE survey_launches ADD CONSTRAINT survey_launch_parent_fk + FOREIGN KEY(parent_launch_id,survey_id,organization_id) REFERENCES survey_launches(id,survey_id,organization_id) ON DELETE RESTRICT; +CREATE UNIQUE INDEX survey_launch_one_initial ON survey_launches(survey_id) WHERE kind='initial'; +CREATE INDEX survey_launch_survey_created ON survey_launches(survey_id,created_at DESC); +CREATE INDEX survey_launch_org_created ON survey_launches(organization_id,created_at DESC); + +CREATE TABLE survey_launch_templates ( + launch_id UUID NOT NULL REFERENCES survey_launches(id) ON DELETE RESTRICT, + language TEXT NOT NULL, + subject TEXT NOT NULL, + body_text TEXT NOT NULL, + template_hash TEXT, + PRIMARY KEY(launch_id,language), + CHECK (language=lower(btrim(language)) AND language<>''), + CHECK (btrim(subject)<>'' AND btrim(body_text)<>'') +); + +CREATE TABLE survey_email_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + launch_id UUID NOT NULL, + survey_id UUID NOT NULL, + organization_id UUID NOT NULL, + respondent_id INTEGER NOT NULL, + to_address TEXT NOT NULL, + recipient_display_name TEXT, + language TEXT NOT NULL, + sender TEXT NOT NULL, + subject TEXT NOT NULL, + template_hash TEXT NOT NULL, + survey_base_url TEXT NOT NULL, + renderer_version TEXT NOT NULL, + render_inputs JSONB NOT NULL DEFAULT '{}'::jsonb, + expected_payload_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','leased','retry_wait','accepted','failed','uncertain','cancelled')), + provider_message_id TEXT, + provider_idempotency_key TEXT NOT NULL UNIQUE, + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count>=0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_owner TEXT, + lease_token UUID, + lease_expires_at TIMESTAMPTZ, + cancellation_requested_at TIMESTAMPTZ, + dispatch_accepted_at TIMESTAMPTZ, + dispatch_failed_at TIMESTAMPTZ, + provider_delivered_at TIMESTAMPTZ, + provider_delayed_at TIMESTAMPTZ, + provider_bounced_at TIMESTAMPTZ, + provider_complained_at TIMESTAMPTZ, + provider_suppressed_at TIMESTAMPTZ, + provider_failed_at TIMESTAMPTZ, + last_error_code TEXT, + last_error_message VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT delivery_launch_scope_fk FOREIGN KEY(launch_id,survey_id,organization_id) REFERENCES survey_launches(id,survey_id,organization_id) ON DELETE RESTRICT, + CONSTRAINT delivery_respondent_survey_fk FOREIGN KEY(respondent_id,survey_id) REFERENCES respondent(respondent_id,survey_id) ON DELETE RESTRICT, + CONSTRAINT delivery_launch_respondent_unique UNIQUE(launch_id,respondent_id), + CHECK(language=lower(btrim(language)) AND language<>'') +); +CREATE UNIQUE INDEX delivery_provider_message_unique ON survey_email_deliveries(provider_message_id) WHERE provider_message_id IS NOT NULL; +CREATE INDEX delivery_due_work ON survey_email_deliveries(next_attempt_at) WHERE status IN ('pending','retry_wait'); +CREATE INDEX delivery_reclaim ON survey_email_deliveries(lease_expires_at) WHERE status='leased'; +CREATE INDEX delivery_survey_created ON survey_email_deliveries(survey_id,created_at DESC); +CREATE INDEX delivery_respondent_created ON survey_email_deliveries(respondent_id,created_at DESC); + +CREATE TABLE survey_email_attempts ( + id BIGSERIAL PRIMARY KEY, + delivery_id UUID NOT NULL REFERENCES survey_email_deliveries(id) ON DELETE RESTRICT, + attempt_number INTEGER NOT NULL CHECK(attempt_number>0), + lease_token UUID NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + provider_started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + outcome TEXT NOT NULL DEFAULT 'in_progress' CHECK(outcome IN ('in_progress','accepted','transient_failure','permanent_failure','uncertain','cancelled')), + provider_code TEXT, + error_message VARCHAR(500), + provider_message_id TEXT, + UNIQUE(delivery_id,attempt_number) +); +CREATE INDEX survey_email_attempt_delivery_started ON survey_email_attempts(delivery_id,started_at DESC); + +CREATE TABLE email_worker_control ( + environment TEXT PRIMARY KEY, + claiming_enabled BOOLEAN NOT NULL DEFAULT false, + minimum_release TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by_user_id INTEGER REFERENCES users(id), + reason VARCHAR(500) +); +INSERT INTO email_worker_control(environment,claiming_enabled,minimum_release) +VALUES ('local',true,''),('test',true,''),('staging',false,''),('prod',false,'') ON CONFLICT DO NOTHING; + +CREATE TABLE email_worker_heartbeats ( + environment TEXT NOT NULL, + worker_instance TEXT NOT NULL, + release_revision TEXT NOT NULL, + enabled BOOLEAN NOT NULL, + claiming BOOLEAN NOT NULL, + heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error VARCHAR(500), + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY(environment,worker_instance) +); +CREATE INDEX email_worker_heartbeat_fresh ON email_worker_heartbeats(environment,heartbeat_at DESC); + +CREATE TABLE email_rate_reservations ( + id BIGSERIAL PRIMARY KEY, + environment TEXT NOT NULL, + reserved_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_email_rate_reservations_budget_time ON email_rate_reservations(environment,reserved_at); +COMMENT ON COLUMN respondent.email_sent IS 'Legacy assumed-provider-accepted flag; not delivery truth. Dual-written only after provider acceptance.'; diff --git a/docs/plan/survey-lifecycle-email-delivery.md b/docs/plan/survey-lifecycle-email-delivery.md new file mode 100644 index 0000000..88e1dba --- /dev/null +++ b/docs/plan/survey-lifecycle-email-delivery.md @@ -0,0 +1,550 @@ +# Survey lifecycle and reliable email delivery plan + +**Status:** Proposed implementation plan +**Branch:** `plan/survey-lifecycle-email-delivery` +**Scope:** API, PostgreSQL/Liquibase, background worker, dashboard UX, deployment, and operations + +## 1. Outcome + +Replace the current meaning of ā€œStart Surveyā€ (fire asynchronous email calls and immediately report success) with two explicit, durable concepts: + +1. **Survey lifecycle** — a survey is `draft`, `active`, or `closed`; archive remains a separate soft-delete state. +2. **Survey launch and delivery history** — every initial launch, reminder, recipient, provider attempt, and known outcome has a durable record. + +The API will return `202 Accepted` after it atomically creates a launch and delivery rows. It will not claim that email was delivered. A PostgreSQL-backed worker will send queued messages, survive process restarts, retry transient failures, and expose progress to the dashboard. + +## 2. Current-state findings + +### Backend and data + +- `POST /api/startSurvey` calls `startSurvey()` and returns `200 "Survey started successfully"`. +- `startSurvey()` calls `sendMail()` inside `respondents.forEach()` without awaiting those promises. The HTTP response can therefore precede all provider results. +- The process-global `emailQueue` is not durable. An API restart loses queued work. +- The database client opened by `startSurvey()` is not released. +- The Resend SDK returns `{ data, error }`; bulk survey sending treats any resolved promise as success and does not consistently inspect `error`. +- Application-only `surveyName` metadata is mixed into the object submitted to Resend instead of being kept locally or sent through documented provider tags. +- Missing language templates can throw only after earlier recipients have already been queued, producing a partial launch. +- Repeated starts resend to every eligible respondent. There is no idempotency key, initial-launch uniqueness, or concurrent-launch guard. +- `Respondent.email_sent` is a single boolean updated by recipient address and legacy survey name. It has no attempt time, provider ID, failure, retry, delivery, bounce, or complaint semantics. +- The current source-regex test for `email_sent` does not test asynchronous correctness. + +### Lifecycle + +- `Survey` has creation and archive timestamps but no draft/active/closed state, starter, start time, closer, or close time. +- Respondent tokens can load/submit without a lifecycle gate; archiving is the only survey-level availability concept. +- Questions, recipients, and templates remain mutable after invitations may have been sent. + +### Dashboard + +- `SurveyTableMenuCell` shows a simple confirmation and success/error Snackbar. +- There is no readiness preview, duplicate-click protection, queued/sending progress, partial-failure state, launch history, or retry flow. +- `RespondentTable.status` is response completion only; there is no independent email status. +- ā€œSend Reminderā€ uses `/api/testEmail`, browser `alert()`, and has no durable history. + +### Deployment + +- The app is one Node API process under PM2 on one EC2 instance, with private RDS PostgreSQL. +- Liquibase runs before PM2 activation. Existing changelog file paths are migration identities and must not change. +- PostgreSQL is the lowest-risk durable queue: it permits transactional launch creation without adding Redis/SQS. SQS would still require a transactional outbox to bridge the database commit. + +## 3. Product semantics + +### Survey lifecycle + +Lifecycle and archive are separate: + +| State | Meaning | Allowed respondent behavior | Admin behavior | +|---|---|---|---| +| `draft` | Configuration is not live | Public respondent token denied | Questions, templates, recipients editable; initial launch allowed | +| `active` | Respondent collection is open | Load and submit allowed | Definition/configuration locked; reminders and retries allowed; close/archive allowed | +| `closed` | Collection intentionally ended | New loads/submissions rejected | Results/history readable; reopen or archive allowed | +| archived (`archived_at`) | Soft-deleted overlay | Rejected | Existing archive authorization remains; data/history retained | + +MVP transitions: + +- `draft -> active`: only through a successful **transactional enqueue** of the one initial launch. +- `active -> closed`: editor-or-higher explicit close action. In the same transaction, cancel all `pending`/`retry_wait` deliveries and request cancellation of leased work. +- `closed -> active`: explicit admin/owner reopen action with audit event. Cancelled launch work is never silently resumed. +- Any non-archived state may be archived under existing admin/owner rules. Archive uses the same delivery-cancellation behavior as close. + +The survey becomes `active` when the launch transaction commits—not when all mail is delivered. Respondent links are then valid while delivery progresses. If all sends ultimately fail, the survey remains active and the dashboard shows the launch failure truthfully. + +An already accepted provider request cannot always be recalled. Close/archive stops unclaimed work, sets `cancellation_requested_at` on leased rows, and makes the worker recheck immediately before provider I/O. A request already crossing the provider boundary is recorded honestly; its link will be unusable after close/archive. + +### Mutation and locking policy + +For MVP, questions, email templates, respondent identity/address/language, and token-bearing identity are locked while active or closed. This preserves the meaning of the launched survey and its response keys. Automatic delivery retries use immutable launch snapshots. + +Every configuration mutation (`/updateEmails`, `/updateTarget`, `/updateTargets`, `/updateQuestions`, `DELETE /user`, `DELETE /question`, and survey-editor save paths), launch, close, reopen, and archive operation must use a transaction, lock the Survey row `FOR UPDATE` in one documented lock order, then check lifecycle/archive state before writing. This prevents a mutation that began while draft from committing after activation. All rejected mutation paths return stable `409 survey_not_editable` responses. + +Respondent submission performs token lookup, lifecycle check, schema validation, and response write in one transaction while holding a Survey `FOR SHARE` lock; close/reopen/archive use `FOR UPDATE`. Two-connection integration tests prove close cannot race a response into a closed survey. + +Adding or changing live recipients/configuration later requires explicit versioning and is deferred. Reminders do not mutate the launched audience; when added in Phase 3, they create a new reminder launch for selected existing respondents. + +### Email terms + +Use precise labels: + +- **Queued:** committed locally and awaiting a worker. +- **Accepted:** Resend accepted the API request and returned a provider message ID. +- **Delivered:** Resend reported mail-server delivery through a verified webhook. +- **Failed:** a permanent provider/application failure or exhausted retries. +- **Uncertain:** provider acceptance may have occurred, but the result could not be reconciled safely. + +Never label `accepted` as `delivered` or show ā€œsurvey started successfullyā€ as proof of email delivery. + +## 4. Additive data model + +Create `db/changelogs/v1_6_survey_lifecycle_email_delivery.sql` and append it to `master-changelog.xml`. Do not edit prior changelogs or rename their recorded paths. + +### Survey additions + +- `lifecycle_status TEXT NOT NULL DEFAULT 'draft'` with check: `draft | active | closed` +- `started_at TIMESTAMPTZ NULL` +- `started_by_user_id INTEGER NULL REFERENCES users(id)` +- `closed_at TIMESTAMPTZ NULL` +- `closed_by_user_id INTEGER NULL REFERENCES users(id)` +- `lifecycle_version INTEGER NOT NULL DEFAULT 0` for optimistic transition checks + +Before new FKs target `Survey.id`, promote it from a nullable column with a partial unique index to a stable key in separate Liquibase changesets: + +1. preflight/backfill null IDs and fail on duplicates/orphaned `survey_id` values; +2. add `CHECK (id IS NOT NULL) NOT VALID`, then validate it; +3. build a non-partial unique index with `CREATE UNIQUE INDEX CONCURRENTLY` in a `runInTransaction:false` changeset; +4. in a brief-lock changeset with explicit `lock_timeout`/`statement_timeout`, set `NOT NULL`, attach a unique constraint using that index, and then remove the temporary check/obsolete partial index. + +Document cleanup for a failed concurrent index. Liquibase's changelog lock does not block application writes, so this sequence must be tested under concurrent traffic against a production-shaped database. Do not drop `Survey.name` or legacy name columns. + +Promote/validate `Survey.organization_id` and `Respondent.survey_id` before enforcing tenant FKs. New tables use `RESTRICT` history retention and composite integrity so IDs cannot cross tenants: launches reference `(Survey.id, organization_id)`, deliveries reference `(launch_id, survey_id, organization_id)`, and deliveries reference `(Respondent.respondent_id, survey_id)`. Quarantine/report legacy orphans rather than silently deleting them. + +### `survey_launches` + +One durable campaign/run: + +- `id UUID PRIMARY KEY DEFAULT gen_random_uuid()` +- `survey_id UUID NOT NULL` +- `organization_id UUID NOT NULL REFERENCES organizations(id)` +- `kind TEXT NOT NULL`: `initial | reminder | retry_failed` +- `parent_launch_id UUID NULL REFERENCES survey_launches(id)` +- `idempotency_key TEXT NOT NULL` +- `request_fingerprint TEXT NOT NULL`, computed from canonical organization ID, survey ID, kind, parent launch ID, and sorted target IDs +- `requested_by_user_id INTEGER NOT NULL REFERENCES users(id)` +- `created_at`, `cancelled_at` as `TIMESTAMPTZ` + +Launch status, counts, first-attempt `startedAt`, and terminal `finishedAt` are **not correctness-critical cached columns**. Status APIs derive them authoritatively in SQL from indexed delivery/attempt rows. This avoids delivery -> launch lock reversal and a separate projector failure mode. Phase-2 provider outcomes are a separate aggregate dimension and are never summed into dispatch progress. If volume later requires materialization, add it only as a disposable cache with freshness monitoring. + +Dispatch invariant: + +`target = pending + leased + retry_wait + accepted + failed + uncertain + cancelled`. + +Launch status is derived in this ordered, mutually exclusive sequence: + +1. `queued`: every delivery is pending and no attempt has started; +2. `processing`: nonterminal work remains and at least one attempt has started; +3. `cancelled`: all deliveries are cancelled; +4. `completed`: all deliveries are accepted; +5. `failed`: all are terminal, none accepted, and at least one is failed or uncertain; +6. `completed_with_errors`: every other all-terminal combination (accepted mixed with failed/uncertain/cancelled). + +Zero-target launches are forbidden. The status endpoint/list query applies these rules directly to delivery rows, so no stale projector can strand a launch. + +Constraints/indexes: + +- `Survey UNIQUE (id, organization_id)` supports `survey_launches (survey_id, organization_id) REFERENCES Survey(id, organization_id) ON DELETE RESTRICT` +- `survey_launches UNIQUE (id, survey_id, organization_id)` supports tenant-scoped delivery and parent references +- parent linkage is composite: `(parent_launch_id, survey_id, organization_id) REFERENCES survey_launches(id, survey_id, organization_id) ON DELETE RESTRICT` +- unique `(organization_id, idempotency_key)`; after authorizing the requested survey, an exact fingerprint replay returns the existing launch while a mismatch returns `409` +- unique initial launch per survey +- for future reminder/retry launches, the common Survey `FOR UPDATE` transaction checks authoritative delivery-derived status and rejects a second nonterminal run; no partial index depends on cached status +- indexes on `(survey_id, created_at DESC)` and `(organization_id, created_at DESC)` + +### `survey_launch_templates` + +Immutable template snapshot per launch/language: + +- `launch_id UUID NOT NULL REFERENCES survey_launches(id) ON DELETE RESTRICT`, normalized `language`, `subject`, `body_text`, optional `template_hash` +- primary key `(launch_id, language)` + +This prevents edits or retries from changing the launch payload. The initial subject may remain the current fixed subject until editable invitation subjects are implemented. + +### `survey_email_deliveries` (transactional outbox) + +One row per launch/respondent: + +- IDs: `id UUID PRIMARY KEY`, `launch_id`, `survey_id`, `organization_id`, `respondent_id` +- immutable snapshots: normalized `to_address`, recipient display name, normalized language, sender, subject, template/version references, `survey_base_url`, `renderer_version`, and non-secret render inputs +- do **not** persist rendered HTML containing the raw bearer respondent UUID; resolve the locked respondent token only at send time. Rendering is deterministic. At enqueue, store a non-reversible hash of the exact expected provider payload; every retry reconstructs and compares it, refusing/marking uncertain on mismatch. Never return/log tokens, rendered bodies, provider payloads, or full addresses in aggregate APIs/logs. +- dispatch state: `pending | leased | retry_wait | accepted | failed | uncertain | cancelled` +- `provider_message_id`, deterministic `provider_idempotency_key` +- `attempt_count`, `next_attempt_at`, `lease_owner`, unique `lease_token`, `lease_expires_at`, `cancellation_requested_at` +- Phase-1 timestamps: `dispatch_accepted_at`, `dispatch_failed_at` +- independent Phase-2 provider timestamps: `provider_delivered_at`, `provider_delayed_at`, `provider_bounced_at`, `provider_complained_at`, `provider_suppressed_at`, `provider_failed_at` +- bounded/sanitized `last_error_code`, `last_error_message` +- `created_at`, `updated_at` + +Constraints/indexes: + +- `Respondent UNIQUE (respondent_id, survey_id)` supports `(respondent_id, survey_id) REFERENCES Respondent(respondent_id, survey_id) ON DELETE RESTRICT` +- `(launch_id, survey_id, organization_id) REFERENCES survey_launches(id, survey_id, organization_id) ON DELETE RESTRICT` +- unique `(launch_id, respondent_id)` +- unique provider message ID when non-null +- partial due-work index on `(next_attempt_at)` for `pending`/`retry_wait` +- partial reclaim index on `(lease_expires_at)` where status is `leased` +- history indexes `(survey_id, created_at DESC)` and `(respondent_id, created_at DESC)` + +Provider outcomes are independent timestamps rather than a single monotonic state because webhooks are at-least-once and may arrive out of order. API presentation uses precedence such as complaint/bounce/suppression over delivered, then accepted. + +### `survey_email_attempts` + +Append-only diagnostics per provider call: + +- `delivery_id UUID NOT NULL REFERENCES survey_email_deliveries(id) ON DELETE RESTRICT`, attempt number, lease token, started/finished time +- outcome (`in_progress | accepted | transient_failure | permanent_failure | uncertain | cancelled`) +- provider HTTP/error code and sanitized message +- provider message ID when known + +The claim transaction increments/allocates the attempt and commits an `in_progress` row **before** network I/O. Finalization is fenced by delivery ID + current lease token. An expired unfinished attempt drives same-key reconciliation or an `uncertain` outcome rather than disappearing from history. Unique `(delivery_id, attempt_number)`. Do not store API keys, raw authorization headers, or unbounded exception payloads. + +### Worker control and heartbeat + +- `email_worker_control`: environment primary key, `claiming_enabled`, minimum allowed release, updated time/actor/reason. Launch readiness/enqueue rejects with `503 worker_unavailable` when claiming is disabled or no fresh compatible heartbeat exists. +- `email_worker_heartbeats`: environment + worker instance primary key, release revision, enabled/claiming state, `heartbeat_at`, bounded last error, startup time. + +The worker updates heartbeat on a fixed interval; deployment requires a compatible heartbeat newer than the configured threshold. An operator script atomically changes the control row. Launch enqueue locks the environment control row `FOR SHARE` inside its transaction before locking the Survey; disabling claims takes `FOR UPDATE`, so no launch can commit after a completed disable operation. Global lock order is control -> Survey -> launch -> delivery. API and worker use least-required DB access where deployment permits; control changes are restricted and audited. + +### Webhook and suppression tables (Phase 2) + +- `email_webhook_events`: unique provider/Svix event ID, verified received time, event type, provider message ID, bounded raw payload with retention deadline; processing state, attempt count, next attempt, lease owner/token/expiry, bounded error, processed/dead-letter timestamps; due/reclaim indexes. +- `email_suppressions`: normalized address, reason (`permanent_bounce | complaint | provider_suppression`), source event, created/overridden timestamps and audited override actor. + +### Legacy compatibility + +- Keep `Respondent.email_sent` during rollout. +- Dual-write it to true only after provider acceptance, by stable respondent and survey IDs. +- Do not backfill new delivery rows from `email_sent`; historical detail cannot be reconstructed. Label it ā€œlegacy assumed accepted.ā€ +- Retire the boolean only after all dashboard/API consumers use delivery records and a later migration is approved. + +### Existing-survey backfill + +Run and retain a pre-migration report. Recommended deterministic backfill: + +- archived surveys -> `closed` +- non-archived surveys with any response or `email_sent=true` -> `active` +- other non-archived surveys -> `draft` + +Before production rollout, review ambiguous rows. A compatibility override may mark selected legacy surveys active if links were distributed outside the system. This avoids inventing delivery history while preserving known live surveys. + +## 5. Launch transaction and readiness + +### Readiness checks + +`GET /api/surveys/:surveyId/launch-readiness` (editor+) returns: + +- lifecycle and archive status +- eligible/excluded recipient counts +- normalized languages and template coverage +- blockers and warnings with stable codes +- `canLaunch` capability + +Block initial launch when: + +- survey is not draft or is archived +- questions are absent/invalid +- there are no eligible `can_respond=true` recipients +- an eligible recipient has invalid email, missing UUID, or unsupported language +- two eligible respondent identities normalize to the same email (avoid sending conflicting respondent links) +- any used language lacks exactly one nonempty template +- survey URL, sender, Resend key, or required worker configuration is absent +- another launch is queued/processing + +### Transactional enqueue + +`POST /api/surveys/:surveyId/launches`, editor+, with `Idempotency-Key: ` and `{ "kind": "initial" }`: + +1. Begin transaction; lock the environment `email_worker_control` row `FOR SHARE` without yet rejecting on availability, then lock the Survey row `FOR UPDATE`—in the declared global order. +2. Resolve organization authorization again inside the operation, preserving the current explicit platform-admin-as-owner override even when no membership row exists. +3. Canonicalize the request, compute its fingerprint, and resolve idempotency. An exact authorized replay returns the existing launch regardless of current worker health; a mismatch returns `409`. +4. Only for new work, validate claiming is enabled plus a fresh compatible heartbeat, then re-run readiness against locked/current data. +5. Create the launch and template snapshots. +6. Bulk-create one pending delivery per eligible respondent. +7. Transition survey `draft -> active`, set starter/time, increment version. +8. Insert `survey.launch_requested` and `survey.lifecycle_changed` audit events using a strict audit function that accepts this transaction's `pg` client; audit failure aborts the transaction. The existing best-effort `logAuditEvent()` is not used for lifecycle operations. +9. Commit; make no provider call inside the transaction. +10. Return `202 Accepted`, `Location` header, lifecycle status, launch ID, and counts. + +Concurrent starts are prevented by the common Survey-row lock, initial-launch constraint, active-run partial index, and idempotency constraint. Database constraint errors are translated to stable `409` responses. + +The dashboard persists the generated key across timeout/ambiguous errors instead of generating a new key on retry. If an initial launch already exists, an authorized request receives its ID/Location rather than an opaque conflict. Retain `POST /api/startSurvey` temporarily as a deprecated adapter: because old clients provide no key, it returns the authorized existing initial launch or creates one with a server-derived initial-launch business key. It must return `202` and the launch payload, not its current false completion response. + +Add `Idempotency-Key` to CORS allowed headers, add `PATCH` to allowed methods for existing/new patch routes, and cover browser preflight. Apply configured dashboard `Origin`/CSRF enforcement to **all authenticated state-changing admin routes**, including lifecycle, questions, templates, targets, deletes, membership, invite, and reset operations; public respondent/demo-token routes use their separate token/rate-limit policy. Change production API sessions from `Domain=.bennetts.work` to a host-only secure cookie so sibling static hosts never receive the API session cookie. Rotate `SESSION_COOKIE_NAME`, explicitly expire the old cookie with the old `.bennetts.work` domain/path attributes, do not accept it, and require a controlled one-time re-login; test requests containing both old and new cookies plus dashboard/API cross-origin credentials. + +## 6. Worker and Resend behavior + +### Process model + +Add a separate `ona-email-worker` PM2 process, sharing the release, environment, database pool utilities, and delivery-domain modules with the API. Refactor reusable mail/render/provider code out of `server.js` so importing worker code cannot start the HTTP server. + +Pin and contract-test a Resend SDK version that demonstrably supports send idempotency (the current `^0.16.0` does not), or send the documented HTTP idempotency header directly. Add a maintained Svix-compatible verifier dependency for Phase 2; do not hand-roll signatures. + +Use one PM2 ecosystem definition for API and worker. Update `scripts/deploy/remote-deploy.sh`, `.github/workflows/rollback-api.yml`, and local development scripts to install, start/restart, verify, stop, and save both processes. A deploy is not healthy merely because PM2 says online: the worker writes a fresh DB heartbeat with enabled/claiming state, release revision, and last error; deployment verifies it. Local scripts/documentation support starting API and worker together. + +### Claim/send/update loop + +1. Before every claim, check the database/runtime claiming kill switch. +2. In a short transaction, claim due rows using `FOR UPDATE SKIP LOCKED`; set owner, a new random fencing `lease_token`, bounded lease expiry, increment attempt count, and insert an `in_progress` attempt; then commit. +3. Acquire an environment-wide rate reservation shared with existing API/demo/account mail (initially a PostgreSQL-backed token bucket with atomic time-slot/token reservation), at its configured budget. If staging and production share one Resend team, configure fixed per-environment budgets whose total—including synchronous mail headroom—stays below the team limit, or use provider accounts with independent limits; separate environment databases alone cannot coordinate a team-wide quota. Limiter wait is included in lease renewal/timeout handling. +4. Immediately before provider I/O, recheck the kill switch, current lease token, cancellation, survey active/non-archived state, launch state, and—after Phase 2—current suppression. Honour a strict provider timeout shorter than the remaining lease (or renew safely). Final-check outcomes are explicit: + - kill switch/temporary worker disable: finish the pre-call attempt with outcome `cancelled` and bounded reason `worker_disabled_before_send`, release the lease to pending/retry-wait, and do not send; + - close/archive/cancellation request: finalize dispatch as cancelled; + - stale lease token: make no delivery/projection mutation; + - current suppression: finalize the attempt and dispatch as cancelled with bounded reason `suppressed`, set `provider_suppressed_at`, and update both dispatch/provider projections. +5. Perform network I/O outside the transaction using deterministic provider idempotency key `survey-delivery/` and documented non-secret provider tags. +6. Explicitly inspect both Resend `{ data, error }`; require the provider message ID before marking accepted. +7. Finalize attempt/delivery only with `WHERE id=? AND status='leased' AND lease_token=?`; stale workers cannot overwrite reclaimed work. Finalization never locks/updates the parent launch; launch APIs derive authoritative aggregates from committed delivery rows. Finalization locks/reads `cancellation_requested_at`: when cancellation was requested, an accepted or genuinely uncertain boundary result is recorded, while a non-acceptance becomes `cancelled` and can never transition to `retry_wait`. Without a cancellation request, ordinary transient failures follow the retry policy and transition to `retry_wait`. +8. Reclaim expired leases using the dedicated partial index. Unfinished attempts remain visible and are reconciled with the same provider key or marked uncertain. + +### Retry classification + +- Retry: timeouts/resets, Resend 5xx, per-second 429, and safe concurrent-idempotency responses. +- Pause/operator action: plan quota exhaustion or provider outage. +- Permanent failure: invalid address/payload, unverified sender, invalid API key, authorization/security rejection, idempotency payload conflict. +- Never auto-resend after bounce, complaint, or suppression. + +Use bounded exponential backoff with full jitter, honor `Retry-After`, cap attempts/age, and mark exhaustion failed. If the worker may have crossed the provider-acceptance boundary and cannot reconcile within Resend's idempotency window, mark `uncertain` rather than risk a duplicate invitation. + +Exactly-once mailbox delivery is not promised. The design provides durable at-least-once processing with local and provider idempotency, and explicitly represents the unavoidable uncertain crash window. + +## 7. Provider webhooks (Phase 2) + +Add `POST /api/webhooks/resend` before JSON parsing for that route, using `express.raw({ type: 'application/json', limit: })`: + +- capture and verify the exact raw bytes with a supported verifier +- verify `svix-id`, bounded timestamp tolerance, and signature with an environment-specific SSM SecureString secret; never log signatures/raw payloads +- atomically insert with unique `svix-id` before returning success; valid duplicates return `200` +- return non-2xx when PostgreSQL is unavailable so Resend retries +- process projections asynchronously using the same fenced lease/retry pattern as delivery work, with bounded retention and a dead-letter/replay state + +Correlate primarily by provider message ID and secondarily by a non-secret delivery-ID tag. Keep unmatched events for reconciliation because a webhook can arrive before the worker saves the provider ID. Track delivered, delayed, bounced, complained, and suppressed independently. Engagement events (open/click) are outside MVP unless explicitly requested. + +Provision separate staging/production webhook endpoints and secrets. Add local suppression enforcement before future launches, with audited manual override. In Phase 2 the worker checks suppression again immediately before every provider call; newly suppressed unsent work atomically finalizes its in-progress attempt and dispatch as cancelled (reason `suppressed`), sets `provider_suppressed_at`, and updates both mutually exclusive dispatch and separate provider-outcome aggregates. Reconciliation and all-targets-suppressed tests cover this case. + +## 8. API surface and authorization + +Stable-ID, organization-scoped endpoints: + +- `GET /api/surveys/:surveyId/launch-readiness` — editor+ +- `POST /api/surveys/:surveyId/launches` — editor+ +- `GET /api/surveys/:surveyId/launches` — viewer+ aggregate history +- `GET /api/surveys/:surveyId/launches/:launchId` — viewer+ aggregates; analyst+ recipient details +- `GET /api/surveys/:surveyId/deliveries?...` — analyst+, cursor pagination/filtering +- `POST /api/surveys/:surveyId/close` — editor+ +- `POST /api/surveys/:surveyId/reopen` — admin/owner + +Phase-3 additions (not exposed in Phase-1 UI): + +- `POST /api/surveys/:surveyId/launches/:launchId/retry-failed` — editor+; creates immutable child launch +- `POST /api/surveys/:surveyId/reminders` — editor+, selected respondent IDs and new idempotency key + +Cross-organization or unknown IDs return `404` to avoid existence disclosure. Server authorization is authoritative; frontend capabilities only mirror it. Preserve the current platform-admin override as owner-equivalent across the API and dashboard; tests cover platform admins without membership rows as well as normal organization roles. + +Extend `GET /api/surveys` with lifecycle status and compact latest-launch aggregates. Extend `/targets` with separate `responseStatus`, latest `emailStatus`, and last attempt time. Do not combine response completion and email delivery. + +Gate respondent question, status, lazy-choice, and submission routes on active/non-archived lifecycle. Submission uses the single locked transaction defined above—not an unprotected second read—to serialize against close/archive. Demo-token routes remain available for authorized demos of drafts and continue to avoid respondent results. + +All configuration endpoint implementations listed in the locking policy move behind shared lifecycle-aware domain services; route handlers cannot call `insertUsers`, `insertEmails`, `insertQuestions`, or response writes outside those transactions. Archive also moves to this service so its audit insertion and queued-work cancellation commit atomically. + +## 9. Dashboard MVP UX + +Follow existing MUI/DataGrid/Dialog/Snackbar patterns rather than introducing a new design system. + +### Survey table + +- Add a compact lifecycle chip: Draft, Active, Closed. +- Add latest invitation summary such as `38 accepted / 42` and failed count. +- Menu action is lifecycle-aware: + - Draft: **Launch Survey** + - Active (Phase 1): **View Delivery Status**, **Close Survey** + - Active (Phase 3): additionally **Send Reminder** and **Retry Failed** + - Closed: **View History**, admin-only **Reopen Survey** +- Keep **Send Email Demo** separate; it does not affect lifecycle. + +### `StartSurveyDialog` (new) + +- Fetch readiness on open. +- Show exact eligible/excluded counts, language/template coverage, blockers, and the warning that real respondent links will be sent. +- Disable confirm for blockers/loading/submitting. +- Generate one stable idempotency key per launch intent; retain it across timeout/network/ambiguous errors and double-clicks. A deliberate fresh intent gets a new key. +- On `202` or an authorized existing-initial replay, say **Invitation launch queued** and navigate/show its status—never ā€œemails delivered.ā€ +- Keep provider/API errors visible and actionable. + +### `SurveyLifecyclePanel` (new) + +Render for the selected survey near the existing table/details: + +- lifecycle chip and starter/start time +- latest launch status and text counts +- Phase-1 linear dispatch progress `(accepted + failed + uncertain + cancelled) / target`; provider outcomes are displayed separately in Phase 2 and never double-counted +- persistent partial-failure/uncertain/cancelled alert +- refresh action; editor retry-failed appears only in Phase 3 +- compact prior-launch history table + +Poll every 2–5 seconds only while queued/processing, using completion-triggered `setTimeout` (no overlapping requests). Cancel when selection changes/unmounts and stop at terminal status. Use text/icons in addition to color and an `aria-live="polite"` progress summary. + +### Respondent table + +- Rename current `Status` to **Response status**. +- Add **Email status** and **Last email attempt**. +- Remove/disable the current `/testEmail` **Send Reminder** action in Phase 1 so it cannot bypass durable history. +- In Phase 3, use Snackbar/Alert feedback and distinguish **Send reminder** from **Retry failed delivery**. +- Recipient-level errors/details require analyst+; future send/retry requires editor+. + +### Accessibility/error states + +- Label action buttons with survey/respondent context. +- Dialogs use descriptions, controlled focus, `aria-busy`, and visible `Alert` blockers. +- Never rely only on chip color. +- Show `409` duplicate/concurrent launch, `422` readiness, `429` throttling/quota, and `500/503` service errors distinctly. +- After every launch/close/reopen and each panel refresh, replace the selected survey object by stable ID from the latest `/surveys` response. Ignore stale poll responses after selection changes. Feed the same lifecycle/capability object to `SurveyTableMenuCell`, `QuestionTable`, `RespondentTable`, `EmailNotificationEditor`, and `SurveyEditor`; active/closed editor surfaces are read-only with an explanatory Alert. + +## 10. Observability and operations + +Before enabling production sending: + +- structured logs keyed by environment, survey ID, launch ID, delivery ID, attempt, and provider ID; redact addresses/error payloads where possible +- dashboard/API visibility for oldest pending age, retry backlog, failed/uncertain count, and worker heartbeat +- alarms/runbook for worker offline, queue age, dead/uncertain deliveries, invalid key/sender, quota errors, and webhook silence +- integrity query/command that validates the dispatch invariant and authoritative derived launch statuses from delivery rows +- audited cancellation/retry/suppression override operations +- explicit retention: no rendered bearer-token HTML is persisted; redact template snapshots/recipient PII after the approved operational window, and purge bounded raw webhook payloads on a fixed configured deadline while retaining aggregate/attempt metadata + +Confirm before production: + +- `survey@cladvisors.com` domain verification and SPF/DKIM/DMARC ownership +- Resend plan quotas and whether staging/production share the team-level rate limit +- before Phase-2 production enablement, separate webhook registrations/secrets in SSM +- final snapshot/rollback path per repository data-preservation policy + +## 11. Delivery phases + +### Phase 0 — immediate correctness hardening + +- Normalize Resend result checking across existing paths. +- Remove application-only properties from provider payloads. +- Replace/bypass the current queue with per-item completion promises for bounded test/reminder paths: each `sendMail` promise settles only after the provider result and legacy DB update. Merely awaiting the existing `rateLimitedSend()` is insufficient because it resolves immediately while another batch is processing. +- Always release DB clients and stop describing provider API acceptance as delivery. +- Add a server-enforced legacy-start kill switch. In staging/production, disable bulk `/startSurvey` before the lifecycle migration; do not hold an HTTP request open for an unbounded campaign or permit ambiguous client retries to resend everyone. Durable Phase 1 re-enables launch through the new adapter. +- Correct invitation rendering before reuse: remove Stripe/lorem-ipsum placeholders, use approved privacy/contact copy, escape stored template text under a plain-text/newline policy (or a separately approved allowlist sanitizer), and generate equivalent HTML and complete plain-text bodies containing the link. Test document language, meaningful logo alt text, descriptive link text, and token appearance only in the intended URL. +- Document `email_sent` as unreliable legacy state. + +This ships as a separate safety release and becomes the minimum rollback release before schema activation. + +### Phase 1 — lifecycle and durable launch MVP + +- Add migration, lifecycle/domain service, template snapshots, delivery outbox, pre-call attempts, atomic audit events, request fingerprints, readiness, and authoritative SQL dispatch/status derivation. +- Add separate PostgreSQL worker, fenced leases, claiming kill switch, local/provider idempotency, automatic classified retries, global rate limiting, PM2 deployment, and DB heartbeat. +- Upgrade/pin and contract-test the Resend integration; add CORS idempotency and mutation Origin/CSRF handling. +- Replace `/startSurvey` with the launch service and return `202`. +- Gate respondent routes and lock every active-survey mutation/submission/lifecycle transition. +- Atomically cancel unsent work on close/archive. +- Add dashboard lifecycle/readiness/Phase-1 dispatch progress/history and separate respondent email status. Manual retries/reminders and suppression are not shown yet. +- Dual-write legacy `email_sent` only on provider acceptance. + +### Phase 2 — delivery truth + +- Add verified, fenced Resend webhook inbox/projection. +- Show delivered/delayed/bounced/complained states as a separate provider-outcome dimension. +- Enforce suppression, add `suppressed` presentation/outcome counts, and add reconciliation/replay runbooks. +- Phase-2 production enablement has its own webhook/signing/suppression gate; it does not block Phase-1 accepted/failed/uncertain production rollout. + +### Phase 3 — reminders and broader mail consolidation + +- Add selected reminders and immutable manual retry launches, their endpoints, dashboard actions, audit events, and phase-specific acceptance tests. +- Move demo, organization invite, and password reset mail onto the same durable service where transaction boundaries allow. +- Account for queue delay when issuing expiring demo/invite/reset tokens. + +### Phase 4 — lifecycle versioning and cleanup + +- Add versioned survey definitions/audiences if live editing after launch is required. +- Replace long-lived raw respondent tokens with hashed, revocable/expiring invitations. +- Complete stable-ID-only joins/FKs and retire legacy name joins and `email_sent` after validated adoption. + +## 12. Rollout and rollback + +1. Ship Phase 0 first, disable the legacy bulk-start route in staging/production, and establish it as the minimum rollback release. Verify old UI/API calls cannot send during maintenance. +2. Put launch/configuration mutations into maintenance-blocked mode, test migration against fresh and production-shaped database copies, and capture pre/post counts and ambiguous lifecycle report. +3. Only then deploy additive schema and dormant lifecycle code. No binary predating the Phase-0 kill switch may serve traffic once migration/backfill starts. +4. Deploy worker disabled by default; validate claim/retry behavior in staging with controlled addresses. +5. Enable v2 launch endpoint/dashboard behind `SURVEY_DELIVERY_V2_ENABLED` in staging. +6. Exercise restart, provider rejection, missing template, duplicate click, partial failure, fenced lease recovery, mutation/launch races, and close-during-submit/send cases. +7. Take/confirm final production snapshot, deploy Phase-1 schema/code, then enable accepted/failed/uncertain tracking. Signed webhooks are a separate Phase-2 staging/production gate. +8. After lifecycle enablement, raise the minimum rollback release to the first lifecycle-aware API/worker release. Do **not** roll back to binaries that ignore lifecycle. +9. Operational rollback first flips the control-table kill switch checked before claims and provider calls, disables new launch creation, waits/bounds in-flight calls, and stops `ona-email-worker` through updated rollback workflow logic independent of the selected artifact. Pending rows remain durable for resume. Do not destructively reverse the migration. + +## 13. Test strategy and acceptance criteria + +### Migration/data + +- fresh migration and production-shaped upgrade +- explicit concurrent-index/constraint promotion sequence, timeouts, failed-index cleanup, and stable Survey ID constraint preflight +- composite candidate-key/FK enforcement (including parent/template/attempt history), mismatched-tenant rejection, orphan validation, and lifecycle backfill report/counts +- no loss or fabricated delivery history +- old API compatibility with additive schema + +### API/security + +- complete role matrix and tenant isolation for readiness/launch/history/details/retry/close/reopen, including platform admin without membership +- cross-org IDs return 404 +- readiness validates every recipient/template before enqueue +- concurrent launches and idempotency replays create exactly one launch/delivery per respondent +- changed payload with reused key returns 409 +- audit rows commit atomically with lifecycle/launch +- draft/closed/archived respondent links denied; active accepted; submission/close two-connection lock race is deterministic +- every listed active mutation route rejected consistently; launch/configuration two-connection races preserve immutable snapshots +- close/archive cancels pending work, requests leased cancellation, and atomically records strict audit events +- CORS preflight accepts Idempotency-Key; all authenticated admin mutations reject untrusted Origin/CSRF; host-only session cookie still supports dashboard/API credentials +- control-row disable/enqueue race proves no launch commits after disable completes + +### Worker/provider + +- resolved `{ error }` is failure; returned provider ID required for acceptance +- pending work survives API/worker restart +- fenced lease expiry/reclaim, stale-worker rejection, dedicated indexes, provider timeout, and `SKIP LOCKED` concurrency +- an `in_progress` attempt exists before provider I/O and unfinished attempts reconcile visibly +- transient/permanent/quota classification, global limiter, `Retry-After`, max attempts/age +- deterministic renderer snapshots base URL/version and verifies provider-payload hash before every retry +- pinned-SDK/API contract proves idempotency transmission, `{data,error}` handling, provider ID requirement, crash-window behavior, and uncertain terminal state +- same email across surveys cannot cross-attribute status +- mutually exclusive dispatch invariant and launch terminal statuses derive correctly from delivery rows after worker/database failures +- PostgreSQL integration suite uses multiple real connections plus fake provider/clock; source-regex mocks are not accepted for concurrency guarantees + +### Webhook + +- bounded exact raw-body signature verification using supported library +- invalid/stale signature rejection, fenced inbox retries, dead-letter/replay, and retention +- duplicate `svix-id` idempotence +- out-of-order and unmatched event handling +- bounce/complaint precedence, send-time suppression, terminal cancelled dispatch projection, and all-targets-suppressed reconciliation + +### Dashboard + +- readiness counts/blockers and accessible dialog +- double-click submits one idempotent request +- truthful queued/progress/partial-failure/completed labels +- polling starts/stops/cancels correctly +- lifecycle actions and role visibility, including lifecycle-read-only editor/table surfaces +- refreshed selected-survey state by stable ID, polling cancellation, and stale-response suppression +- response status remains separate from email status; Phase-1 reminder bypass is absent +- automated accessibility assertions for contextual labels, focus restoration, `aria-busy`, and `aria-live` +- visible actionable errors; no browser `alert()` +- invitation HTML/plain text have approved copy, escaped/sanitized template content, language/accessibility checks, and no unintended token disclosure + +### MVP acceptance + +- Launching a ready draft atomically activates it and returns a durable launch ID within the HTTP request, without waiting on Resend. +- A restart loses no committed delivery and causes no duplicate within provider idempotency guarantees. +- The Phase-1 dashboard shows mutually exclusive pending, processing, retrying, accepted, failed, uncertain, and cancelled dispatch counts and never equates acceptance with delivery. Delivered/bounced/complained/suppressed outcomes appear only after Phase 2. +- Every recipient attempt is attributable to one organization, survey, launch, and respondent. +- Missing templates or invalid recipients cause zero launch emails, not partial sends. +- Initial launch is idempotent by key plus canonical request fingerprint. Automatic transient retries are durable/auditable in Phase 1; manual retries/reminders become explicit and auditable in Phase 3. +- Draft/closed/archived surveys cannot accept real respondent traffic. +- Existing survey/response data and legacy identifiers remain preserved throughout rollout. diff --git a/docs/runbooks/survey-email-worker.md b/docs/runbooks/survey-email-worker.md new file mode 100644 index 0000000..935aade --- /dev/null +++ b/docs/runbooks/survey-email-worker.md @@ -0,0 +1,50 @@ +# Survey email worker rollout and control + +Durable launch rows may be created only when `SURVEY_DELIVERY_V2_ENABLED=true`. Provider dispatch is independently controlled by `email_worker_control.claiming_enabled`; migrations seed hosted environments with claiming disabled. + +## Deployment order + +1. Take or confirm the final database snapshot. +2. Apply Liquibase migrations. +3. Deploy the API and `ona-email-worker` from the same release artifact. +4. Confirm `/health`, worker heartbeat freshness, readiness output, and the release revision while both rollout gates remain disabled. +5. Set the staging Terraform `survey_delivery_v2_enabled` input to true and apply; keep legacy start and claiming disabled. Redeploy the current API artifact so it downloads the updated S3 runtime config, then verify the flag in `/opt/service/current/api/.env.prod`. +6. Enable claiming with the fenced operator command below, then immediately launch a controlled one-recipient staging survey. +7. Inspect its durable delivery/attempt rows and watch acceptance/failure counts, worker heartbeat, and provider logs before production enablement. + +The hosted `.env.prod` must explicitly set: + +- `EMAIL_WORKER_ENV=staging` or `EMAIL_WORKER_ENV=prod` +- `SURVEY_DELIVERY_V2_ENABLED=false` until that environment's controlled rollout +- `LEGACY_START_ENABLED=false` (the compatibility bulk-start adapter remains disabled) +- `EMAIL_RATE_PER_SECOND=1` in staging and at most `4` in production; their independent databases therefore sum to the approved five-request provider-account budget +- `EMAIL_RATE_BUDGET_ENV` equal to the environment so synchronous and worker sends share that environment's allocation + +## Enable or disable claiming + +Run through AWS Systems Manager Session Manager on the target instance. Enabling is revision-fenced and fails unless the matching worker has a fresh heartbeat. + +```bash +cd /opt/service/current/api +REVISION=$(cat /opt/service/current/REVISION) +EMAIL_WORKER_ENV=staging EXPECTED_RELEASE_REVISION="$REVISION" NODE_ENV=prod \ + node ../deploy/set-email-claiming.js true controlled-rollout +``` + +Emergency stop (does not require a healthy worker): + +```bash +cd /opt/service/current/api +EMAIL_WORKER_ENV=staging NODE_ENV=prod \ + node ../deploy/set-email-claiming.js false emergency-stop +``` + +Use `prod` only on the production instance. Never update the control row manually; the script locks the row, validates the namespace, and records the reason. + +## Rollback + +The rollback workflow validates the target artifact before disabling claiming. It refuses pre-lifecycle artifacts. If activation fails, it restores the prior symlink/processes and attempts to re-enable the prior revision. If automatic re-enable cannot verify a fresh heartbeat, claiming stays disabled for safety and must be restored with the command above. + +## Ambiguous provider calls + +A leased attempt recovered inside the configured provider-idempotency window reuses its durable provider key. Once that boundary has expired, the worker marks the delivery `uncertain` rather than risking a duplicate send. Do not manually retry uncertain deliveries in Phase 1. diff --git a/package-lock.json b/package-lock.json index f9ea52b..8c84d7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "network-survey", + "name": "survey-lifecycle-email-delivery", "lockfileVersion": 2, "requires": true, "packages": { @@ -40,14 +40,36 @@ "nanoid": "^3.3.4", "papaparse": "^5.4.1", "pg": "^8.13.1", - "resend": "^0.16.0", - "sqlite3": "^5.1.6" + "resend": "6.18.1", + "sqlite3": "^5.1.6", + "survey-core": "2.5.35" }, "devDependencies": { "nodemon": "^2.0.22", "supertest": "^7.2.2" } }, + "api/node_modules/resend": { + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.18.1.tgz", + "integrity": "sha512-XN8XIaDdKF+ziSQ3K23ndUcyhP7U3ze2gky6SPgYkuAOq54mH4Wdhwm7QylEQ3zlz0NzdX7/l1AgmJUZbdPI/Q==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "dashboard": { "name": "my-app", "version": "0.1.0", @@ -77,7 +99,9 @@ "react-quill": "^2.0.0", "react-router-dom": "^6.30.0", "react-tabs": "^6.0.2", - "survey-creator-react": "^2.0.4", + "survey-core": "2.5.35", + "survey-creator-react": "2.5.35", + "survey-react-ui": "2.5.35", "web-vitals": "^2.1.4" }, "devDependencies": { @@ -2490,6 +2514,12 @@ "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2768,8 +2798,8 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 6" } @@ -3606,8 +3636,6 @@ "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "acorn": "^7.1.1", "acorn-walk": "^7.1.1" @@ -3619,8 +3647,6 @@ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3634,8 +3660,6 @@ "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=0.4.0" } @@ -4059,9 +4083,7 @@ "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true + "license": "BSD-2-Clause" }, "node_modules/browserslist": { "version": "4.28.7", @@ -4762,9 +4784,7 @@ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/cssstyle": { "version": "2.3.0", @@ -5208,8 +5228,6 @@ "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "abab": "^2.0.3", "whatwg-mimetype": "^2.3.0", @@ -5225,8 +5243,6 @@ "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "punycode": "^2.1.1" }, @@ -5240,8 +5256,6 @@ "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "lodash": "^4.7.0", "tr46": "^2.1.0", @@ -5510,8 +5524,6 @@ "deprecated": "Use your platform's native DOMException instead", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "webidl-conversions": "^5.0.0" }, @@ -5525,8 +5537,6 @@ "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", "dev": true, "license": "BSD-2-Clause", - "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -6126,6 +6136,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -6578,8 +6594,6 @@ "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "whatwg-encoding": "^1.0.5" }, @@ -6651,8 +6665,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "@tootallnate/once": "1", "agent-base": "6", @@ -7480,8 +7494,6 @@ "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "abab": "^2.0.5", "acorn": "^8.2.4", @@ -7529,8 +7541,6 @@ "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -7548,8 +7558,6 @@ "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "punycode": "^2.1.1" }, @@ -7563,8 +7571,6 @@ "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "lodash": "^4.7.0", "tr46": "^2.1.0", @@ -9042,9 +9048,7 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/parseley": { "version": "0.11.0", @@ -9232,6 +9236,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.22", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", @@ -10176,8 +10186,6 @@ "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", "dev": true, "license": "ISC", - "optional": true, - "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -10658,6 +10666,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -12138,8 +12156,6 @@ "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "browser-process-hrtime": "^1.0.0" } @@ -12150,8 +12166,6 @@ "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "xml-name-validator": "^3.0.0" }, @@ -12171,8 +12185,6 @@ "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", "dev": true, "license": "BSD-2-Clause", - "optional": true, - "peer": true, "engines": { "node": ">=10.4" } @@ -12184,8 +12196,6 @@ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "iconv-lite": "0.4.24" } @@ -12196,8 +12206,6 @@ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -12210,9 +12218,7 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/whatwg-url": { "version": "5.0.0", @@ -12341,8 +12347,6 @@ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -12364,9 +12368,7 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true + "license": "Apache-2.0" }, "node_modules/xmlchars": { "version": "2.2.0", @@ -13308,7 +13310,8 @@ } }, "@network-survey/frontend-shared": { - "version": "file:frontend-shared" + "version": "file:frontend-shared", + "requires": {} }, "@noble/hashes": { "version": "1.8.0", @@ -13555,6 +13558,11 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==" }, + "@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" + }, "@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -13736,7 +13744,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "optional": true + "devOptional": true }, "@types/aria-query": { "version": "5.0.4", @@ -14268,8 +14276,6 @@ "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", "dev": true, - "optional": true, - "peer": true, "requires": { "acorn": "^7.1.1", "acorn-walk": "^7.1.1" @@ -14279,9 +14285,7 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, - "optional": true, - "peer": true + "dev": true } } }, @@ -14289,9 +14293,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "ag-grid-community": { "version": "29.3.5", @@ -14380,9 +14382,21 @@ "nodemon": "^2.0.22", "papaparse": "^5.4.1", "pg": "^8.13.1", - "resend": "^0.16.0", + "resend": "6.18.1", "sqlite3": "^5.1.6", - "supertest": "^7.2.2" + "supertest": "^7.2.2", + "survey-core": "2.5.35" + }, + "dependencies": { + "resend": { + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.18.1.tgz", + "integrity": "sha512-XN8XIaDdKF+ziSQ3K23ndUcyhP7U3ze2gky6SPgYkuAOq54mH4Wdhwm7QylEQ3zlz0NzdX7/l1AgmJUZbdPI/Q==", + "requires": { + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" + } + } } }, "aproba": { @@ -14588,9 +14602,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "browserslist": { "version": "4.28.7", @@ -15043,9 +15055,7 @@ "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "cssstyle": { "version": "2.3.0", @@ -15344,8 +15354,6 @@ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", "dev": true, - "optional": true, - "peer": true, "requires": { "abab": "^2.0.3", "whatwg-mimetype": "^2.3.0", @@ -15357,8 +15365,6 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", "dev": true, - "optional": true, - "peer": true, "requires": { "punycode": "^2.1.1" } @@ -15368,8 +15374,6 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", "dev": true, - "optional": true, - "peer": true, "requires": { "lodash": "^4.7.0", "tr46": "^2.1.0", @@ -15544,8 +15548,6 @@ "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", "dev": true, - "optional": true, - "peer": true, "requires": { "webidl-conversions": "^5.0.0" }, @@ -15554,9 +15556,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "dev": true, - "optional": true, - "peer": true + "dev": true } } }, @@ -15980,6 +15980,11 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "dev": true }, + "fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -16283,8 +16288,6 @@ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", "dev": true, - "optional": true, - "peer": true, "requires": { "whatwg-encoding": "^1.0.5" } @@ -16334,7 +16337,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "optional": true, + "devOptional": true, "requires": { "@tootallnate/once": "1", "agent-base": "6", @@ -16868,8 +16871,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", "dev": true, - "optional": true, - "peer": true, "requires": { "abab": "^2.0.5", "acorn": "^8.2.4", @@ -16905,8 +16906,6 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz", "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==", "dev": true, - "optional": true, - "peer": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -16920,8 +16919,6 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", "dev": true, - "optional": true, - "peer": true, "requires": { "punycode": "^2.1.1" } @@ -16931,8 +16928,6 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", "dev": true, - "optional": true, - "peer": true, "requires": { "lodash": "^4.7.0", "tr46": "^2.1.0", @@ -17530,7 +17525,9 @@ "react-quill": "^2.0.0", "react-router-dom": "^6.30.0", "react-tabs": "^6.0.2", - "survey-creator-react": "^2.0.4", + "survey-core": "2.5.35", + "survey-creator-react": "2.5.35", + "survey-react-ui": "2.5.35", "vite": "^5.4.11", "vite-plugin-svgr": "^4.3.0", "vitest": "^2.1.8", @@ -17773,6 +17770,7 @@ "@vitejs/plugin-react": "^4.3.4", "cors": "^2.8.5", "dotenv": "^16.3.1", + "jsdom": "^16.7.0", "nanoid": "^3.3.4", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", @@ -17783,6 +17781,7 @@ "survey-react-ui": "2.5.35", "vite": "^5.4.11", "vite-plugin-svgr": "^4.3.0", + "vitest": "^2.1.9", "web-vitals": "^2.1.4" } }, @@ -18106,9 +18105,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "parseley": { "version": "0.11.0", @@ -18236,6 +18233,11 @@ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==" }, + "postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==" + }, "postcss": { "version": "8.5.22", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", @@ -18883,8 +18885,6 @@ "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", "dev": true, - "optional": true, - "peer": true, "requires": { "xmlchars": "^2.2.0" } @@ -19215,6 +19215,15 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "requires": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -20098,8 +20107,6 @@ "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", "dev": true, - "optional": true, - "peer": true, "requires": { "browser-process-hrtime": "^1.0.0" } @@ -20109,8 +20116,6 @@ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", "dev": true, - "optional": true, - "peer": true, "requires": { "xml-name-validator": "^3.0.0" } @@ -20124,17 +20129,13 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "whatwg-encoding": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", "dev": true, - "optional": true, - "peer": true, "requires": { "iconv-lite": "0.4.24" }, @@ -20144,8 +20145,6 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, - "optional": true, - "peer": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -20156,9 +20155,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "whatwg-url": { "version": "5.0.0", @@ -20250,17 +20247,13 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "dev": true, - "optional": true, - "peer": true, "requires": {} }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "xmlchars": { "version": "2.2.0", diff --git a/scripts/ci/api-smoke.sh b/scripts/ci/api-smoke.sh index 084508b..c5e189c 100644 --- a/scripts/ci/api-smoke.sh +++ b/scripts/ci/api-smoke.sh @@ -11,6 +11,11 @@ export DB_PORT=${DB_PORT:-5432} export DB_NAME=${DB_NAME:-ONA} export SESSION_SECRET=${SESSION_SECRET:-ci-smoke-secret} export PORT=${PORT:-3000} +export SURVEY_URL=${SURVEY_URL:-http://survey.example.test} +export RESEND_API_KEY=${RESEND_API_KEY:-ci-not-used-provider-key} +export EMAIL_WORKER_ENV=${EMAIL_WORKER_ENV:-test} +export SURVEY_DELIVERY_V2_ENABLED=true +export LEGACY_START_ENABLED=true BASE="http://127.0.0.1:$PORT" COOKIES=$(mktemp) @@ -89,6 +94,45 @@ echo "==> Authenticated survey CRUD" curl -fsS -b "$COOKIES" -X POST "$BASE/api/survey" \ -H 'Content-Type: application/json' \ -d '{"surveyName":"CISmokeSurvey"}' >/dev/null -curl -fsS -b "$COOKIES" "$BASE/api/surveys" | grep -q 'CISmokeSurvey' +SURVEYS=$(curl -fsS -b "$COOKIES" "$BASE/api/surveys") +echo "$SURVEYS" | grep -q 'CISmokeSurvey' +SURVEY_ID=$(printf '%s' "$SURVEYS" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).surveys.find(x=>x.name==='CISmokeSurvey').id))") + +echo "==> Seed launch readiness and worker heartbeat" +SURVEY_ID="$SURVEY_ID" node <<'NODE' +const { createRequire } = require('module'); +const path = require('path'); +const apiRequire = createRequire(path.resolve(process.cwd(), 'api/package.json')); +const { Pool } = apiRequire('pg'); +const pool = new Pool({ user:process.env.DB_USER, password:process.env.DB_PASSWORD, host:process.env.DB_HOST, port:Number(process.env.DB_PORT), database:process.env.DB_NAME }); +(async () => { + const survey = await pool.query(`UPDATE survey SET questions=$2::jsonb WHERE id=$1 RETURNING id,name`, [process.env.SURVEY_ID, JSON.stringify({elements:[{type:'text',name:'question_1',title:'Smoke question'}]})]); + await pool.query(`INSERT INTO respondent(name,contact_info,survey_name,survey_id,can_respond,uuid,lang,email_sent) VALUES + ('Smoke Respondent','smoke@example.test',$1,$2,true,'ci-smoke-respondent-token','English',false), + ('Smoke Respondent Two','smoke2@example.test',$1,$2,true,'ci-smoke-respondent-token-2','English',false), + ('Smoke Respondent Three','smoke3@example.test',$1,$2,true,'ci-smoke-respondent-token-3','English',false)`, [survey.rows[0].name,survey.rows[0].id]); + await pool.query(`INSERT INTO email(survey_name,survey_id,lang,text) VALUES($1,$2,'English','Please complete the smoke survey.')`, [survey.rows[0].name,survey.rows[0].id]); + await pool.query(`INSERT INTO email_worker_heartbeats(environment,worker_instance,release_revision,enabled,claiming,heartbeat_at) VALUES('test','ci-smoke','local',true,true,now()) ON CONFLICT(environment,worker_instance) DO UPDATE SET heartbeat_at=now(),enabled=true,claiming=true`); + await pool.end(); +})().catch(async (error) => { console.error(error); await pool.end().catch(()=>{}); process.exit(1); }); +NODE + +echo "==> Durable lifecycle launch is ready and idempotent" +curl -fsS -b "$COOKIES" "$BASE/api/surveys/$SURVEY_ID/launch-readiness" | grep -q '"canLaunch":true' +LAUNCH_HEADERS=$(mktemp) +LAUNCH_BODY=$(mktemp) +curl -fsS -D "$LAUNCH_HEADERS" -o "$LAUNCH_BODY" -b "$COOKIES" -X POST "$BASE/api/surveys/$SURVEY_ID/launches" \ + -H 'Content-Type: application/json' -H 'Idempotency-Key: 11111111-1111-4111-8111-111111111111' -d '{"kind":"initial"}' +grep -q '202' "$LAUNCH_HEADERS" +grep -q 'Invitation launch queued' "$LAUNCH_BODY" +curl -fsS -b "$COOKIES" -X POST "$BASE/api/surveys/$SURVEY_ID/launches" \ + -H 'Content-Type: application/json' -H 'Idempotency-Key: 11111111-1111-4111-8111-111111111111' -d '{"kind":"initial"}' | grep -q '"replayed":true' +curl -fsS -b "$COOKIES" "$BASE/api/surveys/$SURVEY_ID/launches" | grep -q '"status":"queued"' + +echo "==> Real PostgreSQL worker/provider-boundary close race is fenced and recorded" +SURVEY_ID="$SURVEY_ID" node scripts/ci/lifecycle-worker-smoke.js +STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/questions?surveyName=CISmokeSurvey&userId=ci-smoke-respondent-token") +[ "$STATUS" = "403" ] || { echo "!! expected closed respondent link to return 403, got $STATUS" >&2; exit 1; } +rm -f "$LAUNCH_HEADERS" "$LAUNCH_BODY" echo "==> Smoke test passed" diff --git a/scripts/ci/lifecycle-worker-smoke.js b/scripts/ci/lifecycle-worker-smoke.js new file mode 100644 index 0000000..43a4823 --- /dev/null +++ b/scripts/ci/lifecycle-worker-smoke.js @@ -0,0 +1,105 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const apiRequire = createRequire(path.resolve(process.cwd(), 'api/package.json')); +const { Pool } = apiRequire('pg'); +const { DeliveryWorker } = require('../../api/email-worker'); +const { reserveProviderRate } = require('../../api/email'); +const lifecycle = require('../../api/lifecycle'); + +const pool = new Pool({ + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT), + database: process.env.DB_NAME, +}); + +(async () => { + const actor = (await pool.query("SELECT id FROM users WHERE username='ci-smoke'")).rows[0]; + let signalProviderStarted; + const providerStarted = new Promise((resolve) => { signalProviderStarted = resolve; }); + let acceptProviderRequest; + let providerObserved = false; + const accepted = new Promise((resolve) => { acceptProviderRequest = resolve; }); + const provider = { + send: async () => { + const marker = await pool.query(`SELECT 1 FROM survey_email_attempts a JOIN survey_email_deliveries d ON d.id=a.delivery_id WHERE d.survey_id=$1 AND a.outcome='in_progress' AND a.provider_started_at IS NOT NULL LIMIT 1`, [process.env.SURVEY_ID]); + assert.equal(marker.rowCount, 1, 'provider boundary marker must commit before provider invocation'); + providerObserved = true; + signalProviderStarted(); + return accepted; + }, + }; + const worker = new DeliveryWorker({ + pool, + provider, + env: { + NODE_ENV: 'test', + EMAIL_WORKER_ENV: 'test', + EMAIL_RATE_BUDGET_ENV: 'test', + RELEASE_REVISION: 'local', + SURVEY_URL: process.env.SURVEY_URL, + EMAIL_RATE_PER_SECOND: '5', + }, + }); + + const competingWorker = new DeliveryWorker({ pool, provider, env: worker.env, instanceId: 'ci-competing-worker' }); + const [firstClaim, secondClaim] = await Promise.all([worker.claim(), competingWorker.claim()]); + assert.ok(firstClaim && secondClaim); + assert.notEqual(firstClaim.id, secondClaim.id, 'SKIP LOCKED must allocate distinct deliveries'); + await worker.finalizeAccepted({ ...firstClaim, lease_token: '00000000-0000-4000-8000-000000000000' }, 'stale-provider-id'); + assert.equal((await pool.query('SELECT status FROM survey_email_deliveries WHERE id=$1', [firstClaim.id])).rows[0].status, 'leased'); + await pool.query('DELETE FROM survey_email_attempts WHERE delivery_id=ANY($1::uuid[])', [[firstClaim.id, secondClaim.id]]); + await pool.query(`UPDATE survey_email_deliveries SET status='pending',attempt_count=0,lease_owner=NULL,lease_token=NULL,lease_expires_at=NULL WHERE id=ANY($1::uuid[])`, [[firstClaim.id, secondClaim.id]]); + + await pool.query(`DELETE FROM email_rate_reservations WHERE environment='test'`); + const reservations = await Promise.all(Array.from({ length: 8 }, () => reserveProviderRate(pool, 'test', 3))); + assert.equal(reservations.filter(Boolean).length, 3, 'sliding provider budget must serialize concurrent reservations'); + await pool.query(`DELETE FROM email_rate_reservations WHERE environment='test'`); + + const boundaryBlocker = await pool.connect(); + await boundaryBlocker.query(`SELECT pg_advisory_lock(hashtextextended($1,0))`, [`survey-provider-boundary:${process.env.SURVEY_ID}`]); + const processing = worker.processOne(); + await new Promise((resolve) => setTimeout(resolve, 100)); + let closeResolved = false; + const closeRequest = lifecycle.transitionSurvey(pool, actor, process.env.SURVEY_ID, 'close').then((value) => { closeResolved = true; return value; }); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(providerObserved, false); + assert.equal(closeResolved, false, 'close must wait behind an earlier provider-boundary waiter'); + await boundaryBlocker.query(`SELECT pg_advisory_unlock(hashtextextended($1,0))`, [`survey-provider-boundary:${process.env.SURVEY_ID}`]); + boundaryBlocker.release(); + await providerStarted; + const closed = await closeRequest; + assert.equal(closed.lifecycleStatus, 'closed'); + acceptProviderRequest({ id: 'ci-provider-message-id' }); + await processing; + + const delivery = (await pool.query( + "SELECT status,cancellation_requested_at,provider_message_id FROM survey_email_deliveries WHERE survey_id=$1 AND provider_message_id='ci-provider-message-id'", + [process.env.SURVEY_ID] + )).rows[0]; + const attempt = (await pool.query( + "SELECT provider_started_at,outcome FROM survey_email_attempts WHERE delivery_id=(SELECT id FROM survey_email_deliveries WHERE survey_id=$1 AND provider_message_id='ci-provider-message-id')", + [process.env.SURVEY_ID] + )).rows[0]; + assert.equal(delivery.status, 'accepted'); + assert.ok(delivery.cancellation_requested_at); + assert.equal(delivery.provider_message_id, 'ci-provider-message-id'); + assert.ok(attempt.provider_started_at); + assert.equal(attempt.outcome, 'accepted'); + + const concurrentSurvey = (await pool.query(`INSERT INTO survey(name,title,creation_date,questions,organization_id) SELECT 'CI Concurrent Launch','Concurrent',now(),'{"elements":[{"type":"text","name":"question_1"}]}'::jsonb,organization_id FROM survey WHERE id=$1 RETURNING *`, [process.env.SURVEY_ID])).rows[0]; + await pool.query(`INSERT INTO respondent(name,contact_info,survey_name,survey_id,can_respond,uuid,lang,email_sent) VALUES('Concurrent Person','concurrent@example.test',$1,$2,true,'ci-concurrent-token','English',false)`, [concurrentSurvey.name, concurrentSurvey.id]); + await pool.query(`INSERT INTO email(survey_name,survey_id,lang,text) VALUES($1,$2,'English','Concurrent launch')`, [concurrentSurvey.name, concurrentSurvey.id]); + const launches = await Promise.allSettled([ + lifecycle.launchSurvey(pool, actor, concurrentSurvey.id, { kind:'initial',idempotencyKey:'22222222-2222-4222-8222-222222222222' }), + lifecycle.launchSurvey(pool, actor, concurrentSurvey.id, { kind:'initial',idempotencyKey:'33333333-3333-4333-8333-333333333333' }), + ]); + assert.equal(launches.filter(({ status }) => status === 'rejected').length, 0); + assert.equal(new Set(launches.map(({ value }) => value.id)).size, 1, 'concurrent initial launches must converge on one launch'); + assert.equal(launches.filter(({ value }) => value.replayed).length, 1); + assert.equal((await pool.query('SELECT count(*)::int AS count FROM survey_launches WHERE survey_id=$1', [concurrentSurvey.id])).rows[0].count, 1); +})().finally(() => pool.end()); diff --git a/scripts/deploy/ecosystem.config.js b/scripts/deploy/ecosystem.config.js new file mode 100644 index 0000000..cc5a6b4 --- /dev/null +++ b/scripts/deploy/ecosystem.config.js @@ -0,0 +1,26 @@ +module.exports = { + apps: [ + { + name: 'ona-api', + script: 'server.js', + cwd: '/opt/service/current/api', + env: { + NODE_ENV: 'prod', + RELEASE_REVISION: process.env.RELEASE_REVISION, + EMAIL_WORKER_ENV: process.env.EMAIL_WORKER_ENV, + }, + kill_timeout: 10000, + }, + { + name: 'ona-email-worker', + script: 'email-worker.js', + cwd: '/opt/service/current/api', + env: { + NODE_ENV: 'prod', + RELEASE_REVISION: process.env.RELEASE_REVISION, + EMAIL_WORKER_ENV: process.env.EMAIL_WORKER_ENV, + }, + kill_timeout: 30000, + }, + ], +}; diff --git a/scripts/deploy/remote-deploy.sh b/scripts/deploy/remote-deploy.sh index c7f0114..3e7d779 100755 --- a/scripts/deploy/remote-deploy.sh +++ b/scripts/deploy/remote-deploy.sh @@ -15,24 +15,31 @@ set -euo pipefail SOURCE_DIR=${1:?usage: remote-deploy.sh } SERVICE_DIR=/opt/service PM2_APP=ona-api +PM2_WORKER=ona-email-worker source "$SERVICE_DIR/deploy.env" export AWS_DEFAULT_REGION REVISION=$(cat "$SOURCE_DIR/REVISION") +DEPLOYMENT_ID="${REVISION}-$(date +%s)-$$" +PREVIOUS_RELEASE=$(readlink -f "$SERVICE_DIR/current" 2>/dev/null || true) RELEASE_DIR="$SERVICE_DIR/releases/$REVISION" +if [ -n "$PREVIOUS_RELEASE" ] && [ "$RELEASE_DIR" = "$PREVIOUS_RELEASE" ]; then + RELEASE_DIR="$SERVICE_DIR/releases/${REVISION}-redeploy-$(date +%s)" +fi run_pm2() { - sudo -u ubuntu -H env NODE_ENV=prod PM2_HOME=/home/ubuntu/.pm2 pm2 "$@" + sudo -u ubuntu -H env NODE_ENV=prod EMAIL_WORKER_ENV="${WORKER_ENV:-prod}" RELEASE_REVISION="${REVISION:-unknown}" DEPLOYMENT_ID="${DEPLOYMENT_ID:-unknown}" PM2_HOME=/home/ubuntu/.pm2 pm2 "$@" } echo "==> Installing release $REVISION to $RELEASE_DIR" rm -rf "$RELEASE_DIR" mkdir -p "$RELEASE_DIR" cp -a "$SOURCE_DIR/api" "$SOURCE_DIR/db" "$SOURCE_DIR/deploy" "$RELEASE_DIR/" +printf '%s\n' "$REVISION" > "$RELEASE_DIR/REVISION" echo "==> Installing production dependencies" -(cd "$RELEASE_DIR/api" && npm ci --omit=dev) +(cd "$RELEASE_DIR/api" && npm ci --omit=dev --workspaces=false) echo "==> Fetching runtime config" aws s3 cp "s3://$CONFIG_BUCKET/configs/.env.prod" "$RELEASE_DIR/api/.env.prod" @@ -76,6 +83,11 @@ echo "==> Resolving runtime secrets from SSM Parameter Store" append_secret_from_ssm DB_PASSWORD DB_PASSWORD_PARAMETER append_secret_from_ssm SESSION_SECRET SESSION_SECRET_PARAMETER append_secret_from_ssm RESEND_API_KEY RESEND_API_KEY_PARAMETER +WORKER_ENV=$(get_env_value EMAIL_WORKER_ENV) +case "$WORKER_ENV" in + staging|prod) ;; + *) echo "EMAIL_WORKER_ENV must be explicitly configured as staging or prod" >&2; exit 1 ;; +esac echo "==> Running database migrations" # Liquibase runs from this host because the database only accepts @@ -125,31 +137,90 @@ if [ -n "$(get_env_value BOOTSTRAP_ADMIN_PASSWORD_PARAMETER)" ]; then unset BOOTSTRAP_ADMIN_PASSWORD fi +CLAIMING_WAS_ENABLED=$(cd "$RELEASE_DIR/api" && node - <<'NODE' +require('dotenv').config({path:'.env.prod'}); +const {Pool}=require('pg'); +const pool=new Pool({user:process.env.DB_USER,password:process.env.DB_PASSWORD,host:process.env.DB_HOST,port:process.env.DB_PORT,database:process.env.DB_NAME||'ONA',ssl:process.env.DB_SSL==='true'?{ca:process.env.DB_SSL_CA?require('fs').readFileSync(process.env.DB_SSL_CA,'utf8'):undefined,rejectUnauthorized:Boolean(process.env.DB_SSL_CA)}:undefined}); +pool.query('SELECT claiming_enabled FROM email_worker_control WHERE environment=$1',[process.env.EMAIL_WORKER_ENV]).then((r)=>process.stdout.write(r.rows[0]?.claiming_enabled?'true':'false')).finally(()=>pool.end()); +NODE +) +if [ "$CLAIMING_WAS_ENABLED" = true ]; then + echo "==> Pausing email claims for release handoff" + (cd "$RELEASE_DIR/api" && EMAIL_WORKER_ENV="$WORKER_ENV" NODE_ENV=prod node ../deploy/set-email-claiming.js false release-handoff) +fi + echo "==> Activating release" chown -R ubuntu:ubuntu "$RELEASE_DIR" +ACTIVATED=false +HANDOFF_REENABLED=false +restore_previous_release() { + local status=$? + if [ "$status" -ne 0 ] && [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE" ]; then + echo "!! Restoring previous release $PREVIOUS_RELEASE" >&2 + set +e + if [ "$ACTIVATED" = true ]; then ln -sfn "$PREVIOUS_RELEASE" "$SERVICE_DIR/current"; fi + REVISION=$(cat "$PREVIOUS_RELEASE/REVISION" 2>/dev/null || basename "$PREVIOUS_RELEASE") + if [ -f "$PREVIOUS_RELEASE/deploy/set-email-claiming.js" ]; then + (cd "$PREVIOUS_RELEASE/api" && EMAIL_WORKER_ENV="$WORKER_ENV" NODE_ENV=prod node ../deploy/set-email-claiming.js false failed-release-handoff) + else + (cd "$RELEASE_DIR/api" && EMAIL_WORKER_ENV="$WORKER_ENV" NODE_ENV=prod node ../deploy/set-email-claiming.js false failed-release-handoff) + fi + if [ -f "$PREVIOUS_RELEASE/deploy/ecosystem.config.js" ]; then + run_pm2 startOrReload "$SERVICE_DIR/current/deploy/ecosystem.config.js" --update-env + else + run_pm2 delete "$PM2_WORKER" >/dev/null 2>&1 || true + run_pm2 delete "$PM2_APP" >/dev/null 2>&1 || true + run_pm2 start "$PREVIOUS_RELEASE/api/server.js" --name "$PM2_APP" --cwd "$PREVIOUS_RELEASE/api" + fi + run_pm2 save + if [ "$CLAIMING_WAS_ENABLED" = true ]; then + if [ -f "$PREVIOUS_RELEASE/deploy/set-email-claiming.js" ]; then + (cd "$PREVIOUS_RELEASE/api" && EMAIL_WORKER_ENV="$WORKER_ENV" EXPECTED_RELEASE_REVISION="$REVISION" NODE_ENV=prod node ../deploy/set-email-claiming.js true failed-release-restore) + fi + fi + set -e + fi +} +trap restore_previous_release EXIT ln -sfn "$RELEASE_DIR" "$SERVICE_DIR/current" +ACTIVATED=true # Drop the legacy cloud-init-era process name if it is still around run_pm2 delete my-service >/dev/null 2>&1 || true -if run_pm2 describe "$PM2_APP" >/dev/null 2>&1; then - run_pm2 restart "$PM2_APP" --update-env -else - run_pm2 start "$SERVICE_DIR/current/api/server.js" --name "$PM2_APP" --cwd "$SERVICE_DIR/current/api" -fi +# The ecosystem keeps API and worker lifecycle coupled while preserving separate +# process names, logs, health signals, and graceful-stop budgets. +run_pm2 startOrReload "$SERVICE_DIR/current/deploy/ecosystem.config.js" --update-env run_pm2 save echo "==> Waiting for health check" for i in $(seq 1 15); do if curl -fsS http://localhost:3000/health >/dev/null 2>&1; then - echo "==> Deploy of $REVISION succeeded" - # Keep the five most recent releases - ls -1dt "$SERVICE_DIR"/releases/* | tail -n +6 | xargs -r rm -rf - exit 0 + if (cd "$RELEASE_DIR/api" && EXPECTED_REVISION="$REVISION" EXPECTED_WORKER_ENV="$WORKER_ENV" EXPECTED_DEPLOYMENT_ID="$DEPLOYMENT_ID" node - <<'NODE' +require('dotenv-flow').config(); +const { Pool } = require('pg'); +const pool = new Pool({ user:process.env.DB_USER,password:process.env.DB_PASSWORD,host:process.env.DB_HOST,port:process.env.DB_PORT,database:process.env.DB_NAME||'ONA',ssl:process.env.DB_SSL==='true'?{ca:process.env.DB_SSL_CA?require('fs').readFileSync(process.env.DB_SSL_CA,'utf8'):undefined,rejectUnauthorized:Boolean(process.env.DB_SSL_CA)}:undefined }); +pool.query(`SELECT 1 FROM email_worker_heartbeats h JOIN email_worker_control c USING(environment) WHERE h.environment=$2 AND h.release_revision=$1 AND h.worker_instance LIKE $3||'/%' AND h.enabled=true AND h.claiming=c.claiming_enabled AND h.heartbeat_at>now()-interval '45 seconds' LIMIT 1`,[process.env.EXPECTED_REVISION,process.env.EXPECTED_WORKER_ENV,process.env.EXPECTED_DEPLOYMENT_ID]).then((r)=>{process.exitCode=r.rowCount?0:1;}).catch(()=>{process.exitCode=1;}).finally(()=>pool.end()); +NODE + ); then + if [ "$CLAIMING_WAS_ENABLED" = true ] && [ "$HANDOFF_REENABLED" = false ]; then + echo "==> Fencing email claims to release $REVISION" + (cd "$RELEASE_DIR/api" && EMAIL_WORKER_ENV="$WORKER_ENV" EXPECTED_RELEASE_REVISION="$REVISION" NODE_ENV=prod node ../deploy/set-email-claiming.js true release-handoff-complete) + HANDOFF_REENABLED=true + sleep 2 + continue + fi + echo "==> Deploy of $REVISION succeeded (API and worker healthy)" + # Keep the five most recent releases + ls -1dt "$SERVICE_DIR"/releases/* | tail -n +6 | xargs -r rm -rf + trap - EXIT + exit 0 + fi fi sleep 2 done echo "!! Health check failed after deploy of $REVISION" >&2 run_pm2 logs "$PM2_APP" --nostream --lines 50 || true +run_pm2 logs "$PM2_WORKER" --nostream --lines 50 || true exit 1 diff --git a/scripts/deploy/set-email-claiming.js b/scripts/deploy/set-email-claiming.js new file mode 100644 index 0000000..d3cd062 --- /dev/null +++ b/scripts/deploy/set-email-claiming.js @@ -0,0 +1,83 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const dotenv = require(path.join(process.cwd(), 'node_modules/dotenv')); +dotenv.config({ path: path.join(process.cwd(), '.env.prod') }); +const { Pool } = require(path.join(process.cwd(), 'node_modules/pg')); + +if (!['true', 'false'].includes(process.argv[2])) { + throw new Error('usage: set-email-claiming.js [reason]'); +} + +const enabled = process.argv[2] === 'true'; +const environment = process.env.EMAIL_WORKER_ENV; +const expectedRevision = process.env.EXPECTED_RELEASE_REVISION; +if (!['staging', 'prod'].includes(environment)) { + throw new Error('EMAIL_WORKER_ENV must be staging or prod'); +} +if (enabled && !expectedRevision) { + throw new Error('EXPECTED_RELEASE_REVISION is required when enabling email claiming'); +} + +const pool = new Pool({ + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + database: process.env.DB_NAME || 'ONA', + ssl: process.env.DB_SSL === 'true' + ? { + ca: process.env.DB_SSL_CA ? fs.readFileSync(process.env.DB_SSL_CA, 'utf8') : undefined, + rejectUnauthorized: Boolean(process.env.DB_SSL_CA), + } + : undefined, +}); + +(async () => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, [`email-provider-boundary:${environment}`]); + const control = await client.query( + 'SELECT environment FROM email_worker_control WHERE environment=$1 FOR UPDATE', + [environment] + ); + if (control.rowCount !== 1) throw new Error(`Worker control row not found for ${environment}`); + + if (enabled) { + const heartbeat = await client.query( + `SELECT 1 FROM email_worker_heartbeats + WHERE environment=$1 AND release_revision=$2 AND enabled=true + AND heartbeat_at>now()-interval '45 seconds' + LIMIT 1`, + [environment, expectedRevision] + ); + if (heartbeat.rowCount !== 1) { + throw new Error(`No fresh worker heartbeat for ${environment} revision ${expectedRevision}`); + } + } + + const update = await client.query( + `UPDATE email_worker_control + SET claiming_enabled=$2, + minimum_release=CASE WHEN $2 THEN $3 ELSE minimum_release END, + updated_at=now(), + reason=$4 + WHERE environment=$1`, + [environment, enabled, expectedRevision || '', String(process.argv[3] || 'operator change').slice(0, 500)] + ); + if (update.rowCount !== 1) throw new Error(`Failed to update worker control for ${environment}`); + await client.query('COMMIT'); + console.log(`email claiming ${enabled ? 'enabled' : 'disabled'} for ${environment}${enabled ? ` at ${expectedRevision}` : ''}`); + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + throw error; + } finally { + client.release(); + await pool.end(); + } +})().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/scripts/local-dev.js b/scripts/local-dev.js index 80283ab..dad709b 100644 --- a/scripts/local-dev.js +++ b/scripts/local-dev.js @@ -225,11 +225,11 @@ function buildChildEnv(overrides) { }; } -function startService({ name, cwd, env }) { +function startService({ name, cwd, env, command = `${npmCommand} run dev` }) { let child; try { - child = spawn(`${npmCommand} run dev`, { + child = spawn(command, { cwd, env: buildChildEnv(env), shell: true, @@ -415,6 +415,14 @@ async function main() { await waitForHttpOk(config.apiHealthUrl, 30000); + console.log('Starting durable email worker...'); + startService({ + name: 'email-worker', + cwd: path.join(repoRoot, 'api'), + command: `${npmCommand} run worker:dev`, + env: { EMAIL_WORKER_ENV: 'local', RELEASE_REVISION: 'local' }, + }); + console.log('Starting dashboard and survey app...'); startService({ name: 'dashboard', diff --git a/terraform/envs/prod/app_stack.tf b/terraform/envs/prod/app_stack.tf index 012a55c..36b4a65 100644 --- a/terraform/envs/prod/app_stack.tf +++ b/terraform/envs/prod/app_stack.tf @@ -118,6 +118,11 @@ module "api_backend" { frontend_url = local.frontend_url survey_url = local.survey_url session_cookie_name = local.session_cookie_name + email_worker_environment = "prod" + survey_delivery_v2_enabled = var.survey_delivery_v2_enabled + legacy_start_enabled = false + email_rate_per_second = var.email_rate_per_second + email_rate_budget_environment = "prod" common_tags = local.app_common_tags config_bucket_tags = merge(local.app_common_tags, { Name = "${local.app_name_prefix}-config", App = "ona-config" }) diff --git a/terraform/envs/prod/variables.tf b/terraform/envs/prod/variables.tf index 233bbb1..33507de 100644 --- a/terraform/envs/prod/variables.tf +++ b/terraform/envs/prod/variables.tf @@ -148,6 +148,22 @@ variable "artifact_retention_days" { default = 30 } +variable "survey_delivery_v2_enabled" { + description = "Explicit production rollout gate for durable survey launches." + type = bool + default = false +} + +variable "email_rate_per_second" { + description = "Approved aggregate Resend account request budget shared with staging." + type = number + default = 4 + validation { + condition = var.email_rate_per_second >= 1 && var.email_rate_per_second <= 4 + error_message = "Production is capped at 4 requests/second so staging plus production remain within the shared budget." + } +} + variable "enable_frontend_custom_domains" { description = "Attach demo dashboard/survey aliases and imported ACM certs to the replacement CloudFront distributions. This is true after prod-v2 DNS cutover." type = bool diff --git a/terraform/envs/staging/locals.tf b/terraform/envs/staging/locals.tf index a868b94..d2fb626 100644 --- a/terraform/envs/staging/locals.tf +++ b/terraform/envs/staging/locals.tf @@ -10,9 +10,9 @@ locals { frontend_url = "https://${var.dashboard_domain}" survey_url = "https://${var.survey_domain}" - # Staging and prod share the .bennetts.work cookie domain, so each - # environment needs its own session cookie name - session_cookie_name = "sessionId-${local.environment}" + # Rotate away from the legacy parent-domain cookie. The API now emits this + # as a host-only cookie, but distinct names keep environments unambiguous. + session_cookie_name = "ona-session-${local.environment}-v2" ssm_parameter_prefix = "/network-survey/${local.environment}" db_password_parameter_name = "${local.ssm_parameter_prefix}/db/password" diff --git a/terraform/envs/staging/main.tf b/terraform/envs/staging/main.tf index 0d3ebaa..7509a38 100644 --- a/terraform/envs/staging/main.tf +++ b/terraform/envs/staging/main.tf @@ -155,6 +155,11 @@ module "api_backend" { frontend_url = local.frontend_url survey_url = local.survey_url session_cookie_name = local.session_cookie_name + email_worker_environment = "staging" + survey_delivery_v2_enabled = var.survey_delivery_v2_enabled + legacy_start_enabled = false + email_rate_per_second = var.email_rate_per_second + email_rate_budget_environment = "staging" common_tags = local.common_tags config_bucket_tags = merge(local.common_tags, { Name = "Config Bucket" }) diff --git a/terraform/envs/staging/variables.tf b/terraform/envs/staging/variables.tf index 8d09c1c..91d98c6 100644 --- a/terraform/envs/staging/variables.tf +++ b/terraform/envs/staging/variables.tf @@ -87,6 +87,22 @@ variable "artifact_retention_days" { default = 30 } +variable "survey_delivery_v2_enabled" { + description = "Explicit staging rollout gate for durable survey launches." + type = bool + default = false +} + +variable "email_rate_per_second" { + description = "Approved aggregate Resend account request budget shared with production." + type = number + default = 1 + validation { + condition = var.email_rate_per_second == 1 + error_message = "Staging is capped at 1 request/second so the shared account budget remains bounded." + } +} + variable "api_config_db_host_override" { description = "Optional DB host written to the API runtime config instead of this stack's RDS address. Temporary safety valve while prod DB ownership is split during the infra refactor. Leave null for normal environments." type = string diff --git a/terraform/locals.tf b/terraform/locals.tf index ca0de3b..5fac591 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -17,9 +17,9 @@ locals { frontend_url = "https://${var.dashboard_domain}" survey_url = "https://${var.survey_domain}" - # Staging and prod share the .bennetts.work cookie domain, so each - # environment needs its own session cookie name - session_cookie_name = local.is_prod ? "sessionId" : "sessionId-${local.environment}" + # Rotated host-only v2 cookie names. This root is legacy, but keep generated + # runtime config consistent if it is used for a plan/import operation. + session_cookie_name = local.is_prod ? "ona-session-prod-v2" : "ona-session-${local.environment}-v2" ssm_parameter_prefix = "/network-survey/${local.environment}" db_password_parameter_name = "${local.ssm_parameter_prefix}/db/password" diff --git a/terraform/main.tf b/terraform/main.tf index d932de9..77b2c0b 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -255,16 +255,29 @@ resource "aws_s3_object" "api_config" { bucket = aws_s3_bucket.config_bucket.id key = "configs/.env.prod" content = templatefile("./templates/env.tmpl", { - db_host = coalesce(var.api_config_db_host_override, aws_db_instance.postgres.address) - db_port = aws_db_instance.postgres.port - db_name = aws_db_instance.postgres.db_name - db_user = var.db_user - db_password_parameter_name = local.db_password_parameter_name - frontend_url = local.frontend_url - survey_url = local.survey_url - session_secret_parameter_name = local.session_secret_parameter_name - session_cookie_name = local.session_cookie_name - resend_api_key_parameter_name = local.resend_api_key_parameter_name + db_host = coalesce(var.api_config_db_host_override, aws_db_instance.postgres.address) + db_port = aws_db_instance.postgres.port + db_name = aws_db_instance.postgres.db_name + db_user = var.db_user + db_password_parameter_name = local.db_password_parameter_name + frontend_url = local.frontend_url + survey_url = local.survey_url + session_secret_parameter_name = local.session_secret_parameter_name + session_cookie_name = local.session_cookie_name + email_worker_environment = local.is_prod ? "prod" : "staging" + survey_delivery_v2_enabled = false + legacy_start_enabled = false + email_rate_per_second = local.is_prod ? 4 : 1 + email_rate_budget_environment = local.is_prod ? "prod" : "staging" + resend_api_key_parameter_name = local.resend_api_key_parameter_name + cla_production_cutover = false + bootstrap_admin_username = null + bootstrap_admin_password_parameter_name = null + bootstrap_admin_email = null + bootstrap_organization_name = "" + bootstrap_organization_slug = "" + bootstrap_platform_admin = false + bootstrap_account_mode = "local" }) } diff --git a/terraform/modules/api_backend/main.tf b/terraform/modules/api_backend/main.tf index 6cba97f..894ca2a 100644 --- a/terraform/modules/api_backend/main.tf +++ b/terraform/modules/api_backend/main.tf @@ -119,6 +119,11 @@ resource "aws_s3_object" "api_config" { survey_url = var.survey_url session_secret_parameter_name = var.session_secret_parameter_name session_cookie_name = var.session_cookie_name + email_worker_environment = var.email_worker_environment + survey_delivery_v2_enabled = var.survey_delivery_v2_enabled + legacy_start_enabled = var.legacy_start_enabled + email_rate_per_second = var.email_rate_per_second + email_rate_budget_environment = var.email_rate_budget_environment resend_api_key_parameter_name = var.resend_api_key_parameter_name bootstrap_admin_username = var.bootstrap_admin_username bootstrap_admin_password_parameter_name = var.bootstrap_admin_password_parameter_name diff --git a/terraform/modules/api_backend/variables.tf b/terraform/modules/api_backend/variables.tf index 44ef204..c00149f 100644 --- a/terraform/modules/api_backend/variables.tf +++ b/terraform/modules/api_backend/variables.tf @@ -174,6 +174,50 @@ variable "session_cookie_name" { type = string } +variable "email_worker_environment" { + description = "Durable email worker control namespace (staging or prod)." + type = string + + validation { + condition = contains(["staging", "prod"], var.email_worker_environment) + error_message = "email_worker_environment must be staging or prod." + } +} + +variable "survey_delivery_v2_enabled" { + description = "Explicit rollout gate for durable survey launch enqueue." + type = bool + default = false +} + +variable "legacy_start_enabled" { + description = "Compatibility start adapter gate; keep false for hosted rollouts." + type = bool + default = false +} + +variable "email_rate_per_second" { + description = "Approved aggregate provider-account email request budget." + type = number + default = 5 + + validation { + condition = var.email_rate_per_second >= 1 && var.email_rate_per_second <= 100 + error_message = "email_rate_per_second must be between 1 and 100." + } +} + +variable "email_rate_budget_environment" { + description = "Shared reservation namespace for deployments using the same provider account." + type = string + default = "prod" + + validation { + condition = contains(["staging", "prod"], var.email_rate_budget_environment) + error_message = "email_rate_budget_environment must name a seeded hosted control namespace." + } +} + variable "cloud_init_template_path" { description = "Path to cloud-init template." type = string diff --git a/terraform/templates/env.tmpl b/terraform/templates/env.tmpl index 88dac30..25efb5e 100644 --- a/terraform/templates/env.tmpl +++ b/terraform/templates/env.tmpl @@ -9,6 +9,11 @@ FRONTEND_URL=${frontend_url} SURVEY_URL=${survey_url} SESSION_SECRET_PARAMETER=${session_secret_parameter_name} SESSION_COOKIE_NAME=${session_cookie_name} +EMAIL_WORKER_ENV=${email_worker_environment} +SURVEY_DELIVERY_V2_ENABLED=${survey_delivery_v2_enabled} +LEGACY_START_ENABLED=${legacy_start_enabled} +EMAIL_RATE_PER_SECOND=${email_rate_per_second} +EMAIL_RATE_BUDGET_ENV=${email_rate_budget_environment} RESEND_API_KEY_PARAMETER=${resend_api_key_parameter_name} CLA_PRODUCTION_CUTOVER=${cla_production_cutover} %{ if bootstrap_admin_password_parameter_name != null ~}