Compare commits

...

3 Commits

Author SHA1 Message Date
ae46f4ad0a ci: trigger
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-08-19 17:40:05 +00:00
1a1b7ea7bc Woodpecker updates for removal of env file 2025-08-19 17:26:22 +00:00
7d27dc160c final fixes before rotating secrets: college profile ciphertext, selectedCareer/premiumonboardingstate carry into CareerRoadmap, etc. 2025-08-19 17:01:31 +00:00
7 changed files with 204 additions and 62 deletions

View File

@ -1 +1 @@
fcb1ff42e88c57ae313a74da813f6a3cdb19904f-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b 2aa2de355546f6401f80a07e00066bd83f37fdc6-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -29,6 +29,7 @@ steps:
- name: staging-deploy - name: staging-deploy
depends_on: [security-scan]
image: google/cloud-sdk:latest image: google/cloud-sdk:latest
entrypoint: entrypoint:
- bash - bash
@ -112,6 +113,19 @@ steps:
export SUPPORT_SENDGRID_API_KEY; \ export SUPPORT_SENDGRID_API_KEY; \
GOOGLE_MAPS_API_KEY=$(gcloud secrets versions access latest --secret=GOOGLE_MAPS_API_KEY_$ENV --project=$PROJECT); \ GOOGLE_MAPS_API_KEY=$(gcloud secrets versions access latest --secret=GOOGLE_MAPS_API_KEY_$ENV --project=$PROJECT); \
export GOOGLE_MAPS_API_KEY; \ export GOOGLE_MAPS_API_KEY; \
SERVER1_PORT=$(gcloud secrets versions access latest --secret=SERVER1_PORT_$ENV --project=$PROJECT); \
export SERVER1_PORT
SERVER2_PORT=$(gcloud secrets versions access latest --secret=SERVER2_PORT_$ENV --project=$PROJECT); \
export SERVER2_PORT
SERVER3_PORT=$(gcloud secrets versions access latest --secret=SERVER3_PORT_$ENV --project=$PROJECT); \
export SERVER3_PORT
ENV_NAME=$(gcloud secrets versions access latest --secret=ENV_NAME_$ENV --project=$PROJECT); \
export ENV_NAME
CORS_ALLOWED_ORIGINS=$(gcloud secrets versions access latest --secret=CORS_ALLOWED_ORIGINS_$ENV --project=$PROJECT); \
export CORS_ALLOWED_ORIGINS
APTIVA_API_BASE=$(gcloud secrets versions access latest --secret=APTIVA_API_BASE_$ENV --project=$PROJECT); \
export APTIVA_API_BASE
export FROM_SECRETS_MANAGER=true; \ export FROM_SECRETS_MANAGER=true; \
\ \
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \ # ── DEK sync: copy dev wrapped DEK into staging volume path ── \
@ -129,9 +143,9 @@ steps:
fi; \ fi; \
\ \
cd /home/jcoakley/aptiva-staging-app; \ cd /home/jcoakley/aptiva-staging-app; \
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,GOOGLE_MAPS_API_KEY \ sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,GOOGLE_MAPS_API_KEY,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE \
docker compose pull; \ docker compose pull; \
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,GOOGLE_MAPS_API_KEY \ sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,GOOGLE_MAPS_API_KEY,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE \
docker compose up -d --force-recreate --remove-orphans; \ docker compose up -d --force-recreate --remove-orphans; \
echo "✅ Staging stack refreshed with tag $IMG_TAG"' echo "✅ Staging stack refreshed with tag $IMG_TAG"'
@ -142,3 +156,6 @@ steps:
when: when:
event: event:
- push - push
branch:
- master
- dev-master

View File

@ -17,6 +17,7 @@ import { v4 as uuidv4 } from 'uuid';
import pkg from 'pdfjs-dist'; import pkg from 'pdfjs-dist';
import pool from './config/mysqlPool.js'; import pool from './config/mysqlPool.js';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { decrypt } from './shared/crypto/encryption.js'
import OpenAI from 'openai'; import OpenAI from 'openai';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
@ -3029,8 +3030,18 @@ app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req,
WHERE cp.user_id = ? WHERE cp.user_id = ?
ORDER BY cp.created_at DESC ORDER BY cp.created_at DESC
`; `;
const [rows] = await pool.query(sql,[req.id]); const [rows] = await pool.query(sql, [req.id]);
res.json({ collegeProfiles: rows }); const decrypted = rows.map(r => {
const row = { ...r };
for (const k of ['career_title', 'selected_school', 'selected_program']) {
const v = row[k];
if (typeof v === 'string' && v.startsWith('gcm:')) {
try { row[k] = decrypt(v); } catch {} // best-effort
}
}
return row;
});
res.json({ collegeProfiles: decrypted });
}); });
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------

