PageSourceSearch

https://smrtbld.ai/js/api/portal-tenders.js

js smrtbld.ai collected 2026-10-05 15:07:11 UTC 8,918 bytes, 170 lines download raw bytes

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.