From 2ce9326cceb458cb606e61e8e9b8e1d0a8e7dd01 Mon Sep 17 00:00:00 2001 From: Bennett Garcia Date: Tue, 4 Aug 2026 10:55:25 -0400 Subject: [PATCH 1/4] Add editable survey instructions and email subjects --- api/server.js | 355 +++++++---- api/test/security.test.js | 200 ++++++- dashboard/src/components/Dashboard.js | 36 +- dashboard/src/components/Dashboard.test.js | 55 ++ .../src/components/EmailNotificationEditor.js | 559 +++++++++--------- .../EmailNotificationEditor.test.js | 90 +++ .../src/components/SurveyContentEditor.js | 133 +++++ .../components/SurveyContentEditor.test.js | 42 ++ db/changelogs/master-changelog.xml | 1 + .../v1_5_editable_survey_content.sql | 23 + network-survey/src/Survey.jsx | 45 +- network-survey/src/SurveyComponent.jsx | 16 +- 12 files changed, 1151 insertions(+), 404 deletions(-) create mode 100644 dashboard/src/components/Dashboard.test.js create mode 100644 dashboard/src/components/EmailNotificationEditor.test.js create mode 100644 dashboard/src/components/SurveyContentEditor.js create mode 100644 dashboard/src/components/SurveyContentEditor.test.js create mode 100644 db/changelogs/v1_5_editable_survey_content.sql diff --git a/api/server.js b/api/server.js index babdeba..99610ed 100644 --- a/api/server.js +++ b/api/server.js @@ -105,20 +105,20 @@ async function sendAccountEmail({ to, subject, html, text }) { } } -async function sendMail(email, id, surveyName, text) { +async function sendMail(email, id, surveyName, text, subject = 'CLA Network Survey') { try { if (!resend) { throw new Error('Missing RESEND_KEY or RESEND_API_KEY environment variable'); } - text = "

" + text.replace(/"/g, '') + "

"; + text = "

" + text + "

"; text = text.replace(/

/g, '

'); let customLink = `${process.env.SURVEY_URL}/?surveyName=${surveyName}&userId=${id}`; const emailData = { from: 'CLA Survey ', to: email, - subject: 'CLA Network Survey', + subject: subject || 'CLA Network Survey', html: EMAIL_HTML[0] + text + EMAIL_HTML[1] + customLink + EMAIL_HTML[2], surveyName }; @@ -138,14 +138,14 @@ 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 +function rateLimitedSend(emailData) { emailQueue.push(emailData); - - // Start processing if not already running + if (!isProcessing) { isProcessing = true; - await processEmailQueue(); + void processEmailQueue().catch((error) => { + console.error('Unexpected email queue processing failure:', error); + }); } } @@ -198,19 +198,21 @@ async function processEmailQueue() { } // User test email function (allow admin user to send test email to themselves) -async function sendTestMail(email, survey, lang) { +async function sendTestMail(email, survey, lang, mailer = sendMail) { const client = await pool.connect(); try { - const query = `SELECT text 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) { + const response = await client.query( + `SELECT lang, text, subject FROM email WHERE ${legacySurveyPredicate()}`, + [survey.id, survey.name] + ); + const language = normalizeLanguage(lang); + const template = language ? buildNotificationMaps(response.rows).templates[language] : null; + + if (!template) { 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) { + if (template.text === undefined || template.text === null) { throw new Error(`Email text is undefined for survey '${survey.name}'`); } @@ -231,50 +233,45 @@ async function sendTestMail(email, survey, lang) { throw error; } - await sendMail(email, respondentToken, survey.name, text); + await mailer(email, respondentToken, survey.name, template.text, template.subject); } finally { client.release(); } } -async function startSurvey(survey){ - // Pull all users from the database +async function startSurvey(survey) { 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 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 - })); - }); - // Create a map from language to email text - const emailMap = emails.reduce((map, email) => { - map[email.language.replace(/"/g, "").replace(/'/g, "")] = '

' + email.text + '

