T7.6 - CR-014/D2/D9/D10: the Ready for QA gate, notification only, shipped off
Marlena's ask: QA sees inbound work ahead of time, not after the fact. The rung: 'Ready for QA' sits between In Progress and QC in BOTH ladders (wp-creation-app.js STATUS_ORDER, server/app.py STATUS_ORDER) and in Field View's list - inside the T7.3 transition model, not beside it: entering it from an unreleased state crosses the release gates, and 'Issue' (hold) stays a branch. The dashboard filter and the navigator grouping learned the state from the ladder without their own edits. Who hears (D2): the QA GROUP, a multi-pick of project members on the SOP wizard's team step, stored as account ids at data.sop.project.qaGroupIds. Entering Ready for QA emails that list and nobody else. A rejection emails the owner AND the same list (amended answer), returns the package to In Progress, and REQUIRES a fresh comment - server-enforced on both write paths (the first version accepted any old comment already on the record, which made every rejection after the first one free; the gate now demands a new entry). Accept and reject are real buttons on the release banner; the comment modal enforces its field; qa_ready / qa_rejected / status_changed all land in the audit history. The link (X1): wp_link() now opens THE package - wp-creation-index.html ?project&wp=<id>, which the creator boots directly and login.html?next= round-trips for a signed-out recipient. It previously pointed at the suite root, which is exactly the failure X1 names; assignment mail inherits the fix. Email discipline (D10 + standing rules): ships OFF (the stored setting the admin console already owns; PUT /api/settings is admin-only, 403 for anyone else, and audited). With it off, transitions write outbox rows marked 'skipped' and the sink receives nothing. With it on, the probe runs a REAL SMTP conversation against an in-process capture sink and asserts the count and the exact recipient set. A dead SMTP host leaves a 'failed' outbox row with the error recorded. The SMTP password exists only in the environment. DEVIATION, stated: the task's Do-paragraph asks the email to include location and a scope summary; the done-when list (and CLAUDE.md) says no customer IP in a message body. The done-when wins: bodies carry the WP number, who moved it, and the deep link. A location canary planted on the package is asserted absent from every captured message. If the fuller body is wanted, that is a product call - needs Nick. Found while building, logged not fixed (BL-021): project_sop_team() reads sop.data['project'], a path pushSOP never writes - the critical-reopen email has never actually reached the PM/CM. One-line fix, owned by T9.9. Field View (D9): 'Ready for QA' is carried by TEXT on the card at 390px. Verification (each probe run alone): NEW tests/qa_gate_check.py 40/40. Regressions: hold_check 50/50, pipeline_check 44/44, aggregates_check 16/16, frame_check 39/39, validation_check 83/83. Items: CR-014, D2, D9, D10 (X1, X3 honored) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ let state = {
|
||||
// notification routing uses — a typed name can't be emailed.
|
||||
team: {pm:'', apm:'', cm:'', qm:''},
|
||||
teamIds: {pm:'', apm:'', cm:'', qm:''},
|
||||
qaGroupIds: [], // D2: who is emailed when a package reaches Ready for QA
|
||||
teamMembers: [],
|
||||
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
|
||||
wpTypes: [],
|
||||
@@ -419,6 +420,7 @@ function loadSampleData(){
|
||||
state.team.cm = 'K. Boyd';
|
||||
state.team.qm = 'D. Nguyen';
|
||||
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||
state.qaGroupIds = [];
|
||||
renderTeamPickers();
|
||||
|
||||
// Step 3 — standard required roles
|
||||
@@ -500,6 +502,7 @@ function restoreSavedSOP(){
|
||||
// A SOP saved before the team was account-backed has no teamIds; default them
|
||||
// so the pickers render (the stored names show as "(no account)" until linked).
|
||||
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||
if(!Array.isArray(state.qaGroupIds)) state.qaGroupIds = []; // D2, pre-T7.6 SOPs
|
||||
|
||||
// Re-render dynamic lists from restored state.
|
||||
renderWPTypes();
|
||||
@@ -820,6 +823,7 @@ function renderTeamPickers(){
|
||||
sel.innerHTML = html;
|
||||
sel.onchange = function(){ setTeamLead(key, this.value); };
|
||||
});
|
||||
renderQaGroupPicker();
|
||||
if(!warn) return;
|
||||
if(projectUsersLoaded && !projectUsers.length){
|
||||
warn.style.display = '';
|
||||
@@ -835,6 +839,20 @@ function renderTeamPickers(){
|
||||
}
|
||||
}
|
||||
|
||||
// D2: the QA group - a multi-pick of project members, chosen once here and read
|
||||
// by the server when a package reaches Ready for QA. Stored as account ids;
|
||||
// display names are derived at save so a rename never breaks the routing.
|
||||
function renderQaGroupPicker(){
|
||||
const sel = document.getElementById('proj_qagroup');
|
||||
if(!sel) return;
|
||||
const cur = new Set(state.qaGroupIds || []);
|
||||
sel.innerHTML = projectUsers.map(u =>
|
||||
`<option value="${escAttr(u.id)}"${cur.has(u.id)?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
|
||||
sel.onchange = function(){
|
||||
state.qaGroupIds = [...this.selectedOptions].map(o => o.value).filter(Boolean);
|
||||
};
|
||||
}
|
||||
|
||||
// One <select> of project people, reused everywhere the SOP names someone. Keeps a
|
||||
// name that has no matching account as a selected "(no account)" option so older
|
||||
// SOPs — and the sample's fictional names — are never silently dropped.
|
||||
@@ -2124,6 +2142,9 @@ function completeSOP(){
|
||||
apmId: state.teamIds.apm || '',
|
||||
cmId: state.teamIds.cm || '',
|
||||
qmId: state.teamIds.qm || '',
|
||||
// D2: the QA group - ids are the routing, names are for display.
|
||||
qaGroupIds: (state.qaGroupIds || []).slice(),
|
||||
qaGroup: (state.qaGroupIds || []).map(id => { const u = userById(id); return u ? (u.full_name || u.username) : ''; }).filter(Boolean),
|
||||
site: state.project.site,
|
||||
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user