1/** 2 * portal-tenders.js â bidder-facing tender reads (Phase D, D4/D5). 3 * 4 * Raw fetch + the portal Bearer token (portalSession). The tenant rides the token (DatabaseName 5 * claim), so these authed reads need no companyCode segment. camelCased by hand like the other 6 * portal API modules. 7 */ 8import { config } from '../config.js'; 9import { toCamelCase } from '../utils/case-transform.js'; 10import { portalSession } from '../portal/portal-session.js'; 11 12const base = () => `${config.apiBaseUrl}/smrtbuild/portal/tenders`; 13 14async function call(url, { method = 'GET', body = null, signal } = {}) { 15 const headers = { 'Accept': 'application/json', 'X-API-Key': config.apiKey }; 16 if (body) headers['Content-Type'] = 'application/json'; 17 const token = portalSession.getToken(); 18 if (token) headers['Authorization'] = `Bearer ${token}`; 19 20 // `signal` is opt-in per call. A transport rejection becomes OfflineCacheMissError in 21 // api/base.js:204-207's shape, so the offline sentence reaches the bidder (CA-216, MCF-0672). 22 let resp; 23 try { 24 resp = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal }); 25 } catch (err) { 26 if (err?.name === 'AbortError') throw err; 27 // Message stays URL-free: bidder-facing toasts still print err.message raw (MCF-0672's 28 // sweep). The endpoint rides the property, as api/base.js:206 does. 29 const offline = new Error("Couldn't reach the server."); 30 offline.name = 'OfflineCacheMissError'; 31 offline.endpoint = url; 32 throw offline; 33 } 34 let json = {}; 35 try { json = await resp.json(); } catch { /* non-JSON */ } 36 if (!resp.ok) { 37 // 401 mid-session = the ~2h portal token died server-side. Clear it (isAuthenticated() 38 // only checks the JWT's own exp, so it would keep lying) and bounce through /portal so 39 // portalGuard lands the bidder on sign-in instead of them retrying a dead session. 40 if (resp.status === 401) { 41 portalSession.logout(); 42 window.page?.('/portal'); 43 const err = new Error('Your session has expired â please sign in again'); 44 err.status = 401; 45 throw err; 46 } 47 const err = new Error(json?.message || json?.title || `Request failed (${resp.status})`); 48 err.status = resp.status; 49 throw err; 50 } 51 return toCamelCase(json); 52} 53 54// Multipart variant â same headers/401 handling, but the browser sets the Content-Type 55// (multipart boundary), so we never set it ourselves. Used by the My Bid file uploads. 56async function callForm(url, formData) { 57 const headers = { 'Accept': 'application/json', 'X-API-Key': config.apiKey }; 58 const token = portalSession.getToken(); 59 if (token) headers['Authorization'] = `Bearer ${token}`; 60 61 const resp = await fetch(url, { method: 'POST', headers, body: formData }); 62 let json = {}; 63 try { json = await resp.json(); } catch { /* non-JSON */ } 64 if (!resp.ok) { 65 if (resp.status === 401) { 66 portalSession.logout(); 67 window.page?.('/portal'); 68 const err = new Error('Your session has expired â please sign in again'); 69 err.status = 401; 70 throw err; 71 } 72 const err = new Error(json?.message || json?.title || `Upload failed (${resp.status})`); 73 err.status = resp.status; 74 throw err; 75 } 76 return toCamelCase(json); 77} 78 79// Blob variant â same auth/401 posture as call(), but the success body is a FILE (the receipt 80// PDF), so we read a blob and pull the filename off Content-Disposition. Errors still arrive 81// as JSON and get the same message extraction. 82async function callBlob(url) { 83 const headers = { 'X-API-Key': config.apiKey }; 84 const token = portalSession.getToken(); 85 if (token) headers['Authorization'] = `Bearer ${token}`; 86 87 const resp = await fetch(url, { headers }); 88 if (!resp.ok) { 89 if (resp.status === 401) { 90 portalSession.logout(); 91 window.page?.('/portal'); 92 const err = new Error('Your session has expired â please sign in again'); 93 err.status = 401; 94 throw err; 95 } 96 let json = {}; 97 try { json = await resp.json(); } catch { /* non-JSON */ } 98 const err = new Error(json?.message || json?.title || `Request failed (${resp.status})`); 99 err.status = resp.status; 100 throw err; 101 } 102 const blob = await resp.blob(); 103 const disposition = resp.headers.get('Content-Disposition') || ''; 104 const match = /filename\*=UTF-8''([^;]+)/i.exec(disposition) || /filename="?([^";]+)"?/i.exec(disposition); 105 return { blob, fileName: match ? decodeURIComponent(match[1]) : '' }; 106} 107 108const enc = encodeURIComponent; 109 110export const portalTendersApi = { 111 /** My invited/downloaded tenders. showPast=true includes closed ones. */ 112 getMyTenders: (showPast = false, options = {}) => call(`${base()}${showPast ? '?showPast=true' : ''}`, options), 113 114 /** E8 â the dashboard landing: KPI counts + invitation/closing-soon rows + the cross-tender feed. */ 115 getDashboard: (options = {}) => call(`${config.apiBaseUrl}/smrtbuild/portal/dashboard`, options), 116 117 /** Flat tender detail (header + confirm state + bidder-visible documents). */
118 getDetail: (tenderId, options = {}) => call(`${base()}/${enc(tenderId)}`, options), 119 120 /** Record my Yes/Maybe/No ('Y'|'M'|'N'). Returns the refreshed detail. */ 121 confirm: (tenderId, answer) => call(`${base()}/${enc(tenderId)}/confirm`, { method: 'POST', body: { answer } }), 122 123 /** Get a SAS URL for a document, then the browser pulls it from Azure. 124 * attachment=true (E11) forces a SAVE (Content-Disposition) â the sequential Download-all. */ 125 getDownloadUrl: (tenderId, fileId, attachment = false) => call(`${base()}/${enc(tenderId)}/files/${enc(fileId)}/download-url${attachment ? '?attachment=true' : ''}`), 126 127 /** My visible clarifications (own any-status + shared answered/issued, anonymized). D7c. */ 128 getClarifications: (tenderId, options = {}) => call(`${base()}/${enc(tenderId)}/clarifications`, options), 129 130 /** Ask a question ({ subject?, question }) â open tenders only. Returns the refreshed list. */ 131 askClarification: (tenderId, data) => call(`${base()}/${enc(tenderId)}/clarifications`, { method: 'POST', body: data }), 132 133 // ââ My Bid (D8). ONE bid per FIRM â colleagues share it; the server keys everything on the 134 // JWT. Every response below is the refreshed { bid } workspace state. Writes are openness- 135 // gated server-side; submit re-validates every checklist step. ââ 136 137 /** My firm's bid â Assembly Monitor state + pricing lines + package files (or the not-started shape). */ 138 getMyBid: (tenderId, options = {}) => call(`${base()}/${enc(tenderId)}/bid`, options), 139 140 /** Mark a monitor step done: 'scope' | 'documents' | 'siteVisit' | 'addenda'. Lazy-creates the draft. */ 141 bidProgress: (tenderId, step) => call(`${base()}/${enc(tenderId)}/bid/progress`, { method: 'POST', body: { step } }), 142 143 /** Replace-set the Priced Bid Form ({ lines:[{lineDescription, qty, unit, unitRate}], bondsIncluded }). The server computes the total. */ 144 saveBidPricing: (tenderId, data) => call(`${base()}/${enc(tenderId)}/bid/pricing`, { method: 'PUT', body: data }), 145 146 /** Add one file to my firm's submission package (25 MB cap; pdf/doc/xls/csv/img/zip). 147 * slotId (E10) = the required-document slot it fills; re-attaching replaces the slot's file. */ 148 uploadBidFile: (tenderId, file, slotId = null) => { 149 const fd = new FormData(); 150 fd.append('file', file); 151 if (slotId != null) fd.append('slotId', String(slotId)); 152 return callForm(`${base()}/${enc(tenderId)}/bid/files`, fd); 153 }, 154 155 /** Remove one of my firm's package files (open tenders only). */ 156 deleteBidFile: (tenderId, fileId) => call(`${base()}/${enc(tenderId)}/bid/files/${enc(fileId)}`, { method: 'DELETE' }), 157 158 /** SAS URL for one of my firm's own package files. */ 159 getBidFileUrl: (tenderId, fileId) => call(`${base()}/${enc(tenderId)}/bid/files/${enc(fileId)}/download-url`), 160 161 /** Submit (or re-submit) my firm's bid â the server re-validates readiness + openness. 162 * E11: requires the ticked authorization; the canonical text is stamped server-side. */ 163 submitBid: (tenderId) => call(`${base()}/${enc(tenderId)}/bid/submit`, { method: 'POST', body: { confirmed: true } }), 164 165 /** E11 withdraw v1 â my submitted bid â back to draft (everything survives), re-submittable before close. */ 166 withdrawBid: (tenderId) => call(`${base()}/${enc(tenderId)}/bid/withdraw`, { method: 'POST' }), 167 168 /** The submitted-bid receipt PDF (glow-up) â { blob, fileName }. Drafts 400 server-side. */ 169 getBidReceipt: (tenderId) => callBlob(`${base()}/${enc(tenderId)}/bid/receipt`), 170};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.