diff --git a/.build.hash b/.build.hash index 2d5e863..737d2bc 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -720d57c0d6d787c629f53d47689088a50b9e9b5b-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b +90af14136b0b935418ae62167703d1dcbcb7b3ce-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/.dockerignore b/.dockerignore index 200191e..2e2c4da 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,3 +30,10 @@ coverage *.crt *.pfx +# Test sources & artifacts +tests/ +playwright-report/ +test-results/ +blob-report/ +*.trace.zip + diff --git a/backend/server3.js b/backend/server3.js index d0b97e8..6903bcf 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -580,143 +580,9 @@ async function storeRiskAnalysisInDB({ } const COOKIE_NAME = process.env.COOKIE_NAME || 'aptiva_session'; -//*PremiumOnboarding draft -// GET current user's draft -app.get('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { - const [[row]] = await pool.query( - `SELECT id, step, data - FROM onboarding_drafts - WHERE user_id=? - ORDER BY updated_at DESC, id DESC - LIMIT 1`, - [req.id] - ); - return res.json(row || null); -}); - -// POST upsert draft (ID-agnostic, partial merge, 1 draft per user) -app.post('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { - try { - // ---- 0) Harden req.body and incoming shapes - const body = (req && typeof req.body === 'object' && req.body) ? req.body : {}; - let { id, step } = body; - - // Accept either {data:{careerData/financialData/collegeData}} or section keys at top level - let incoming = {}; - if (body.data != null) { - if (typeof body.data === 'string') { - try { incoming = JSON.parse(body.data); } catch { incoming = {}; } - } else if (typeof body.data === 'object') { - incoming = body.data; - } - } - // If callers provided sections directly (EducationalProgramsPage), - // lift them into the data envelope without crashing if body is blank. - ['careerData','financialData','collegeData'].forEach(k => { - if (Object.prototype.hasOwnProperty.call(body, k)) { - if (!incoming || typeof incoming !== 'object') incoming = {}; - incoming[k] = body[k]; - } - }); - - // ---- 1) Base draft: by id (if provided) else latest for this user - let base = null; - if (id) { - const [[row]] = await pool.query( - `SELECT id, step, data FROM onboarding_drafts WHERE user_id=? AND id=? LIMIT 1`, - [req.id, id] - ); - base = row || null; - } else { - const [[row]] = await pool.query( - `SELECT id, step, data - FROM onboarding_drafts - WHERE user_id=? - ORDER BY updated_at DESC, id DESC - LIMIT 1`, - [req.id] - ); - base = row || null; - } - - // ---- 2) Parse prior JSON safely - let prev = {}; - if (base?.data != null) { - try { - if (typeof base.data === 'string') prev = JSON.parse(base.data); - else if (Buffer.isBuffer(base.data)) prev = JSON.parse(base.data.toString('utf8')); - else if (typeof base.data === 'object') prev = base.data; - } catch { prev = {}; } - } - - // ---- 3) Section-wise shallow merge (prev + incoming) - const merged = mergeDraft(prev, (incoming && typeof incoming === 'object') ? incoming : {}); - - // ---- 3.5) Refuse empty drafts so we don't wipe real data - const isEmptyObject = (o) => o && typeof o === 'object' && !Array.isArray(o) && Object.keys(o).length === 0; - const allSubsectionsEmpty = - isEmptyObject(merged) || - ( - typeof merged === 'object' && - ['careerData','financialData','collegeData'].every(k => !merged[k] || isEmptyObject(merged[k])) - ); - if (allSubsectionsEmpty) { - return res.status(400).json({ error: 'empty_draft' }); - } - - console.log('[draft-upsert]', { - userId : req.id, - draftId : draftId, - step : finalStep, - incoming : Object.keys(incoming || {}).sort(), - mergedKeys: Object.keys(merged || {}).sort(), - }); - - // ---- 4) Final id/step and upsert - const draftId = base?.id || id || uuidv4(); - const finalStep = Number.isInteger(step) ? step : (parseInt(step,10) || base?.step || 0); - - await pool.query( - `INSERT INTO onboarding_drafts (user_id, id, step, data) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - step = VALUES(step), - data = VALUES(data), - updated_at = CURRENT_TIMESTAMP`, - [req.id, draftId, finalStep, JSON.stringify(merged)] - ); - - return res.json({ id: draftId, step: finalStep }); - } catch (e) { - console.error('draft upsert failed:', e?.message || e); - return res.status(500).json({ error: 'draft_upsert_failed' }); - } -}); - -// unchanged -function mergeDraft(a = {}, b = {}) { - const out = { ...a }; - for (const k of Object.keys(b || {})) { - const left = a[k]; - const right = b[k]; - if ( - left && typeof left === 'object' && !Array.isArray(left) && - right && typeof right === 'object' && !Array.isArray(right) - ) { - out[k] = { ...left, ...right }; - } else { - out[k] = right; - } - } - return out; -} - -// DELETE draft (after finishing / cancelling) -app.delete('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { - await pool.query('DELETE FROM onboarding_drafts WHERE user_id=?', [req.id]); - res.json({ ok: true }); -}); +//Stripe webhook endpoint (raw body) +// 1) Raw body parser (must be before express.json) app.post( '/api/premium/stripe/webhook', express.raw({ type: 'application/json' }), @@ -808,14 +674,153 @@ app.post( } ); - - // 2) Basic middlewares app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false })); //leave below Stripe webhook app.use(express.json({ limit: '5mb' })); + +//*PremiumOnboarding draft +// GET current user's draft +app.get('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { + const [[row]] = await pool.query( + `SELECT id, step, data + FROM onboarding_drafts + WHERE user_id=? + ORDER BY updated_at DESC, id DESC + LIMIT 1`, + [req.id] + ); + return res.json(row || null); +}); + +// POST upsert draft (ID-agnostic, partial merge, 1 draft per user) +app.post('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { + try { + + // ---- 0) Harden req.body and incoming shapes (accept object *or* JSON string) + let body = {}; + if (req && req.body != null) { + if (typeof req.body === 'object') { + body = req.body; + } else if (typeof req.body === 'string') { + try { body = JSON.parse(req.body); } catch { body = {}; } + } + } + let { id, step } = body; + + // Accept either {data:{careerData/financialData/collegeData}} or section keys at top level + let incoming = {}; + if (body.data != null) { + if (typeof body.data === 'string') { + try { incoming = JSON.parse(body.data); } catch { incoming = {}; } + } else if (typeof body.data === 'object') { + incoming = body.data; + } + } + // If callers provided sections directly (EducationalProgramsPage), + // lift them into the data envelope without crashing if body is blank. + ['careerData','financialData','collegeData'].forEach(k => { + if (Object.prototype.hasOwnProperty.call(body, k)) { + if (!incoming || typeof incoming !== 'object') incoming = {}; + incoming[k] = body[k]; + } + }); + + // ---- 1) Base draft: by id (if provided) else latest for this user + let base = null; + if (id) { + const [[row]] = await pool.query( + `SELECT id, step, data FROM onboarding_drafts WHERE user_id=? AND id=? LIMIT 1`, + [req.id, id] + ); + base = row || null; + } else { + const [[row]] = await pool.query( + `SELECT id, step, data + FROM onboarding_drafts + WHERE user_id=? + ORDER BY updated_at DESC, id DESC + LIMIT 1`, + [req.id] + ); + base = row || null; + } + + // ---- 2) Parse prior JSON safely + let prev = {}; + if (base?.data != null) { + try { + if (typeof base.data === 'string') prev = JSON.parse(base.data); + else if (Buffer.isBuffer(base.data)) prev = JSON.parse(base.data.toString('utf8')); + else if (typeof base.data === 'object') prev = base.data; + } catch { prev = {}; } + } + + // ---- 3) Section-wise shallow merge (prev + incoming) + const merged = mergeDraft(prev, (incoming && typeof incoming === 'object') ? incoming : {}); + + // ---- 3.5) Only reject when there's truly no incoming content AND no prior draft + const isPlainObj = (o) => o && typeof o === 'object' && !Array.isArray(o); + const isEmptyObj = (o) => isPlainObj(o) && Object.keys(o).length === 0; + const hasIncoming = isPlainObj(incoming) && !isEmptyObj(incoming); + const hasPrior = !!base && isPlainObj(prev) && !isEmptyObj(prev); + if (!hasIncoming && !hasPrior) { + return res.status(400).json({ error: 'empty_draft' }); + } + + // ---- 4) Final id/step and upsert + const draftId = base?.id || id || uuidv4(); + const finalStep = Number.isInteger(step) ? step : (parseInt(step,10) || base?.step || 0); + console.log('[draft-upsert]', { + userId : req.id, + draftId : draftId, + step : finalStep, + incoming : Object.keys(incoming || {}).sort(), + mergedKeys: Object.keys(merged || {}).sort(), + }); + await pool.query( + `INSERT INTO onboarding_drafts (user_id, id, step, data) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + step = VALUES(step), + data = VALUES(data), + updated_at = CURRENT_TIMESTAMP`, + [req.id, draftId, finalStep, JSON.stringify(merged)] + ); + + return res.json({ id: draftId, step: finalStep }); + } catch (e) { + console.error('draft upsert failed:', e?.message || e); + return res.status(500).json({ error: 'draft_upsert_failed' }); + } +}); + +// unchanged +function mergeDraft(a = {}, b = {}) { + const out = { ...a }; + for (const k of Object.keys(b || {})) { + const left = a[k]; + const right = b[k]; + if ( + left && typeof left === 'object' && !Array.isArray(left) && + right && typeof right === 'object' && !Array.isArray(right) + ) { + out[k] = { ...left, ...right }; + } else { + out[k] = right; + } + } + return out; +} + +// DELETE draft (after finishing / cancelling) +app.delete('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { + await pool.query('DELETE FROM onboarding_drafts WHERE user_id=?', [req.id]); + res.json({ ok: true }); +}); + /* ─── Require critical env vars ─────────────────────────────── */ if (!process.env.CORS_ALLOWED_ORIGINS) { console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); diff --git a/src/App.js b/src/App.js index ff52db3..eb47c66 100644 --- a/src/App.js +++ b/src/App.js @@ -120,6 +120,14 @@ useEffect(() => { if (!ok) { // bounce back to where the user was navigate(prevPathRef.current, { replace: true }); + } else { + // user chose to leave: clear client pointer and server draft immediately + try { + safeLocal.clearMany(['premiumOnboardingPointer', 'premiumOnboardingState']); + } catch {} + (async () => { + try { await api.delete('/api/premium/onboarding/draft'); } catch {} + })(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.pathname]); diff --git a/src/components/EducationalProgramsPage.js b/src/components/EducationalProgramsPage.js index 5926687..1f65b98 100644 --- a/src/components/EducationalProgramsPage.js +++ b/src/components/EducationalProgramsPage.js @@ -188,7 +188,7 @@ const handleSelectSchool = async (school) => { // 1) normalize college fields const selected_school = school?.INSTNM || ''; - const selected_program = (school?.CIPDESC || '').replace(/\.\s*$/, ''); + const selected_program = (school?.CIPDESC || ''); const program_type = school?.CREDDESC || ''; const unit_id = school?.UNITID || '';