'; - return map; - }, {}); - - // Send the emails - respondents.forEach(respondent => { - sendMail(respondent.email, respondent.userId, survey.name, emailMap[respondent.language].replace(/"/g, "").replace(/'/g, "")); - }); + let respondents; + let templates; + try { + const respondentResult = await client.query( + `SELECT name, contact_info, uuid, lang FROM Respondent WHERE ${legacySurveyPredicate()} AND can_respond = true`, + [survey.id, survey.name] + ); + const emailResult = await client.query( + `SELECT lang, text, subject FROM email WHERE ${legacySurveyPredicate()}`, + [survey.id, survey.name] + ); + respondents = respondentResult.rows; + templates = emailResult.rows; + } finally { + client.release(); } + + const emailMap = buildNotificationMaps(templates).templates; + const pendingEmails = respondents.map((respondent) => { + const language = normalizeLanguage(respondent.lang); + const template = language ? emailMap[language] : null; + if (!template) { + throw new Error(`Email template not found for language '${respondent.lang}'`); + } + return { respondent, template }; + }); + + await Promise.all(pendingEmails.map(({ respondent, template }) => + sendMail(respondent.contact_info, respondent.uuid, survey.name, template.text, template.subject) + )); +} // sendMail('bgarcia2324@gmail.com', 'byVHldRI2ZgaOXNhE-ih7', 'GEEEEEE'); // Function to execute a query @@ -378,8 +375,97 @@ const EDITOR_ROLES = ['owner', 'admin', 'editor']; const ADMIN_ROLES = ['owner', 'admin']; const ORG_ROLES = ['owner', 'admin', 'editor', 'analyst', 'viewer']; const USER_STATUSES = ['invited', 'active', 'disabled']; +// Keep this definition aligned with frontend-shared/src/constants.js LANGUAGES. +const LANGUAGE_DEFINITIONS = [ + ['en', 'English'], ['es', 'Spanish'], ['fr', 'French'], ['de', 'German'], + ['it', 'Italian'], ['pt', 'Portuguese'], ['nl', 'Dutch'], ['pl', 'Polish'], + ['ru', 'Russian'], ['ja', 'Japanese'], ['zh', 'Chinese'], ['ko', 'Korean'], +]; +const LANGUAGE_LABELS = new Set(LANGUAGE_DEFINITIONS.map(([, label]) => label)); +const LANGUAGE_LOOKUP = new Map(LANGUAGE_DEFINITIONS.flatMap(([code, label]) => [ + [code.toLowerCase(), label], + [label.toLowerCase(), label], +])); +const LEGACY_EMAIL_SUBJECT = 'CLA Network Survey'; +const MAX_NOTIFICATION_TEXT_LENGTH = 10000; 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; +function normalizeLanguage(value) { + if (typeof value !== 'string') return null; + const unwrapped = value.trim().replace(/^["']+|["']+$/g, '').trim().toLowerCase(); + return LANGUAGE_LOOKUP.get(unwrapped) || null; +} + +function buildNotificationMaps(rows = []) { + const notifications = {}; + const subjects = {}; + const templates = {}; + const priorities = {}; + for (const row of rows) { + const language = normalizeLanguage(row.lang); + if (!language) continue; + // Prefer a canonical-label row written by the current application over a + // duplicate legacy code/wrapped row, regardless of database row order. + const priority = row.lang.trim() === language ? 2 : 1; + if ((priorities[language] || 0) > priority) continue; + const subject = row.subject || LEGACY_EMAIL_SUBJECT; + priorities[language] = priority; + notifications[language] = row.text; + subjects[language] = subject; + templates[language] = { text: row.text, subject }; + } + return { notifications, subjects, templates }; +} + +function validateNotification({ language, subject, text } = {}) { + if (!normalizeLanguage(language)) return 'Language must be a known language label.'; + if (typeof subject !== 'string' || !subject.trim()) return 'Subject is required.'; + if (subject.length > 255) return 'Subject must be at most 255 characters.'; + if (/[\p{Cc}\p{Cf}]/u.test(subject)) return 'Subject cannot contain line breaks or control characters.'; + if (typeof text !== 'string') return 'Text must be a string.'; + if (text.length > MAX_NOTIFICATION_TEXT_LENGTH) return `Text must be at most ${MAX_NOTIFICATION_TEXT_LENGTH} characters.`; + return null; +} + +function parseEmailCsv(csvData, surveyName) { + if (typeof csvData !== 'string') throw new Error('CSV data must be a string.'); + const normalizeHeader = (header) => String(header) + .replace(/^\uFEFF/, '') + .trim() + .toLowerCase() + .replace(/[ _-]+/g, ''); + const parsed = Papa.parse(csvData, { + header: true, + skipEmptyLines: 'greedy', + transformHeader: normalizeHeader, + }); + if (parsed.errors.length) throw new Error(`Invalid CSV: ${parsed.errors[0].message}`); + + const fields = parsed.meta.fields || []; + const findField = (aliases) => fields.find((field) => aliases.includes(field)); + const languageField = findField(['language', 'languagecode', 'lang']); + const textField = findField(['text', 'notificationtext', 'emailtext', 'message']); + const subjectField = findField(['subject', 'emailsubject']); + if (!languageField || !textField) { + throw new Error('CSV must include recognized Language and Text headers.'); + } + + return parsed.data.map((row, index) => { + const language = normalizeLanguage(row[languageField]); + const text = row[textField]; + if (!language || typeof text !== 'string') throw new Error(`Invalid email template on row ${index + 2}.`); + if (text.length > MAX_NOTIFICATION_TEXT_LENGTH) throw new Error(`Row ${index + 2}: Text must be at most ${MAX_NOTIFICATION_TEXT_LENGTH} characters.`); + const item = { surveyName, language, text }; + if (subjectField) { + const subject = row[subjectField]; + const validationError = validateNotification({ language, subject, text }); + if (validationError) throw new Error(`Row ${index + 2}: ${validationError}`); + item.subject = subject; + } + return item; + }); +} + function hasAnyRole(role, allowedRoles) { return Boolean(role && allowedRoles.includes(role)); } @@ -1109,7 +1195,7 @@ 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, + `SELECT s.id, s.name, s.title, s.creation_date, s.questions, s.instructions, s.organization_id, s.created_by_user_id, om.role FROM Survey s LEFT JOIN organization_memberships om @@ -1180,8 +1266,9 @@ async function insertUsers(users, deleteRow = null, survey = null) { survey?.name || user.surveyName, survey?.id || user.surveyId || null, user.canRespond !== undefined ? user.canRespond : true, // Default to true if not specified - user.language || 'English' // Default to English if not specified + normalizeLanguage(user.language || 'English') ]; + if (!values[6]) throw new Error(`Unknown respondent language '${user.language}'.`); await client.query(query, values); } @@ -1196,38 +1283,35 @@ async function insertUsers(users, deleteRow = null, survey = null) { } } async function insertEmails(data, survey = null) { - // Start a PostgreSQL client from the pool const client = await pool.connect(); - console.log(data); try { - // Begin a transaction await client.query('BEGIN'); - - // Iterate through the emails and insert or update them for (const email of data) { - const query = ` - INSERT INTO email (survey_name, survey_id, lang, text) - VALUES ($1, $2, $3, $4) - ON CONFLICT (survey_name, lang) DO UPDATE - SET text = EXCLUDED.text, - survey_id = EXCLUDED.survey_id - `; - const values = [survey?.name || email.surveyName, survey?.id || email.surveyId || null, email.language, email.text.replace(/"/g, "").replace(/'/g, "")]; - await client.query(query, values); + const surveyName = survey?.name || email.surveyName; + const surveyId = survey?.id || email.surveyId || null; + const language = normalizeLanguage(email.language); + if (!language) throw new Error(`Unknown email template language '${email.language}'.`); + if (email.subject === undefined) { + await client.query(` + INSERT INTO email (survey_name, survey_id, lang, text) + VALUES ($1, $2, $3, $4) + ON CONFLICT (survey_name, lang) DO UPDATE + SET text = EXCLUDED.text, survey_id = EXCLUDED.survey_id + `, [surveyName, surveyId, language, email.text]); + } else { + await client.query(` + INSERT INTO email (survey_name, survey_id, lang, subject, text) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (survey_name, lang) DO UPDATE + SET subject = EXCLUDED.subject, text = EXCLUDED.text, survey_id = EXCLUDED.survey_id + `, [surveyName, surveyId, language, email.subject, email.text]); + } } - - // Commit the transaction await client.query('COMMIT'); - - // Release the client back to the pool - client.release(); - - console.log('Email data inserted or updated successfully!'); } catch (error) { - // If an error occurs, rollback the transaction - console.log(error); - await client.query('ROLLBACK'); - console.error('Error inserting or updating emails:', error); + try { await client.query('ROLLBACK'); } catch (rollbackError) { console.error('Email rollback failed:', rollbackError); } + throw error; + } finally { client.release(); } } @@ -1965,19 +2049,12 @@ app.post('/api/updateEmails', express.json(), requireAuth, async (req, res) => { return; } - let csvArray = csvData.split('\n'); - const header = csvArray.shift().split(','); - - csvArray = csvArray.map((row, index) => { - const columns = row.split(','); - // combine all strings after index 0 - columns[1] = columns.slice(1).join(','); - return { - surveyName: surveyName, - language: columns[0].replace(/(\r\n|\n|\r)/gm, ""), - text: columns[1].replace(/(\r\n|\n|\r)/gm, "") - } - }); + let csvArray; + try { + csvArray = parseEmailCsv(csvData, surveyName); + } catch (error) { + return res.status(400).json({ message: error.message }); + } try { const survey = await resolveSurveyForUser(req, res, { surveyName, allowedRoles: EDITOR_ROLES }); @@ -1987,7 +2064,7 @@ app.post('/api/updateEmails', express.json(), requireAuth, async (req, res) => { } catch (error) { console.error('Error updating email templates:', error); res.status(500).json({ message: 'Failed to update email data.' }); - } + } }); // Modify the POST /api/updateTarget endpoint to handle the new fields app.post('/api/updateTarget', requireAuth, async (req, res) => { @@ -2072,6 +2149,43 @@ app.post('/api/updateTarget', requireAuth, async (req, res) => { }); } }); +app.get('/api/survey-content/:surveyId', requireAuth, async (req, res) => { + const surveyId = req.params.surveyId; + try { + const survey = await resolveSurveyForUser(req, res, { + surveyName: UUID_RE.test(surveyId) ? null : surveyId, + surveyId: UUID_RE.test(surveyId) ? surveyId : null, + allowedRoles: READ_SURVEY_ROLES, + }); + if (!survey) return; + res.json({ instructions: survey.instructions }); + } catch (error) { + console.error('Error retrieving survey content:', error); + res.status(500).json({ message: 'Failed to retrieve survey content.' }); + } +}); + +app.put('/api/survey-content/:surveyId', express.json(), requireAuth, async (req, res) => { + const { instructions } = req.body || {}; + if (typeof instructions !== 'string') return res.status(400).json({ message: 'Instructions must be a string.' }); + if (instructions.length > 10000) return res.status(400).json({ message: 'Instructions must be at most 10000 characters.' }); + + try { + const surveyId = req.params.surveyId; + const survey = await resolveSurveyForUser(req, res, { + surveyName: UUID_RE.test(surveyId) ? null : surveyId, + surveyId: UUID_RE.test(surveyId) ? surveyId : null, + allowedRoles: EDITOR_ROLES, + }); + if (!survey) return; + const result = await pool.query('UPDATE Survey SET instructions = $1 WHERE id = $2 RETURNING instructions', [instructions, survey.id]); + res.json({ instructions: result.rows[0].instructions }); + } catch (error) { + console.error('Error updating survey content:', error); + res.status(500).json({ message: 'Failed to update survey content.' }); + } +}); + // GET API endpoint for retrieving email texts and available languages app.get('/api/survey-notifications/:surveyId', requireAuth, async (req, res) => { const surveyId = req.params.surveyId; @@ -2086,19 +2200,15 @@ app.get('/api/survey-notifications/:surveyId', requireAuth, async (req, res) => const survey = await resolveSurveyForUser(req, res, { surveyName: surveyId, surveyId: UUID_RE.test(surveyId) ? surveyId : null, allowedRoles: ANALYST_ROLES }); if (!survey) return; const query = ` - SELECT lang, text + SELECT lang, text, subject FROM EMAIL WHERE ${legacySurveyPredicate()} `; const result = await client.query(query, [survey.id, survey.name]); - const notifications = result.rows.reduce((acc, row) => { - acc[row.lang] = row.text; - return acc; - }, {}); - - res.status(200).json({ notifications }); + const { notifications, subjects } = buildNotificationMaps(result.rows); + res.status(200).json({ notifications, subjects }); } catch (error) { console.error('Error retrieving email texts:', error); res.status(500).json({ message: 'Failed to retrieve email texts.' }); @@ -2107,6 +2217,34 @@ app.get('/api/survey-notifications/:surveyId', requireAuth, async (req, res) => } }); +app.post('/api/survey-notifications/:surveyId', express.json(), requireAuth, async (req, res) => { + const validationError = validateNotification(req.body); + if (validationError) return res.status(400).json({ message: validationError }); + + try { + const surveyId = req.params.surveyId; + const survey = await resolveSurveyForUser(req, res, { + surveyName: UUID_RE.test(surveyId) ? null : surveyId, + surveyId: UUID_RE.test(surveyId) ? surveyId : null, + allowedRoles: EDITOR_ROLES, + }); + if (!survey) return; + const { language, subject, text } = req.body; + const canonicalLanguage = normalizeLanguage(language); + const result = await pool.query(` + INSERT INTO EMAIL (survey_name, survey_id, lang, subject, text) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (survey_name, lang) DO UPDATE + SET survey_id = EXCLUDED.survey_id, subject = EXCLUDED.subject, text = EXCLUDED.text + RETURNING lang, subject, text + `, [survey.name, survey.id, canonicalLanguage, subject, text]); + res.json({ language: result.rows[0].lang, subject: result.rows[0].subject, text: result.rows[0].text }); + } catch (error) { + console.error('Error updating survey notification:', error); + res.status(500).json({ message: 'Failed to update survey notification.' }); + } +}); + // Modify the POST /api/updateTargets endpoint to handle the new fields app.post('/api/updateTargets', express.json(), requireAuth, async (req, res) => { const data = req.body; @@ -2614,7 +2752,7 @@ app.get('/api/questions', respondentRateLimiter, async (req, res) => { try { const query = ` - SELECT questions, title + SELECT questions, title, instructions FROM Survey WHERE name = $1; `; @@ -2624,7 +2762,7 @@ app.get('/api/questions', respondentRateLimiter, async (req, res) => { return res.status(404).json({ message: 'Survey not found.' }); } - const jsonData = { title: result.rows[0].title, questions: result.rows[0].questions }; + const jsonData = { title: result.rows[0].title, questions: result.rows[0].questions, instructions: result.rows[0].instructions }; res.status(200).json(jsonData); } catch (error) { console.error('Error fetching survey questions:', error); @@ -3011,4 +3149,15 @@ module.exports = { validateRequiredAnswers, normalizeQuestionNames, formatRespondentChoice, + LANGUAGE_LABELS, + normalizeLanguage, + buildNotificationMaps, + sendTestMail, + startSurvey, + emailQueue, + LEGACY_EMAIL_SUBJECT, + MAX_NOTIFICATION_TEXT_LENGTH, + validateNotification, + parseEmailCsv, + insertEmails, }; diff --git a/api/test/security.test.js b/api/test/security.test.js index c5ea1cd..5214e79 100644 --- a/api/test/security.test.js +++ b/api/test/security.test.js @@ -30,6 +30,14 @@ const { EDITOR_ROLES, ADMIN_ROLES, hashToken, + parseEmailCsv, + validateNotification, + insertEmails, + normalizeLanguage, + buildNotificationMaps, + sendTestMail, + startSurvey, + emailQueue, parseRequiredCsvValue, csvToJson, SUPPORTED_QUESTION_TYPES, @@ -1327,6 +1335,161 @@ test('/api/user returns a client error for malformed or non-object answers', asy assert.equal(schemaQueries, 0, 'invalid answer envelopes must fail before loading the schema'); }); +test('editable survey content migration is additive, included, and backfills exact legacy defaults', () => { + const changelog = fs.readFileSync(path.join(__dirname, '../../db/changelogs/master-changelog.xml'), 'utf8'); + const migration = fs.readFileSync(path.join(__dirname, '../../db/changelogs/v1_5_editable_survey_content.sql'), 'utf8'); + const instructions = 'For each question below, indicate the people you interact with at work. The survey will take 10-15 minutes to complete; please plan to finish in one session.'; + + assert.match(changelog, /v1_5_editable_survey_content\.sql/); + assert.match(migration, /ADD COLUMN IF NOT EXISTS instructions TEXT/); + assert.equal((migration.match(new RegExp(instructions.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length, 2); + assert.match(migration, /ADD COLUMN IF NOT EXISTS subject VARCHAR\(255\)/); + assert.match(migration, /DEFAULT 'CLA Network Survey'/); + assert.match(migration, /UPDATE EMAIL[\s\S]*SET subject = 'CLA Network Survey'[\s\S]*WHERE subject IS NULL/i); + assert.doesNotMatch(migration, /\bDROP\b|\bTRUNCATE\b|\bDELETE\s+FROM\b/i); +}); + +test('email CSV parser supports legacy and subject formats without changing punctuation', () => { + const legacy = parseEmailCsv('Language,Text\nEnglish,"Hello, team — don\'t strip punctuation!"', 'Survey A'); + assert.deepEqual(legacy, [{ surveyName: 'Survey A', language: 'English', text: "Hello, team — don't strip punctuation!" }]); + + const oldExport = parseEmailCsv('language_code,notification_text\nen,"Exact old export"', 'Survey A'); + assert.deepEqual(oldExport, [{ surveyName: 'Survey A', language: 'English', text: 'Exact old export' }]); + + const current = parseEmailCsv('Language,Subject,Text\nFrench,"Objet : réseau!","Bonjour, équipe!"', 'Survey A'); + assert.deepEqual(current, [{ + surveyName: 'Survey A', + language: 'French', + subject: 'Objet : réseau!', + text: 'Bonjour, équipe!', + }]); +}); + +test('email CSV parser normalizes header aliases and rejects missing language or text headers', () => { + assert.deepEqual( + parseEmailCsv(' Language-Code , Email Subject , E-mail Text \nes,Asunto,Mensaje', 'Survey A'), + [{ surveyName: 'Survey A', language: 'Spanish', subject: 'Asunto', text: 'Mensaje' }] + ); + assert.throws(() => parseEmailCsv('subject,text\nHello,Body', 'Survey A'), /Language and Text headers/); + assert.throws(() => parseEmailCsv('language,subject\nen,Hello', 'Survey A'), /Language and Text headers/); +}); + +test('legacy language codes and wrapped labels normalize to shared canonical labels', () => { + assert.equal(normalizeLanguage('en'), 'English'); + assert.equal(normalizeLanguage(' "Spanish" '), 'Spanish'); + assert.equal(normalizeLanguage("'French'"), 'French'); + assert.equal(normalizeLanguage('KLINGON'), null); + + assert.deepEqual(buildNotificationMaps([ + { lang: "'en'", subject: null, text: 'Legacy hello' }, + { lang: 'English', subject: null, text: 'Hello' }, + { lang: '"en"', subject: null, text: 'Older duplicate' }, + { lang: '"Spanish"', subject: 'Hola subject', text: 'Hola' }, + ]), { + notifications: { English: 'Hello', Spanish: 'Hola' }, + subjects: { English: 'CLA Network Survey', Spanish: 'Hola subject' }, + templates: { + English: { subject: 'CLA Network Survey', text: 'Hello' }, + Spanish: { subject: 'Hola subject', text: 'Hola' }, + }, + }); + + const parsed = parseEmailCsv('Language,Text\nen,Hello\n"\'Spanish\'",Hola', 'Survey A'); + assert.deepEqual(parsed.map(({ language }) => language), ['English', 'Spanish']); +}); + +test('sendTestMail selects current localized subjects and bodies for legacy respondent and EMAIL languages', async (t) => { + const originalConnect = pool.connect; + const sent = []; + let released = 0; + t.after(() => { pool.connect = originalConnect; }); + + const templateRows = [ + { lang: '"en"', text: 'Stale English body', subject: 'Stale English subject' }, + { lang: 'English', text: "Current English body — don't alter", subject: 'Current English subject' }, + { lang: "'Spanish'", text: 'Cuerpo actual', subject: 'Asunto actual' }, + ]; + pool.connect = async () => ({ + async query(sql, values) { + if (/FROM email/i.test(sql)) { + assert.doesNotMatch(sql, /lang\s*=\s*\$3/i); + assert.deepEqual(values, ['survey-id', 'Survey A']); + return { rows: templateRows }; + } + assert.match(sql, /FROM Respondent/i); + return { rows: [{ uuid: 'respondent-token' }] }; + }, + release() { released += 1; }, + }); + const mailer = async (...args) => { sent.push(args); }; + + await sendTestMail('english@example.com', { id: 'survey-id', name: 'Survey A' }, 'en', mailer); + await sendTestMail('spanish@example.com', { id: 'survey-id', name: 'Survey A' }, '"Spanish"', mailer); + + assert.deepEqual(sent, [ + ['english@example.com', 'respondent-token', 'Survey A', "Current English body — don't alter", 'Current English subject'], + ['spanish@example.com', 'respondent-token', 'Survey A', 'Cuerpo actual', 'Asunto actual'], + ]); + assert.equal(released, 2); +}); + +test('startSurvey releases its client and prevalidates every canonicalized template before enqueueing', async (t) => { + const originalConnect = pool.connect; + const initialQueueLength = emailQueue.length; + let released = false; + t.after(() => { + pool.connect = originalConnect; + emailQueue.splice(initialQueueLength); + }); + pool.connect = async () => ({ + async query(sql) { + if (/FROM Respondent/i.test(sql)) { + return { rows: [ + { contact_info: 'one@example.com', uuid: 'one', lang: 'en' }, + { contact_info: 'two@example.com', uuid: 'two', lang: "'Spanish'" }, + ] }; + } + return { rows: [{ lang: '"English"', text: 'Hello', subject: 'Subject' }] }; + }, + release() { released = true; }, + }); + + await assert.rejects(startSurvey({ id: 'survey-id', name: 'Survey A' }), /Spanish/); + assert.equal(released, true); + assert.equal(emailQueue.length, initialQueueLength, 'no recipient is queued when any template is missing'); +}); + +test('notification validation enforces language, safe nonblank subjects, and text bounds', () => { + assert.equal(validateNotification({ language: 'English', subject: 'A subject!', text: '' }), null); + assert.match(validateNotification({ language: 'Klingon', subject: 'Subject', text: 'Text' }), /known language/); + assert.match(validateNotification({ language: 'English', subject: ' ', text: 'Text' }), /required/); + assert.match(validateNotification({ language: 'English', subject: 'Header\r\nBcc: bad', text: 'Text' }), /line breaks|control/); + assert.match(validateNotification({ language: 'English', subject: 'x'.repeat(256), text: 'Text' }), /255/); + assert.match(validateNotification({ language: 'English', subject: 'Subject', text: 'x'.repeat(10001) }), /10000/); +}); + +test('legacy email upserts preserve subjects and release clients while propagating failures', async (t) => { + const originalConnect = pool.connect; + t.after(() => { pool.connect = originalConnect; }); + const calls = []; + let released = false; + pool.connect = async () => ({ + async query(sql, values) { + calls.push({ sql, values }); + if (/INSERT INTO email/i.test(sql)) throw new Error('write failed'); + return { rows: [] }; + }, + release() { released = true; }, + }); + + await assert.rejects(insertEmails([{ surveyName: 'Survey A', language: 'English', text: "Don't alter me!" }]), /write failed/); + const insert = calls.find((call) => /INSERT INTO email/i.test(call.sql)); + assert.doesNotMatch(insert.sql, /subject/i); + assert.equal(insert.values[3], "Don't alter me!"); + assert.equal(calls.some((call) => call.sql === 'ROLLBACK'), true); + assert.equal(released, true); +}); + test('dashboard URL helpers prefer DASHBOARD_URL and fall back to FRONTEND_URL', (t) => { const originalDashboardUrl = process.env.DASHBOARD_URL; const originalFrontendUrl = process.env.FRONTEND_URL; @@ -1357,6 +1520,9 @@ test('dashboard/admin endpoints require authentication', async () => { ['post', '/api/updateTargets', { surveyName: 'S', csvData: 'First,Last,Email\nA,B,a@example.com' }], ['post', '/api/updateQuestions', { surveyName: 'S', questions: { elements: [] } }], ['get', '/api/survey-notifications/S'], + ['post', '/api/survey-notifications/S', { language: 'English', subject: 'Subject', text: 'Text' }], + ['get', '/api/survey-content/S'], + ['put', '/api/survey-content/S', { instructions: '' }], ['get', '/api/admin/names?surveyName=S'], ['get', '/api/admin/questions?surveyName=S'], ['get', '/api/listQuestions?surveyName=S'], @@ -1784,7 +1950,7 @@ 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 FROM email/.test(sql)) return { rows: [{ text: 'Hello {{link}}' }] }; + if (/SELECT lang, text, subject FROM email/.test(sql)) return { rows: [{ lang: 'English', text: 'Hello {{link}}', subject: 'Localized subject' }] }; 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']); @@ -2056,6 +2222,38 @@ test('respondent routes reject archived survey tokens without dashboard session' assert.equal(calls.length, 4); }); +test('/api/questions returns instructions only after respondent token validation', async (t) => { + const originalQuery = pool.query; + const originalConnect = pool.connect; + t.after(() => { + pool.query = originalQuery; + pool.connect = originalConnect; + }); + + pool.query = async (sql, values) => { + assert.match(sql, /JOIN Survey s/); + assert.deepEqual(values, ['valid-token', 'Survey A']); + return { rows: [{ respondent_id: 1, can_respond: true, survey_id: 'survey-a-id', response: null }] }; + }; + let released = false; + pool.connect = async () => ({ + async query(sql, values) { + assert.match(sql, /SELECT questions, title, instructions/); + assert.deepEqual(values, ['Survey A']); + return { rows: [{ title: 'Survey', questions: { elements: [] }, instructions: 'Read this first.' }] }; + }, + release() { released = true; }, + }); + + const res = await request(app) + .get('/api/questions') + .query({ surveyName: 'Survey A', userId: 'valid-token' }); + + assert.equal(res.status, 200); + assert.equal(res.body.instructions, 'Read this first.'); + assert.equal(released, true); +}); + test('/api/questions rejects demo token for arbitrary survey definitions', async (t) => { const originalQuery = pool.query; t.after(() => { diff --git a/dashboard/src/components/Dashboard.js b/dashboard/src/components/Dashboard.js index 48c2cea..b411015 100644 --- a/dashboard/src/components/Dashboard.js +++ b/dashboard/src/components/Dashboard.js @@ -8,6 +8,7 @@ import api from "../api/axios"; import QuestionTable from "./QuestionTable"; import CreateSurveyDialog from "./CreateSurveyDialog"; import EmailNotificationEditor from "./EmailNotificationEditor"; +import SurveyContentEditor from "./SurveyContentEditor"; import CollapsibleSection from "./CollapsibleSection"; import { useAuth } from "../context/AuthContext"; @@ -19,6 +20,10 @@ const Dashboard = () => { const [respondentData, setRespondentData] = React.useState(null); const [createDialogOpen, setCreateDialogOpen] = React.useState(false); const [snackbar, setSnackbar] = React.useState(null); + const [contentDirty, setContentDirty] = React.useState(false); + const [notificationDirty, setNotificationDirty] = React.useState(false); + const [contentBusy, setContentBusy] = React.useState(false); + const [notificationBusy, setNotificationBusy] = React.useState(false); const { memberships, canViewSensitiveSurveyData, canEditSurvey } = useAuth(); const fetchSurveyData = async () => { @@ -87,6 +92,21 @@ const Dashboard = () => { }, [selectSurvey, canViewSensitiveSurveyData]); const handleSelectRow = (childData) => { + const currentId = selectSurvey?.id || selectSurvey?.name; + const nextId = childData?.id || childData?.name; + if (currentId && currentId !== nextId && (contentBusy || notificationBusy)) { + setSnackbar({ + severity: 'warning', + message: 'Please wait for the current save or CSV import to finish before switching surveys.', + }); + return; + } + if (currentId && currentId !== nextId && (contentDirty || notificationDirty)) { + const shouldDiscard = window.confirm('Discard unsaved changes and switch surveys?'); + if (!shouldDiscard) return; + } + setContentDirty(false); + setNotificationDirty(false); setSelectSurvey(childData); }; @@ -193,9 +213,23 @@ const Dashboard = () => { /> + {canEditSurvey(selectSurvey) && ( + + + + )} + {canEditSurvey(selectSurvey) && ( - + )} diff --git a/dashboard/src/components/Dashboard.test.js b/dashboard/src/components/Dashboard.test.js new file mode 100644 index 0000000..bddccf4 --- /dev/null +++ b/dashboard/src/components/Dashboard.test.js @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '@emotion/react'; +import Dashboard from './Dashboard'; +import api from '../api/axios'; + +vi.mock('../api/axios', () => ({ + default: { get: vi.fn(), post: vi.fn() }, +})); +vi.mock('../context/AuthContext', () => ({ + useAuth: () => ({ + memberships: [{ role: 'editor' }], + canViewSensitiveSurveyData: () => false, + canEditSurvey: () => true, + }), +})); +vi.mock('./CollapsibleSection', () => ({ default: ({ children }) =>
{children}
})); +vi.mock('./SurveyTable', () => ({ + default: ({ selectRow, selectedSurvey }) => ( +
+
{selectedSurvey?.name || 'none'}
+ + +
+ ), +})); +vi.mock('./EmailNotificationEditor', () => ({ + default: ({ onBusyChange }) => , +})); +vi.mock('./SurveyContentEditor', () => ({ default: () => null })); +vi.mock('./QuestionTable', () => ({ default: () => null })); +vi.mock('./RespondentTable', () => ({ default: () => null })); +vi.mock('./CreateSurveyDialog', () => ({ default: () => null })); + +beforeEach(() => { + vi.clearAllMocks(); + api.get.mockImplementation(async (url) => { + if (url === '/surveys') return { data: { surveys: [] } }; + return { data: { questions: [] } }; + }); +}); + +test('rejects survey switching with clear feedback while notification save/import is busy', async () => { + const theme = { palette: { divider: '#ddd', mode: 'light', background: { paper: '#fff' } } }; + render(); + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await waitFor(() => expect(screen.getByTestId('selected-survey')).toHaveTextContent('Survey One')); + + await userEvent.click(screen.getByRole('button', { name: 'Begin import' })); + await userEvent.click(screen.getByRole('button', { name: 'Select two' })); + + expect(screen.getByTestId('selected-survey')).toHaveTextContent('Survey One'); + expect(screen.getByText(/wait for the current save or CSV import/i)).toBeInTheDocument(); +}); diff --git a/dashboard/src/components/EmailNotificationEditor.js b/dashboard/src/components/EmailNotificationEditor.js index 6c4c97d..f6c6858 100644 --- a/dashboard/src/components/EmailNotificationEditor.js +++ b/dashboard/src/components/EmailNotificationEditor.js @@ -1,334 +1,339 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { - Box, - TextField, + Alert, Autocomplete, + Box, + Button, + CircularProgress, Paper, + TextField, Typography, - Button, - Alert, - Collapse, - IconButton, -} from "@mui/material"; -import { useTheme } from "@mui/material/styles"; -import EditableTableWrapper from "./EditableTableWrapper"; -import UploadFileIcon from "@mui/icons-material/UploadFile"; -import CloseIcon from "@mui/icons-material/Close"; -import DownloadIcon from "@mui/icons-material/Download"; -import api from "../api/axios"; -import { LANGUAGES } from "@network-survey/frontend-shared"; -const HEADER = "Language,Text\n"; +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import DownloadIcon from '@mui/icons-material/Download'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import Papa from 'papaparse'; +import api from '../api/axios'; +import { LANGUAGES } from '@network-survey/frontend-shared'; + +const EMPTY_MESSAGE = { subject: '', text: '' }; +const LEGACY_EMAIL_SUBJECT = 'CLA Network Survey'; -const EmailNotificationEditor = ({ surveyId }) => { +const languageFromInput = (value) => { + const normalized = String(value ?? '').trim().toLowerCase(); + return LANGUAGES.find( + (language) => language.label.toLowerCase() === normalized || language.code.toLowerCase() === normalized + ) || null; +}; + +const normalizedHeader = (key) => String(key).trim().toLowerCase().replace(/[ _-]+/g, ''); + +const valueForHeader = (row, aliases) => { + const entry = Object.entries(row).find(([key]) => aliases.includes(normalizedHeader(key))); + return entry ? String(entry[1] ?? '') : ''; +}; + +const hasHeader = (row, aliases) => Object.keys(row).some((key) => aliases.includes(normalizedHeader(key))); + +const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { const theme = useTheme(); - const [selectedLanguage, setSelectedLanguage] = useState(null); - const [notificationText, setNotificationText] = useState(""); - const [originalText, setOriginalText] = useState(""); - const [hasChanges, setHasChanges] = useState(false); + const uploadInputRef = useRef(null); + const surveyIdRef = useRef(surveyId); + const generationRef = useRef(0); + const requestControllerRef = useRef(null); + surveyIdRef.current = surveyId; + const [selectedLanguage, setSelectedLanguage] = useState(LANGUAGES[0]); const [notifications, setNotifications] = useState({}); - const [alert, setAlert] = useState({ - show: false, - type: "info", - message: "", - }); + const [subjects, setSubjects] = useState({}); + const [draft, setDraft] = useState(EMPTY_MESSAGE); + const [original, setOriginal] = useState(EMPTY_MESSAGE); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [alert, setAlert] = useState(null); - const handleCloseAlert = () => { - setAlert({ ...alert, show: false }); - }; + const dirty = draft.subject !== original.subject || draft.text !== original.text; - // Fetch notifications for the survey useEffect(() => { - const fetchNotifications = async () => { - try { - const response = await api.get(`/survey-notifications/${surveyId}`); - setNotifications(response.data.notifications); - - // set available languages to all languages - const availableLanguages = LANGUAGES.map((lang) => { return lang.label; }); - if (Object.keys(response.data.notifications).length > 0) { - const firstLanguageCode = availableLanguages[0]; - const firstLanguage = LANGUAGES.find( - (lang) => lang.label === firstLanguageCode - ); - - if (firstLanguage) { - setSelectedLanguage(firstLanguage); - setNotificationText(response.data.notifications[firstLanguageCode].replace(/"/g, '')); - setOriginalText(response.data.notifications[firstLanguageCode]); - } else { - setSelectedLanguage(null); - setNotificationText(""); - setOriginalText(""); - } - } else { - setSelectedLanguage({ code: "en", label: "English" }); - setNotificationText(""); - setOriginalText(""); - } - } catch (error) { - console.error("Failed to fetch notifications:", error); - setAlert({ - show: true, - type: "error", - message: "Failed to load notifications", - }); - } - }; - if (surveyId) { - fetchNotifications(); - } - }, [surveyId]); + onDirtyChange?.(dirty); + return () => onDirtyChange?.(false); + }, [dirty, onDirtyChange]); - // Update the notification text and original text when the selected language changes useEffect(() => { - console.log(notifications) - if (selectedLanguage && notifications[selectedLanguage.label]) { - setNotificationText(notifications[selectedLanguage.label].replace(/"/g, '') || ""); - setOriginalText(notifications[selectedLanguage.label] || ""); - } else { - setNotificationText(""); - setOriginalText(""); - } - }, [selectedLanguage, notifications, surveyId]); + onBusyChange?.(saving); + return () => onBusyChange?.(false); + }, [saving, onBusyChange]); + + useEffect(() => { + const generation = ++generationRef.current; + requestControllerRef.current?.abort(); + requestControllerRef.current = null; + let active = true; + setSelectedLanguage(LANGUAGES[0]); + setNotifications({}); + setSubjects({}); + setSaving(false); + setDraft(EMPTY_MESSAGE); + setOriginal(EMPTY_MESSAGE); + setAlert(null); - const handleLanguageChange = (event, newValue) => { - if (newValue) { - setSelectedLanguage(newValue); + if (!surveyId) { + setLoading(false); + return () => { active = false; }; } - }; - const handleTextChange = (event) => { - const newText = event.target.value; - setNotificationText(newText); - setHasChanges(newText !== originalText); + const isCurrent = () => active && generation === generationRef.current && surveyId === surveyIdRef.current; + const loadNotifications = async () => { + setLoading(true); + try { + const response = await api.get(`/survey-notifications/${surveyId}`); + if (!isCurrent()) return; + const nextNotifications = response.data?.notifications || {}; + const nextSubjects = response.data?.subjects || {}; + const language = LANGUAGES.find( + (candidate) => Object.prototype.hasOwnProperty.call(nextNotifications, candidate.label) + || Object.prototype.hasOwnProperty.call(nextSubjects, candidate.label) + ) || LANGUAGES[0]; + const nextDraft = { + subject: nextSubjects[language.label] ?? '', + text: nextNotifications[language.label] ?? '', + }; + setNotifications(nextNotifications); + setSubjects(nextSubjects); + setSelectedLanguage(language); + setDraft(nextDraft); + setOriginal(nextDraft); + } catch (error) { + if (isCurrent()) setAlert({ severity: 'error', message: 'Failed to load email notifications.' }); + } finally { + if (isCurrent()) setLoading(false); + } + }; + + loadNotifications(); + return () => { + active = false; + requestControllerRef.current?.abort(); + requestControllerRef.current = null; + }; + }, [surveyId]); + + const selectLanguage = (_, language) => { + if (!language || dirty) return; + const nextDraft = { + subject: subjects[language.label] ?? '', + text: notifications[language.label] ?? '', + }; + setSelectedLanguage(language); + setDraft(nextDraft); + setOriginal(nextDraft); + setAlert(null); }; const handleSave = async () => { + if (!surveyId || !selectedLanguage || !dirty) return; + const operationSurveyId = surveyId; + const generation = generationRef.current; + const controller = new AbortController(); + requestControllerRef.current = controller; + const isCurrent = () => generation === generationRef.current && operationSurveyId === surveyIdRef.current; + setSaving(true); + setAlert(null); try { - const csvData = HEADER + LANGUAGES.map(lang => { - const text = lang.label === selectedLanguage.label ? `"${notificationText.replace(/"/g, '""')}"` : notifications[lang.label] ? `"${notifications[lang.label].replace(/"/g, '""')}"` : '""'; - return `${lang.label},${text}`; - }).join('\n'); - - console.log(csvData); - - await api.post(`/updateEmails`, { - surveyName: surveyId, - csvData: csvData, - }); - - setNotifications((prevNotifications) => ({ - ...prevNotifications, - [selectedLanguage.label]: notificationText, - })); - - setOriginalText(notificationText); - setHasChanges(false); - setAlert({ - show: true, - type: "success", - message: "Notification text saved successfully", - }); + await api.post(`/survey-notifications/${operationSurveyId}`, { + language: selectedLanguage.label, + subject: draft.subject, + text: draft.text, + }, { signal: controller.signal }); + if (!isCurrent()) return; + setNotifications((current) => ({ ...current, [selectedLanguage.label]: draft.text })); + setSubjects((current) => ({ ...current, [selectedLanguage.label]: draft.subject })); + setOriginal(draft); + setAlert({ severity: 'success', message: 'Email notification saved.' }); } catch (error) { - console.error("Failed to save notification:", error); - setAlert({ - show: true, - type: "error", - message: "Failed to save notification", - }); + if (isCurrent() && !controller.signal.aborted) { + setAlert({ severity: 'error', message: 'Failed to save email notification.' }); + } + } finally { + if (isCurrent()) { + requestControllerRef.current = null; + setSaving(false); + } } }; - const handleFileUpload = async (event) => { - const file = event.target.files?.[0]; - if (!file) return; + const handleReset = () => { + setDraft(original); + setAlert(null); + }; - const reader = new FileReader(); - reader.onload = async (e) => { - try { - const csvData = e.target?.result; + const downloadRows = useMemo(() => LANGUAGES.map((language) => ({ + Language: language.label, + Subject: subjects[language.label] ?? LEGACY_EMAIL_SUBJECT, + Text: notifications[language.label] ?? '', + })), [notifications, subjects]); - const csvArray = csvData.split("\n"); - // const header = csvArray.shift().split(","); + const handleDownload = () => { + const csv = Papa.unparse(downloadRows, { columns: ['Language', 'Subject', 'Text'] }); + const url = window.URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'survey-notifications.csv'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.URL.revokeObjectURL(url); + }; - const newNotifications = csvArray.reduce((acc, row) => { - // Handle quotes and commas properly using regex - const matches = row.match(/(?:^|,)("(?:[^"]|"")*"|[^,]*)/g); - if (matches && matches.length >= 2) { - const langCode = matches[0].replace(/^,|"/g, '').trim(); - const text = matches[1].replace(/^,|"/g, '').replace(/""/g, '"').trim(); - acc[langCode] = text; - } - return acc; - }, {}); + const handleFileUpload = (event) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file || dirty || !surveyId) return; - setNotifications(prevNotifications => ({ - ...prevNotifications, - ...newNotifications - })); + const operationSurveyId = surveyId; + const generation = generationRef.current; + const controller = new AbortController(); + requestControllerRef.current = controller; + const isCurrent = () => generation === generationRef.current && operationSurveyId === surveyIdRef.current; + const finish = () => { + if (!isCurrent()) return; + requestControllerRef.current = null; + setSaving(false); + }; - // Update the selected language if it's not available in the new notifications - if (!newNotifications[selectedLanguage?.code]) { - const availableLanguages = Object.keys(newNotifications); - if (availableLanguages.length > 0) { - setSelectedLanguage( - LANGUAGES.find((lang) => lang.code === availableLanguages[0]) - ); + setSaving(true); + setAlert(null); + Papa.parse(file, { + header: true, + skipEmptyLines: 'greedy', + complete: async ({ data, errors, meta }) => { + try { + if (!isCurrent()) return; + if (errors.length) throw new Error(errors[0].message); + const normalizedFields = (meta?.fields || []).map(normalizedHeader); + const textAliases = ['text', 'notificationtext', 'emailtext', 'message']; + if (!normalizedFields.some((field) => textAliases.includes(field))) { + throw new Error('CSV must include a recognized Text or Message header.'); } - } - await api.post("/updateEmails", { - surveyName: surveyId, - csvData: csvData, - }); + const rows = data.map((row, index) => { + const languageValue = valueForHeader(row, ['language', 'languagecode', 'lang']); + const language = languageFromInput(languageValue); + if (!language) throw new Error(`Unknown language on CSV row ${index + 2}: ${languageValue || '(blank)'}`); + const includesSubject = hasHeader(row, ['subject', 'emailsubject']); + return { + Language: language.label, + Subject: includesSubject + ? valueForHeader(row, ['subject', 'emailsubject']) + : (subjects[language.label] ?? LEGACY_EMAIL_SUBJECT), + Text: valueForHeader(row, textAliases), + }; + }); - setAlert({ - show: true, - type: "success", - message: "Notifications updated successfully from CSV", - }); - } catch (error) { - console.error("Failed to process CSV:", error); - setAlert({ - show: true, - type: "error", - message: "Failed to update notifications from CSV", - }); - } - }; - reader.readAsText(file); - event.target.value = ""; - }; + if (!rows.length) throw new Error('No recognized notification rows were found.'); - const handleDownloadTemplate = () => { - const csvContent = [ - "language_code,notification_text", - ...LANGUAGES.map((lang) => `${lang.code},""`), - ].join("\n"); + const csvData = Papa.unparse(rows, { columns: ['Language', 'Subject', 'Text'] }); + await api.post('/updateEmails', { surveyName: operationSurveyId, csvData }, { signal: controller.signal }); + if (!isCurrent()) return; - const blob = new Blob([csvContent], { type: "text/csv" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "notification_template.csv"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); + const importedNotifications = {}; + const importedSubjects = {}; + rows.forEach((row) => { + importedNotifications[row.Language] = row.Text; + importedSubjects[row.Language] = row.Subject; + }); + const nextNotifications = { ...notifications, ...importedNotifications }; + const nextSubjects = { ...subjects, ...importedSubjects }; + const nextLanguage = languageFromInput(rows[0].Language) || LANGUAGES[0]; + const nextDraft = { + subject: nextSubjects[nextLanguage.label] ?? '', + text: nextNotifications[nextLanguage.label] ?? '', + }; + setNotifications(nextNotifications); + setSubjects(nextSubjects); + setSelectedLanguage(nextLanguage); + setDraft(nextDraft); + setOriginal(nextDraft); + setAlert({ severity: 'success', message: 'Email notifications imported.' }); + } catch (error) { + if (isCurrent() && !controller.signal.aborted) { + setAlert({ severity: 'error', message: error.message || 'Failed to import email notifications.' }); + } + } finally { + finish(); + } + }, + error: (error) => { + if (isCurrent() && !controller.signal.aborted) { + setAlert({ severity: 'error', message: error.message || 'Failed to read the CSV file.' }); + } + finish(); + }, + }); }; return ( - - - - - - - } - sx={{ mb: 2 }} - > - {alert.message} - - - - - - - Survey Notification Text + + + + Survey Email Notification - - - + - - + {alert && setAlert(null)} sx={{ mb: 2 }}>{alert.message}} + + {loading ? ( + + ) : ( + option?.label || ""} - renderInput={(params) => ( - - )} - sx={{ width: "100%", maxWidth: 300 }} + getOptionLabel={(option) => option?.label || ''} + isOptionEqualToValue={(option, value) => option.code === value?.code} + disabled={dirty || saving} + renderInput={(params) => } + sx={{ maxWidth: 320 }} + /> + setDraft((current) => ({ ...current, subject: event.target.value }))} + disabled={!surveyId || saving} /> - setDraft((current) => ({ ...current, text: event.target.value }))} + disabled={!surveyId || saving} /> + + + + - + )} ); }; diff --git a/dashboard/src/components/EmailNotificationEditor.test.js b/dashboard/src/components/EmailNotificationEditor.test.js new file mode 100644 index 0000000..137e278 --- /dev/null +++ b/dashboard/src/components/EmailNotificationEditor.test.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import EmailNotificationEditor from './EmailNotificationEditor'; +import api from '../api/axios'; + +vi.mock('../api/axios', () => ({ + default: { get: vi.fn(), post: vi.fn() }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + api.get.mockResolvedValue({ + data: { + notifications: { English: 'Hello, "team"! Don’t remove punctuation.' }, + subjects: { English: 'You’re invited!' }, + }, + }); + api.post.mockResolvedValue({ data: { success: true } }); +}); + +test('loads subject and text, protects language changes while dirty, and saves JSON unchanged', async () => { + render(); + + const subject = await screen.findByLabelText('Email subject'); + const text = screen.getByLabelText('Notification text'); + expect(subject).toHaveValue('You’re invited!'); + expect(text).toHaveValue('Hello, "team"! Don’t remove punctuation.'); + + await userEvent.clear(subject); + await userEvent.type(subject, 'Reminder: don’t forget!'); + await userEvent.clear(text); + await userEvent.type(text, 'Hi, "friend" — please respond!'); + + expect(screen.getByLabelText('Language')).toBeDisabled(); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/survey-notifications/survey-1', { + language: 'English', + subject: 'Reminder: don’t forget!', + text: 'Hi, "friend" — please respond!', + }, expect.objectContaining({ signal: expect.any(AbortSignal) }))); + expect(screen.getByLabelText('Language')).not.toBeDisabled(); +}); + +test('handles absent notification maps without undefined errors', async () => { + api.get.mockResolvedValueOnce({ data: {} }); + render(); + expect(await screen.findByLabelText('Email subject')).toHaveValue(''); + expect(screen.getByLabelText('Notification text')).toHaveValue(''); +}); + +test('rejects a CSV without a recognized text or message header before saving', async () => { + const { container } = render(); + await screen.findByLabelText('Email subject'); + const file = new File(['Language,Subject,Body\nEnglish,Hello,Wrong column'], 'bad.csv', { type: 'text/csv' }); + + await userEvent.upload(container.querySelector('input[type="file"]'), file); + + expect(await screen.findByText(/recognized Text or Message header/i)).toBeInTheDocument(); + expect(api.post).not.toHaveBeenCalled(); +}); + +test('an in-flight CSV import cannot update state after surveyId changes', async () => { + let resolveImport; + const importRequest = new Promise((resolve) => { resolveImport = resolve; }); + api.get.mockImplementation(async (url) => ({ + data: url.endsWith('survey-2') + ? { notifications: { English: 'Survey two text' }, subjects: { English: 'Survey two subject' } } + : { notifications: { English: 'Survey one text' }, subjects: { English: 'Survey one subject' } }, + })); + api.post.mockReturnValueOnce(importRequest); + const onBusyChange = vi.fn(); + const { container, rerender } = render( + + ); + expect(await screen.findByLabelText('Email subject')).toHaveValue('Survey one subject'); + + const file = new File(['Language,Message\nEnglish,Imported old text'], 'valid.csv', { type: 'text/csv' }); + await userEvent.upload(container.querySelector('input[type="file"]'), file); + await waitFor(() => expect(api.post).toHaveBeenCalled()); + await waitFor(() => expect(onBusyChange).toHaveBeenCalledWith(true)); + + rerender(); + expect(await screen.findByLabelText('Email subject')).toHaveValue('Survey two subject'); + resolveImport({ data: { success: true } }); + + await waitFor(() => expect(screen.getByLabelText('Notification text')).toHaveValue('Survey two text')); + expect(screen.queryByText('Email notifications imported.')).not.toBeInTheDocument(); +}); diff --git a/dashboard/src/components/SurveyContentEditor.js b/dashboard/src/components/SurveyContentEditor.js new file mode 100644 index 0000000..e61bd94 --- /dev/null +++ b/dashboard/src/components/SurveyContentEditor.js @@ -0,0 +1,133 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Paper, + TextField, + Typography, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import api from '../api/axios'; + +const SurveyContentEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { + const theme = useTheme(); + const [instructions, setInstructions] = useState(''); + const [originalInstructions, setOriginalInstructions] = useState(''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [alert, setAlert] = useState(null); + + useEffect(() => { + let active = true; + + setInstructions(''); + setOriginalInstructions(''); + setAlert(null); + + if (!surveyId) { + setLoading(false); + return () => { active = false; }; + } + + const loadContent = async () => { + setLoading(true); + try { + const response = await api.get(`/survey-content/${surveyId}`); + if (!active) return; + const value = typeof response.data?.instructions === 'string' + ? response.data.instructions + : ''; + setInstructions(value); + setOriginalInstructions(value); + } catch (error) { + if (!active) return; + setAlert({ severity: 'error', message: 'Failed to load survey instructions.' }); + } finally { + if (active) setLoading(false); + } + }; + + loadContent(); + return () => { active = false; }; + }, [surveyId]); + + const dirty = instructions !== originalInstructions; + + useEffect(() => { + onDirtyChange?.(dirty); + return () => onDirtyChange?.(false); + }, [dirty, onDirtyChange]); + + useEffect(() => { + onBusyChange?.(saving); + return () => onBusyChange?.(false); + }, [saving, onBusyChange]); + + const handleSave = async () => { + if (!surveyId || !dirty) return; + setSaving(true); + setAlert(null); + try { + await api.put(`/survey-content/${surveyId}`, { instructions }); + setOriginalInstructions(instructions); + setAlert({ severity: 'success', message: 'Survey instructions saved.' }); + } catch (error) { + setAlert({ severity: 'error', message: 'Failed to save survey instructions.' }); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + setInstructions(originalInstructions); + setAlert(null); + }; + + return ( + + + Survey Instructions + + + {alert && ( + setAlert(null)} sx={{ mb: 2 }}> + {alert.message} + + )} + + {loading ? ( + + + + ) : ( + <> + { + setInstructions(event.target.value); + if (alert?.severity === 'success') setAlert(null); + }} + disabled={!surveyId || saving} + helperText="Plain text only. Line breaks will be preserved. Leave empty to hide the instructions block." + /> + + + + + + )} + + ); +}; + +export default SurveyContentEditor; diff --git a/dashboard/src/components/SurveyContentEditor.test.js b/dashboard/src/components/SurveyContentEditor.test.js new file mode 100644 index 0000000..1b368a9 --- /dev/null +++ b/dashboard/src/components/SurveyContentEditor.test.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import SurveyContentEditor from './SurveyContentEditor'; +import api from '../api/axios'; + +vi.mock('../api/axios', () => ({ + default: { get: vi.fn(), put: vi.fn() }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + api.get.mockResolvedValue({ data: { instructions: 'First line\nSecond line' } }); + api.put.mockResolvedValue({ data: { instructions: 'Updated' } }); +}); + +test('loads, resets, and saves multiline survey instructions', async () => { + render(); + + const input = await screen.findByLabelText('Instructions shown to respondents'); + expect(input).toHaveValue('First line\nSecond line'); + expect(api.get).toHaveBeenCalledWith('/survey-content/survey-1'); + + await userEvent.clear(input); + await userEvent.type(input, 'Changed instructions'); + await userEvent.click(screen.getByRole('button', { name: 'Reset' })); + expect(input).toHaveValue('First line\nSecond line'); + + await userEvent.clear(input); + await userEvent.type(input, 'Updated'); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(api.put).toHaveBeenCalledWith('/survey-content/survey-1', { instructions: 'Updated' })); + expect(await screen.findByText('Survey instructions saved.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); +}); + +test('shows a load error without throwing on a missing response', async () => { + api.get.mockRejectedValueOnce(new Error('network')); + render(); + expect(await screen.findByText('Failed to load survey instructions.')).toBeInTheDocument(); +}); diff --git a/db/changelogs/master-changelog.xml b/db/changelogs/master-changelog.xml index e6742a8..359e9be 100644 --- a/db/changelogs/master-changelog.xml +++ b/db/changelogs/master-changelog.xml @@ -12,4 +12,5 @@ + diff --git a/db/changelogs/v1_5_editable_survey_content.sql b/db/changelogs/v1_5_editable_survey_content.sql new file mode 100644 index 0000000..30ef80d --- /dev/null +++ b/db/changelogs/v1_5_editable_survey_content.sql @@ -0,0 +1,23 @@ +--liquibase formatted sql + +--changeset cladvisors:editable-survey-content-1 +--comment Add editable respondent instructions and localized email subjects, preserving legacy content. +ALTER TABLE Survey + ADD COLUMN IF NOT EXISTS instructions TEXT + DEFAULT 'For each question below, indicate the people you interact with at work. The survey will take 10-15 minutes to complete; please plan to finish in one session.'; + +UPDATE Survey +SET instructions = 'For each question below, indicate the people you interact with at work. The survey will take 10-15 minutes to complete; please plan to finish in one session.' +WHERE instructions IS NULL; + +ALTER TABLE EMAIL + ADD COLUMN IF NOT EXISTS subject VARCHAR(255) + DEFAULT 'CLA Network Survey'; + +UPDATE EMAIL +SET subject = 'CLA Network Survey' +WHERE subject IS NULL; + +-- The legacy column was length-limited; editable templates use a bounded API +-- limit while the database safely stores existing and future content. +ALTER TABLE EMAIL ALTER COLUMN text TYPE TEXT; diff --git a/network-survey/src/Survey.jsx b/network-survey/src/Survey.jsx index 4cd8f9f..aa0e45f 100644 --- a/network-survey/src/Survey.jsx +++ b/network-survey/src/Survey.jsx @@ -6,8 +6,14 @@ import SurveyComponent from './SurveyComponent'; import Logo from './logo.svg?react'; import { PRODUCTION_SURVEY_WRAPPER_SX } from '@network-survey/frontend-shared'; +const LEGACY_INSTRUCTIONS = 'For each question below, indicate the people you interact with at work. The survey will take 10-15 minutes to complete; please plan to finish in one session.'; + const Survey = () => { const [title, setTitle] = useState(''); + const [instructions, setInstructions] = useState(LEGACY_INSTRUCTIONS); + const handleInstructions = React.useCallback((value) => { + setInstructions(value === undefined ? LEGACY_INSTRUCTIONS : value); + }, []); return ( @@ -28,22 +34,27 @@ const Survey = () => { boxShadow: { xs: 'none', sm: appShadows.surface }, }} > - - - Survey Instructions - - - For each question below, indicate the people you interact with at work. - {' '}The survey will take 10-15 minutes to complete; please plan to finish in one session. - - + {instructions !== '' && ( + + + Survey Instructions + + + {instructions} + + + )} { ...PRODUCTION_SURVEY_WRAPPER_SX, }} > - + diff --git a/network-survey/src/SurveyComponent.jsx b/network-survey/src/SurveyComponent.jsx index 87a33e9..2e67c8f 100644 --- a/network-survey/src/SurveyComponent.jsx +++ b/network-survey/src/SurveyComponent.jsx @@ -36,7 +36,7 @@ Serializer.addClass( "question" ); -function SurveyComponent({setTitle}) { +function SurveyComponent({ setTitle, setInstructions }) { const theme = useTheme(); const [json, setJson] = React.useState(null); const [survey, setSurvey] = React.useState(null); @@ -60,13 +60,19 @@ function SurveyComponent({setTitle}) { React.useEffect(() => { if (!userId || !surveyName) return; - + + // Reset to the legacy fallback while loading another survey. An explicit + // empty string from the API still hides the instructions block. + setInstructions(undefined); const url = buildApiUrl('/questions', { surveyName, userId }); sendRequest(url, (data) => { - setJson(data.questions); - setTitle(data.title); + setJson(data.questions); + setTitle(data.title); + setInstructions(data && Object.prototype.hasOwnProperty.call(data, 'instructions') + ? (data.instructions ?? '') + : undefined); }); - }, [surveyName, setTitle, userId]); + }, [surveyName, setInstructions, setTitle, userId]); React.useEffect(() => { if (!json) return; From 97aa56a2939bebd8509c8cb694400c64dab8a76b Mon Sep 17 00:00:00 2001 From: Bennett Garcia Date: Tue, 4 Aug 2026 11:30:21 -0400 Subject: [PATCH 2/4] Harden survey content editing and email delivery --- api/server.js | 203 ++++++++++++------ api/test/security.test.js | 160 +++++++++++++- dashboard/src/App.js | 14 +- dashboard/src/components/Dashboard.js | 47 ++++ dashboard/src/components/Dashboard.test.js | 82 ++++++- .../src/components/EmailNotificationEditor.js | 56 +++-- .../EmailNotificationEditor.test.js | 32 ++- .../src/components/SurveyContentEditor.js | 25 ++- .../components/SurveyContentEditor.test.js | 13 +- dashboard/src/components/SurveyTable.js | 21 +- .../src/components/SurveyTableMenuCell.js | 12 +- 11 files changed, 553 insertions(+), 112 deletions(-) diff --git a/api/server.js b/api/server.js index 99610ed..4c7bfc1 100644 --- a/api/server.js +++ b/api/server.js @@ -105,7 +105,7 @@ async function sendAccountEmail({ to, subject, html, text }) { } } -async function sendMail(email, id, surveyName, text, subject = 'CLA Network Survey') { +async function sendMail(email, id, surveyName, text, subject = 'CLA Network Survey', options = {}) { try { if (!resend) { throw new Error('Missing RESEND_KEY or RESEND_API_KEY environment variable'); @@ -119,12 +119,15 @@ async function sendMail(email, id, surveyName, text, subject = 'CLA Network Surv from: 'CLA Survey ', to: email, subject: subject || 'CLA Network Survey', - html: EMAIL_HTML[0] + text + EMAIL_HTML[1] + customLink + EMAIL_HTML[2], - surveyName + html: EMAIL_HTML[0] + text + EMAIL_HTML[1] + customLink + EMAIL_HTML[2] }; - // Add delay to respect rate limit - await rateLimitedSend(emailData); + await rateLimitedSend(emailData, { + surveyId: options.surveyId, + surveyName, + respondentToken: id, + trackDelivery: options.trackDelivery !== false, + }); } catch (error) { console.error(`Failed to send email to ${email}:`, error); @@ -132,81 +135,131 @@ async function sendMail(email, id, surveyName, text, subject = 'CLA Network Surv } } -// Queue for managing email sending with rate limiting -const emailQueue = []; -let isProcessing = false; +// Queue for managing email sending with rate limiting. Each enqueue call owns a +// promise, so callers observe the result of their provider request rather than +// merely observing that the message was added to the queue. const RATE_LIMIT = 10; // emails per second const DELAY = 1000; // 1 second delay between batches -function rateLimitedSend(emailData) { - emailQueue.push(emailData); - - if (!isProcessing) { - isProcessing = true; - void processEmailQueue().catch((error) => { - console.error('Unexpected email queue processing failure:', error); - }); - } -} +function createEmailQueueProcessor({ + sendEmail, + markSuccessful = async () => {}, + rateLimit = RATE_LIMIT, + delay = DELAY, + wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +}) { + const queue = []; + let isProcessing = false; + + const processQueue = async () => { + let activeBatch = []; + try { + while (queue.length > 0) { + const batch = queue.splice(0, rateLimit); + activeBatch = batch; + const results = await Promise.all(batch.map(async (item) => { + try { + const result = await sendEmail(item.emailData); + return { success: true, item, result }; + } catch (error) { + console.error(`Failed to send email to ${item.emailData.to}:`, error); + return { success: false, item, error }; + } + })); -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 }; - } - })); + const successfulBySurvey = new Map(); + for (const result of results) { + if (!result.success || result.item.metadata.trackDelivery === false) continue; + const { surveyId, surveyName, respondentToken } = result.item.metadata; + if (!surveyName || !respondentToken) continue; + const surveyKey = surveyId || `legacy:${surveyName}`; + if (!successfulBySurvey.has(surveyKey)) { + successfulBySurvey.set(surveyKey, { surveyId, surveyName, respondentTokens: [] }); + } + successfulBySurvey.get(surveyKey).respondentTokens.push(respondentToken); + } + for (const delivery of successfulBySurvey.values()) { + try { + await markSuccessful(delivery); + } catch (error) { + // A bookkeeping failure must not turn a successful provider send + // into a delivery failure, but it must remain visible operationally. + console.error('Failed to update email_sent status:', error); + } + } - // 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); + // Settle all items in the batch, including every provider failure. + for (const result of results) { + if (result.success) result.item.resolve(result.result); + else result.item.reject(result.error); } + activeBatch = []; + + if (queue.length > 0) await wait(delay); } + } catch (error) { + // Defensive drain: an injected helper failure cannot strand either the + // currently spliced batch or items still waiting in the queue. + const stranded = [...activeBatch, ...queue.splice(0)]; + stranded.forEach((item) => item.reject(error)); + console.error('Unexpected email queue processing failure:', error); + } finally { + isProcessing = false; + // Normally no task can interleave here, but this also makes the helper + // robust to unusual thenables or test schedulers. + if (queue.length > 0) startProcessing(); } + }; - // Wait for rate limit window if more emails remain - if (emailQueue.length > 0) { - await new Promise(resolve => setTimeout(resolve, DELAY)); - } + const startProcessing = () => { + if (isProcessing) return; + isProcessing = true; + void processQueue(); + }; + + const enqueue = (emailData, metadata = {}) => new Promise((resolve, reject) => { + queue.push({ emailData, metadata, resolve, reject }); + startProcessing(); + }); + + return { queue, enqueue, processQueue, isProcessing: () => isProcessing }; +} + +async function sendEmailWithResend(emailData, resendClient = resend) { + const result = await resendClient.emails.send(emailData); + if (result?.error) { + const error = new Error(result.error.message || 'Email provider rejected the message.'); + error.providerError = result.error; + throw error; } - - isProcessing = false; + return result; } +const emailQueueProcessor = createEmailQueueProcessor({ + sendEmail: sendEmailWithResend, + markSuccessful: ({ surveyId, surveyName, respondentTokens }) => pool.query( + `UPDATE Respondent SET email_sent = true + WHERE uuid = ANY($1) + AND (survey_id = $2 OR (survey_id IS NULL AND survey_name = $3))`, + [respondentTokens, surveyId || null, surveyName] + ), +}); +const emailQueue = emailQueueProcessor.queue; +const rateLimitedSend = emailQueueProcessor.enqueue; +const processEmailQueue = emailQueueProcessor.processQueue; + // User test email function (allow admin user to send test email to themselves) async function sendTestMail(email, survey, lang, mailer = sendMail) { const client = await pool.connect(); + let template; + let respondentToken; try { const response = await client.query( `SELECT lang, text, subject FROM email WHERE ${legacySurveyPredicate()}`, [survey.id, survey.name] ); const language = normalizeLanguage(lang); - const template = language ? buildNotificationMaps(response.rows).templates[language] : null; + template = language ? buildNotificationMaps(response.rows).templates[language] : null; if (!template) { throw new Error(`Email template not found for survey '${survey.name}' in language '${lang}'`); @@ -226,26 +279,34 @@ async function sendTestMail(email, survey, lang, mailer = sendMail) { LIMIT 1`, [survey.id, survey.name, email] ); - const respondentToken = respondentResult.rows[0]?.uuid; + 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 mailer(email, respondentToken, survey.name, template.text, template.subject); } finally { client.release(); } + + // Do not hold a scarce database connection while waiting in the rate-limit + // queue or on the external provider. + await mailer(email, respondentToken, survey.name, template.text, template.subject, { + surveyId: survey.id, + trackDelivery: false, + }); } -async function startSurvey(survey) { +async function startSurvey(survey, mailer = sendMail) { const client = await pool.connect(); let respondents; let templates; try { const respondentResult = await client.query( - `SELECT name, contact_info, uuid, lang FROM Respondent WHERE ${legacySurveyPredicate()} AND can_respond = true`, + `SELECT name, contact_info, uuid, lang FROM Respondent + WHERE ${legacySurveyPredicate()} + AND can_respond = true + AND email_sent IS NOT TRUE`, [survey.id, survey.name] ); const emailResult = await client.query( @@ -268,9 +329,14 @@ async function startSurvey(survey) { return { respondent, template }; }); - await Promise.all(pendingEmails.map(({ respondent, template }) => - sendMail(respondent.contact_info, respondent.uuid, survey.name, template.text, template.subject) + const results = await Promise.allSettled(pendingEmails.map(({ respondent, template }) => + mailer(respondent.contact_info, respondent.uuid, survey.name, template.text, template.subject, { + surveyId: survey.id, + trackDelivery: true, + }) )); + const failure = results.find((result) => result.status === 'rejected'); + if (failure) throw failure.reason; } // sendMail('bgarcia2324@gmail.com', 'byVHldRI2ZgaOXNhE-ih7', 'GEEEEEE'); @@ -3152,6 +3218,11 @@ module.exports = { LANGUAGE_LABELS, normalizeLanguage, buildNotificationMaps, + createEmailQueueProcessor, + sendEmailWithResend, + rateLimitedSend, + processEmailQueue, + sendMail, sendTestMail, startSurvey, emailQueue, diff --git a/api/test/security.test.js b/api/test/security.test.js index 5214e79..a507a18 100644 --- a/api/test/security.test.js +++ b/api/test/security.test.js @@ -35,6 +35,8 @@ const { insertEmails, normalizeLanguage, buildNotificationMaps, + createEmailQueueProcessor, + sendEmailWithResend, sendTestMail, startSurvey, emailQueue, @@ -1421,18 +1423,125 @@ test('sendTestMail selects current localized subjects and bodies for legacy resp }, release() { released += 1; }, }); - const mailer = async (...args) => { sent.push(args); }; + const mailer = async (...args) => { + assert.equal(released > sent.length, true, 'database client is released before provider work starts'); + sent.push(args); + }; await sendTestMail('english@example.com', { id: 'survey-id', name: 'Survey A' }, 'en', mailer); await sendTestMail('spanish@example.com', { id: 'survey-id', name: 'Survey A' }, '"Spanish"', mailer); assert.deepEqual(sent, [ - ['english@example.com', 'respondent-token', 'Survey A', "Current English body — don't alter", 'Current English subject'], - ['spanish@example.com', 'respondent-token', 'Survey A', 'Cuerpo actual', 'Asunto actual'], + ['english@example.com', 'respondent-token', 'Survey A', "Current English body — don't alter", 'Current English subject', { + surveyId: 'survey-id', trackDelivery: false, + }], + ['spanish@example.com', 'respondent-token', 'Survey A', 'Cuerpo actual', 'Asunto actual', { + surveyId: 'survey-id', trackDelivery: false, + }], ]); assert.equal(released, 2); }); +test('Resend response errors are treated as provider failures', async () => { + const providerError = { message: 'invalid recipient', statusCode: 422 }; + const resendClient = { + emails: { send: async () => ({ data: null, error: providerError }) }, + }; + + await assert.rejects( + sendEmailWithResend({ to: 'bad@example.com' }, resendClient), + (error) => error.message === providerError.message && error.providerError === providerError + ); +}); + +test('email queue books successful respondent tokens without conflating duplicate addresses', async () => { + const marked = []; + const providerError = new Error('provider rejected message'); + let releaseFirst; + const firstSend = new Promise((resolve) => { releaseFirst = resolve; }); + const processor = createEmailQueueProcessor({ + rateLimit: 3, + delay: 0, + wait: async () => {}, + sendEmail: async (emailData) => { + if (emailData.subject === 'first') await firstSend; + if (emailData.subject === 'duplicate-failed') throw providerError; + return { id: emailData.subject }; + }, + markSuccessful: async (delivery) => { + marked.push(delivery); + }, + }); + const survey = { surveyId: 'survey-id', surveyName: 'Survey A' }; + + const first = processor.enqueue( + { to: 'duplicate@example.com', subject: 'first' }, + { ...survey, respondentToken: 'respondent-one' } + ); + const failed = processor.enqueue( + { to: 'duplicate@example.com', subject: 'duplicate-failed' }, + { ...survey, respondentToken: 'respondent-two' } + ); + const second = processor.enqueue( + { to: 'second@example.com', subject: 'second' }, + { ...survey, respondentToken: 'respondent-three' } + ); + const duplicateSuccess = processor.enqueue( + { to: 'duplicate@example.com', subject: 'duplicate-success' }, + { ...survey, respondentToken: 'respondent-four' } + ); + releaseFirst(); + + assert.deepEqual(await first, { id: 'first' }); + await assert.rejects(failed, (error) => error === providerError); + assert.deepEqual(await second, { id: 'second' }); + assert.deepEqual(await duplicateSuccess, { id: 'duplicate-success' }); + assert.deepEqual(marked, [ + { ...survey, respondentTokens: ['respondent-one'] }, + { ...survey, respondentTokens: ['respondent-three', 'respondent-four'] }, + ]); + assert.equal(processor.queue.length, 0); + assert.equal(processor.isProcessing(), false); +}); + +test('test and reminder queue sends await providers without marking initial invitation delivery', async () => { + const marked = []; + let releaseProvider; + const providerResult = new Promise((resolve) => { releaseProvider = resolve; }); + const processor = createEmailQueueProcessor({ + sendEmail: async () => providerResult, + markSuccessful: async (delivery) => { marked.push(delivery); }, + }); + + let settled = false; + const send = processor.enqueue( + { to: 'reminder@example.com' }, + { surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'token', trackDelivery: false } + ).then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + assert.equal(settled, false, 'enqueue promise still waits for the provider'); + releaseProvider({ id: 'reminder' }); + + assert.deepEqual(await send, { id: 'reminder' }); + assert.deepEqual(marked, []); +}); + +test('email queue settles successful sends even when email_sent bookkeeping fails', async () => { + const processor = createEmailQueueProcessor({ + sendEmail: async () => ({ id: 'sent' }), + markSuccessful: async () => { throw new Error('database unavailable'); }, + }); + + assert.deepEqual(await processor.enqueue( + { to: 'sent@example.com' }, + { surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'respondent-token' } + ), { id: 'sent' }); + assert.equal(processor.isProcessing(), false); +}); + test('startSurvey releases its client and prevalidates every canonicalized template before enqueueing', async (t) => { const originalConnect = pool.connect; const initialQueueLength = emailQueue.length; @@ -1459,6 +1568,46 @@ test('startSurvey releases its client and prevalidates every canonicalized templ assert.equal(emailQueue.length, initialQueueLength, 'no recipient is queued when any template is missing'); }); +test('startSurvey awaits every provider send and rejects delivery failures after releasing its client', async (t) => { + const originalConnect = pool.connect; + let released = false; + let respondentSql = ''; + const sent = []; + t.after(() => { pool.connect = originalConnect; }); + pool.connect = async () => ({ + async query(sql) { + if (/FROM Respondent/i.test(sql)) { + respondentSql = sql; + return { rows: [ + { contact_info: 'one@example.com', uuid: 'one', lang: 'English' }, + { contact_info: 'two@example.com', uuid: 'two', lang: 'English' }, + ] }; + } + return { rows: [{ lang: 'English', text: 'Hello', subject: 'Subject' }] }; + }, + release() { released = true; }, + }); + const providerError = new Error('Resend failed'); + const mailer = async (email, token, surveyName, text, subject, options) => { + assert.equal(released, true); + sent.push({ email, token, surveyName, text, subject, options }); + if (email === 'two@example.com') throw providerError; + }; + + await assert.rejects(startSurvey({ id: 'survey-id', name: 'Survey A' }, mailer), (error) => error === providerError); + assert.match(respondentSql, /email_sent\s+IS\s+NOT\s+TRUE/i, 'retry query excludes already-successful recipients'); + assert.deepEqual(sent, [ + { + email: 'one@example.com', token: 'one', surveyName: 'Survey A', text: 'Hello', subject: 'Subject', + options: { surveyId: 'survey-id', trackDelivery: true }, + }, + { + email: 'two@example.com', token: 'two', surveyName: 'Survey A', text: 'Hello', subject: 'Subject', + options: { surveyId: 'survey-id', trackDelivery: true }, + }, + ], 'all eligible unsent respondents are attempted with exact delivery metadata'); +}); + test('notification validation enforces language, safe nonblank subjects, and text bounds', () => { assert.equal(validateNotification({ language: 'English', subject: 'A subject!', text: '' }), null); assert.match(validateNotification({ language: 'Klingon', subject: 'Subject', text: 'Text' }), /known language/); @@ -2133,7 +2282,10 @@ test('survey create organization defaulting handles none, one, multiple, and pla 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 Respondent SET email_sent = true[\s\S]*WHERE uuid = ANY\(\$1\)[\s\S]*survey_id = \$2 OR \(survey_id IS NULL AND survey_name = \$3\)/); + assert.doesNotMatch(serverSource, /email_sent = true WHERE contact_info = ANY/); + assert.match(serverSource, /respondentToken: id/); + assert.match(serverSource, /trackDelivery: false/); 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/); }); diff --git a/dashboard/src/App.js b/dashboard/src/App.js index d7c7f84..bb454ec 100644 --- a/dashboard/src/App.js +++ b/dashboard/src/App.js @@ -1,5 +1,5 @@ import React from 'react'; -import { BrowserRouter as Router, Routes, Route, useNavigate, useLocation, Navigate } from 'react-router-dom'; +import { createBrowserRouter, RouterProvider, Routes, Route, useNavigate, useLocation, Navigate } from 'react-router-dom'; import { AppProvider, DashboardLayout } from '@toolpad/core'; import Dashboard from './components/Dashboard'; import Settings from './components/Settings'; @@ -127,10 +127,10 @@ const AppContent = () => { ); }; -const App = () => ( - - - -); +const router = createBrowserRouter([ + { path: '*', element: }, +]); -export default App; \ No newline at end of file +const App = () => ; + +export default App; diff --git a/dashboard/src/components/Dashboard.js b/dashboard/src/components/Dashboard.js index b411015..e9dce4b 100644 --- a/dashboard/src/components/Dashboard.js +++ b/dashboard/src/components/Dashboard.js @@ -11,6 +11,7 @@ import EmailNotificationEditor from "./EmailNotificationEditor"; import SurveyContentEditor from "./SurveyContentEditor"; import CollapsibleSection from "./CollapsibleSection"; import { useAuth } from "../context/AuthContext"; +import { useBlocker } from "react-router-dom"; const Dashboard = () => { const theme = useTheme(); @@ -25,6 +26,37 @@ const Dashboard = () => { const [contentBusy, setContentBusy] = React.useState(false); const [notificationBusy, setNotificationBusy] = React.useState(false); const { memberships, canViewSensitiveSurveyData, canEditSurvey } = useAuth(); + const hasBusyEdits = contentBusy || notificationBusy; + const hasDirtyEdits = contentDirty || notificationDirty; + const blocker = useBlocker(({ currentLocation, nextLocation }) => ( + (hasBusyEdits || hasDirtyEdits) + && `${currentLocation.pathname}${currentLocation.search}${currentLocation.hash}` + !== `${nextLocation.pathname}${nextLocation.search}${nextLocation.hash}` + )); + + React.useEffect(() => { + if (blocker.state !== 'blocked') return; + if (hasBusyEdits) { + setSnackbar({ + severity: 'warning', + message: 'Please wait for the current save or CSV import to finish before leaving the dashboard.', + }); + blocker.reset(); + return; + } + if (window.confirm('Discard unsaved changes and leave the dashboard?')) blocker.proceed(); + else blocker.reset(); + }, [blocker.state, blocker.proceed, blocker.reset, hasBusyEdits]); + + React.useEffect(() => { + const handleBeforeUnload = (event) => { + if (!hasBusyEdits && !hasDirtyEdits) return; + event.preventDefault(); + event.returnValue = ''; + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [hasBusyEdits, hasDirtyEdits]); const fetchSurveyData = async () => { try { @@ -110,6 +142,20 @@ const Dashboard = () => { setSelectSurvey(childData); }; + const guardSelectedSurveyAction = (survey) => { + const selectedId = selectSurvey?.id || selectSurvey?.name; + const actionSurveyId = survey?.id || survey?.name; + if (!selectedId || selectedId !== actionSurveyId || (!hasBusyEdits && !hasDirtyEdits)) return true; + + setSnackbar({ + severity: 'warning', + message: hasBusyEdits + ? 'Please wait for the current save or CSV import to finish before starting or archiving this survey.' + : 'Save or reset unsaved changes before starting or archiving this survey.', + }); + return false; + }; + const handleCreateSurvey = async (surveyName, organizationId) => { try { const response = await api.post("/survey", { surveyName, organizationId }); @@ -194,6 +240,7 @@ const Dashboard = () => { selectRow={handleSelectRow} onSurveyDeleted={handleSurveyDeleted} selectedSurvey={selectSurvey} + guardSurveyAction={guardSelectedSurveyAction} /> diff --git a/dashboard/src/components/Dashboard.test.js b/dashboard/src/components/Dashboard.test.js index bddccf4..a8893df 100644 --- a/dashboard/src/components/Dashboard.test.js +++ b/dashboard/src/components/Dashboard.test.js @@ -1,7 +1,8 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@emotion/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import Dashboard from './Dashboard'; import api from '../api/axios'; @@ -17,18 +18,22 @@ vi.mock('../context/AuthContext', () => ({ })); vi.mock('./CollapsibleSection', () => ({ default: ({ children }) =>
{children}
})); vi.mock('./SurveyTable', () => ({ - default: ({ selectRow, selectedSurvey }) => ( + default: ({ selectRow, selectedSurvey, guardSurveyAction }) => (
{selectedSurvey?.name || 'none'}
+ +
), })); vi.mock('./EmailNotificationEditor', () => ({ default: ({ onBusyChange }) => , })); -vi.mock('./SurveyContentEditor', () => ({ default: () => null })); +vi.mock('./SurveyContentEditor', () => ({ + default: ({ onDirtyChange }) => , +})); vi.mock('./QuestionTable', () => ({ default: () => null })); vi.mock('./RespondentTable', () => ({ default: () => null })); vi.mock('./CreateSurveyDialog', () => ({ default: () => null })); @@ -41,9 +46,18 @@ beforeEach(() => { }); }); -test('rejects survey switching with clear feedback while notification save/import is busy', async () => { +const renderDashboard = (routerOptions = {}) => { const theme = { palette: { divider: '#ddd', mode: 'light', background: { paper: '#fff' } } }; - render(); + const router = createMemoryRouter([ + { path: '/', element: }, + { path: '/settings', element:
Settings page
}, + ], routerOptions); + const rendered = render(); + return { ...rendered, router }; +}; + +test('rejects survey switching with clear feedback while notification save/import is busy', async () => { + renderDashboard(); await userEvent.click(screen.getByRole('button', { name: 'Select one' })); await waitFor(() => expect(screen.getByTestId('selected-survey')).toHaveTextContent('Survey One')); @@ -53,3 +67,61 @@ test('rejects survey switching with clear feedback while notification save/impor expect(screen.getByTestId('selected-survey')).toHaveTextContent('Survey One'); expect(screen.getByText(/wait for the current save or CSV import/i)).toBeInTheDocument(); }); + +test('refuses data-router navigation and browser unload while an import is busy', async () => { + const { router } = renderDashboard(); + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await userEvent.click(screen.getByRole('button', { name: 'Begin import' })); + + act(() => { void router.navigate('/settings'); }); + expect(await screen.findByText(/before leaving the dashboard/i)).toBeInTheDocument(); + expect(router.state.location.pathname).toBe('/'); + + const unloadEvent = new Event('beforeunload', { cancelable: true }); + let unloadAllowed; + act(() => { unloadAllowed = window.dispatchEvent(unloadEvent); }); + expect(unloadAllowed).toBe(false); +}); + +test('retains survey-switch confirmation and blocks selected-survey actions for dirty drafts', async () => { + renderDashboard(); + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await userEvent.click(screen.getByRole('button', { name: 'Edit content' })); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + + await userEvent.click(screen.getByRole('button', { name: 'Select two' })); + expect(screen.getByTestId('selected-survey')).toHaveTextContent('Survey One'); + expect(confirm).toHaveBeenCalledWith('Discard unsaved changes and switch surveys?'); + + await userEvent.click(screen.getByRole('button', { name: 'Act on selected' })); + expect(screen.getByText(/Save or reset unsaved changes before starting or archiving/i)).toBeInTheDocument(); + + // The guard only applies to the survey whose editors own the dirty draft. + await userEvent.click(screen.getByRole('button', { name: 'Act on other' })); + confirm.mockRestore(); +}); + +test('allows or cancels data-router navigation from a dirty draft based on confirmation', async () => { + const { router } = renderDashboard(); + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await userEvent.click(screen.getByRole('button', { name: 'Edit content' })); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + + act(() => { void router.navigate('/settings'); }); + await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + expect(router.state.location.pathname).toBe('/'); + confirm.mockReturnValue(true); + act(() => { void router.navigate('/settings'); }); + await waitFor(() => expect(router.state.location.pathname).toBe('/settings')); + confirm.mockRestore(); +}); + +test('blocks POP history navigation while busy', async () => { + const { router } = renderDashboard({ initialEntries: ['/settings', '/'], initialIndex: 1 }); + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await userEvent.click(screen.getByRole('button', { name: 'Begin import' })); + + act(() => { void router.navigate(-1); }); + expect(await screen.findByText(/before leaving the dashboard/i)).toBeInTheDocument(); + expect(router.state.location.pathname).toBe('/'); +}); diff --git a/dashboard/src/components/EmailNotificationEditor.js b/dashboard/src/components/EmailNotificationEditor.js index f6c6858..364b48b 100644 --- a/dashboard/src/components/EmailNotificationEditor.js +++ b/dashboard/src/components/EmailNotificationEditor.js @@ -35,6 +35,18 @@ const valueForHeader = (row, aliases) => { const hasHeader = (row, aliases) => Object.keys(row).some((key) => aliases.includes(normalizedHeader(key))); +export const buildNotificationDownloadRows = (notifications = {}, subjects = {}) => LANGUAGES + .filter((language) => ( + Object.prototype.hasOwnProperty.call(notifications, language.label) + || Object.prototype.hasOwnProperty.call(subjects, language.label) + )) + .map((language) => ({ + Language: language.label, + Subject: subjects[language.label] ?? LEGACY_EMAIL_SUBJECT, + // An explicitly persisted empty string must remain a row in the export. + Text: notifications[language.label] ?? '', + })); + const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { const theme = useTheme(); const uploadInputRef = useRef(null); @@ -48,6 +60,8 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { const [draft, setDraft] = useState(EMPTY_MESSAGE); const [original, setOriginal] = useState(EMPTY_MESSAGE); const [loading, setLoading] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); const [saving, setSaving] = useState(false); const [alert, setAlert] = useState(null); @@ -75,6 +89,7 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { setDraft(EMPTY_MESSAGE); setOriginal(EMPTY_MESSAGE); setAlert(null); + setLoadFailed(false); if (!surveyId) { setLoading(false); @@ -103,7 +118,7 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { setDraft(nextDraft); setOriginal(nextDraft); } catch (error) { - if (isCurrent()) setAlert({ severity: 'error', message: 'Failed to load email notifications.' }); + if (isCurrent()) setLoadFailed(true); } finally { if (isCurrent()) setLoading(false); } @@ -115,7 +130,7 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { requestControllerRef.current?.abort(); requestControllerRef.current = null; }; - }, [surveyId]); + }, [surveyId, loadAttempt]); const selectLanguage = (_, language) => { if (!language || dirty) return; @@ -130,7 +145,7 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { }; const handleSave = async () => { - if (!surveyId || !selectedLanguage || !dirty) return; + if (!surveyId || loadFailed || !selectedLanguage || !dirty) return; const operationSurveyId = surveyId; const generation = generationRef.current; const controller = new AbortController(); @@ -166,11 +181,10 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { setAlert(null); }; - const downloadRows = useMemo(() => LANGUAGES.map((language) => ({ - Language: language.label, - Subject: subjects[language.label] ?? LEGACY_EMAIL_SUBJECT, - Text: notifications[language.label] ?? '', - })), [notifications, subjects]); + const downloadRows = useMemo( + () => buildNotificationDownloadRows(notifications, subjects), + [notifications, subjects] + ); const handleDownload = () => { const csv = Papa.unparse(downloadRows, { columns: ['Language', 'Subject', 'Text'] }); @@ -187,7 +201,7 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { const handleFileUpload = (event) => { const file = event.target.files?.[0]; event.target.value = ''; - if (!file || dirty || !surveyId) return; + if (!file || dirty || !surveyId || loadFailed) return; const operationSurveyId = surveyId; const generation = generationRef.current; @@ -278,14 +292,14 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { Survey Email Notification
- } + > + Failed to load email notifications. Editing and CSV actions are disabled until the content is reloaded. + + )} + {alert && setAlert(null)} sx={{ mb: 2 }}>{alert.message}} {loading ? ( @@ -306,7 +330,7 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { options={LANGUAGES} getOptionLabel={(option) => option?.label || ''} isOptionEqualToValue={(option, value) => option.code === value?.code} - disabled={dirty || saving} + disabled={loadFailed || dirty || saving} renderInput={(params) => } sx={{ maxWidth: 320 }} /> @@ -315,7 +339,7 @@ const EmailNotificationEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { label="Email subject" value={draft.subject} onChange={(event) => setDraft((current) => ({ ...current, subject: event.target.value }))} - disabled={!surveyId || saving} + disabled={!surveyId || loadFailed || saving} /> { label="Notification text" value={draft.text} onChange={(event) => setDraft((current) => ({ ...current, text: event.target.value }))} - disabled={!surveyId || saving} + disabled={!surveyId || loadFailed || saving} /> - - + diff --git a/dashboard/src/components/EmailNotificationEditor.test.js b/dashboard/src/components/EmailNotificationEditor.test.js index 137e278..0ec4421 100644 --- a/dashboard/src/components/EmailNotificationEditor.test.js +++ b/dashboard/src/components/EmailNotificationEditor.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import EmailNotificationEditor from './EmailNotificationEditor'; +import EmailNotificationEditor, { buildNotificationDownloadRows } from './EmailNotificationEditor'; import api from '../api/axios'; vi.mock('../api/axios', () => ({ @@ -19,6 +19,18 @@ beforeEach(() => { api.post.mockResolvedValue({ data: { success: true } }); }); +test('CSV export includes only persisted languages and retains explicitly blank bodies', () => { + expect(buildNotificationDownloadRows( + { English: 'Hello', Spanish: '' }, + { English: 'Invitation', Spanish: 'Invitación' } + )).toEqual([ + { Language: 'English', Subject: 'Invitation', Text: 'Hello' }, + { Language: 'Spanish', Subject: 'Invitación', Text: '' }, + ]); + + expect(buildNotificationDownloadRows({}, {})).toEqual([]); +}); + test('loads subject and text, protects language changes while dirty, and saves JSON unchanged', async () => { render(); @@ -50,6 +62,24 @@ test('handles absent notification maps without undefined errors', async () => { expect(screen.getByLabelText('Notification text')).toHaveValue(''); }); +test('disables editing and CSV actions after load failure and safely retries', async () => { + api.get.mockRejectedValueOnce(new Error('network')); + render(); + + expect(await screen.findByText(/Failed to load email notifications/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Email subject')).toBeDisabled(); + expect(screen.getByLabelText('Notification text')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Download CSV/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Upload CSV/i })).toBeDisabled(); + + await userEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(screen.getByLabelText('Email subject')).toHaveValue('You’re invited!')); + await waitFor(() => expect(screen.getByLabelText('Email subject')).not.toBeDisabled()); + expect(screen.getByRole('button', { name: /Download CSV/i })).not.toBeDisabled(); + expect(api.get).toHaveBeenCalledTimes(2); +}); + test('rejects a CSV without a recognized text or message header before saving', async () => { const { container } = render(); await screen.findByLabelText('Email subject'); diff --git a/dashboard/src/components/SurveyContentEditor.js b/dashboard/src/components/SurveyContentEditor.js index e61bd94..5f5ce02 100644 --- a/dashboard/src/components/SurveyContentEditor.js +++ b/dashboard/src/components/SurveyContentEditor.js @@ -16,6 +16,8 @@ const SurveyContentEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { const [instructions, setInstructions] = useState(''); const [originalInstructions, setOriginalInstructions] = useState(''); const [loading, setLoading] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); const [saving, setSaving] = useState(false); const [alert, setAlert] = useState(null); @@ -25,6 +27,7 @@ const SurveyContentEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { setInstructions(''); setOriginalInstructions(''); setAlert(null); + setLoadFailed(false); if (!surveyId) { setLoading(false); @@ -43,7 +46,7 @@ const SurveyContentEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { setOriginalInstructions(value); } catch (error) { if (!active) return; - setAlert({ severity: 'error', message: 'Failed to load survey instructions.' }); + setLoadFailed(true); } finally { if (active) setLoading(false); } @@ -51,7 +54,7 @@ const SurveyContentEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { loadContent(); return () => { active = false; }; - }, [surveyId]); + }, [surveyId, loadAttempt]); const dirty = instructions !== originalInstructions; @@ -66,7 +69,7 @@ const SurveyContentEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { }, [saving, onBusyChange]); const handleSave = async () => { - if (!surveyId || !dirty) return; + if (!surveyId || loadFailed || !dirty) return; setSaving(true); setAlert(null); try { @@ -91,6 +94,16 @@ const SurveyContentEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { Survey Instructions + {loadFailed && ( + setLoadAttempt((value) => value + 1)}>Retry} + > + Failed to load survey instructions. Editing is disabled until the content is reloaded. + + )} + {alert && ( setAlert(null)} sx={{ mb: 2 }}> {alert.message} @@ -113,14 +126,14 @@ const SurveyContentEditor = ({ surveyId, onDirtyChange, onBusyChange }) => { setInstructions(event.target.value); if (alert?.severity === 'success') setAlert(null); }} - disabled={!surveyId || saving} + disabled={!surveyId || loading || loadFailed || saving} helperText="Plain text only. Line breaks will be preserved. Leave empty to hide the instructions block." /> - - diff --git a/dashboard/src/components/SurveyContentEditor.test.js b/dashboard/src/components/SurveyContentEditor.test.js index 1b368a9..8aafd95 100644 --- a/dashboard/src/components/SurveyContentEditor.test.js +++ b/dashboard/src/components/SurveyContentEditor.test.js @@ -35,8 +35,17 @@ test('loads, resets, and saves multiline survey instructions', async () => { expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); }); -test('shows a load error without throwing on a missing response', async () => { +test('disables editing from an unknown baseline after load failure and safely retries', async () => { api.get.mockRejectedValueOnce(new Error('network')); render(); - expect(await screen.findByText('Failed to load survey instructions.')).toBeInTheDocument(); + + expect(await screen.findByText(/Failed to load survey instructions/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Instructions shown to respondents')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(api.put).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(screen.getByLabelText('Instructions shown to respondents')).toHaveValue('First line\nSecond line')); + await waitFor(() => expect(screen.getByLabelText('Instructions shown to respondents')).not.toBeDisabled()); + expect(api.get).toHaveBeenCalledTimes(2); }); diff --git a/dashboard/src/components/SurveyTable.js b/dashboard/src/components/SurveyTable.js index a7621bf..7f11a10 100644 --- a/dashboard/src/components/SurveyTable.js +++ b/dashboard/src/components/SurveyTable.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { DataGrid, GridToolbar } from '@mui/x-data-grid'; import MenuCell from './SurveyTableMenuCell'; @@ -14,12 +14,25 @@ const columns = [ width: 100, sortable: false, filterable: false, - renderCell: (params) => }, ]; -const SurveyTable = ({ rows, selectRow, onSurveyDeleted, selectedSurvey }) => { +const SurveyTable = ({ rows, selectRow, onSurveyDeleted, selectedSurvey, guardSurveyAction }) => { const [tableRows, setTableRows] = useState([]); + const tableColumns = useMemo(() => columns.map((column) => ( + column.field === 'actions' + ? { + ...column, + renderCell: (params) => ( + + ), + } + : column + )), [guardSurveyAction, onSurveyDeleted]); useEffect(() => { if (rows) { @@ -40,7 +53,7 @@ const SurveyTable = ({ rows, selectRow, onSurveyDeleted, selectedSurvey }) => {
{ +const MenuCell = ({ row, onSurveyDeleted, guardSurveyAction }) => { const [anchorEl, setAnchorEl] = useState(null); const [startConfirmOpen, setStartConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -53,12 +53,17 @@ const MenuCell = ({ row, onSurveyDeleted }) => { const handleStartClick = (event) => { event.stopPropagation(); + if (guardSurveyAction?.(row) === false) return; setStartConfirmOpen(true); handleClose(); }; // Modify handleStartConfirm const handleStartConfirm = async () => { + if (guardSurveyAction?.(row) === false) { + setStartConfirmOpen(false); + return; + } try { await api.post('/startSurvey', { surveyName: row.id || row.name }); setStartConfirmOpen(false); @@ -86,11 +91,16 @@ const MenuCell = ({ row, onSurveyDeleted }) => { const handleDeleteClick = (event) => { event.stopPropagation(); + if (guardSurveyAction?.(row) === false) return; setDeleteConfirmOpen(true); handleClose(); }; const handleDeleteConfirm = async () => { + if (guardSurveyAction?.(row) === false) { + setDeleteConfirmOpen(false); + return; + } try { const response = await api.delete(`/survey/${row.id || row.name}`); if (response.status === 200) { From 72df27fc284954c38a6ccbb83c2ccd25ebc45cb2 Mon Sep 17 00:00:00 2001 From: Bennett Garcia Date: Tue, 4 Aug 2026 13:32:51 -0400 Subject: [PATCH 3/4] Harden survey editing and invitation delivery --- README.md | 2 +- api/.env.local.example | 2 + api/package-lock.json | 542 +++++++--- api/package.json | 2 +- api/server.js | 510 ++++++++-- api/test/security.test.js | 950 ++++++++++++++++-- dashboard/src/components/AddRowButton.js | 45 +- dashboard/src/components/Dashboard.js | 83 +- dashboard/src/components/Dashboard.test.js | 102 +- dashboard/src/components/QuestionTable.js | 93 +- dashboard/src/components/RespondentTable.js | 103 +- dashboard/src/components/TableActions.test.js | 180 ++++ dashboard/src/components/TableMenuCell.js | 13 +- dashboard/src/components/TableUploadButton.js | 12 +- .../v1_5_editable_survey_content.sql | 28 + package-lock.json | 287 ++++-- package.json | 2 +- 17 files changed, 2528 insertions(+), 428 deletions(-) create mode 100644 dashboard/src/components/TableActions.test.js diff --git a/README.md b/README.md index 48c7663..782b73c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Use the root scripts instead of starting each service in a separate terminal. - `dashboard/.env.development.example` - `network-survey/.env.development.example` -The API runner treats `RESEND_API_KEY` or `RESEND_KEY` as optional for startup. Email-sending features will fail at runtime if neither key is configured. +The API runner treats `RESEND_API_KEY` or `RESEND_KEY` as optional for startup. Email-sending features will fail at runtime if neither key is configured. Resend requests have a 30-second fetch timeout by default; set `RESEND_FETCH_TIMEOUT_MS` to a positive millisecond value to override it. Public dashboard self-signup is controlled by `ALLOW_PUBLIC_SIGNUP`. It defaults to enabled only in local/dev/test when unset; set `ALLOW_PUBLIC_SIGNUP=false` for staging/production unless public account creation is explicitly desired. `AUTH_RATE_LIMIT_MAX` and `RESPONDENT_RATE_LIMIT_MAX` can tune API throttling for login/register and respondent-token endpoints. diff --git a/api/.env.local.example b/api/.env.local.example index 803e7f4..5e8788d 100644 --- a/api/.env.local.example +++ b/api/.env.local.example @@ -14,6 +14,8 @@ AUTH_RATE_LIMIT_MAX=20 RESPONDENT_RATE_LIMIT_MAX=120 # Optional for email sending features. RESEND_API_KEY=replace-me +# Optional bounded provider fetch timeout; defaults to 30000 milliseconds. +RESEND_FETCH_TIMEOUT_MS=30000 # Optional for local DB bootstrap admin created by `npm run db:setup`. LOCAL_ADMIN_USERNAME=admin LOCAL_ADMIN_PASSWORD=admin123 diff --git a/api/package-lock.json b/api/package-lock.json index dd84782..45f613a 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..5c202ce 100644 --- a/api/package.json +++ b/api/package.json @@ -23,7 +23,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 4c7bfc1..1c06743 100644 --- a/api/server.js +++ b/api/server.js @@ -3,7 +3,6 @@ const fs = require('fs'); const path = require('path'); const cors = require('cors'); const { Resend } = require('resend'); -const { nanoid } = require('nanoid'); const { Pool } = require('pg'); const session = require('express-session'); const pgSession = require('connect-pg-simple')(session); @@ -44,6 +43,15 @@ const pool = new Pool({ }); const resend = resendApiKey ? new Resend(resendApiKey) : null; +const DEFAULT_RESEND_FETCH_TIMEOUT_MS = 30_000; + +function getResendFetchTimeoutMs(value = process.env.RESEND_FETCH_TIMEOUT_MS) { + if (value === undefined || value === null || value === '') return DEFAULT_RESEND_FETCH_TIMEOUT_MS; + const timeoutMs = Number(value); + return Number.isSafeInteger(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DEFAULT_RESEND_FETCH_TIMEOUT_MS; +} const EMAIL_HTML = [` @@ -85,19 +93,23 @@ const EMAIL_HTML = [`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) { +async function sendAccountEmail( + { to, subject, html, text }, + resendClient = resend, + timeoutMs = getResendFetchTimeoutMs() +) { + if (!resendClient) { return { sent: false, message: 'Email delivery is not configured; deliver the returned link manually.' }; } try { - await resend.emails.send({ + await sendEmailWithResend({ from: 'CLA Survey ', to, subject, html, text, - }); + }, undefined, resendClient, timeoutMs); return { sent: true }; } catch (error) { console.error(`Failed to send account email to ${to}:`, error.message); @@ -106,6 +118,7 @@ async function sendAccountEmail({ to, subject, html, text }) { } async function sendMail(email, id, surveyName, text, subject = 'CLA Network Survey', options = {}) { + let providerRequestEnqueued = false; try { if (!resend) { throw new Error('Missing RESEND_KEY or RESEND_API_KEY environment variable'); @@ -122,14 +135,26 @@ async function sendMail(email, id, surveyName, text, subject = 'CLA Network Surv html: EMAIL_HTML[0] + text + EMAIL_HTML[1] + customLink + EMAIL_HTML[2] }; - await rateLimitedSend(emailData, { + const queuedSend = rateLimitedSend(emailData, { surveyId: options.surveyId, surveyName, respondentToken: id, trackDelivery: options.trackDelivery !== false, + invitationClaimToken: options.invitationClaimToken, + invitationDeliveryId: options.invitationDeliveryId, + idempotencyKey: options.invitationDeliveryId + ? `survey-invitation-${options.invitationDeliveryId}` + : undefined, }); + providerRequestEnqueued = true; + await queuedSend; } catch (error) { + // startSurvey can safely release claims only when no provider request could + // have started. Queue/provider failures are reconciled by the ownership path. + if (!providerRequestEnqueued && error && typeof error === 'object') { + error.invitationKnownUnsent = true; + } console.error(`Failed to send email to ${email}:`, error); throw error; } @@ -143,7 +168,7 @@ const DELAY = 1000; // 1 second delay between batches function createEmailQueueProcessor({ sendEmail, - markSuccessful = async () => {}, + withInvitationOwnership = async (_metadata, providerRequest) => providerRequest(), rateLimit = RATE_LIMIT, delay = DELAY, wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), @@ -159,7 +184,16 @@ function createEmailQueueProcessor({ activeBatch = batch; const results = await Promise.all(batch.map(async (item) => { try { - const result = await sendEmail(item.emailData); + const providerOptions = item.metadata.idempotencyKey + ? { idempotencyKey: item.metadata.idempotencyKey } + : undefined; + const providerRequest = () => sendEmail(item.emailData, providerOptions); + // Invitation ownership is checked under a row lock immediately before + // the provider request. The callback retains that lock until delivery + // bookkeeping commits, closing the recipient-edit TOCTOU window. + const result = item.metadata.invitationClaimToken + ? await withInvitationOwnership(item.metadata, providerRequest) + : await providerRequest(); return { success: true, item, result }; } catch (error) { console.error(`Failed to send email to ${item.emailData.to}:`, error); @@ -167,28 +201,7 @@ function createEmailQueueProcessor({ } })); - const successfulBySurvey = new Map(); - for (const result of results) { - if (!result.success || result.item.metadata.trackDelivery === false) continue; - const { surveyId, surveyName, respondentToken } = result.item.metadata; - if (!surveyName || !respondentToken) continue; - const surveyKey = surveyId || `legacy:${surveyName}`; - if (!successfulBySurvey.has(surveyKey)) { - successfulBySurvey.set(surveyKey, { surveyId, surveyName, respondentTokens: [] }); - } - successfulBySurvey.get(surveyKey).respondentTokens.push(respondentToken); - } - for (const delivery of successfulBySurvey.values()) { - try { - await markSuccessful(delivery); - } catch (error) { - // A bookkeeping failure must not turn a successful provider send - // into a delivery failure, but it must remain visible operationally. - console.error('Failed to update email_sent status:', error); - } - } - - // Settle all items in the batch, including every provider failure. + // Settle all items in the batch, including every provider or bookkeeping failure. for (const result of results) { if (result.success) result.item.resolve(result.result); else result.item.reject(result.error); @@ -225,24 +238,249 @@ function createEmailQueueProcessor({ return { queue, enqueue, processQueue, isProcessing: () => isProcessing }; } -async function sendEmailWithResend(emailData, resendClient = resend) { - const result = await resendClient.emails.send(emailData); +async function sendEmailWithResend( + emailData, + options, + resendClient = resend, + timeoutMs = getResendFetchTimeoutMs() +) { + // Keep the injected-client call shape usable for focused tests while the queue + // uses Resend's supported second-argument request options in production. + if (options?.emails && resendClient === resend) { + resendClient = options; + options = undefined; + } + + // Resend forwards its runtime request options to fetch. Bound every provider + // request so an unavailable endpoint cannot retain an invitation row lock and + // pooled client indefinitely. A caller-supplied signal supports fast tests and + // explicit cancellation; otherwise Node 20's timeout signal enforces the limit. + const requestOptions = { + ...options, + signal: options?.signal || AbortSignal.timeout(getResendFetchTimeoutMs(timeoutMs)), + }; + let result; + try { + result = await resendClient.emails.send(emailData, requestOptions); + } catch (error) { + // An abort can race with provider acceptance, so it must never be treated as + // a definitive rejection by invitation claim bookkeeping. + if (error && typeof error === 'object' + && (error.name === 'AbortError' || error.name === 'TimeoutError')) { + error.definitiveProviderRejection = false; + } + throw error; + } if (result?.error) { const error = new Error(result.error.message || 'Email provider rejected the message.'); error.providerError = result.error; + // Resend may return application/transport errors with no HTTP status even + // though delivery acceptance is uncertain. A known rejection 4xx is + // definitive except for request timeouts, conflicts, and idempotency errors: + // each can mean the original request was accepted. Normalize both provider + // identifiers because SDK/API versions may expose the category as name or code. + const { statusCode } = result.error; + const normalizedErrorIdentifiers = [result.error.name, result.error.code] + .filter((value) => typeof value === 'string') + .map((value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_')); + const isIdempotencyConflict = normalizedErrorIdentifiers.some( + (value) => /idempot(?:ent|ency)/.test(value) + ); + const isTimeoutOrAbort = normalizedErrorIdentifiers.some( + (value) => /(?:time_?out|timed_?out|abort)/.test(value) + ); + const isKnownRejectionStatus = typeof statusCode === 'number' + && Number.isFinite(statusCode) + && Number.isInteger(statusCode) + && statusCode >= 400 + && statusCode < 500; + const isAmbiguousStatus = statusCode === 408 || statusCode === 409; + error.definitiveProviderRejection = isKnownRejectionStatus + && !isAmbiguousStatus + && !isIdempotencyConflict + && !isTimeoutOrAbort; throw error; } return result; } +async function deliverInvitationWithOwnership(metadata, providerRequest, dbPool = pool) { + const { + surveyId, + surveyName, + respondentToken, + invitationClaimToken, + invitationDeliveryId, + } = metadata; + const ownershipValues = [ + respondentToken, + invitationClaimToken, + invitationDeliveryId, + surveyId || null, + surveyName, + ]; + const client = await dbPool.connect(); + let transactionStarted = false; + let pendingError; + + const lockOwnedInvitation = async (requireActiveHorizon = false) => { + const owned = await client.query( + `SELECT uuid FROM Respondent + WHERE uuid = $1 + AND invitation_claim_token = $2 + AND invitation_delivery_id = $3 + AND email_sent IS NOT TRUE + AND can_respond = true + AND (survey_id = $4 OR (survey_id IS NULL AND survey_name = $5)) + ${requireActiveHorizon ? `AND invitation_first_attempted_at IS NOT NULL + AND invitation_first_attempted_at > CURRENT_TIMESTAMP - ($6 * INTERVAL '1 hour')` : ''} + FOR UPDATE`, + requireActiveHorizon + ? [...ownershipValues, INVITATION_CLAIM_RETRY_CUTOFF_HOURS] + : ownershipValues + ); + if (owned.rowCount !== 1) { + const error = new Error(requireActiveHorizon + ? 'Survey invitation claim was lost or quarantined before delivery.' + : 'Survey invitation claim was lost before delivery.'); + error.code = 'INVITATION_CLAIM_LOST'; + throw error; + } + }; + + try { + // Commit the at-most-once horizon before any provider I/O. If the process + // dies after this point, recovery quarantines the attempt rather than + // risking a duplicate accepted delivery. + await client.query('BEGIN'); + transactionStarted = true; + await lockOwnedInvitation(); + const refreshed = await client.query( + `UPDATE Respondent + SET invitation_claimed_at = CURRENT_TIMESTAMP, + invitation_first_attempted_at = COALESCE(invitation_first_attempted_at, CURRENT_TIMESTAMP) + WHERE uuid = $1 + AND invitation_claim_token = $2 + AND invitation_delivery_id = $3 + AND email_sent IS NOT TRUE + AND can_respond = true + AND (survey_id = $4 OR (survey_id IS NULL AND survey_name = $5))`, + ownershipValues + ); + if (refreshed.rowCount !== 1) throw new Error('Could not refresh owned invitation claim.'); + await client.query('COMMIT'); + transactionStarted = false; + + // An edit may rotate the claim in the gap between transactions. Reacquire + // the lock and reverify every ownership/eligibility field before sending. + await client.query('BEGIN'); + transactionStarted = true; + await lockOwnedInvitation(true); + + let providerResult; + try { + // Hold this lock through provider I/O and its atomic bookkeeping so an + // edit cannot invalidate this exact delivery while it is in flight. + providerResult = await providerRequest(); + } catch (providerError) { + const definitiveRejection = providerError?.definitiveProviderRejection === true; + try { + const bookkeeping = definitiveRejection + ? await client.query( + `UPDATE Respondent + SET email_sent = false, + invitation_claim_token = NULL, + invitation_claimed_at = NULL, + invitation_first_attempted_at = NULL + WHERE uuid = $1 + AND invitation_claim_token = $2 + AND invitation_delivery_id = $3 + AND (survey_id = $4 OR (survey_id IS NULL AND survey_name = $5))`, + ownershipValues + ) + : await client.query( + `UPDATE Respondent + SET invitation_claimed_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + AND invitation_claim_token = $2 + AND invitation_delivery_id = $3 + AND email_sent IS NOT TRUE + AND (survey_id = $4 OR (survey_id IS NULL AND survey_name = $5))`, + ownershipValues + ); + if (bookkeeping.rowCount !== 1) { + throw new Error(definitiveRejection + ? 'Could not release rejected invitation claim.' + : 'Could not preserve ambiguous invitation claim.'); + } + await client.query('COMMIT'); + transactionStarted = false; + } catch (bookkeepingError) { + throw new AggregateError( + [providerError, bookkeepingError], + definitiveRejection + ? 'Invitation provider rejection and claim release failed.' + : 'Ambiguous invitation failure and claim preservation failed.', + { cause: providerError } + ); + } + throw providerError; + } + + const finalized = await client.query( + `UPDATE Respondent + SET email_sent = true, + invitation_claim_token = NULL, + invitation_claimed_at = NULL, + invitation_first_attempted_at = NULL + WHERE uuid = $1 + AND invitation_claim_token = $2 + AND invitation_delivery_id = $3 + AND (survey_id = $4 OR (survey_id IS NULL AND survey_name = $5))`, + ownershipValues + ); + if (finalized.rowCount !== 1) throw new Error('Could not finalize successful invitation delivery.'); + await client.query('COMMIT'); + transactionStarted = false; + return providerResult; + } catch (error) { + pendingError = error; + if (transactionStarted) { + try { + await client.query('ROLLBACK'); + transactionStarted = false; + } catch (rollbackError) { + pendingError = new AggregateError( + [error, rollbackError], + 'Invitation delivery failed and its transaction could not be rolled back.', + { cause: error } + ); + } + } + throw pendingError; + } finally { + try { + await client.release(); + } catch (releaseError) { + if (pendingError) { + throw new AggregateError( + [pendingError, releaseError], + 'Invitation delivery failed and its database client could not be released.', + { cause: pendingError } + ); + } + throw releaseError; + } + } +} + const emailQueueProcessor = createEmailQueueProcessor({ sendEmail: sendEmailWithResend, - markSuccessful: ({ surveyId, surveyName, respondentTokens }) => pool.query( - `UPDATE Respondent SET email_sent = true - WHERE uuid = ANY($1) - AND (survey_id = $2 OR (survey_id IS NULL AND survey_name = $3))`, - [respondentTokens, surveyId || null, surveyName] - ), + withInvitationOwnership: deliverInvitationWithOwnership, + // Invitation clients are acquired only when a rate-limited batch actually + // runs, never while items wait. Reserve one default-pool client when possible + // so unrelated work is not starved by provider-held invitation transactions. + rateLimit: Math.max(1, Math.min(RATE_LIMIT, (pool.options.max || 10) - 1)), }); const emailQueue = emailQueueProcessor.queue; const rateLimitedSend = emailQueueProcessor.enqueue; @@ -297,46 +535,151 @@ async function sendTestMail(email, survey, lang, mailer = sendMail) { }); } +// Claims are retried only in this bounded window. Thirty minutes is longer than +// the provider timeout; 23 hours stays conservatively inside Resend's 24-hour +// idempotency retention. Older uncertain deliveries remain deliberately quarantined +// for manual reconciliation rather than risking a duplicate. Truly unclaimed rows +// remain eligible immediately, and invitation_delivery_id stays stable across retries. +const INVITATION_CLAIM_TIMEOUT_MINUTES = 30; +const INVITATION_CLAIM_RETRY_CUTOFF_HOURS = 23; + async function startSurvey(survey, mailer = sendMail) { + // Fail before opening a transaction or claiming any rows. Injected mailers do + // not require production Resend configuration and remain independently testable. + if (mailer === sendMail && !resend) { + throw new Error('Missing RESEND_KEY or RESEND_API_KEY environment variable'); + } + const client = await pool.connect(); - let respondents; - let templates; + const claimToken = crypto.randomUUID(); + let pendingEmails = []; + let transactionStarted = false; try { + await client.query('BEGIN'); + transactionStarted = true; const respondentResult = await client.query( - `SELECT name, contact_info, uuid, lang FROM Respondent + `SELECT name, contact_info, uuid, lang, invitation_delivery_id FROM Respondent WHERE ${legacySurveyPredicate()} AND can_respond = true - AND email_sent IS NOT TRUE`, - [survey.id, survey.name] + AND email_sent IS NOT TRUE + AND uuid IS NOT NULL + AND (invitation_claim_token IS NULL + OR (invitation_claimed_at < CURRENT_TIMESTAMP - ($3 * INTERVAL '1 minute') + AND (invitation_first_attempted_at IS NULL + OR invitation_first_attempted_at > CURRENT_TIMESTAMP - ($4 * INTERVAL '1 hour')))) + ORDER BY uuid + FOR UPDATE SKIP LOCKED`, + [ + survey.id || null, + survey.name, + INVITATION_CLAIM_TIMEOUT_MINUTES, + INVITATION_CLAIM_RETRY_CUTOFF_HOURS, + ] ); const emailResult = await client.query( `SELECT lang, text, subject FROM email WHERE ${legacySurveyPredicate()}`, - [survey.id, survey.name] + [survey.id || null, survey.name] ); - respondents = respondentResult.rows; - templates = emailResult.rows; + const emailMap = buildNotificationMaps(emailResult.rows).templates; + const validatedEmails = respondentResult.rows.map((respondent) => { + const language = normalizeLanguage(respondent.lang); + const template = language ? emailMap[language] : null; + if (!template) throw new Error(`Email template not found for language '${respondent.lang}'`); + if (template.text === undefined || template.text === null) { + throw new Error(`Email text is undefined for language '${respondent.lang}'`); + } + return { respondent, template }; + }); + + if (validatedEmails.length > 0) { + const claimResult = await client.query( + `UPDATE Respondent + SET invitation_claim_token = $1, invitation_claimed_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($2) + AND (survey_id = $3 OR (survey_id IS NULL AND survey_name = $4)) + AND email_sent IS NOT TRUE + AND (invitation_claim_token IS NULL + OR (invitation_claimed_at < CURRENT_TIMESTAMP - ($5 * INTERVAL '1 minute') + AND (invitation_first_attempted_at IS NULL + OR invitation_first_attempted_at > CURRENT_TIMESTAMP - ($6 * INTERVAL '1 hour')))) + RETURNING uuid`, + [ + claimToken, + validatedEmails.map(({ respondent }) => respondent.uuid), + survey.id || null, + survey.name, + INVITATION_CLAIM_TIMEOUT_MINUTES, + INVITATION_CLAIM_RETRY_CUTOFF_HOURS, + ] + ); + const claimedUuids = new Set(claimResult.rows.map(({ uuid }) => uuid)); + pendingEmails = validatedEmails.filter(({ respondent }) => claimedUuids.has(respondent.uuid)); + } + await client.query('COMMIT'); + transactionStarted = false; + } catch (error) { + if (transactionStarted) { + try { + await client.query('ROLLBACK'); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Failed to claim survey invitations and roll back the transaction.', + { cause: error } + ); + } + } + throw error; } finally { client.release(); } - const emailMap = buildNotificationMaps(templates).templates; - const pendingEmails = respondents.map((respondent) => { - const language = normalizeLanguage(respondent.lang); - const template = language ? emailMap[language] : null; - if (!template) { - throw new Error(`Email template not found for language '${respondent.lang}'`); - } - return { respondent, template }; - }); - const results = await Promise.allSettled(pendingEmails.map(({ respondent, template }) => - mailer(respondent.contact_info, respondent.uuid, survey.name, template.text, template.subject, { - surveyId: survey.id, - trackDelivery: true, + new Promise((resolve, reject) => { + let sendResult; + try { + sendResult = mailer(respondent.contact_info, respondent.uuid, survey.name, template.text, template.subject, { + surveyId: survey.id, + trackDelivery: false, + invitationClaimToken: claimToken, + invitationDeliveryId: respondent.invitation_delivery_id, + }); + } catch (error) { + // A synchronous mailer failure occurred before it could enqueue work. + if (error && typeof error === 'object') error.invitationKnownUnsent = true; + reject(error); + return; + } + Promise.resolve(sendResult).then(resolve, reject); }) )); - const failure = results.find((result) => result.status === 'rejected'); - if (failure) throw failure.reason; + const failures = results.filter((result) => result.status === 'rejected'); + const knownUnsentUuids = results.flatMap((result, index) => + result.status === 'rejected' && result.reason?.invitationKnownUnsent + ? [pendingEmails[index].respondent.uuid] + : [] + ); + if (knownUnsentUuids.length > 0) { + try { + await pool.query( + `UPDATE Respondent + SET invitation_claim_token = NULL, + invitation_claimed_at = NULL, + invitation_first_attempted_at = NULL + WHERE uuid = ANY($1) + AND invitation_claim_token = $2 + AND (survey_id = $3 OR (survey_id IS NULL AND survey_name = $4))`, + [knownUnsentUuids, claimToken, survey.id || null, survey.name] + ); + } catch (releaseError) { + throw new AggregateError( + [failures[0].reason, releaseError], + 'A known-unsent invitation failed and its claim could not be released.', + { cause: failures[0].reason } + ); + } + } + if (failures.length > 0) throw failures[0].reason; } // sendMail('bgarcia2324@gmail.com', 'byVHldRI2ZgaOXNhE-ih7', 'GEEEEEE'); @@ -1319,6 +1662,46 @@ async function insertUsers(users, deleteRow = null, survey = null) { ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (name, survey_name) DO UPDATE SET + uuid = CASE + WHEN Respondent.contact_info IS DISTINCT FROM EXCLUDED.contact_info + THEN EXCLUDED.uuid + ELSE Respondent.uuid + END, + invitation_delivery_id = CASE + WHEN Respondent.contact_info IS DISTINCT FROM EXCLUDED.contact_info + OR Respondent.lang IS DISTINCT FROM EXCLUDED.lang + OR Respondent.can_respond IS DISTINCT FROM EXCLUDED.can_respond + THEN gen_random_uuid() + ELSE Respondent.invitation_delivery_id + END, + email_sent = CASE + WHEN Respondent.contact_info IS DISTINCT FROM EXCLUDED.contact_info + OR Respondent.lang IS DISTINCT FROM EXCLUDED.lang + OR Respondent.can_respond IS DISTINCT FROM EXCLUDED.can_respond + THEN false + ELSE Respondent.email_sent + END, + invitation_claim_token = CASE + WHEN Respondent.contact_info IS DISTINCT FROM EXCLUDED.contact_info + OR Respondent.lang IS DISTINCT FROM EXCLUDED.lang + OR Respondent.can_respond IS DISTINCT FROM EXCLUDED.can_respond + THEN NULL + ELSE Respondent.invitation_claim_token + END, + invitation_claimed_at = CASE + WHEN Respondent.contact_info IS DISTINCT FROM EXCLUDED.contact_info + OR Respondent.lang IS DISTINCT FROM EXCLUDED.lang + OR Respondent.can_respond IS DISTINCT FROM EXCLUDED.can_respond + THEN NULL + ELSE Respondent.invitation_claimed_at + END, + invitation_first_attempted_at = CASE + WHEN Respondent.contact_info IS DISTINCT FROM EXCLUDED.contact_info + OR Respondent.lang IS DISTINCT FROM EXCLUDED.lang + OR Respondent.can_respond IS DISTINCT FROM EXCLUDED.can_respond + THEN NULL + ELSE Respondent.invitation_first_attempted_at + END, contact_info = EXCLUDED.contact_info, can_respond = EXCLUDED.can_respond, survey_id = EXCLUDED.survey_id, @@ -1328,7 +1711,7 @@ async function insertUsers(users, deleteRow = null, survey = null) { const values = [ user.userName, user.email, - nanoid(), // Generate new UUID for all rows + crypto.randomUUID(), // Globally unique access token; rotated on contact changes survey?.name || user.surveyName, survey?.id || user.surveyId || null, user.canRespond !== undefined ? user.canRespond : true, // Default to true if not specified @@ -3219,7 +3602,10 @@ module.exports = { normalizeLanguage, buildNotificationMaps, createEmailQueueProcessor, + deliverInvitationWithOwnership, + sendAccountEmail, sendEmailWithResend, + getResendFetchTimeoutMs, rateLimitedSend, processEmailQueue, sendMail, diff --git a/api/test/security.test.js b/api/test/security.test.js index a507a18..4fe104a 100644 --- a/api/test/security.test.js +++ b/api/test/security.test.js @@ -36,7 +36,10 @@ const { normalizeLanguage, buildNotificationMaps, createEmailQueueProcessor, + deliverInvitationWithOwnership, + sendAccountEmail, sendEmailWithResend, + getResendFetchTimeoutMs, sendTestMail, startSurvey, emailQueue, @@ -1348,6 +1351,13 @@ test('editable survey content migration is additive, included, and backfills exa assert.match(migration, /ADD COLUMN IF NOT EXISTS subject VARCHAR\(255\)/); assert.match(migration, /DEFAULT 'CLA Network Survey'/); assert.match(migration, /UPDATE EMAIL[\s\S]*SET subject = 'CLA Network Survey'[\s\S]*WHERE subject IS NULL/i); + assert.match(migration, /ADD COLUMN IF NOT EXISTS invitation_claim_token UUID/); + assert.match(migration, /ADD COLUMN IF NOT EXISTS invitation_claimed_at TIMESTAMP/); + assert.match(migration, /ADD COLUMN IF NOT EXISTS invitation_first_attempted_at TIMESTAMP/); + assert.match(migration, /ADD COLUMN IF NOT EXISTS invitation_delivery_id UUID/); + assert.match(migration, /UPDATE Respondent[\s\S]*invitation_delivery_id = gen_random_uuid\(\)[\s\S]*WHERE invitation_delivery_id IS NULL/i); + assert.match(migration, /ALTER COLUMN invitation_delivery_id SET DEFAULT gen_random_uuid\(\)[\s\S]*ALTER COLUMN invitation_delivery_id SET NOT NULL/i); + assert.match(migration, /CREATE INDEX IF NOT EXISTS idx_respondent_pending_invitation_claim[\s\S]*WHERE email_sent IS NOT TRUE/i); assert.doesNotMatch(migration, /\bDROP\b|\bTRUNCATE\b|\bDELETE\s+FROM\b/i); }); @@ -1442,66 +1452,169 @@ test('sendTestMail selects current localized subjects and bodies for legacy resp assert.equal(released, 2); }); -test('Resend response errors are treated as provider failures', async () => { - const providerError = { message: 'invalid recipient', statusCode: 422 }; +test('Resend fetch timeout configuration is positive and falls back safely', () => { + assert.equal(getResendFetchTimeoutMs(undefined), 30_000); + assert.equal(getResendFetchTimeoutMs('1250'), 1250); + assert.equal(getResendFetchTimeoutMs('0'), 30_000); + assert.equal(getResendFetchTimeoutMs('invalid'), 30_000); +}); + +test('Resend sends preserve idempotency and include an injectable bounded fetch signal', async () => { + const calls = []; + const resendClient = { + emails: { + async send(emailData, options) { + calls.push({ emailData, options }); + return { data: { id: 'accepted' }, error: null }; + }, + }, + }; + + await sendEmailWithResend( + { to: 'invite@example.com' }, + { idempotencyKey: 'survey-invitation-delivery-id' }, + resendClient, + 5 + ); + + assert.equal(calls[0].options.idempotencyKey, 'survey-invitation-delivery-id'); + assert.ok(calls[0].options.signal instanceof AbortSignal); +}); + +test('Resend timeout aborts promptly and is explicitly ambiguous', async () => { + let requestOptions; + const timeoutController = new AbortController(); const resendClient = { - emails: { send: async () => ({ data: null, error: providerError }) }, + emails: { + send: async (_emailData, options) => { + requestOptions = options; + queueMicrotask(() => timeoutController.abort(new DOMException( + 'The operation was aborted due to timeout', + 'TimeoutError' + ))); + return new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true }); + }); + }, + }, }; await assert.rejects( - sendEmailWithResend({ to: 'bad@example.com' }, resendClient), - (error) => error.message === providerError.message && error.providerError === providerError + sendEmailWithResend( + { to: 'timeout@example.com' }, + { signal: timeoutController.signal }, + resendClient + ), + (error) => error.name === 'TimeoutError' && error.definitiveProviderRejection === false ); + assert.equal(requestOptions.signal, timeoutController.signal); + assert.equal(requestOptions.signal.aborted, true); }); -test('email queue books successful respondent tokens without conflating duplicate addresses', async () => { - const marked = []; - const providerError = new Error('provider rejected message'); - let releaseFirst; - const firstSend = new Promise((resolve) => { releaseFirst = resolve; }); +test('Resend response errors classify only unambiguous known-rejection 4xx as definitive', async () => { + const cases = [ + [{ message: 'bad request', statusCode: 400 }, true], + [{ message: 'unauthorized', statusCode: 401 }, true], + [{ message: 'invalid recipient', statusCode: 422 }, true], + [{ message: 'rate limited', statusCode: 429 }, true], + [{ message: 'request timed out', statusCode: 408 }, false], + [{ message: 'request timed out', name: 'timeout_error', statusCode: 422 }, false], + [{ message: 'request aborted', code: 'ABORTED', statusCode: 400 }, false], + [{ message: 'conflict', statusCode: 409 }, false], + [{ message: 'concurrent request', name: 'concurrent_idempotent_requests', statusCode: 409 }, false], + [{ message: 'invalid request', name: 'invalid_idempotent_request', statusCode: 409 }, false], + [{ message: 'normalized conflict', code: 'IDEMPOTENCY-CONFLICT', statusCode: 422 }, false], + [{ message: 'application error', name: 'application_error', statusCode: null }, false], + [{ message: 'provider unavailable', statusCode: 500 }, false], + [{ message: 'malformed status', statusCode: '422' }, false], + [{ message: 'non-finite status', statusCode: Number.NaN }, false], + ]; + + for (const [providerError, definitiveProviderRejection] of cases) { + const resendClient = { + emails: { send: async () => ({ data: null, error: providerError }) }, + }; + await assert.rejects( + sendEmailWithResend({ to: 'bad@example.com' }, resendClient), + (error) => error.message === providerError.message + && error.providerError === providerError + && error.definitiveProviderRejection === definitiveProviderRejection + ); + } +}); + +test('account email reports Resend result errors as unsent with manual-delivery guidance', async (t) => { + const originalConsoleError = console.error; + const calls = []; + t.after(() => { console.error = originalConsoleError; }); + console.error = () => {}; + const providerErrors = [ + { message: 'rejected', statusCode: 422 }, + { message: 'application error', name: 'application_error', statusCode: null }, + ]; + const resendClient = { + emails: { + send: async (emailData, options) => { + calls.push({ emailData, options }); + return { data: null, error: providerErrors[calls.length - 1] }; + }, + }, + }; + + for (const to of ['rejected@example.com', 'ambiguous@example.com']) { + const result = await sendAccountEmail({ + to, subject: 'Invite', html: '

Invite

', text: 'Invite', + }, resendClient); + assert.equal(result.sent, false); + assert.match(result.message, /deliver the returned link manually/i); + } + assert.equal(calls.length, 2); + assert.deepEqual(calls.map(({ emailData }) => emailData.to), [ + 'rejected@example.com', 'ambiguous@example.com', + ]); + assert.ok(calls[0].options.signal instanceof AbortSignal); + assert.ok(calls[1].options.signal instanceof AbortSignal); +}); + +test('account email reports sent only when Resend accepts the request', async () => { + const resendClient = { + emails: { send: async () => ({ data: { id: 'accepted' }, error: null }) }, + }; + + assert.deepEqual(await sendAccountEmail({ + to: 'ok@example.com', subject: 'Invite', html: '

Invite

', text: 'Invite', + }, resendClient), { sent: true }); +}); + +test('email queue invokes invitation ownership only when a queued provider request starts', async () => { + const events = []; const processor = createEmailQueueProcessor({ - rateLimit: 3, + rateLimit: 1, delay: 0, wait: async () => {}, sendEmail: async (emailData) => { - if (emailData.subject === 'first') await firstSend; - if (emailData.subject === 'duplicate-failed') throw providerError; - return { id: emailData.subject }; + events.push(`provider:${emailData.to}`); + return { id: emailData.to }; }, - markSuccessful: async (delivery) => { - marked.push(delivery); + withInvitationOwnership: async (metadata, providerRequest) => { + events.push(`ownership:${metadata.respondentToken}`); + return providerRequest(); }, }); - const survey = { surveyId: 'survey-id', surveyName: 'Survey A' }; - const first = processor.enqueue( - { to: 'duplicate@example.com', subject: 'first' }, - { ...survey, respondentToken: 'respondent-one' } - ); - const failed = processor.enqueue( - { to: 'duplicate@example.com', subject: 'duplicate-failed' }, - { ...survey, respondentToken: 'respondent-two' } - ); - const second = processor.enqueue( - { to: 'second@example.com', subject: 'second' }, - { ...survey, respondentToken: 'respondent-three' } - ); - const duplicateSuccess = processor.enqueue( - { to: 'duplicate@example.com', subject: 'duplicate-success' }, - { ...survey, respondentToken: 'respondent-four' } - ); - releaseFirst(); - - assert.deepEqual(await first, { id: 'first' }); - await assert.rejects(failed, (error) => error === providerError); - assert.deepEqual(await second, { id: 'second' }); - assert.deepEqual(await duplicateSuccess, { id: 'duplicate-success' }); - assert.deepEqual(marked, [ - { ...survey, respondentTokens: ['respondent-one'] }, - { ...survey, respondentTokens: ['respondent-three', 'respondent-four'] }, + assert.deepEqual(await processor.enqueue( + { to: 'invite@example.com' }, + { respondentToken: 'respondent-one', invitationClaimToken: 'claim' } + ), { id: 'invite@example.com' }); + assert.deepEqual(await processor.enqueue( + { to: 'reminder@example.com' }, + { trackDelivery: false } + ), { id: 'reminder@example.com' }); + assert.deepEqual(events, [ + 'ownership:respondent-one', + 'provider:invite@example.com', + 'provider:reminder@example.com', ]); - assert.equal(processor.queue.length, 0); - assert.equal(processor.isProcessing(), false); }); test('test and reminder queue sends await providers without marking initial invitation delivery', async () => { @@ -1529,83 +1642,703 @@ test('test and reminder queue sends await providers without marking initial invi assert.deepEqual(marked, []); }); -test('email queue settles successful sends even when email_sent bookkeeping fails', async () => { +test('email queue ownership rejection skips stale queued sends', async () => { + let providerCalls = 0; + const processor = createEmailQueueProcessor({ + withInvitationOwnership: async () => { + const error = new Error('lost'); + error.code = 'INVITATION_CLAIM_LOST'; + throw error; + }, + sendEmail: async () => { providerCalls += 1; }, + }); + + await assert.rejects( + processor.enqueue( + { to: 'stale@example.com' }, + { invitationClaimToken: 'stale-claim', idempotencyKey: 'survey-invitation-delivery-id' } + ), + (error) => error.code === 'INVITATION_CLAIM_LOST' + ); + assert.equal(providerCalls, 0); +}); + +test('email queue passes a stable provider idempotency option only for invitations', async () => { + const calls = []; + const processor = createEmailQueueProcessor({ + sendEmail: async (emailData, options) => { + calls.push({ emailData, options }); + return { id: 'accepted' }; + }, + }); + const idempotencyKey = 'survey-invitation-11111111-1111-4111-8111-111111111111'; + + await processor.enqueue({ to: 'invite@example.com' }, { idempotencyKey }); + await processor.enqueue({ to: 'reminder@example.com' }, { trackDelivery: false }); + await processor.enqueue({ to: 'invite@example.com' }, { idempotencyKey }); + + assert.deepEqual(calls.map(({ options }) => options), [ + { idempotencyKey }, + undefined, + { idempotencyKey }, + ]); +}); + +test('email queue propagates invitation ownership bookkeeping failures', async () => { + const bookkeepingError = new Error('database unavailable'); const processor = createEmailQueueProcessor({ sendEmail: async () => ({ id: 'sent' }), - markSuccessful: async () => { throw new Error('database unavailable'); }, + withInvitationOwnership: async () => { throw bookkeepingError; }, }); - assert.deepEqual(await processor.enqueue( + await assert.rejects(processor.enqueue( { to: 'sent@example.com' }, - { surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'respondent-token' } - ), { id: 'sent' }); + { invitationClaimToken: 'claim' } + ), (error) => error === bookkeepingError); assert.equal(processor.isProcessing(), false); }); -test('startSurvey releases its client and prevalidates every canonicalized template before enqueueing', async (t) => { +test('startSurvey validates production Resend configuration before connecting or claiming', async (t) => { const originalConnect = pool.connect; - const initialQueueLength = emailQueue.length; - let released = false; - t.after(() => { - pool.connect = originalConnect; - emailQueue.splice(initialQueueLength); - }); + let connectCalls = 0; + t.after(() => { pool.connect = originalConnect; }); + pool.connect = async () => { + connectCalls += 1; + throw new Error('must not connect'); + }; + + await assert.rejects( + startSurvey({ id: 'survey-id', name: 'Survey A' }), + /Missing RESEND_KEY or RESEND_API_KEY/ + ); + assert.equal(connectCalls, 0, 'configuration failure persists zero claims'); +}); + +test('startSurvey prevalidates all templates before persisting a claim or sending', async (t) => { + const originalConnect = pool.connect; + const calls = []; + t.after(() => { pool.connect = originalConnect; }); pool.connect = async () => ({ async query(sql) { - if (/FROM Respondent/i.test(sql)) { + if (sql === 'BEGIN' || sql === 'ROLLBACK') calls.push(sql); + else if (/^\s*SELECT[\s\S]*FROM Respondent/i.test(sql)) { + calls.push('SELECT respondents'); return { rows: [ - { contact_info: 'one@example.com', uuid: 'one', lang: 'en' }, - { contact_info: 'two@example.com', uuid: 'two', lang: "'Spanish'" }, + { contact_info: 'one@example.com', uuid: 'one', lang: 'English' }, + { contact_info: 'two@example.com', uuid: 'two', lang: 'Spanish' }, ] }; - } - return { rows: [{ lang: '"English"', text: 'Hello', subject: 'Subject' }] }; + } else if (/^\s*SELECT[\s\S]*FROM email/i.test(sql)) { + calls.push('SELECT templates'); + return { rows: [{ lang: 'English', text: 'Hello', subject: 'Subject' }] }; + } else calls.push('WRITE'); + return { rows: [] }; }, - release() { released = true; }, + release() { calls.push('release'); }, }); - await assert.rejects(startSurvey({ id: 'survey-id', name: 'Survey A' }), /Spanish/); - assert.equal(released, true); - assert.equal(emailQueue.length, initialQueueLength, 'no recipient is queued when any template is missing'); + let sent = false; + await assert.rejects( + startSurvey({ id: 'survey-id', name: 'Survey A' }, async () => { sent = true; }), + /Spanish/ + ); + assert.deepEqual(calls, ['BEGIN', 'SELECT respondents', 'SELECT templates', 'ROLLBACK', 'release']); + assert.equal(sent, false); }); -test('startSurvey awaits every provider send and rejects delivery failures after releasing its client', async (t) => { +test('startSurvey uses claimed_at for retry delay and immutable first_attempted_at for the 23h cutoff', async (t) => { const originalConnect = pool.connect; - let released = false; - let respondentSql = ''; - const sent = []; + const transactionCalls = []; t.after(() => { pool.connect = originalConnect; }); pool.connect = async () => ({ - async query(sql) { - if (/FROM Respondent/i.test(sql)) { - respondentSql = sql; + async query(sql, values) { + transactionCalls.push({ sql, values }); + if (/^\s*SELECT[\s\S]*FROM Respondent/i.test(sql)) { + assert.match(sql, /invitation_claim_token IS NULL[\s\S]*claimed_at < CURRENT_TIMESTAMP - \(\$3 \* INTERVAL '1 minute'\)[\s\S]*first_attempted_at IS NULL[\s\S]*first_attempted_at > CURRENT_TIMESTAMP - \(\$4 \* INTERVAL '1 hour'\)[\s\S]*FOR UPDATE SKIP LOCKED/i); + assert.deepEqual(values, ['survey-id', 'Survey A', 30, 23]); + // Never-attempted abandoned claims can retry after claimed_at goes stale. + // Once a provider request starts, the immutable first attempt controls the + // cutoff even though every ambiguous failure refreshes claimed_at. return { rows: [ - { contact_info: 'one@example.com', uuid: 'one', lang: 'English' }, - { contact_info: 'two@example.com', uuid: 'two', lang: 'English' }, + { contact_info: 'new@example.com', uuid: 'new', lang: 'English', invitation_delivery_id: '11111111-1111-4111-8111-111111111111' }, + { contact_info: 'retry@example.com', uuid: 'retry', lang: 'English', invitation_delivery_id: '22222222-2222-4222-8222-222222222222' }, ] }; } - return { rows: [{ lang: 'English', text: 'Hello', subject: 'Subject' }] }; + if (/^\s*SELECT[\s\S]*FROM email/i.test(sql)) { + return { rows: [{ lang: 'English', text: 'Hello', subject: 'Subject' }] }; + } + if (/^\s*UPDATE Respondent/i.test(sql)) { + assert.match(sql, /invitation_claim_token IS NULL[\s\S]*claimed_at < CURRENT_TIMESTAMP - \(\$5 \* INTERVAL '1 minute'\)[\s\S]*first_attempted_at IS NULL[\s\S]*first_attempted_at > CURRENT_TIMESTAMP - \(\$6 \* INTERVAL '1 hour'\)/i); + assert.deepEqual(values.slice(4), [30, 23]); + return { rows: [{ uuid: 'new' }, { uuid: 'retry' }] }; + } + return { rows: [] }; }, - release() { released = true; }, + release() { transactionCalls.push({ sql: 'RELEASE' }); }, }); - const providerError = new Error('Resend failed'); - const mailer = async (email, token, surveyName, text, subject, options) => { - assert.equal(released, true); - sent.push({ email, token, surveyName, text, subject, options }); - if (email === 'two@example.com') throw providerError; + + const sent = []; + await startSurvey({ id: 'survey-id', name: 'Survey A' }, async (_email, uuid, _name, _text, _subject, options) => { + sent.push({ uuid, options }); + }); + assert.deepEqual(sent.map(({ uuid }) => uuid), ['new', 'retry']); + assert.equal(sent[0].options.invitationClaimToken, sent[1].options.invitationClaimToken); + assert.equal(sent[0].options.invitationDeliveryId, '11111111-1111-4111-8111-111111111111'); + assert.equal(transactionCalls.at(-2).sql, 'COMMIT'); + assert.equal(transactionCalls.at(-1).sql, 'RELEASE'); +}); + +test('startSurvey releases a claim after a synchronous pre-enqueue mailer failure', async (t) => { + const originalConnect = pool.connect; + const originalQuery = pool.query; + const releases = []; + t.after(() => { + pool.connect = originalConnect; + pool.query = originalQuery; + }); + pool.connect = async () => ({ + async query(sql) { + if (/^\s*SELECT[\s\S]*FROM Respondent/i.test(sql)) { + return { rows: [{ + contact_info: 'one@example.com', uuid: 'one', lang: 'English', + invitation_delivery_id: '11111111-1111-4111-8111-111111111111', + }] }; + } + if (/^\s*SELECT[\s\S]*FROM email/i.test(sql)) { + return { rows: [{ lang: 'English', text: 'Hello', subject: 'Subject' }] }; + } + if (/^\s*UPDATE Respondent/i.test(sql)) return { rows: [{ uuid: 'one' }] }; + return { rows: [] }; + }, + release() {}, + }); + pool.query = async (sql, values) => { + releases.push({ sql, values }); + return { rowCount: 1, rows: [] }; }; + const localError = new Error('could not construct request'); - await assert.rejects(startSurvey({ id: 'survey-id', name: 'Survey A' }, mailer), (error) => error === providerError); - assert.match(respondentSql, /email_sent\s+IS\s+NOT\s+TRUE/i, 'retry query excludes already-successful recipients'); - assert.deepEqual(sent, [ - { - email: 'one@example.com', token: 'one', surveyName: 'Survey A', text: 'Hello', subject: 'Subject', - options: { surveyId: 'survey-id', trackDelivery: true }, + await assert.rejects( + startSurvey({ id: 'survey-id', name: 'Survey A' }, () => { throw localError; }), + (error) => error === localError + ); + assert.equal(releases.length, 1); + assert.match(releases[0].sql, /invitation_claim_token = NULL[\s\S]*invitation_claimed_at = NULL[\s\S]*invitation_first_attempted_at = NULL/i); + assert.deepEqual(releases[0].values[0], ['one']); + assert.equal(typeof releases[0].values[1], 'string'); +}); + +test('a concurrent start skips claimed rows instead of relying on process-local state', async (t) => { + const originalConnect = pool.connect; + const originalQuery = pool.query; + let connectionNumber = 0; + t.after(() => { + pool.connect = originalConnect; + pool.query = originalQuery; + }); + pool.query = async (_sql, values) => ({ rowCount: values?.[0]?.length || 0, rows: [] }); + pool.connect = async () => { + const ownNumber = ++connectionNumber; + return { + async query(sql) { + if (/^\s*SELECT[\s\S]*FROM Respondent/i.test(sql)) { + return { rows: ownNumber === 1 + ? [{ contact_info: 'one@example.com', uuid: 'one', lang: 'English' }] + : [] }; + } + if (/^\s*SELECT[\s\S]*FROM email/i.test(sql)) { + return { rows: [{ lang: 'English', text: 'Hello', subject: 'Subject' }] }; + } + if (/^\s*UPDATE Respondent/i.test(sql)) return { rows: [{ uuid: 'one' }] }; + return { rows: [] }; + }, + release() {}, + }; + }; + + let finishFirst; + let firstReached; + const reached = new Promise((resolve) => { firstReached = resolve; }); + const first = startSurvey({ id: 'survey-id', name: 'Survey A' }, async () => { + firstReached(); + await new Promise((resolve) => { finishFirst = resolve; }); + }); + await reached; + let secondSent = false; + await startSurvey({ id: 'survey-id', name: 'Survey A' }, async () => { secondSent = true; }); + assert.equal(secondSent, false); + finishFirst(); + await first; +}); + +test('attempt horizon commits before provider I/O and the second row lock spans delivery', async () => { + const events = []; + let lockHeld = false; + let commitCount = 0; + const client = { + async query(sql) { + if (sql === 'BEGIN') return { rowCount: 0 }; + if (/SELECT uuid FROM Respondent/i.test(sql)) { + lockHeld = true; + events.push(`send-lock-${commitCount + 1}`); + return { rowCount: 1, rows: [{ uuid: 'respondent-token' }] }; + } + if (/invitation_first_attempted_at = COALESCE/i.test(sql)) { + events.push('horizon-update'); + return { rowCount: 1 }; + } + if (/SET email_sent = true/i.test(sql)) { + events.push('send-finalize'); + return { rowCount: 1 }; + } + if (sql === 'COMMIT') { + commitCount += 1; + events.push(`send-commit-${commitCount}`); + lockHeld = false; + return { rowCount: 0 }; + } + if (sql === 'ROLLBACK') return { rowCount: 0 }; + throw new Error(`Unexpected SQL: ${sql}`); + }, + release() { events.push('release'); }, + }; + let releaseProvider; + const providerHeld = new Promise((resolve) => { releaseProvider = resolve; }); + const delivery = deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'respondent-token', + invitationClaimToken: 'claim-token', invitationDeliveryId: 'delivery-id', + }, async () => { + events.push('provider-start'); + assert.equal(commitCount, 1, 'durable horizon transaction committed before provider invocation'); + assert.equal(lockHeld, true, 'second SELECT FOR UPDATE lock is held during provider I/O'); + await providerHeld; + events.push('provider-end'); + return { id: 'accepted' }; + }, { connect: async () => client }); + + while (!events.includes('provider-start')) await Promise.resolve(); + events.push('edit-attempt'); + assert.equal(lockHeld, true); + releaseProvider(); + assert.deepEqual(await delivery, { id: 'accepted' }); + assert.deepEqual(events, [ + 'send-lock-1', 'horizon-update', 'send-commit-1', 'send-lock-2', + 'provider-start', 'edit-attempt', 'provider-end', 'send-finalize', + 'send-commit-2', 'release', + ]); +}); + +test('exact 23-hour invitation horizon boundary is quarantined before provider I/O', async () => { + const cutoffHours = 23; + const now = Date.parse('2026-01-02T12:00:00.000Z'); + const row = { + claimToken: 'claim-token', + firstAttemptedAt: now - (cutoffHours * 60 * 60 * 1000), + }; + const calls = []; + let providerCalled = false; + const client = { + async query(sql, values) { + calls.push({ sql, values }); + if (/SELECT uuid FROM Respondent/i.test(sql)) { + const checksHorizon = /invitation_first_attempted_at IS NOT NULL/i.test(sql); + const insideHorizon = row.firstAttemptedAt !== null + && row.firstAttemptedAt > now - (values.at(-1) * 60 * 60 * 1000); + return { rowCount: checksHorizon && !insideHorizon ? 0 : 1, rows: [{ uuid: 'respondent-token' }] }; + } + if (/invitation_first_attempted_at = COALESCE/i.test(sql)) return { rowCount: 1 }; + return { rowCount: 0 }; + }, + release() { calls.push({ sql: 'RELEASE' }); }, + }; + + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'respondent-token', + invitationClaimToken: row.claimToken, invitationDeliveryId: 'delivery-id', + }, async () => { providerCalled = true; }, { connect: async () => client }), (error) => { + assert.equal(error.code, 'INVITATION_CLAIM_LOST'); + assert.match(error.message, /quarantined/i); + return true; + }); + + const horizonLock = calls.find(({ sql }) => /invitation_first_attempted_at IS NOT NULL/i.test(sql)); + assert.ok(horizonLock); + assert.match(horizonLock.sql, /invitation_first_attempted_at > CURRENT_TIMESTAMP - \(\$6 \* INTERVAL '1 hour'\)/i); + assert.equal(horizonLock.values[5], cutoffHours); + assert.equal(providerCalled, false); + assert.equal(row.claimToken, 'claim-token'); + assert.equal(row.firstAttemptedAt, now - (cutoffHours * 60 * 60 * 1000)); + assert.deepEqual(calls.filter(({ sql }) => sql === 'COMMIT' || sql === 'ROLLBACK').map(({ sql }) => sql), ['COMMIT', 'ROLLBACK']); +}); + +test('queued retry crossing the 23-hour cutoff leaves its claim and horizon quarantined', async () => { + const cutoffMs = 23 * 60 * 60 * 1000; + let databaseNow = Date.parse('2026-01-02T12:00:00.000Z'); + const row = { + claimToken: 'claim-token', + firstAttemptedAt: databaseNow - cutoffMs + 1, + claimedAt: databaseNow - (31 * 60 * 1000), + }; + const originalHorizon = row.firstAttemptedAt; + let commitCount = 0; + let providerCalled = false; + const client = { + async query(sql, values) { + if (/SELECT uuid FROM Respondent/i.test(sql)) { + const checksHorizon = /invitation_first_attempted_at IS NOT NULL/i.test(sql); + const insideHorizon = row.firstAttemptedAt !== null + && row.firstAttemptedAt > databaseNow - (values.at(-1) * 60 * 60 * 1000); + return { rowCount: checksHorizon && !insideHorizon ? 0 : 1, rows: [{ uuid: 'respondent-token' }] }; + } + if (/invitation_first_attempted_at = COALESCE/i.test(sql)) { + row.claimedAt = databaseNow; + row.firstAttemptedAt ??= databaseNow; + return { rowCount: 1 }; + } + if (sql === 'COMMIT') { + commitCount += 1; + if (commitCount === 1) databaseNow += 2; + } + return { rowCount: 0 }; + }, + release() {}, + }; + + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'respondent-token', + invitationClaimToken: row.claimToken, invitationDeliveryId: 'delivery-id', + }, async () => { providerCalled = true; }, { connect: async () => client }), (error) => { + assert.equal(error.code, 'INVITATION_CLAIM_LOST'); + assert.match(error.message, /quarantined/i); + return true; + }); + assert.equal(providerCalled, false); + assert.equal(row.claimToken, 'claim-token'); + assert.equal(row.firstAttemptedAt, originalHorizon, 'the immutable horizon remains available for quarantine'); +}); + +test('first-ever invitation passes final horizon validation after its timestamp commits', async () => { + let databaseNow = Date.parse('2026-01-02T12:00:00.000Z'); + const row = { firstAttemptedAt: null }; + let commitCount = 0; + let providerCalled = false; + const client = { + async query(sql, values) { + if (/SELECT uuid FROM Respondent/i.test(sql)) { + const checksHorizon = /invitation_first_attempted_at IS NOT NULL/i.test(sql); + const insideHorizon = row.firstAttemptedAt !== null + && row.firstAttemptedAt > databaseNow - (values.at(-1) * 60 * 60 * 1000); + return { rowCount: checksHorizon && !insideHorizon ? 0 : 1, rows: [{ uuid: 'respondent-token' }] }; + } + if (/invitation_first_attempted_at = COALESCE/i.test(sql)) { + row.firstAttemptedAt ??= databaseNow; + return { rowCount: 1 }; + } + if (/SET email_sent = true/i.test(sql)) return { rowCount: 1 }; + if (sql === 'COMMIT' && ++commitCount === 1) databaseNow += 1000; + return { rowCount: 0 }; + }, + release() {}, + }; + + const result = await deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'respondent-token', + invitationClaimToken: 'claim-token', invitationDeliveryId: 'delivery-id', + }, async () => { + providerCalled = true; + return { id: 'accepted' }; + }, { connect: async () => client }); + + assert.equal(providerCalled, true); + assert.deepEqual(result, { id: 'accepted' }); +}); + +test('ownership loss between horizon commit and second lock skips provider', async () => { + const calls = []; + let selectCount = 0; + let providerCalled = false; + const client = { + async query(sql) { + calls.push(sql); + if (/SELECT uuid FROM Respondent/i.test(sql)) { + selectCount += 1; + return selectCount === 1 + ? { rowCount: 1, rows: [{ uuid: 'respondent-token' }] } + : { rowCount: 0, rows: [] }; + } + if (/UPDATE Respondent/i.test(sql)) return { rowCount: 1 }; + return { rowCount: 0 }; + }, + release() { calls.push('RELEASE'); }, + }; + + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'respondent-token', + invitationClaimToken: 'claim-token', invitationDeliveryId: 'delivery-id', + }, async () => { providerCalled = true; }, { connect: async () => client }), (error) => { + assert.equal(error.code, 'INVITATION_CLAIM_LOST'); + return true; + }); + assert.equal(providerCalled, false); + assert.deepEqual(calls.filter((sql) => sql === 'COMMIT' || sql === 'ROLLBACK'), ['COMMIT', 'ROLLBACK']); + assert.equal(calls.at(-1), 'RELEASE'); +}); + +test('definitive provider rejection releases the exact claim atomically before surfacing the error', async () => { + const calls = []; + const providerDetail = { message: 'provider rejected', statusCode: 422 }; + const resendClient = { + emails: { send: async () => ({ data: null, error: providerDetail }) }, + }; + const client = { + async query(sql, values) { + calls.push({ sql, values }); + if (/SELECT uuid FROM Respondent/i.test(sql)) return { rowCount: 1, rows: [{ uuid: 'token' }] }; + if (/UPDATE Respondent/i.test(sql)) return { rowCount: 1 }; + return { rowCount: 0 }; + }, + release() { calls.push({ sql: 'RELEASE' }); }, + }; + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'token', + invitationClaimToken: 'claim', invitationDeliveryId: 'stable-delivery-id', + }, () => sendEmailWithResend({ to: 'bad@example.com' }, resendClient), { connect: async () => client }), (error) => { + assert.equal(error.providerError, providerDetail); + assert.equal(error.definitiveProviderRejection, true); + return true; + }); + const releaseClaim = calls.find(({ sql }) => /SET email_sent = false,[\s\S]*invitation_claim_token = NULL/i.test(sql)); + assert.ok(releaseClaim); + assert.match(releaseClaim.sql, /invitation_first_attempted_at = NULL/); + assert.deepEqual(releaseClaim.values.slice(0, 3), ['token', 'claim', 'stable-delivery-id']); + assert.deepEqual(calls.slice(-2).map(({ sql }) => sql), ['COMMIT', 'RELEASE']); +}); + +test('ambiguous Resend statuses and idempotency conflicts retain the durable invitation horizon', async () => { + const cases = [ + { message: 'application error', name: 'application_error', statusCode: null }, + { message: 'provider unavailable', statusCode: 500 }, + { message: 'malformed status', statusCode: '422' }, + { message: 'request timed out', statusCode: 408 }, + { message: 'request timed out', name: 'timeout_error', statusCode: 422 }, + { message: 'request aborted', code: 'ABORTED', statusCode: 400 }, + { message: 'conflict', statusCode: 409 }, + { message: 'concurrent request', name: 'concurrent_idempotent_requests', statusCode: 409 }, + { message: 'invalid request', name: 'invalid_idempotent_request', statusCode: 409 }, + ]; + + for (const providerDetail of cases) { + const calls = []; + const client = { + async query(sql, values) { + calls.push({ sql, values }); + if (/SELECT uuid FROM Respondent/i.test(sql)) return { rowCount: 1, rows: [{ uuid: 'token' }] }; + if (/UPDATE Respondent/i.test(sql)) return { rowCount: 1 }; + return { rowCount: 0 }; + }, + release() { calls.push({ sql: 'RELEASE' }); }, + }; + const resendClient = { + emails: { send: async () => ({ data: null, error: providerDetail }) }, + }; + + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'token', + invitationClaimToken: 'claim', invitationDeliveryId: 'stable-delivery-id', + }, () => sendEmailWithResend({ to: 'bad@example.com' }, resendClient), { connect: async () => client }), (error) => { + assert.equal(error.providerError, providerDetail); + assert.equal(error.definitiveProviderRejection, false); + return true; + }); + + const updates = calls.filter(({ sql }) => /UPDATE Respondent/i.test(sql)); + assert.equal(updates.length, 2); + assert.match(updates[0].sql, /invitation_first_attempted_at = COALESCE/i); + assert.match(updates[1].sql, /SET invitation_claimed_at = CURRENT_TIMESTAMP/i); + assert.doesNotMatch(updates[1].sql, /invitation_first_attempted_at\s*=|invitation_claim_token\s*=\s*NULL/i); + assert.deepEqual(calls.slice(-2).map(({ sql }) => sql), ['COMMIT', 'RELEASE']); + } +}); + +test('provider timeout retains the durable invitation horizon and releases its transaction client', async () => { + const calls = []; + const client = { + async query(sql) { + calls.push(sql); + if (/SELECT uuid FROM Respondent/i.test(sql)) return { rowCount: 1, rows: [{ uuid: 'token' }] }; + if (/UPDATE Respondent/i.test(sql)) return { rowCount: 1 }; + return { rowCount: 0 }; + }, + release() { calls.push('RELEASE'); }, + }; + const timeoutController = new AbortController(); + const resendClient = { + emails: { + send: async (_emailData, options) => { + queueMicrotask(() => timeoutController.abort(new DOMException( + 'The operation was aborted due to timeout', + 'TimeoutError' + ))); + return new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true }); + }); + }, }, + }; + + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'token', + invitationClaimToken: 'claim', invitationDeliveryId: 'stable-delivery-id', + }, () => sendEmailWithResend( + { to: 'timeout@example.com' }, { - email: 'two@example.com', token: 'two', surveyName: 'Survey A', text: 'Hello', subject: 'Subject', - options: { surveyId: 'survey-id', trackDelivery: true }, + idempotencyKey: 'survey-invitation-stable-delivery-id', + signal: timeoutController.signal, }, - ], 'all eligible unsent respondents are attempted with exact delivery metadata'); + resendClient + ), { connect: async () => client }), (error) => { + assert.equal(error.name, 'TimeoutError'); + assert.equal(error.definitiveProviderRejection, false); + return true; + }); + + const updates = calls.filter((sql) => /UPDATE Respondent/i.test(sql)); + assert.equal(updates.length, 2); + assert.match(updates[0], /invitation_first_attempted_at = COALESCE/i); + assert.match(updates[1], /SET invitation_claimed_at = CURRENT_TIMESTAMP/i); + assert.doesNotMatch(updates[1], /invitation_claim_token\s*=\s*NULL|invitation_first_attempted_at\s*=/i); + assert.deepEqual(calls.slice(-2), ['COMMIT', 'RELEASE']); +}); + +test('repeated ambiguous provider failures retain the immutable first attempt and refresh only retry time', async () => { + const calls = []; + const transportError = new Error('request timed out'); + const client = { + async query(sql, values) { + calls.push({ sql, values }); + if (/SELECT uuid FROM Respondent/i.test(sql)) return { rowCount: 1, rows: [{ uuid: 'token' }] }; + if (/UPDATE Respondent/i.test(sql)) return { rowCount: 1 }; + return { rowCount: 0 }; + }, + release() { calls.push({ sql: 'RELEASE' }); }, + }; + + const attempt = () => assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'token', + invitationClaimToken: 'claim', invitationDeliveryId: 'stable-delivery-id', + }, async () => { throw transportError; }, { connect: async () => client }), (error) => error === transportError); + await attempt(); + await attempt(); + + const updates = calls.filter(({ sql }) => /UPDATE Respondent/i.test(sql)); + assert.equal(updates.length, 4, 'each request refreshes retry time before and after its ambiguous failure'); + for (const requestUpdate of [updates[0], updates[2]]) { + assert.match(requestUpdate.sql, /invitation_first_attempted_at = COALESCE\(invitation_first_attempted_at, CURRENT_TIMESTAMP\)/i); + } + for (const failureUpdate of [updates[1], updates[3]]) { + assert.match(failureUpdate.sql, /SET invitation_claimed_at = CURRENT_TIMESTAMP/i); + assert.doesNotMatch(failureUpdate.sql, /invitation_first_attempted_at\s*=/i); + assert.match(failureUpdate.sql, /invitation_claim_token = \$2[\s\S]*invitation_delivery_id = \$3/i); + assert.doesNotMatch(failureUpdate.sql, /invitation_claim_token = NULL|invitation_claimed_at = NULL|invitation_first_attempted_at = NULL/i); + assert.deepEqual(failureUpdate.values.slice(0, 3), ['token', 'claim', 'stable-delivery-id']); + } + assert.deepEqual(calls.slice(-2).map(({ sql }) => sql), ['COMMIT', 'RELEASE']); +}); + +test('hard provider throw retains the already committed durable horizon', async () => { + const calls = []; + const transportError = new Error('socket closed'); + const client = { + async query(sql) { + calls.push(sql); + if (/SELECT uuid FROM Respondent/i.test(sql)) return { rowCount: 1, rows: [{ uuid: 'token' }] }; + if (/UPDATE Respondent/i.test(sql)) return { rowCount: 1 }; + return { rowCount: 0 }; + }, + release() { calls.push('RELEASE'); }, + }; + + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'token', + invitationClaimToken: 'claim', invitationDeliveryId: 'delivery-id', + }, async () => { + assert.equal(calls.filter((sql) => sql === 'COMMIT').length, 1); + throw transportError; + }, { connect: async () => client }), (error) => error === transportError); + + const updates = calls.filter((sql) => /UPDATE Respondent/i.test(sql)); + assert.match(updates[0], /invitation_first_attempted_at = COALESCE/i); + assert.doesNotMatch(updates[1], /invitation_first_attempted_at\s*=|invitation_claim_token\s*=\s*NULL/i); + assert.deepEqual(calls.filter((sql) => sql === 'COMMIT'), ['COMMIT', 'COMMIT']); + assert.equal(calls.at(-1), 'RELEASE'); +}); + +test('invitation callback rolls back and releases when final bookkeeping fails', async () => { + const calls = []; + const bookkeepingError = new Error('finalization failed'); + const client = { + async query(sql) { + calls.push(sql); + if (/SELECT uuid FROM Respondent/i.test(sql)) return { rowCount: 1, rows: [{ uuid: 'token' }] }; + if (/SET invitation_claimed_at/i.test(sql)) return { rowCount: 1 }; + if (/SET email_sent = true/i.test(sql)) throw bookkeepingError; + return { rowCount: 0 }; + }, + release() { calls.push('RELEASE'); }, + }; + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'token', + invitationClaimToken: 'claim', invitationDeliveryId: 'delivery-id', + }, async () => ({ id: 'accepted' }), { connect: async () => client }), (error) => error === bookkeepingError); + assert.equal(calls.at(-2), 'ROLLBACK'); + assert.equal(calls.at(-1), 'RELEASE'); + assert.equal(calls.filter((sql) => /invitation_first_attempted_at = COALESCE/i.test(sql)).length, 1); +}); + +test('first transaction commit failure rolls back, releases, and skips provider', async () => { + const calls = []; + const commitError = new Error('commit failed'); + let providerCalled = false; + const client = { + async query(sql) { + calls.push(sql); + if (/SELECT uuid FROM Respondent/i.test(sql)) return { rowCount: 1, rows: [{ uuid: 'token' }] }; + if (/UPDATE Respondent/i.test(sql)) return { rowCount: 1 }; + if (sql === 'COMMIT') throw commitError; + return { rowCount: 0 }; + }, + release() { calls.push('RELEASE'); }, + }; + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'token', + invitationClaimToken: 'claim', invitationDeliveryId: 'delivery-id', + }, async () => { providerCalled = true; }, { connect: async () => client }), (error) => error === commitError); + assert.equal(providerCalled, false); + assert.equal(calls.at(-3), 'COMMIT'); + assert.equal(calls.at(-2), 'ROLLBACK'); + assert.equal(calls.at(-1), 'RELEASE'); +}); + +test('final commit failure rolls back while retaining the durable first-attempt horizon', async () => { + const calls = []; + const commitError = new Error('final commit failed'); + let commitCount = 0; + const client = { + async query(sql) { + calls.push(sql); + if (/SELECT uuid FROM Respondent/i.test(sql)) return { rowCount: 1, rows: [{ uuid: 'token' }] }; + if (/UPDATE Respondent/i.test(sql)) return { rowCount: 1 }; + if (sql === 'COMMIT' && ++commitCount === 2) throw commitError; + return { rowCount: 0 }; + }, + release() { calls.push('RELEASE'); }, + }; + await assert.rejects(deliverInvitationWithOwnership({ + surveyId: 'survey-id', surveyName: 'Survey A', respondentToken: 'token', + invitationClaimToken: 'claim', invitationDeliveryId: 'delivery-id', + }, async () => ({ id: 'accepted' }), { connect: async () => client }), (error) => error === commitError); + assert.deepEqual(calls.filter((sql) => sql === 'COMMIT' || sql === 'ROLLBACK'), ['COMMIT', 'COMMIT', 'ROLLBACK']); + assert.equal(calls.filter((sql) => /invitation_first_attempted_at = COALESCE/i.test(sql)).length, 1); + assert.equal(calls.at(-1), 'RELEASE'); }); test('notification validation enforces language, safe nonblank subjects, and text bounds', () => { @@ -2132,7 +2865,7 @@ test('dashboard read-only tables hide edit controls and demo email avoids public assert.match(questionTable, /editable: !readOnly/); assert.match(questionTable, /!readOnly &&/); assert.match(respondentTable, /readOnly = false/); - assert.match(respondentTable, /disabled=\{readOnly\}/); + assert.match(respondentTable, /disabled=\{readOnly \|\| !loaded \|\| busy\}/); assert.match(respondentTable, /!readOnly &&/); assert.match(respondentTable, /surveyName,/); assert.doesNotMatch(respondentTable, /params\.row\.surveyName/); @@ -2280,12 +3013,39 @@ 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', () => { +test('respondent contact changes revoke old access UUIDs and start a fresh delivery without clearing responses', () => { + const serverSource = fs.readFileSync(path.join(__dirname, '../server.js'), 'utf8'); + const insertUsersSource = serverSource.match(/async function insertUsers[\s\S]*?async function insertEmails/)?.[0] || ''; + assert.match(insertUsersSource, /Respondent\.contact_info IS DISTINCT FROM EXCLUDED\.contact_info/); + assert.match(insertUsersSource, /Respondent\.lang IS DISTINCT FROM EXCLUDED\.lang/); + assert.match(insertUsersSource, /Respondent\.can_respond IS DISTINCT FROM EXCLUDED\.can_respond/); + assert.match(insertUsersSource, /uuid = CASE[\s\S]*contact_info IS DISTINCT FROM EXCLUDED\.contact_info[\s\S]*THEN EXCLUDED\.uuid[\s\S]*ELSE Respondent\.uuid/); + assert.match(insertUsersSource, /crypto\.randomUUID\(\)/); + assert.doesNotMatch(insertUsersSource, /response\s*=/); + assert.match(insertUsersSource, /invitation_delivery_id = CASE[\s\S]*THEN gen_random_uuid\(\)[\s\S]*ELSE Respondent\.invitation_delivery_id/); + assert.match(insertUsersSource, /THEN false\s+ELSE Respondent\.email_sent/); + assert.match(insertUsersSource, /invitation_claim_token = CASE[\s\S]*THEN NULL[\s\S]*ELSE Respondent\.invitation_claim_token/); + assert.match(insertUsersSource, /invitation_claimed_at = CASE[\s\S]*THEN NULL[\s\S]*ELSE Respondent\.invitation_claimed_at/); + assert.match(insertUsersSource, /invitation_first_attempted_at = CASE[\s\S]*THEN NULL[\s\S]*ELSE Respondent\.invitation_first_attempted_at/); +}); + +test('startSurvey durable claim implementation and survey archive are scoped and non-destructive', () => { const serverSource = fs.readFileSync(path.join(__dirname, '../server.js'), 'utf8'); - assert.match(serverSource, /UPDATE Respondent SET email_sent = true[\s\S]*WHERE uuid = ANY\(\$1\)[\s\S]*survey_id = \$2 OR \(survey_id IS NULL AND survey_name = \$3\)/); - assert.doesNotMatch(serverSource, /email_sent = true WHERE contact_info = ANY/); + const startSurveySource = serverSource.match(/async function startSurvey[\s\S]*?\/\/ sendMail/)?.[0] || ''; + const deliverySource = serverSource.match(/async function deliverInvitationWithOwnership[\s\S]*?const emailQueueProcessor/)?.[0] || ''; + assert.match(startSurveySource, /FOR UPDATE SKIP LOCKED/); + assert.match(startSurveySource, /SET invitation_claim_token = \$1, invitation_claimed_at = CURRENT_TIMESTAMP[\s\S]*RETURNING uuid/); + assert.match(startSurveySource, /first_attempted_at > CURRENT_TIMESTAMP - \(\$4 \* INTERVAL '1 hour'\)/); + assert.match(deliverySource, /FOR UPDATE/); + assert.match(deliverySource, /invitation_first_attempted_at = COALESCE\(invitation_first_attempted_at, CURRENT_TIMESTAMP\)[\s\S]*providerResult = await providerRequest\(\)/); + assert.match(deliverySource, /providerResult = await providerRequest\(\)[\s\S]*SET email_sent = true,[\s\S]*invitation_claim_token = NULL/); + assert.match(deliverySource, /SET email_sent = false,[\s\S]*invitation_claim_token = NULL[\s\S]*invitation_delivery_id = \$3/); + assert.doesNotMatch(startSurveySource, /SET email_sent = true|SET email_sent = false/); assert.match(serverSource, /respondentToken: id/); - assert.match(serverSource, /trackDelivery: false/); + assert.match(serverSource, /idempotencyKey: options\.invitationDeliveryId[\s\S]*survey-invitation-/); + assert.match(deliverySource, /SET invitation_claimed_at = CURRENT_TIMESTAMP[\s\S]*invitation_claim_token = \$2[\s\S]*invitation_delivery_id = \$3/); + assert.match(startSurveySource, /trackDelivery: false/); + assert.doesNotMatch(startSurveySource, /trackDelivery: true/); 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/); }); diff --git a/dashboard/src/components/AddRowButton.js b/dashboard/src/components/AddRowButton.js index e9c56ef..5d29689 100644 --- a/dashboard/src/components/AddRowButton.js +++ b/dashboard/src/components/AddRowButton.js @@ -2,27 +2,26 @@ import React from 'react'; import { Button, Zoom } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; -const AddRowButton = ({ onClick }) => { - return ( - - - - ); -}; +const AddRowButton = ({ onClick, disabled = false }) => ( + + + +); -export default AddRowButton; \ No newline at end of file +export default AddRowButton; diff --git a/dashboard/src/components/Dashboard.js b/dashboard/src/components/Dashboard.js index e9dce4b..27ef037 100644 --- a/dashboard/src/components/Dashboard.js +++ b/dashboard/src/components/Dashboard.js @@ -23,11 +23,17 @@ const Dashboard = () => { const [snackbar, setSnackbar] = React.useState(null); const [contentDirty, setContentDirty] = React.useState(false); const [notificationDirty, setNotificationDirty] = React.useState(false); + const [questionDirty, setQuestionDirty] = React.useState(false); + const [respondentDirty, setRespondentDirty] = React.useState(false); const [contentBusy, setContentBusy] = React.useState(false); const [notificationBusy, setNotificationBusy] = React.useState(false); + const [questionBusy, setQuestionBusy] = React.useState(false); + const [respondentBusy, setRespondentBusy] = React.useState(false); + const relatedRequestRef = React.useRef(0); const { memberships, canViewSensitiveSurveyData, canEditSurvey } = useAuth(); - const hasBusyEdits = contentBusy || notificationBusy; - const hasDirtyEdits = contentDirty || notificationDirty; + const canViewSelectedSurveyData = canViewSensitiveSurveyData(selectSurvey); + const hasBusyEdits = contentBusy || notificationBusy || questionBusy || respondentBusy; + const hasDirtyEdits = contentDirty || notificationDirty || questionDirty || respondentDirty; const blocker = useBlocker(({ currentLocation, nextLocation }) => ( (hasBusyEdits || hasDirtyEdits) && `${currentLocation.pathname}${currentLocation.search}${currentLocation.hash}` @@ -69,6 +75,7 @@ const Dashboard = () => { survey => (survey.id || survey.name) === (selectSurvey.id || selectSurvey.name) ); if (!surveyStillExists) { + relatedRequestRef.current += 1; setSelectSurvey(null); setQuestionData(null); setRespondentData(null); @@ -84,61 +91,80 @@ const Dashboard = () => { }, []); React.useEffect(() => { - const fetchRelatedData = async () => { - if (!selectSurvey) return; + const requestId = ++relatedRequestRef.current; + const controller = new AbortController(); + const isCurrent = () => ( + relatedRequestRef.current === requestId && !controller.signal.aborted + ); + + // Never render the previous survey's data under a newly selected heading. + setQuestionData(null); + setRespondentData(null); + if (!selectSurvey) return () => controller.abort(); + const selectedSurveyId = selectSurvey.id || selectSurvey.name; + const mayViewRespondents = canViewSelectedSurveyData; + const fetchRelatedData = async () => { try { - // Fetch question data; viewers are allowed to see question text. const questionResponse = await api.get( - `/listQuestions?surveyName=${selectSurvey.id || selectSurvey.name}` + `/listQuestions?surveyName=${selectedSurveyId}`, + { signal: controller.signal } ); - setQuestionData(questionResponse.data.questions); + if (isCurrent()) setQuestionData(questionResponse.data.questions); } catch (err) { - console.log(err); - setQuestionData(null); + if (isCurrent()) { + console.log(err); + setQuestionData(null); + } } - if (!canViewSensitiveSurveyData(selectSurvey)) { - setRespondentData(null); - return; - } + if (!mayViewRespondents || !isCurrent()) return; try { - // Fetch respondent data for analyst+ roles only because it includes PII. const respondentResponse = await api.get( - `/targets?surveyName=${selectSurvey.id || selectSurvey.name}` + `/targets?surveyName=${selectedSurveyId}`, + { signal: controller.signal } ); - - // Remove dummy user with name 'None' - const filteredRespondents = respondentResponse.data.filter( + if (!isCurrent()) return; + setRespondentData(respondentResponse.data.filter( (respondent) => respondent.name !== "None" - ); - setRespondentData(filteredRespondents); + )); } catch (err) { - console.log(err); - setRespondentData(null); + if (isCurrent()) { + console.log(err); + setRespondentData(null); + } } }; - fetchRelatedData(); - }, [selectSurvey, canViewSensitiveSurveyData]); + void fetchRelatedData(); + return () => controller.abort(); + }, [selectSurvey, canViewSelectedSurveyData]); const handleSelectRow = (childData) => { const currentId = selectSurvey?.id || selectSurvey?.name; const nextId = childData?.id || childData?.name; - if (currentId && currentId !== nextId && (contentBusy || notificationBusy)) { + if (currentId && currentId === nextId) return; + if (currentId && currentId !== nextId && hasBusyEdits) { setSnackbar({ severity: 'warning', message: 'Please wait for the current save or CSV import to finish before switching surveys.', }); return; } - if (currentId && currentId !== nextId && (contentDirty || notificationDirty)) { + if (currentId && currentId !== nextId && hasDirtyEdits) { const shouldDiscard = window.confirm('Discard unsaved changes and switch surveys?'); if (!shouldDiscard) return; } + // Invalidate in-flight results immediately after all discard guards pass; + // waiting for the next effect cleanup leaves a microtask-sized stale window. + relatedRequestRef.current += 1; + setQuestionData(null); + setRespondentData(null); setContentDirty(false); setNotificationDirty(false); + setQuestionDirty(false); + setRespondentDirty(false); setSelectSurvey(childData); }; @@ -175,6 +201,7 @@ const Dashboard = () => { const handleSurveyDeleted = async (deletedSurveyName) => { await fetchSurveyData(); if (selectSurvey && selectSurvey.name === deletedSurveyName) { + relatedRequestRef.current += 1; setSelectSurvey(null); setQuestionData(null); setRespondentData(null); @@ -257,6 +284,8 @@ const Dashboard = () => { surveyName={selectSurvey?.id || selectSurvey?.name} onQuestionsUpdate={handleQuestionsUpdate} readOnly={!canEditSurvey(selectSurvey)} + onDirtyChange={setQuestionDirty} + onBusyChange={setQuestionBusy} /> @@ -287,6 +316,8 @@ const Dashboard = () => { surveyName={selectSurvey?.id || selectSurvey?.name} onRespondentsUpdate={handleRespondentsUpdate} readOnly={!canEditSurvey(selectSurvey)} + onDirtyChange={setRespondentDirty} + onBusyChange={setRespondentBusy} /> )} diff --git a/dashboard/src/components/Dashboard.test.js b/dashboard/src/components/Dashboard.test.js index a8893df..5cb361f 100644 --- a/dashboard/src/components/Dashboard.test.js +++ b/dashboard/src/components/Dashboard.test.js @@ -12,7 +12,7 @@ vi.mock('../api/axios', () => ({ vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ memberships: [{ role: 'editor' }], - canViewSensitiveSurveyData: () => false, + canViewSensitiveSurveyData: () => true, canEditSurvey: () => true, }), })); @@ -34,14 +34,31 @@ vi.mock('./EmailNotificationEditor', () => ({ vi.mock('./SurveyContentEditor', () => ({ default: ({ onDirtyChange }) => , })); -vi.mock('./QuestionTable', () => ({ default: () => null })); -vi.mock('./RespondentTable', () => ({ default: () => null })); +vi.mock('./QuestionTable', () => ({ + default: ({ rows, onDirtyChange, onBusyChange }) => ( +
+
{JSON.stringify(rows)}
+ + +
+ ), +})); +vi.mock('./RespondentTable', () => ({ + default: ({ rows, onDirtyChange, onBusyChange }) => ( +
+
{JSON.stringify(rows)}
+ + +
+ ), +})); vi.mock('./CreateSurveyDialog', () => ({ default: () => null })); beforeEach(() => { vi.clearAllMocks(); api.get.mockImplementation(async (url) => { if (url === '/surveys') return { data: { surveys: [] } }; + if (url.startsWith('/targets')) return { data: [] }; return { data: { questions: [] } }; }); }); @@ -56,6 +73,43 @@ const renderDashboard = (routerOptions = {}) => { return { ...rendered, router }; }; +test('rapid survey switching cannot apply stale question or respondent fetches', async () => { + const deferred = () => { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; + }; + const oneQuestions = deferred(); + const twoQuestions = deferred(); + const oneTargets = deferred(); + const twoTargets = deferred(); + api.get.mockImplementation((url) => { + if (url === '/surveys') return Promise.resolve({ data: { surveys: [] } }); + if (url.includes('listQuestions?surveyName=one')) return oneQuestions.promise; + if (url.includes('listQuestions?surveyName=two')) return twoQuestions.promise; + if (url.includes('targets?surveyName=one')) return oneTargets.promise; + if (url.includes('targets?surveyName=two')) return twoTargets.promise; + throw new Error(`Unexpected URL ${url}`); + }); + + renderDashboard(); + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await userEvent.click(screen.getByRole('button', { name: 'Select two' })); + expect(screen.getByTestId('question-rows')).toHaveTextContent('null'); + expect(screen.getByTestId('respondent-rows')).toHaveTextContent('null'); + + twoQuestions.resolve({ data: { questions: [{ text: 'two question' }] } }); + await waitFor(() => expect(screen.getByTestId('question-rows')).toHaveTextContent('two question')); + twoTargets.resolve({ data: [{ name: 'Two Respondent' }] }); + await waitFor(() => expect(screen.getByTestId('respondent-rows')).toHaveTextContent('Two Respondent')); + + oneQuestions.resolve({ data: { questions: [{ text: 'stale one question' }] } }); + oneTargets.resolve({ data: [{ name: 'Stale One Respondent' }] }); + await act(async () => { await Promise.resolve(); }); + expect(screen.getByTestId('question-rows')).not.toHaveTextContent('stale one question'); + expect(screen.getByTestId('respondent-rows')).not.toHaveTextContent('Stale One Respondent'); +}); + test('rejects survey switching with clear feedback while notification save/import is busy', async () => { renderDashboard(); await userEvent.click(screen.getByRole('button', { name: 'Select one' })); @@ -101,6 +155,48 @@ test('retains survey-switch confirmation and blocks selected-survey actions for confirm.mockRestore(); }); +test.each([ + ['question', 'Edit question'], + ['respondent', 'Edit respondent'], +])('includes dirty %s table edits in survey switching and start/archive guards', async (_editor, buttonName) => { + renderDashboard(); + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await userEvent.click(screen.getByRole('button', { name: buttonName })); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + + await userEvent.click(screen.getByRole('button', { name: 'Select two' })); + expect(screen.getByTestId('selected-survey')).toHaveTextContent('Survey One'); + expect(confirm).toHaveBeenCalledWith('Discard unsaved changes and switch surveys?'); + + await userEvent.click(screen.getByRole('button', { name: 'Act on selected' })); + expect(screen.getByText(/Save or reset unsaved changes before starting or archiving/i)).toBeInTheDocument(); + confirm.mockRestore(); +}); + +test('respondent saves block starting so stale local recipient edits cannot be used', async () => { + renderDashboard(); + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await userEvent.click(screen.getByRole('button', { name: 'Save respondent' })); + await userEvent.click(screen.getByRole('button', { name: 'Act on selected' })); + expect(screen.getByText(/wait for the current save or CSV import.*starting or archiving/i)).toBeInTheDocument(); +}); + +test('reselecting the current survey preserves dirty navigation and action protection', async () => { + const { router } = renderDashboard(); + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await userEvent.click(screen.getByRole('button', { name: 'Edit content' })); + + await userEvent.click(screen.getByRole('button', { name: 'Select one' })); + await userEvent.click(screen.getByRole('button', { name: 'Act on selected' })); + expect(screen.getByText(/Save or reset unsaved changes before starting or archiving/i)).toBeInTheDocument(); + + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + act(() => { void router.navigate('/settings'); }); + await waitFor(() => expect(confirm).toHaveBeenCalledWith('Discard unsaved changes and leave the dashboard?')); + expect(router.state.location.pathname).toBe('/'); + confirm.mockRestore(); +}); + test('allows or cancels data-router navigation from a dirty draft based on confirmation', async () => { const { router } = renderDashboard(); await userEvent.click(screen.getByRole('button', { name: 'Select one' })); diff --git a/dashboard/src/components/QuestionTable.js b/dashboard/src/components/QuestionTable.js index 2fa9639..6963d81 100644 --- a/dashboard/src/components/QuestionTable.js +++ b/dashboard/src/components/QuestionTable.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { DataGrid, GridToolbar } from '@mui/x-data-grid'; import TableUploadButton from './TableUploadButton'; import AddRowButton from './AddRowButton'; @@ -11,11 +11,43 @@ import TableMenuCell from './TableMenuCell'; import { parseQuestionsCsv } from '../utils/questionsCsv'; import { buildQuestionTableSchema } from '../utils/questionTableSchema'; -const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false }) => { +const QuestionTable = ({ + rows, + surveyName, + onQuestionsUpdate, + readOnly = false, + onDirtyChange, + onBusyChange, +}) => { const theme = useTheme(); const [tableRows, setTableRows] = useState([]); const [hasChanges, setHasChanges] = useState(false); const [originalRows, setOriginalRows] = useState([]); + const busyCountRef = useRef(0); + const loaded = rows !== null && rows !== undefined && Boolean(surveyName); + const [busy, setBusy] = useState(false); + + const beginBusy = useCallback(() => { + busyCountRef.current += 1; + setBusy(true); + let finished = false; + return () => { + if (finished) return; + finished = true; + busyCountRef.current = Math.max(0, busyCountRef.current - 1); + if (busyCountRef.current === 0) setBusy(false); + }; + }, []); + + useEffect(() => { + onDirtyChange?.(hasChanges); + return () => onDirtyChange?.(false); + }, [hasChanges, onDirtyChange]); + + useEffect(() => { + onBusyChange?.(busy); + return () => onBusyChange?.(false); + }, [busy, onBusyChange]); const [sortModel, setSortModel] = useState([ { field: 'id', @@ -29,19 +61,27 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } }); useEffect(() => { - if (rows) { - console.log('rows:', rows); - const updatedRows = rows.map((row, index) => ({ - ...row, - id: index + 1, - questions: row.questions === "null" ? "0" : row.questions - })); - setTableRows(updatedRows); - setOriginalRows(JSON.parse(JSON.stringify(updatedRows))); + if (!rows) { + // A guarded survey switch clears parent rows before loading the next survey. + // Render-time `loaded` disables controls before this effect clears the draft. + setTableRows([]); + setOriginalRows([]); + setHasChanges(false); + return; } - }, [rows]); + const updatedRows = rows.map((row, index) => ({ + ...row, + id: index + 1, + questions: row.questions === "null" ? "0" : row.questions + })); + setTableRows(updatedRows); + setOriginalRows(JSON.parse(JSON.stringify(updatedRows))); + setHasChanges(false); + }, [rows, surveyName]); const handleDeleteQuestion = async (row) => { + if (!loaded) return; + const finishBusy = beginBusy(); try { const response = await api.delete('/question', { data: { @@ -82,15 +122,17 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } message: 'Failed to delete question. Please try again.', severity: 'error' }); + } finally { + finishBusy(); } }; const columns = [ { field: 'id', headerName: 'ID', width: 90 }, - { field: 'text', headerName: 'Question text', width: 500, editable: !readOnly }, + { field: 'text', headerName: 'Question text', width: 500, editable: !readOnly && loaded && !busy }, { field: 'type', headerName: 'Question type', width: 150, editable: false }, - { field: 'required', headerName: 'Required', width: 100, type: 'boolean', editable: !readOnly }, - { field: 'max', headerName: 'Max answers', width: 150, editable: !readOnly }, + { field: 'required', headerName: 'Required', width: 100, type: 'boolean', editable: !readOnly && loaded && !busy }, + { field: 'max', headerName: 'Max answers', width: 150, editable: !readOnly && loaded && !busy }, { field: 'actions', headerName: 'Actions', @@ -100,6 +142,7 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } renderCell: (params) => ( { - if (readOnly) return originalRows.find(row => row.id === newRow.id) || newRow; + if (readOnly || !loaded || busy) return tableRows.find(row => row.id === newRow.id) || newRow; const updatedRows = tableRows.map((row) => (row.id === newRow.id ? newRow : row)); setTableRows(updatedRows); @@ -144,6 +187,8 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } }; const handleSave = async () => { + if (!loaded) return; + const finishBusy = beginBusy(); try { const response = await saveRows(tableRows); @@ -178,10 +223,14 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } message: 'Failed to save changes. Please try again.', severity: 'error' }); + } finally { + finishBusy(); } }; const handleUpload = async (csvContent) => { + if (!loaded) return; + const finishBusy = beginBusy(); try { // Parse the new CSV content const newQuestions = parseQuestionsCsv(csvContent); @@ -209,7 +258,8 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } setTableRows(refreshedRows); setOriginalRows(JSON.parse(JSON.stringify(refreshedRows))); - + setHasChanges(false); + const surveysResponse = await api.get('/surveys'); if (onQuestionsUpdate) { onQuestionsUpdate(surveysResponse.data.surveys); @@ -229,10 +279,13 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } severity: 'error' }); throw err; + } finally { + finishBusy(); } }; const handleAddRow = () => { + if (!loaded) return; const newId = Math.max(0, ...tableRows.map(row => row.id)) + 1; const newRow = { id: newId, @@ -272,11 +325,12 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } {!readOnly && ( - + {hasChanges && ( @@ -300,7 +355,7 @@ const QuestionTable = ({ rows, surveyName, onQuestionsUpdate, readOnly = false } }} pageSizeOptions={[10, 25, 50, { value: -1, label: 'All' }]} disableSelectionOnClick - processRowUpdate={readOnly ? undefined : handleProcessRowUpdate} + processRowUpdate={readOnly || !loaded || busy ? undefined : handleProcessRowUpdate} components={{ Toolbar: GridToolbar, }} diff --git a/dashboard/src/components/RespondentTable.js b/dashboard/src/components/RespondentTable.js index 905ff3d..d7c4aa6 100644 --- a/dashboard/src/components/RespondentTable.js +++ b/dashboard/src/components/RespondentTable.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { DataGrid, GridToolbar } from '@mui/x-data-grid'; import TableUploadButton from './TableUploadButton'; import AddRowButton from './AddRowButton'; @@ -37,11 +37,43 @@ const formatRowsToCSV = (rows) => { return `${CSV_HEADER}\n${dataRows.join('\n')}`; }; -const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = false }) => { +const RespondentTable = ({ + rows, + surveyName, + onRespondentsUpdate, + readOnly = false, + onDirtyChange, + onBusyChange, +}) => { const theme = useTheme(); const [tableRows, setTableRows] = useState([]); const [hasChanges, setHasChanges] = useState(false); const [originalRows, setOriginalRows] = useState([]); + const busyCountRef = useRef(0); + const loaded = rows !== null && rows !== undefined && Boolean(surveyName); + const [busy, setBusy] = useState(false); + + const beginBusy = useCallback(() => { + busyCountRef.current += 1; + setBusy(true); + let finished = false; + return () => { + if (finished) return; + finished = true; + busyCountRef.current = Math.max(0, busyCountRef.current - 1); + if (busyCountRef.current === 0) setBusy(false); + }; + }, []); + + useEffect(() => { + onDirtyChange?.(hasChanges); + return () => onDirtyChange?.(false); + }, [hasChanges, onDirtyChange]); + + useEffect(() => { + onBusyChange?.(busy); + return () => onBusyChange?.(false); + }, [busy, onBusyChange]); const [sortModel, setSortModel] = useState([ { field: 'id', @@ -50,13 +82,13 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal ]); const columns = [ - { field: 'name', headerName: 'User Name', width: 150, editable: !readOnly }, - { field: 'email', headerName: 'Email', width: 200, editable: !readOnly }, + { field: 'name', headerName: 'User Name', width: 150, editable: !readOnly && loaded && !busy }, + { field: 'email', headerName: 'Email', width: 200, editable: !readOnly && loaded && !busy }, { field: 'language', headerName: 'Language', width: 130, - editable: !readOnly, + editable: !readOnly && loaded && !busy, type: 'singleSelect', valueOptions: LANGUAGES.map(lang => lang.label), renderCell: (params) => { @@ -73,7 +105,7 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal renderCell: (params) => ( { e.stopPropagation(); const newValue = e.target.checked; @@ -95,11 +127,14 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal renderCell: (params) => ( , handler: async (row) => { + if (!loaded) return; + const finishBusy = beginBusy(); try { const response = await api.post('/testEmail', { email: row.email, @@ -111,6 +146,8 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal const errorMsg = error.response?.data?.message || error.message || 'An unknown error occurred'; console.error('Error sending reminder:', error); alert('Failed to send reminder: ' + errorMsg); + } finally { + finishBusy(); } } }, @@ -119,6 +156,8 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal icon: , color: 'error.main', handler: async (row) => { + if (!loaded) return; + const finishBusy = beginBusy(); try { await api.delete('/user', { data: { @@ -126,9 +165,11 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal surveyName: surveyName } }); - params.row.onRespondentDeleted(); + await params.row.onRespondentDeleted(); } catch (error) { console.error('Error deleting respondent:', error); + } finally { + finishBusy(); } } } @@ -141,19 +182,27 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal useEffect(() => { - if (rows) { - const updatedRows = rows.map(row => ({ - ...row, - language: row.language || 'English', - canRespond: row.canRespond === undefined ? true : row.canRespond, - onRespondentDeleted: fetchRespondentData - })); - setTableRows(updatedRows); - setOriginalRows(JSON.parse(JSON.stringify(updatedRows))); + if (!rows) { + // Never retain one survey's local rows while the next survey is loading or + // has failed to load. `loaded` disables controls before this effect runs. + setTableRows([]); + setOriginalRows([]); + setHasChanges(false); + return; } - }, [rows]); + const updatedRows = rows.map(row => ({ + ...row, + language: row.language || 'English', + canRespond: row.canRespond === undefined ? true : row.canRespond, + onRespondentDeleted: fetchRespondentData + })); + setTableRows(updatedRows); + setOriginalRows(JSON.parse(JSON.stringify(updatedRows))); + setHasChanges(false); + }, [rows, surveyName]); const fetchRespondentData = async () => { + if (!loaded) return; try { const response = await api.get(`/targets?surveyName=${surveyName}`); const refreshedRows = response.data.map(row => ({ @@ -164,7 +213,8 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal })); setTableRows(refreshedRows); setOriginalRows(JSON.parse(JSON.stringify(refreshedRows))); - + setHasChanges(false); + const surveysResponse = await api.get('/surveys'); if (onRespondentsUpdate) { onRespondentsUpdate(surveysResponse.data.surveys); @@ -175,7 +225,7 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal }; const handleProcessRowUpdate = (newRow) => { - if (readOnly) return originalRows.find(row => row.id === newRow.id) || newRow; + if (readOnly || !loaded || busy) return tableRows.find(row => row.id === newRow.id) || newRow; const updatedRows = tableRows.map((row) => (row.id === newRow.id ? newRow : row)); setTableRows(updatedRows); @@ -193,6 +243,8 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal }; const handleSave = async () => { + if (!loaded) return; + const finishBusy = beginBusy(); try { const changedRows = tableRows.filter(row => { const original = originalRows.find(origRow => origRow.id === row.id); @@ -224,10 +276,14 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal } } catch (error) { console.error('Failed to save changes:', error); + } finally { + finishBusy(); } }; const handleUpload = async (csvContent) => { + if (!loaded) return; + const finishBusy = beginBusy(); try { const response = await api.post('/updateTargets', { csvData: csvContent, @@ -240,10 +296,13 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal } catch (err) { console.error('Error updating respondents:', err); throw err; + } finally { + finishBusy(); } }; const handleAddRow = () => { + if (!loaded) return; const newId = Math.max(0, ...tableRows.map(row => row.id)) + 1; const newRow = { id: newId, @@ -274,11 +333,12 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal {!readOnly && ( - + {hasChanges && ( @@ -302,7 +363,7 @@ const RespondentTable = ({ rows, surveyName, onRespondentsUpdate, readOnly = fal }} pageSizeOptions={[10, 25, 50, { value: -1, label: 'All' }]} disableSelectionOnClick - processRowUpdate={readOnly ? undefined : handleProcessRowUpdate} + processRowUpdate={readOnly || !loaded || busy ? undefined : handleProcessRowUpdate} components={{ Toolbar: GridToolbar, }} diff --git a/dashboard/src/components/TableActions.test.js b/dashboard/src/components/TableActions.test.js new file mode 100644 index 0000000..6914a99 --- /dev/null +++ b/dashboard/src/components/TableActions.test.js @@ -0,0 +1,180 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import QuestionTable from './QuestionTable'; +import RespondentTable from './RespondentTable'; +import api from '../api/axios'; + +vi.mock('../api/axios', () => ({ + default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() }, +})); +vi.mock('@mui/x-data-grid', () => ({ + GridToolbar: () => null, + DataGrid: ({ rows, columns, processRowUpdate }) => { + const row = rows[0]; + const actions = columns.find((column) => column.field === 'actions'); + return ( +
+ + {row && actions?.renderCell({ row, id: row.id, api: { updateRows: vi.fn() } })} +
+ ); + }, +})); +vi.mock('./AddRowButton', () => ({ default: ({ disabled }) => })); +vi.mock('./TableUploadButton', () => ({ + default: ({ disabled, onUpload }) => , +})); +vi.mock('./TableMenuCell', () => ({ + default: ({ row, disabled, actions }) => ( +
+ {actions.map((action) => ( + + ))} +
+ ), +})); + +const renderWithTheme = (node) => render({node}); + +beforeEach(() => vi.clearAllMocks()); + +test('question drafts disable upload and destructive menu actions', async () => { + renderWithTheme( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Edit row' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled()); + expect(screen.getByRole('button', { name: 'Delete Question' })).toBeDisabled(); + expect(api.delete).not.toHaveBeenCalled(); +}); + +test('respondent drafts disable upload, delete, and reminders so unsaved addresses are never used', async () => { + renderWithTheme( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Edit row' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled()); + expect(screen.getByRole('button', { name: 'Send Reminder' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Delete Respondent' })).toBeDisabled(); + expect(api.post).not.toHaveBeenCalled(); + expect(api.delete).not.toHaveBeenCalled(); +}); + +test('question survey switches clear drafts and cannot save or act until new rows load', async () => { + const view = renderWithTheme( + + ); + await userEvent.click(screen.getByRole('button', { name: 'Edit row' })); + expect(await screen.findByRole('button', { name: 'Save' })).toBeEnabled(); + + view.rerender( + + + + ); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add Row' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Delete Question' })).not.toBeInTheDocument(); + expect(api.post).not.toHaveBeenCalled(); + expect(api.delete).not.toHaveBeenCalled(); + + api.delete.mockResolvedValue({ status: 200 }); + view.rerender( + + + + ); + await userEvent.click(await screen.findByRole('button', { name: 'Delete Question' })); + expect(api.delete).toHaveBeenCalledWith('/question', { + data: { questionName: 'new_question', surveyName: 'new-survey' }, + }); +}); + +test('respondent survey switches clear drafts and quarantine actions through load failure', async () => { + const view = renderWithTheme( + + ); + await userEvent.click(screen.getByRole('button', { name: 'Edit row' })); + expect(await screen.findByRole('button', { name: 'Save' })).toBeEnabled(); + + view.rerender( + + + + ); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add Row' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Send Reminder' })).not.toBeInTheDocument(); + expect(api.post).not.toHaveBeenCalled(); + + api.post.mockResolvedValue({ data: { message: 'sent' } }); + const alert = vi.spyOn(window, 'alert').mockImplementation(() => {}); + view.rerender( + + + + ); + await userEvent.click(await screen.findByRole('button', { name: 'Send Reminder' })); + expect(api.post).toHaveBeenCalledWith('/testEmail', { + email: 'new@example.com', + surveyName: 'new-survey', + language: 'Spanish', + }); + expect(api.post).not.toHaveBeenCalledWith('/testEmail', expect.objectContaining({ + email: 'old@example.com', + })); + alert.mockRestore(); +}); diff --git a/dashboard/src/components/TableMenuCell.js b/dashboard/src/components/TableMenuCell.js index 1226cef..ef015bd 100644 --- a/dashboard/src/components/TableMenuCell.js +++ b/dashboard/src/components/TableMenuCell.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { IconButton, Menu, MenuItem } from '@mui/material'; import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -6,10 +6,14 @@ import EditIcon from '@mui/icons-material/Edit'; import SendIcon from '@mui/icons-material/Send'; import EmailIcon from '@mui/icons-material/Email'; -const TableMenuCell = ({ row, actions }) => { +const TableMenuCell = ({ row, actions, disabled = false }) => { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); - + + useEffect(() => { + if (disabled) setAnchorEl(null); + }, [disabled]); + const handleClick = (event) => { event.stopPropagation(); setAnchorEl(event.currentTarget); @@ -24,6 +28,7 @@ const TableMenuCell = ({ row, actions }) => { const handleAction = (action, event) => { event.stopPropagation(); + if (disabled) return; action.handler(row); handleClose(); }; @@ -33,6 +38,7 @@ const TableMenuCell = ({ row, actions }) => { { {actions.map((action) => ( handleAction(action, e)} sx={action.color ? { color: action.color } : {}} > diff --git a/dashboard/src/components/TableUploadButton.js b/dashboard/src/components/TableUploadButton.js index b0956ec..e559016 100644 --- a/dashboard/src/components/TableUploadButton.js +++ b/dashboard/src/components/TableUploadButton.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Box, Button, @@ -6,15 +6,21 @@ import { import UploadFileIcon from '@mui/icons-material/UploadFile'; import DownloadIcon from '@mui/icons-material/Download'; -const TableUploadButton = ({ onUpload, templateData, tableName }) => { +const TableUploadButton = ({ onUpload, templateData, tableName, disabled = false }) => { + const disabledRef = useRef(disabled); + disabledRef.current = disabled; const handleFileUpload = async (event) => { + if (disabledRef.current) return; const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (e) => { try { + // A draft may be created while FileReader is still loading. Re-check the + // latest prop so that delayed upload cannot overwrite those edits. + if (disabledRef.current) return; const csv = e.target?.result; await onUpload(csv); } catch (error) { @@ -54,11 +60,13 @@ const TableUploadButton = ({ onUpload, templateData, tableName }) => { component="label" startIcon={} size="small" + disabled={disabled} > Upload diff --git a/db/changelogs/v1_5_editable_survey_content.sql b/db/changelogs/v1_5_editable_survey_content.sql index 30ef80d..aa38619 100644 --- a/db/changelogs/v1_5_editable_survey_content.sql +++ b/db/changelogs/v1_5_editable_survey_content.sql @@ -21,3 +21,31 @@ WHERE subject IS NULL; -- The legacy column was length-limited; editable templates use a bounded API -- limit while the database safely stores existing and future content. ALTER TABLE EMAIL ALTER COLUMN text TYPE TEXT; + +--changeset cladvisors:editable-survey-invitation-delivery-1 +--comment Add durable, idempotent invitation delivery claims without changing the published editable-content changeset. +-- Durable invitation claims replace the former email_sent preclaim. A worker owns +-- a recipient only while this UUID matches. Abandoned claims are automatically +-- retried after 30 minutes only while still inside Resend's idempotency retention. +-- invitation_first_attempted_at is set once immediately before provider I/O, so +-- repeated ambiguous failures cannot move the 23-hour quarantine horizon. +ALTER TABLE Respondent + ADD COLUMN IF NOT EXISTS invitation_claim_token UUID, + ADD COLUMN IF NOT EXISTS invitation_claimed_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS invitation_first_attempted_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS invitation_delivery_id UUID; + +-- This UUID is the durable provider idempotency identity for one invitation +-- lifecycle. It survives claim expiry/process crashes and is rotated by recipient +-- upserts when delivery-relevant data changes. +UPDATE Respondent +SET invitation_delivery_id = gen_random_uuid() +WHERE invitation_delivery_id IS NULL; + +ALTER TABLE Respondent + ALTER COLUMN invitation_delivery_id SET DEFAULT gen_random_uuid(), + ALTER COLUMN invitation_delivery_id SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_respondent_pending_invitation_claim + ON Respondent (survey_id, survey_name, invitation_claimed_at) + WHERE email_sent IS NOT TRUE; diff --git a/package-lock.json b/package-lock.json index f9ea52b..9fe9cd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "network-survey", + "name": "editable-survey-content", "lockfileVersion": 2, "requires": true, "packages": { @@ -18,7 +18,7 @@ "@mui/x-data-grid": "^7.23.1", "dotenv-flow": "^4.1.0", "pg": "^8.11.0", - "resend": "^0.16.0" + "resend": "^6.18.1" }, "devDependencies": { "tree-kill": "^1.2.2" @@ -40,8 +40,9 @@ "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", @@ -77,7 +78,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": { @@ -2098,6 +2101,8 @@ "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, "dependencies": { "html-to-text": "9.0.3", "pretty": "2.0.0", @@ -2476,6 +2481,8 @@ "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, "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.10.0" @@ -2490,6 +2497,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", @@ -3862,16 +3875,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -4604,7 +4607,9 @@ "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "optional": true, + "peer": true }, "node_modules/component-emitter": { "version": "1.3.1", @@ -4626,6 +4631,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, "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", @@ -4639,6 +4646,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, "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" @@ -5346,6 +5355,8 @@ "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==", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5483,6 +5494,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, "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -5501,7 +5514,9 @@ "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "optional": true, + "peer": true }, "node_modules/domexception": { "version": "2.0.1", @@ -5535,6 +5550,8 @@ "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, "dependencies": { "domelementtype": "^2.3.0" }, @@ -5549,6 +5566,8 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "optional": true, + "peer": true, "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -5609,6 +5628,8 @@ "version": "0.15.3", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz", "integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==", + "optional": true, + "peer": true, "dependencies": { "commander": "^2.19.0", "lru-cache": "^4.1.5", @@ -5680,6 +5701,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "devOptional": true, "engines": { "node": ">=0.12" }, @@ -6100,6 +6122,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, "dependencies": { "is-extendable": "^0.1.0" }, @@ -6126,6 +6150,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", @@ -6407,6 +6437,8 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "optional": true, + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -6438,6 +6470,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -6446,6 +6480,8 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -6591,6 +6627,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, "dependencies": { "@selderee/plugin-htmlparser2": "^0.10.0", "deepmerge": "^4.2.2", @@ -6613,6 +6651,8 @@ "url": "https://github.com/sponsors/fb55" } ], + "optional": true, + "peer": true, "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", @@ -6925,7 +6965,9 @@ "node_modules/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 }, "node_modules/is-callable": { "version": "1.2.7", @@ -6974,6 +7016,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7184,6 +7228,8 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz", "integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7440,6 +7486,8 @@ "version": "1.14.8", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.8.tgz", "integrity": "sha512-4S7HFeI9YfRvRgKnEweohs0tgJj28InHVIj4Nl8Htf96Y6pHg3+tJrmo4ucAM9f7l4SHbFI3IvFAZ2a1eQPbyg==", + "optional": true, + "peer": true, "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^0.15.3", @@ -7459,6 +7507,8 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "optional": true, + "peer": true, "dependencies": { "abbrev": "^1.0.0" }, @@ -7607,6 +7657,8 @@ "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, "dependencies": { "is-buffer": "^1.1.5" }, @@ -7618,6 +7670,8 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "optional": true, + "peer": true, "funding": { "url": "https://ko-fi.com/killymxi" } @@ -9050,6 +9104,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, "dependencies": { "leac": "^0.6.0", "peberminta": "^0.8.0" @@ -9116,6 +9172,8 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.8.0.tgz", "integrity": "sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==", + "optional": true, + "peer": true, "funding": { "url": "https://ko-fi.com/killymxi" } @@ -9232,6 +9290,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", @@ -9341,6 +9405,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, "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", @@ -9431,7 +9497,9 @@ "node_modules/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 }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -9975,12 +10043,24 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" }, "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/resolve": { @@ -10197,6 +10277,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, "dependencies": { "parseley": "^0.11.0" }, @@ -10208,6 +10290,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "devOptional": true, "bin": { "semver": "bin/semver" } @@ -10392,7 +10475,9 @@ "node_modules/sigmund": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", - "integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==" + "integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==", + "optional": true, + "peer": true }, "node_modules/signal-exit": { "version": "3.0.7", @@ -10658,6 +10743,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", @@ -13308,7 +13403,8 @@ } }, "@network-survey/frontend-shared": { - "version": "file:frontend-shared" + "version": "file:frontend-shared", + "requires": {} }, "@noble/hashes": { "version": "1.8.0", @@ -13362,6 +13458,8 @@ "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", @@ -13545,6 +13643,8 @@ "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" @@ -13555,6 +13655,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", @@ -14380,9 +14485,10 @@ "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" } }, "aproba": { @@ -14451,16 +14557,6 @@ "possible-typed-array-names": "^1.0.0" } }, - "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" - } - }, "babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -14926,7 +15022,9 @@ "commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "optional": true, + "peer": true }, "component-emitter": { "version": "1.3.1", @@ -14943,6 +15041,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", @@ -14953,6 +15053,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" @@ -15435,7 +15537,9 @@ "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 }, "define-data-property": { "version": "1.1.4", @@ -15528,6 +15632,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", @@ -15537,7 +15643,9 @@ "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 }, "domexception": { "version": "2.0.1", @@ -15564,6 +15672,8 @@ "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" } @@ -15572,6 +15682,8 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "optional": true, + "peer": true, "requires": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -15615,6 +15727,8 @@ "version": "0.15.3", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz", "integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==", + "optional": true, + "peer": true, "requires": { "commander": "^2.19.0", "lru-cache": "^4.1.5", @@ -15663,7 +15777,8 @@ "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==", + "devOptional": true }, "env-paths": { "version": "2.2.1", @@ -15960,6 +16075,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" } @@ -15980,6 +16097,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", @@ -16169,6 +16291,8 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "optional": true, + "peer": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -16181,6 +16305,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "optional": true, + "peer": true, "requires": { "balanced-match": "^1.0.0" } @@ -16189,6 +16315,8 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "optional": true, + "peer": true, "requires": { "brace-expansion": "^2.0.1" } @@ -16293,6 +16421,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", @@ -16305,6 +16435,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", @@ -16514,7 +16646,9 @@ "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-callable": { "version": "1.2.7", @@ -16541,7 +16675,9 @@ "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", @@ -16660,7 +16796,9 @@ "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 }, "isarray": { "version": "2.0.5", @@ -16841,6 +16979,8 @@ "version": "1.14.8", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.8.tgz", "integrity": "sha512-4S7HFeI9YfRvRgKnEweohs0tgJj28InHVIj4Nl8Htf96Y6pHg3+tJrmo4ucAM9f7l4SHbFI3IvFAZ2a1eQPbyg==", + "optional": true, + "peer": true, "requires": { "config-chain": "^1.1.13", "editorconfig": "^0.15.3", @@ -16852,6 +16992,8 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "optional": true, + "peer": true, "requires": { "abbrev": "^1.0.0" } @@ -16960,6 +17102,8 @@ "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" } @@ -16967,7 +17111,9 @@ "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 }, "lines-and-columns": { "version": "1.2.4", @@ -17530,7 +17676,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", @@ -18114,6 +18262,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" @@ -18159,7 +18309,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.22.0", @@ -18236,6 +18388,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", @@ -18297,6 +18454,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", @@ -18366,7 +18525,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", @@ -18753,12 +18914,12 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" }, "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" } }, "resolve": { @@ -18901,6 +19062,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" } @@ -18908,7 +19071,8 @@ "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "devOptional": true }, "send": { "version": "0.19.2", @@ -19045,7 +19209,9 @@ "sigmund": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", - "integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==" + "integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==", + "optional": true, + "peer": true }, "signal-exit": { "version": "3.0.7", @@ -19215,6 +19381,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", diff --git a/package.json b/package.json index d35b28b..a93d459 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,6 @@ "@mui/x-data-grid": "^7.23.1", "dotenv-flow": "^4.1.0", "pg": "^8.11.0", - "resend": "^0.16.0" + "resend": "^6.18.1" } } From 725616bc943ccf5515eef3097b3a6d349915dd98 Mon Sep 17 00:00:00 2001 From: Bennett Garcia Date: Tue, 4 Aug 2026 13:53:48 -0400 Subject: [PATCH 4/4] Fix platform admin survey creation --- .../src/components/CreateSurveyDialog.js | 5 +++- .../src/components/CreateSurveyDialog.test.js | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/components/CreateSurveyDialog.test.js diff --git a/dashboard/src/components/CreateSurveyDialog.js b/dashboard/src/components/CreateSurveyDialog.js index f05a1d3..442cc65 100644 --- a/dashboard/src/components/CreateSurveyDialog.js +++ b/dashboard/src/components/CreateSurveyDialog.js @@ -30,7 +30,10 @@ const CreateSurveyDialog = ({ open, onClose, onSubmit, memberships = [] }) => { setError('Choose an organization for this survey'); return; } - onSubmit(surveyName, organizationId || undefined); + const resolvedOrganizationId = organizationId || ( + creatableMemberships.length === 1 ? creatableMemberships[0].organizationId : undefined + ); + onSubmit(surveyName, resolvedOrganizationId); setSurveyName(''); setOrganizationId(''); setError(''); diff --git a/dashboard/src/components/CreateSurveyDialog.test.js b/dashboard/src/components/CreateSurveyDialog.test.js new file mode 100644 index 0000000..3f99e3a --- /dev/null +++ b/dashboard/src/components/CreateSurveyDialog.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import CreateSurveyDialog from './CreateSurveyDialog'; + +const renderDialog = (props) => render( + + + +); + +test('submits the sole creatable organization for platform-admin survey creation', async () => { + const onSubmit = vi.fn(); + renderDialog({ + onSubmit, + memberships: [{ + organizationId: 'default-imported', + organizationName: 'Default Imported', + role: 'owner', + }], + }); + + await userEvent.type(screen.getByLabelText('Survey Name'), 'QAEditableContent'); + await userEvent.click(screen.getByRole('button', { name: 'Create' })); + + expect(onSubmit).toHaveBeenCalledWith('QAEditableContent', 'default-imported'); +});