Fixed onboardingdraft for School/Program. Added pointer removal in App.js after navigate. Altered server3 draft body checks.

This commit is contained in:
Josh 2025-09-11 17:02:13 +00:00
parent 4a2aaedf63
commit a2a2d9b558
5 changed files with 160 additions and 140 deletions

View File

@ -1 +1 @@
720d57c0d6d787c629f53d47689088a50b9e9b5b-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b 90af14136b0b935418ae62167703d1dcbcb7b3ce-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -30,3 +30,10 @@ coverage
*.crt *.crt
*.pfx *.pfx
# Test sources & artifacts
tests/
playwright-report/
test-results/
blob-report/
*.trace.zip

View File

@ -580,143 +580,9 @@ async function storeRiskAnalysisInDB({
} }
const COOKIE_NAME = process.env.COOKIE_NAME || 'aptiva_session'; 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( app.post(
'/api/premium/stripe/webhook', '/api/premium/stripe/webhook',
express.raw({ type: 'application/json' }), express.raw({ type: 'application/json' }),
@ -808,14 +674,153 @@ app.post(
} }
); );
// 2) Basic middlewares // 2) Basic middlewares
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false })); app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
//leave below Stripe webhook //leave below Stripe webhook
app.use(express.json({ limit: '5mb' })); 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 ─────────────────────────────── */ /* ─── Require critical env vars ─────────────────────────────── */
if (!process.env.CORS_ALLOWED_ORIGINS) { if (!process.env.CORS_ALLOWED_ORIGINS) {
console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); console.error('FATAL CORS_ALLOWED_ORIGINS is not set');

View File

@ -120,6 +120,14 @@ useEffect(() => {
if (!ok) { if (!ok) {
// bounce back to where the user was // bounce back to where the user was
navigate(prevPathRef.current, { replace: true }); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]); }, [location.pathname]);

View File

@ -188,7 +188,7 @@ const handleSelectSchool = async (school) => {
// 1) normalize college fields // 1) normalize college fields
const selected_school = school?.INSTNM || ''; const selected_school = school?.INSTNM || '';
const selected_program = (school?.CIPDESC || '').replace(/\.\s*$/, ''); const selected_program = (school?.CIPDESC || '');
const program_type = school?.CREDDESC || ''; const program_type = school?.CREDDESC || '';
const unit_id = school?.UNITID || ''; const unit_id = school?.UNITID || '';