View File

@ -126,41 +126,42 @@ export default function CareerCoach({
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight; if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
}, [messages]); }, [messages]);
// chat history persistence
useEffect(() => {
const saved = localStorage.getItem('coachChat:'+careerProfileId);
if (saved) setMessages(JSON.parse(saved));
}, [careerProfileId]);
useEffect(() => {
localStorage.setItem('coachChat:'+careerProfileId, JSON.stringify(messages.slice(-20)));
}, [messages, careerProfileId]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!careerProfileId) return; if (!careerProfileId) return;
// try to reuse the newest coach thread; create one named after the scenario
const r = await authFetch('/api/premium/coach/chat/threads'); // list threads for this profile
const r = await authFetch(
`/api/premium/coach/chat/threads?careerProfileId=${encodeURIComponent(careerProfileId)}`
);
if (!(r.ok && (r.headers.get('content-type') || '').includes('application/json'))) {
setThreadId(null); // coach offline; no network errors on mount
return;
}
const { threads = [] } = await r.json(); const { threads = [] } = await r.json();
const existing = threads.find(Boolean); const existing = threads.find(Boolean);
let id = existing?.id; if (!existing?.id) {
if (!id) { setThreadId(null); // no thread yet; lazy-create on first send
const r2 = await authFetch('/api/premium/coach/chat/threads', { return;
method:'POST',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ title: (scenarioRow?.scenario_title || scenarioRow?.career_name || 'Coach chat') })
});
({ id } = await r2.json());
} }
const id = existing.id;
setThreadId(id); setThreadId(id);
// preload history // preload history
const r3 = await authFetch(`/api/premium/coach/chat/threads/${id}`); const r3 = await authFetch(
const { messages: msgs = [] } = await r3.json(); `/api/premium/coach/chat/threads/${id}?careerProfileId=${encodeURIComponent(careerProfileId)}`
setMessages(msgs); );
if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) {
const { messages: msgs = [] } = await r3.json();
setMessages(msgs);
}
})(); })();
}, [careerProfileId]); }, [careerProfileId]);
/* -------------- intro ---------------- */ /* -------------- intro ---------------- */
useEffect(() => { useEffect(() => {
if (!scenarioRow) return; if (!scenarioRow) return;
@ -234,14 +235,18 @@ I'm here to support you with personalized coaching. What would you like to focus
async function callAi(updatedHistory, opts = {}) { async function callAi(updatedHistory, opts = {}) {
setLoading(true); setLoading(true);
try { try {
if (!threadId) throw new Error('thread not ready');
const context = { userProfile, financialProfile, scenarioRow, collegeProfile }; const context = { userProfile, financialProfile, scenarioRow, collegeProfile };
const r = await authFetch(`/api/premium/coach/chat/threads/${threadId}/messages`, { const r = await authFetch(`/api/premium/coach/chat/threads/${threadId}/messages`, {
method:'POST', method:'POST',
headers:{ 'Content-Type':'application/json' }, headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ content: updatedHistory.at(-1)?.content || '', context }) body: JSON.stringify({ content: updatedHistory.at(-1)?.content || '', context })
}); });
const data = await r.json(); let reply = 'Sorry, something went wrong.';
const reply = (data?.reply || '').trim() || 'Sorry, something went wrong.'; if (r.ok && (r.headers.get('content-type')||'').includes('application/json')) {
const data = await r.json();
reply = (data?.reply || '').trim() || reply;
}
setMessages(prev => [...prev, { role:'assistant', content: reply }]); setMessages(prev => [...prev, { role:'assistant', content: reply }]);
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@ -84,7 +84,10 @@ export default function CareerProfileForm() {
}) })
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
nav(-1); const data = await res.json(); // { career_profile_id: '...' }
const activeId = data.career_profile_id || id; // handle edit vs new
localStorage.setItem('lastSelectedCareerProfileId', activeId);
nav(`/career-roadmap/${activeId}`, { replace: true }); // guarantees Roadmap selects this one
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert(err.message); alert(err.message);

View File

@ -395,7 +395,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const chat = useContext(ChatCtx) || {}; const chat = useContext(ChatCtx) || {};
const setChatSnapshot = chat?.setChatSnapshot; const setChatSnapshot = chat?.setChatSnapshot;
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null; const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null; // unchanged; {} is truthy
const reloadScenarioAndCollege = useCallback(async () => { const reloadScenarioAndCollege = useCallback(async () => {
if (!careerProfileId) return; if (!careerProfileId) return;
@ -409,10 +409,24 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
setScenarioRow(row); setScenarioRow(row);
} }
const c = await authFetch( const c = await authFetch(`/api/premium/college-profile?careerProfileId=${encodeURIComponent(careerProfileId)}`);
`/api/premium/college-profile?careerProfileId=${careerProfileId}` if (c.status === 404) {
); setCollegeProfile({}); // no profile yet → use defaults
if (c.ok) setCollegeProfile(await c.json()); return;
}
if (c.ok) {
const ct = c.headers.get('content-type') || '';
if (ct.includes('application/json')) {
setCollegeProfile(await c.json());
} else {
// defensive: upstream returned HTML (e.g., 502 body with wrong status)
setCollegeProfile({});
}
return;
}
// non-OK (e.g., 502)
console.error('college-profile fetch failed', c.status);
setCollegeProfile({});
}, [careerProfileId]); }, [careerProfileId]);
const milestoneGroups = useMemo(() => { const milestoneGroups = useMemo(() => {
@ -514,15 +528,16 @@ useEffect(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const up = await authFetch('/api/user-profile'); const up = await authFetch('/api/user-profile');
if (up.ok) setUserProfile(await up.json()); if (up.ok && (up.headers.get('content-type')||'').includes('application/json')) {
setUserProfile(await up.json());
}
const fp = await authFetch('/api/premium/financial-profile'); const fp = await authFetch('/api/premium/financial-profile');
if (fp.status === 404) { if (fp.status === 404) {
// user skipped onboarding treat as empty object // user skipped onboarding treat as empty object
setFinancialProfile({}); setFinancialProfile({});
} else if (fp.ok) { } else if (fp.ok && (fp.headers.get('content-type')||'').includes('application/json')) {
setFinancialProfile(await fp.json()); setFinancialProfile(await fp.json());
} }
})(); })();
}, []); }, []);
@ -689,7 +704,92 @@ useEffect(() => {
let cancelled = false; let cancelled = false;
(async function init () { (async function init () {
/* 1 ▸ get every row the user owns */ // ─────────────────────────────────────────────────────────────
// 0) Prefer a real profile. Use ephemeral ONLY if none matches.
// Precedence when arriving with premiumOnboardingState:
// a) SOC match
// b) title match
// c) school match (via college_profile/all)
// ─────────────────────────────────────────────────────────────
if (!careerId) {
const pos = location.state?.premiumOnboardingState;
const sc = pos?.selectedCareer;
if (sc) {
const chosenSoc = sc.code || sc.soc_code || sc.socCode || '';
const chosenName = (sc.title || sc.career_name || '').trim();
// 1) fetch all user profiles
let all = [];
try {
all = await getAllCareerProfiles(); // uses authFetch
} catch { /* ignore; backend will log */ }
const bySoc = chosenSoc
? all.find(p => p.soc_code === chosenSoc)
: null;
const norm = (s='') => s.toLowerCase().replace(/\s+/g,' ').trim();
const byTitle = !bySoc && chosenName
? all.find(p => norm(p.career_name||p.scenario_title||'') === norm(chosenName))
: null;
let bySchool = null;
if (!bySoc && !byTitle && pos.selectedSchool) {
const schoolName = norm(pos.selectedSchool?.INSTNM || '');
try {
const r = await authFetch('/api/premium/college-profile/all');
if (r.ok && (r.headers.get('content-type')||'').includes('application/json')) {
const { collegeProfiles = [] } = await r.json();
const ids = new Set(
collegeProfiles
.filter(cp => norm(cp.selected_school || '') === schoolName)
.map(cp => cp.career_profile_id)
);
// pick newest start_date among candidates
const sorted = [...all].sort((a,b) => (a.start_date > b.start_date ? -1 : 1));
bySchool = sorted.find(p => ids.has(p.id)) || null;
}
} catch { /* ignore */ }
}
const match = bySoc || byTitle || bySchool;
if (match && !cancelled) {
// ✅ Normal mode: select existing profile
setCareerProfileId(match.id);
setSelectedCareer(match);
localStorage.setItem('lastSelectedCareerProfileId', match.id);
window.history.replaceState({}, '', location.pathname); // clear bridge
return; // let the rest of the effects run as usual
}
// ⚠️ No matching profile → ephemeral (financial profile is still used)
if (!cancelled) {
setCareerProfileId(null);
setSelectedCareer(sc);
setScenarioRow({
id: 'temp',
scenario_title: chosenName,
career_name : chosenName,
status : 'planned',
start_date : new Date().toISOString().slice(0,10),
college_enrollment_status: ''
});
const sch = pos.selectedSchool;
setCollegeProfile(sch ? {
selected_school : sch?.INSTNM || '',
selected_program : sch?.CIPDESC || '',
tuition : Number(sch?.['In_state cost'] ?? sch?.['Out_state cost'] ?? 0) || 0
} : {});
if (chosenSoc) {
setFullSocCode(chosenSoc);
setStrippedSocCode(chosenSoc.split('.')[0]);
}
window.history.replaceState({}, '', location.pathname); // clear bridge
return;
}
}
}
const r = await authFetch('/api/premium/career-profile/all'); const r = await authFetch('/api/premium/career-profile/all');
if (!r?.ok || cancelled) return; if (!r?.ok || cancelled) return;
const { careerProfiles=[] } = await r.json(); const { careerProfiles=[] } = await r.json();
@ -943,10 +1043,10 @@ useEffect(() => {
{ signal: ctrl.signal } { signal: ctrl.signal }
); );
if (res.ok) { if (res.ok && (res.headers.get('content-type')||'').includes('application/json')) {
setEconomicProjections(await res.json()); setEconomicProjections(await res.json());
setEconLoading(false); setEconLoading(false);
} else { } else {
console.error('[Econ fetch]', res.status); console.error('[Econ fetch]', res.status);
setEconLoading(false); setEconLoading(false);
} }
@ -1101,7 +1201,7 @@ if (allMilestones.length) {
annualFinancialAid: collegeData.annualFinancialAid, annualFinancialAid: collegeData.annualFinancialAid,
calculatedTuition: collegeData.calculatedTuition, calculatedTuition: collegeData.calculatedTuition,
extraPayment: collegeData.extraPayment, extraPayment: collegeData.extraPayment,
enrollmentDate: collegeProfile.enrollment_Date || null, enrollmentDate: collegeProfile.enrollment_date || null,
inCollege: collegeData.inCollege, inCollege: collegeData.inCollege,
gradDate: collegeData.gradDate, gradDate: collegeData.gradDate,
programType: collegeData.programType, programType: collegeData.programType,
@ -1138,7 +1238,7 @@ if (allMilestones.length) {
} }
useEffect(() => { useEffect(() => {
if (!financialProfile || !scenarioRow || !collegeProfile) return; if (!financialProfile || !scenarioRow || collegeProfile === null) return;
fetchMilestones(); fetchMilestones();
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]); }, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
@ -1284,16 +1384,11 @@ const currentIdRef = useRef(null);
const fetchMilestones = useCallback(async () => { const fetchMilestones = useCallback(async () => {
if (!careerProfileId) return; if (!careerProfileId) return;
const [profRes, uniRes] = await Promise.all([ const profRes = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`);
authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`), if (!profRes.ok) return;
authFetch(`/api/premium/milestones?careerProfileId=universal`) const ct = profRes.headers.get('content-type') || '';
]); const { milestones: profMs = [] } = ct.includes('application/json') ? await profRes.json() : { milestones: [] };
if (!profRes.ok || !uniRes.ok) return; const merged = profMs;
const [{ milestones: profMs }, { milestones: uniMs }] =
await Promise.all([profRes.json(), uniRes.json()]);
const merged = [...profMs, ...uniMs];
setScenarioMilestones(merged); setScenarioMilestones(merged);
if (financialProfile && scenarioRow && collegeProfile) { if (financialProfile && scenarioRow && collegeProfile) {
buildProjection(merged); buildProjection(merged);

View File

@ -172,10 +172,21 @@ function normalizeCipList(arr) {
const proceed = window.confirm( const proceed = window.confirm(
'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?' 'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?'
); );
if (proceed) { if (!proceed) return;
navigate('/career-roadmap', { state: { selectedSchool: school } }); // normalize selectedCareer and carry it forward
} const sel = selectedCareer
}; ? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode }
: null;
navigate('/career-roadmap', {
state: {
premiumOnboardingState: {
selectedCareer: sel, // SOC-bearing career object
selectedSchool: school // school card just chosen
}
}
});
};
function getSearchLinks(ksaName, careerTitle) { function getSearchLinks(ksaName, careerTitle) {
const combinedQuery = `${careerTitle} ${ksaName}`.trim(); const combinedQuery = `${careerTitle} ${ksaName}`.trim();