PageSourceSearch

https://smrtbld.ai/js/pages/message-create-link.js

js smrtbld.ai collected 2026-10-05 15:09:02 UTC 38,224 bytes, 817 lines download raw bytes

1/**
2 * Message Create-and-Link Module
3 *
4 * Opens the same create modals used on contract-admin pages (RFI, CHG, SI)
5 * from the message center's "Link Message To" flow. After creation the new
6 * item is auto-linked to the message.
7 */
8
9// ========================================
10// IMPORTS
11// ========================================
12
13import { rfisApi } from '../api/rfis.js';
14import { changeRequestsApi } from '../api/change-requests.js';
15import { siteInstructionsApi } from '../api/site-instructions.js';
16import { submittalsApi } from '../api/submittals.js';
17import { messagesApi } from '../api/messages.js';
18import { calendarApi } from '../api/calendar.js';
19import { projectEmailApi } from '../api/project-email.js';
20import { loadTemplates } from '../utils/templates.js';
21import { showModal } from '../utils/modal.js';
22import { setLoading, afterRender } from '../utils/dom.js';
23import { SearchableDropdown } from '../components/SearchableDropdown.js';
24import { DatePicker } from '../components/DatePicker.js';
25import { validateRequired, validateForm } from '../utils/form-validation.js';
26import { wireHelpButtons } from '../utils/help-popover.js';
27import { applyCreateModalDefaults } from '../utils/create-modal-defaults.js';
28import { openChgCreateModal } from '../utils/chg-create-modal.js';
29import { stripHtml } from '../utils/html-sanitizer.js';
30
31// ========================================
32// TEMPLATE GUARDS
33// ========================================
34
35// Template-loaded guards (one fetch per session)
36let rfiTemplatesLoaded = false;
37let siTemplatesLoaded = false;
38let submittalTemplatesLoaded = false;
39let messageTemplatesLoaded = false;
40const allowSettlement = () => true;
41
42/** Ensure the shared message templates (link pill, attachment row) are in the DOM. */
43async function ensureMessageTemplates() {
44    if (!messageTemplatesLoaded) {
45        await loadTemplates('components/message-sub-templates');
46        messageTemplatesLoaded = true;
47    }
48}
49
50// ========================================
51// CREATE-AND-LINK: RFI
52// ========================================
53
54export async function showCreateRfiAndLink(projectId, messageId, { signal, attachment, prefill, onCreated } = {}) {
55    if (!rfiTemplatesLoaded) {
56        await loadTemplates('components/rfi-templates');
57        rfiTemplatesLoaded = true;
58    }
59    await ensureMessageTemplates();
60
61    try {
62        const [response, recipients] = await Promise.all([
63            rfisApi.getAddOptions(projectId),
64            projectEmailApi.getRecipients(projectId).catch(() => ({ contacts: [] })),
65        ]);
66        const members = response.members || [];
67        const contacts = recipients.contacts || [];
68        const nextRefNumber = response.nextRefNumber || 1;
69
70        const modal = await showModal('#tpl-rfi-add-modal', { members, contacts, nextRefNumber }, { signal });
71        applyCreateModalDefaults(modal.element, { nextReferenceNumber: response.nextRefNumber });
72        showPreloadedAttachment(modal.element, 'rfi', attachment);
73
74        // The shared #tpl-rfi-add-modal carries the project RFI form's three buttons
75        // (Review & Send · Save Draft · Reset). Create-and-link only creates+links —
76        // it has no send pipeline here — so hide the two that don't apply and drive
77        // creation off the Save Draft button below.
78        modal.element.querySelector('#btn-rfi-review-send')?.classList.add('hidden');
79        modal.element.querySelector('#btn-rfi-reset')?.classList.add('hidden');
80        if (prefill?.title) {
81            const titleEl = modal.element.querySelector('#rfi-title');
82            if (titleEl) titleEl.value = prefill.title;
83        }
84        if (prefill?.description) {
85            // #rfi-clarification is the "Information Requested" field
86            const descEl = modal.element.querySelector('#rfi-clarification');
87            if (descEl) descEl.value = prefill.description;
88        }
89
90        // Wire create button (Save Draft = create without send; see hidden buttons above)
91        const createBtn = modal.element.querySelector('#btn-rfi-draft');
92        let committedRfi = null;
93        createBtn?.addEventListener('click', async () => {
94            const el = modal.element;
95
96            const title = el.querySelector('#rfi-title')?.value?.trim();
97            const clarification = el.querySelector('#rfi-clarification')?.value?.trim() || null;
98            const specReference = el.querySelector('#rfi-ref-docs')?.value?.trim() || null;
99            const suggestions = el.querySelector('#rfi-suggestions')?.value?.trim() || null;
100            const dateDue = el.querySelector('#rfi-date-created')?.value || null;
101            const rfiType = el.querySelector('#rfi-type')?.value || null;
102
103            // Priority is multi-select (legacy parity) — stored pipe-delimited.
104            const priorityVals = Array.from(el.querySelector('#rfi-priority')?.selectedOptions || [])
105                .map(o =>
105 o.value).filter(Boolean);
106            const priority = priorityVals.length ? priorityVals.join('|') : null;
107            const priorityReason = el.querySelector('#rfi-priority-reason')?.value?.trim() || null;
108
109            // Assigned To — multi-select (SearchableDropdown-enhanced), same shape as the main
110            // create form's collectForm. Reading `.value` returns only the FIRST selected option
111            // (empty once enhanced), so gather every selected option — the create-from-message /
112            // markup→RFI path was silently dropping all-but-first assignee.
113            const assignedUserIds = Array.from(el.querySelector('#rfi-assigned-to')?.selectedOptions || [])
114                .map(o => parseInt(o.value, 10)).filter(v => !isNaN(v));
115
116            // Issued To (single contact → ToContactId) + CC (multi contact)
117            const issuedToVal = el.querySelector('#rfi-issued-to')?.value;
118            const toContactId = issuedToVal ? parseInt(issuedToVal) : null;
119            const ccContactIds = Array.from(el.querySelector('#rfi-cc')?.selectedOptions || [])
120                .map(o => parseInt(o.value)).filter(v => !isNaN(v));
121
122            const rules = [
123                { el: el.querySelector('#rfi-title'), label: 'Title', maxLength: 500 },
124            ];
125            if (priorityVals.length > 0) {
126                rules.push({ el: el.querySelector('#rfi-priority-reason'), label: 'Priority Reason' });
127            }
128
129            if (!validateRequired(el, rules)) return;
130
131            setLoading(createBtn, true, committedRfi ? 'Attaching...' : 'Creating...');
132
133            if (!committedRfi) {
134                try {
135                    const result = await rfisApi.create(projectId, {
136                        title,
137                        comments: clarification,
138                        dateDue,
139                        rfiType,
140                        priority,
141                        priorityReason,
142                        suggestions,
143                        specReference,
144                        assignedUserIds: assignedUserIds.length ? assignedUserIds : null,
145                        toContactId: toContactId && !isNaN(toContactId) ? toContactId : null,
146                        ccContactIds: ccContactIds.length ? ccContactIds : null,
147                    });
148                    committedRfi = Object.freeze({ itemId: result.rfiId, title, attachment });
149                } catch (error) {
150                    if (error.name === 'AbortError') return;
151                    window.showToast?.('Failed to create RFI', 'error', { description: error.message });
152                    setLoading(createBtn, false);
153                    return;
154                }
155            }
156
157            const { itemId, title: committedTitle } = committedRfi;
158            try {
159                await uploadMessageAttachment(rfisApi.uploadDocument, projectId, itemId, committedRfi.attachment);
160            } catch (error) {
161                if (error.name === 'AbortError') return;
162                // The RFI already exists. Preserve its identity and the exact Blob
163                // in this modal closure; the only retry offered is attachment-only.
164                window.showToast?.('RFI created; attachment failed', 'warning', {
165                    description: `${error.message || 'The attachment could not be uploaded.'} Retry the attachment or close and attach it manually to RFI ${itemId}.`,
166                });
167                setLoading(createBtn, false);
168                createBtn.textContent = 'Retry Attachment';
169                return;
170            }
171
172            try {
173                // Callers that aren't tied to the message center (e.g. markup
174                // layer "Create RFI") use onCreated to hook in their own
175                // follow-up (e.g. create Link annotations back to the RFI).
176                if (onCreated) {
177                    try { await onCreated(itemId, { title: committedTitle }); }
178                    catch (hookErr) { console.error('onCreated hook failed', hookErr); }
179                }
180                await linkAndFinish(modal, messageId, 'RFI', itemId, committedTitle);
181            } catch (error) {
182                if (error.name === 'AbortError') return;
183                window.showToast?.('RFI created; follow-up failed', 'warning', {
184                    description: `${error.message || 'The follow-up link could not be completed.'} RFI ${itemId}
184 was created and will not be created again.`,
185                });
186                setLoading(createBtn, false);
187            }
188        });
189
190    } catch (error) {
191        if (error.name === 'AbortError') return;
192        window.showToast?.('Failed to open RFI form', 'error', { description: error.message });
193    }
194}
195
196// ========================================
197// CREATE-AND-LINK: CHANGE REQUEST
198// ========================================
199
200export async function showCreateChgAndLink(projectId, messageId, options = {}) {
201    const { signal, attachment, prefill, onCreated } = options;
202    const hasCustomSettlementOwnership = typeof options.canPublish === 'function'
203        || typeof options.canContinueDurable === 'function';
204    let modalOpen = true;
205    const parentCanPublish = options.canPublish || allowSettlement;
206    const canPublish = () => modalOpen && parentCanPublish();
207    const canContinueDurable = options.canContinueDurable || allowSettlement;
208    if (!canPublish() || !canContinueDurable()) return false;
209    await ensureMessageTemplates();
210    if (!canPublish() || !canContinueDurable()) return false;
211
212    let opened = false;
213    await openChgCreateModal({
214        projectId,
215        signal,
216        prefill,
217        canPublish,
218        canContinueDurable,
219        closeBeforeCreated: false,
220        onOpen: (element) => {
221            opened = true;
222            showPreloadedAttachment(element, 'chg', attachment);
223        },
224        onClosed: () => { modalOpen = false; },
225        onCreated: async ({ chgId, title }) => {
226            if (!canContinueDurable()) return false;
227            try {
228                const attached = await uploadMessageAttachment(
229                    changeRequestsApi.uploadDocument,
230                    projectId,
231                    chgId,
232                    attachment ? { ...attachment, failurePolicy: 'throw' } : null,
233                    hasCustomSettlementOwnership ? { canContinueDurable } : null
234                );
235                if (!attached || !canContinueDurable()) return false;
236            } catch (attachmentError) {
237                if (attachmentError.name === 'AbortError' || !canContinueDurable()) return false;
238                console.error('Proposed Change message attachment failed', attachmentError);
239                if (canPublish()) window.showToast?.('Proposed Change created; attachment failed', 'warning', {
240                    description: `Change ${chgId} was saved. Attach the message file from its Documents panel.`,
241                });
242            }
243            let externalFollowUpFailed = false;
244            if (onCreated) {
245                try {
246                    if (await onCreated(chgId, { title }) === false) return false;
247                } catch (hookError) {
248                    externalFollowUpFailed = true;
249                    console.error('Proposed Change creation follow-up failed', hookError);
250                    if (canPublish()) window.showToast?.('Proposed Change created; follow-up failed', 'warning', {
251                        description: `Change ${chgId} was saved, but its external follow-up needs attention.`,
252                    });
253                }
254            }
255            if (!canContinueDurable()) return false;
256            if (!messageId && externalFollowUpFailed) return false;
257            return linkAndFinish(null, messageId, 'CHG', chgId, title, {
258                closeOperationModal: () => {},
259                canPublish,
260                canContinueDurable,
261            });
262        },
263    });
264    return opened;
265}
266// ========================================
267// CREATE-AND-LINK: SITE INSTRUCTION
268// ========================================
269
270export async function showCreateSiAndLink(projectId, messageId, { signal, attachment, prefill, onCreated } = {}) {
271    if (!siTemplatesLoaded) {
272        await loadTemplates('components/si-templates');
273        siTemplatesLoaded = true;
274    }
275    await ensureMessageTemplates();
276
277    try {
278        const response = await siteInstructionsApi.getAddOptions(projectId);
279        const members = response.members || [];
280        const nextRefNumber = response.nextRefNumber || 1;
281
282        const modal = await showModal('#tpl-si-add-modal', { members, nextRefNumber }, { signal });
283        applyCreateModalDefaults(modal.element, { nextReferenceNumber: response.nextRefNumber });
284        showPreloadedAttachment(modal.element, 'si', attachment);
285        if (prefill?.title) { const t = modal.element.querySelector('#si-title'); if (t) t.value = prefill.title; }
286        if (prefill?.description) { const d = modal.element.querySelector('#si-description'); if (d) d.value = prefill.description; }
287
288        // Wire cost impact conditional callout
289        const costImpactSelect = modal.element.querySelector('#si-cost-impact');
290        const priceCallout = modal.element.querySelector('#si-price-callout');
291        costImpactSelect?.addEventListener('change', () => {
292            priceCallout?.classList.toggle('hidden', costImpactSelect.value !== 'Yes');
293        });
294
295        const createBtn = modal.element.querySelector('#btn-create-si');
296        createBtn?.addEventListener('click', async () => {
297            const el = modal.element;
298
299            const title = el.querySelector('#si-title')?.value?.trim();
300            const description = el.querySelector('#si-description')?.value?.trim() || null;
301            const specReference = el.querySelector('#si-spec-ref')?.value?.trim() || null;
302            const costImpact = el.querySelector('#si-cost-impact')?.value || null;
303            const scheduleImpact = el.querySelector('#si-schedule-impact')?.value || null;
304            const comments = el.querySelector('#si-comments')?.value?.trim() || null;
305
306            const toVal = el.querySelector('#si-issued-to')?.value;
307            const toContactId = toVal ? parseInt(toVal) : null;
308
309            const assignedSelect = el.querySelector('#si-assigned-to');
310            const assignedUserIds = assignedSelect
311                ? Array.from(assignedSelect.selectedOptions).map(o => parseInt(o.value)).filter(v => !isNaN(v))
312                : [];
313
314            const rules = [
315                { el: el.querySelector('#si-title'), label: 'Title', maxLength: 500 },
316                { el: el.querySelector('#si-description'), label: 'Description' },
317                { el: el.querySelector('#si-issued-to'), label: 'Issued To', type: 'select' },
318            ];
319
320            if (!validateRequired(el, rules)) return;
321
322            setLoading(createBtn, true, 'Creating...');
323
324            try {
325                const result = await siteInstructionsApi.create(projectId, {
326                    title, description, comments, specReference, costImpact, scheduleImpact,
327                    toContactId: toContactId && !isNaN(toContactId) ? toContactId : null,
328                    assignedUserIds: assignedUserIds.length > 0 ? assignedUserIds : null,
329                });
330
331                const itemId = result.rfiId;
332                await uploadMessageAttachment(siteInstructionsApi.uploadDocument, projectId, itemId, attachment);
333                if (onCreated) { try { await onCreated(itemId, { title }); } catch (hookErr) { console.error('onCreated hook failed', hookErr); } }
334                await linkAndFinish(modal, messageId, 'SI', itemId, title);
335            } catch (error) {
336                if (error.name === 'AbortError') return;
337                window.showToast?.('Failed to create Site Instruction', 'error', { description: error.message });
338                setLoading(createBtn, false);
339            }
340        });
341
342    } catch (error) {
343        if (error.name === 'AbortError') return;
344        window.showToast?.('Failed to open Site Instruction form', 'error', { description: error.message });
345    }
346}
347
348// ========================================
349// CREATE-AND-LINK: SUBMITTAL
350// ========================================
351
352export async function showCreateSubmittalAndLink(projectId, messageId, config = {}) {
353    const { signal, attachment, prefill, onCreated } = config;
354    const canPublishOrigin = config.canPublish || allowSettlement;
355    const canContinueDurable = config.canContinueDurable || allowSettlement;
356    let modal = null;
357    let operationModalClosed = false;
358    const isCurrent = () => !operationModalClosed && !signal?.aborted
359        && (!modal || modal.element.isConnected) && canPublishOrigin() && canContinueDurable();
360    function closeOperationModal(reason = 'stale-owner') {
361        if (operationModalClosed || !modal) return false;
362        operationModalClosed = true;
363        modal.close(reason);
364        return true;
365    }
366    if (!isCurrent()) return false;
367    if (!submittalTemplatesLoaded) {
368        await loadTemplates('components/submittal-templates');
369        submittalTemplatesLoaded = true;
370    }
371    if (!isCurrent()) return false;
372    await ensureMessageTemplates();
373    if (!isCurrent()) return false;
374
375    try {
376        const options = await submittalsApi.getAddOptions(projectId, signal ? { signal } : {});
377        if (!isCurrent()) return false;
378        const users = options.users || [];
379
380        modal = await showModal('#tpl-submittal-add-modal', { users }, {
381            signal,
382            onClose: () => { operationModalClosed = true; },
383        });
384        if (!isCurrent()) {
385            closeOperationModal();
386            return false;
387        }
388        applyCreateModalDefaults(modal.element, { nextReferenceNumber: options.nextRefNumber });
389        showPreloadedAttachment(modal.element, 'submittal', attachment);
390        if (prefill?.title) { const t = modal.element.querySelector('#submittal-title'); if (t) t.value = prefill.title; }
391
392        // The submittal add modal has manual / from-list tabs. We only drive the
393        // default "manual" create path (the from-list tab isn't wired — not needed
394        // for create-from-email).
395        const createBtn = modal.element.querySelector('#btn-create-submittal');
396        createBtn?.addEventListener('click', async () => {
397            if (!isCurrent()) {
398                closeOperationModal();
399                return;
400            }
401            const el = modal.element;
402
403            const refNumber = el.querySelector('#submittal-ref')?.value?.trim();
404            const title = el.querySelector('#submittal-title')?.value?.trim();
405
406            const rules = [
407                { el: el.querySelector('#submittal-ref'), label: 'Reference #', maxLength: 50 },
408                { el: el.querySelector('#submittal-title'), label: 'Title', maxLength: 500 },
409            ];
410            if (!validateRequired(el, rules)) return;
411
412            setLoading(createBtn, true, 'Creating...');
413
414            try {
415                if (!canContinueDurable()) return;
416                const result = await submittalsApi.create(projectId, { title, refNumber });
417                const itemId = result.submittalId;
418                if (!canContinueDurable()) return;
419                const followUpFailures = [];
420
421                try {
422                    const strictAttachment = attachment ? { ...attachment, failurePolicy: 'throw' } : attachment;
423                    const continued = await uploadMessageAttachment(
424                        submittalsApi.uploadDocument, projectId, itemId, str
424ictAttachment, { canContinueDurable }
425                    );
426                    if (!continued || !canContinueDurable()) return;
427                } catch (uploadErr) {
428                    if (!canContinueDurable()) return;
429                    console.error('Submittal attachment upload failed', uploadErr);
430                    followUpFailures.push(`Attachment upload failed: ${uploadErr?.message || 'Unknown error'}`);
431                }
432
433                if (onCreated) {
434                    try {
435                        if (!canContinueDurable()) return;
436                        const continued = await onCreated(itemId, { title });
437                        if (!canContinueDurable()) return;
438                        if (continued === false) throw new Error('The provider follow-up did not complete');
439                    } catch (hookErr) {
440                        if (!canContinueDurable()) return;
441                        console.error('Submittal provider follow-up failed', hookErr);
442                        followUpFailures.push(`Provider follow-up failed: ${hookErr?.message || 'Unknown error'}`);
443                    }
444                }
445
446                if (!canContinueDurable()) return;
447                const linkOutcome = await linkAndFinish(modal, messageId, 'SUBMITTAL', itemId, title, {
448                    canPublish: isCurrent, canContinueDurable, closeOperationModal, deferOutcome: true,
449                });
450                if (!canContinueDurable()) return;
451                if (!linkOutcome.ok) {
452                    followUpFailures.push(`Message link failed: ${linkOutcome.error?.message || 'Unknown error'}`);
453                }
454
455                if (!isCurrent()) {
456                    closeOperationModal();
457                    return;
458                }
459                if (followUpFailures.length > 0) {
460                    window.showToast?.('Submittal created with follow-up issues', 'warning', {
461                        description: followUpFailures.join(' '),
462                    });
463                } else {
464                    window.showToast?.(messageId ? 'Submittal created & linked' : 'Submittal created', 'success', { description: title });
465                }
466                closeOperationModal('created');
467            } catch (error) {
468                if (error.name === 'AbortError' || !isCurrent()) {
469                    closeOperationModal();
470                    return;
471                }
472                window.showToast?.('Failed to create Submittal', 'error', { description: error.message });
473                setLoading(createBtn, false);
474            }
475        });
476
477        return true;
478
479    } catch (error) {
480        if (error.name === 'AbortError' || !isCurrent()) {
481            closeOperationModal();
482            return false;
483        }
484        closeOperationModal('open-error');
485        window.showToast?.('Failed to open Submittal form', 'error', { description: error.message });
486        return false;
487    }
488}
489
490// ========================================
491// SHARED HELPERS (attachments, linking, link pill)
492// ========================================
493
494/** Show a read-only pre-populated attachment in a create modal's file section. */
495function showPreloadedAttachment(modalEl, prefix, attachment) {
496    if (!attachment) return;
497
498    const dropZone = modalEl.querySelector(`#${prefix}-file-drop-zone`);
499    const preloadedHost = modalEl.querySelector(`#${prefix}-preloaded-attachment`);
500    const fileList = preloadedHost || modalEl.querySelector(`#${prefix}-file-list`);
501
502    if (!preloadedHost && dropZone) dropZone.classList.add('hidden');
503
504    if (fileList) {
505        const row = document.createElement('div');
506        row.className = 'flex items-center gap-3 p-2 rounded-lg bg-surface-2/10 border border-theme';
507        row.innerHTML = $.templates('#tpl-message-preloaded-attachment').render({
508            isImage: /^image\//i.test(attachment.contentType || ''),
509            fileName: attachment.fileName
510        });
511        fileList.appendChild(row);
512        afterRender(row);
513    }
514}
515
516/** Fetch message attachment blob and upload it as a document on the newly created item. */
517async function uploadMessageAttachment(uploadFn, projectId, itemId, attachment, ownership = null) {
518    if ((!attachment?.url && !attachment?.blob && !attachment?.file) || !itemId) return true;
519    const canContinueDurable = ownership?.canContinueDurable || allowSettlement;
520    if (!canContinueDurable()) return false;
521    try {
522        let file = attachment.file;
523        if (!file) {
524            let blob = attachment.blob;
525            if (!blob) {
526                const resp = await fetch(attachment.url, ownership ? {} : { signal: attachment.signal });
527                if (!resp.ok) throw new Error(`Attachment fetch failed (${resp.status})`);
528                blob = await resp.blob();
529                if (!canContinueDurable()) return false;
530            }
531            file = new File([blob], attachment.fileName || 'attachment', {
532                type: attachment.contentType || blob.type,
533            });
534        }
535        if (!canContinueDurable()) return false;
536        const formData = new FormData();
537        formData.append('file', file);
538        formData.append('title', file.name);
539        formData.append('description', '');
540        await uploadFn(projectId, itemId, formData, ownership ? {} : { signal: attachment.signal });
541        return true;
542    } catch (err) {
543        if (attachment.failurePolicy === 'throw' || err.name === 'AbortError') throw err;
544        console.error('Failed to upload message attachment:', err);
545        return true;
546    }
547}
548
549/** Upload a message attachment to a linked item.
550 *
551 * Two paths:
552 *   1. RFI / CHG / SI / SUBMITTAL — call the domain's uploadDocument with a fresh
553 *      FormData copy of the file (creates a new blob in the item's storage).
554 *   2. PROGBILL / SUBMITTALITEM / SUPPLIERITEM — unified copy-to-item endpoint
555 *      that inserts a reference row in the target's _doc table pointing at the
556 *      same blob (no copy — matches legacy `savefile` semantics for these types).
557 *
558 * Both paths are best-effort: if the file copy fails, the message-link is still
559 * created and the user can attach manually from the item's docs UI.
560 */
561export async function uploadAttachmentToItem(linkType, projectId, itemId, attachment) {
562    if (!attachment?.id || !itemId) return { attempted: false, copied: false };
563
564    const COPY_BY_REF_TYPES = new Set(['PROGBILL', 'SUBMITTALITEM', 'SUPPLIERITEM']);
565    if (COPY_BY_REF_TYPES.has(linkType)) {
566        try {
567            await messagesApi.copyAttachmentToItem(attachment.id, {
568                targetType: linkType,
569                targetId: itemId,
570            });
571            return { attempted: true, copied: true };
572        } catch (err) {
573            if (err.name === 'AbortError') throw err;
574            console.error(`Failed to copy attachment to ${linkType}:`, err);
575            return { attempted: true, copied: false, error: err };
576        }
577    }
578
579    // Domains with their own uploadDocument endpoints — copy the file bytes via
580    // the blob URL so the target gets its own independent blob.
581    const uploadFns = {
582        RFI: rfisApi.uploadDocument,
583        CHG: changeRequestsApi.uploadDocument,
584        SI: siteInstructionsApi.uploadDocument,
585        SUBMITTAL: submittalsApi.uploadDocument,
586    };
587    const fn = uploadFns[linkType];
588    if (!fn) return { attempted: false, copied: false };
589    if (!attachment.url) return { attempted: true, copied: false, error: new Error('Attachment file is unavailable') };
590    try {
591        const copied = await uploadMessageAttachment(fn, projectId, itemId, { ...attachment, failurePolicy: 'throw' });
592        return { attempted: true, copied, error: copied ? null : new Error('Attachment copy did not complete') };
593    } catch (err) {
594        if (err.name === 'AbortError') throw err;
595        console.error(`Failed to upload attachment to ${linkType}:`, err);
596        return { attempted: true, copied: false, error: err };
597    }
598}
599
600const TYPE_LABELS = { RFI: 'RFI', CHG: 'Change Request', SI: 'Site Instruction', SUBMITTAL: 'Submittal' };
601
602async function linkAndFinish(modal, messageId, linkType, itemId, title, ownership = null) {
603    const isCurrent = ownership?.canPublish || allowSettlement;
604    const canContinueDurable = ownership?.canContinueDurable || allowSettlement;
605    const closeOperationModal = ownership?.closeOperationModal || (() => modal.close());
606    const deferOutcome = ownership?.deferOutcome === true;
607    if (!messageId) {
608        if (!deferOutcome && isCurrent()) {
609            window.showToast?.(`${TYPE_LABELS[linkType]} created`, 'success', { description: title });
610        }
611        if (!deferOutcome) closeOperationModal();
612        return deferOutcome ? { ok: isCurrent(), error: null } : isCurrent();
613    }
614    if (!canContinueDurable()) {
615        if (!deferOutcome) closeOperationModal();
616        return deferOutcome ? { ok: false, error: new Error('Message linking did n
616ot continue') } : false;
617    }
618    try {
619        const newLink = await messagesApi.createLink(messageId, linkType, itemId);
620        if (isCurrent()) {
621            if (!deferOutcome) window.showToast?.(`${TYPE_LABELS[linkType]} created & linked`, 'success', { description: title });
622            appendLinkPill(newLink);
623        }
624        if (deferOutcome) return { ok: isCurrent(), error: null };
625    } catch (linkErr) {
626        if (deferOutcome) return { ok: false, error: linkErr };
627        if (linkErr.name === 'AbortError') return false;
628        // Item was created but linking failed — tell user so they can link manually
629        if (isCurrent()) {
630            window.showToast?.(`${TYPE_LABELS[linkType]} created but linking failed`, 'warning', {
631                description: 'You can link it manually from the dropdown.',
632            });
633        }
634    } finally {
635        if (!deferOutcome) closeOperationModal();
636    }
637    return isCurrent();
638}
639
640/** Surgically append a link pill to #meta-links without re-rendering the entire message detail. */
641function appendLinkPill(link) {
642    if (!link?.id) return;
643
644    let metaLinks = document.querySelector('#meta-links');
645
646    // If no links section exists yet, create it inside #message-meta-block
647    if (!metaLinks) {
648        const metaBlock = document.querySelector('#message-meta-block');
649        if (!metaBlock) return;
650
651        metaLinks = document.createElement('div');
652        metaLinks.id = 'meta-links';
653
654        const flexWrap = document.createElement('div');
655        flexWrap.className = 'flex items-start gap-1.5 flex-wrap';
656
657        const label = document.createElement('span');
658        label.className = 'text-xs font-semibold text-theme-tertiary shrink-0';
659        label.textContent = 'Linked Items:';
660
661        flexWrap.appendChild(label);
662        metaLinks.appendChild(flexWrap);
663
664        // Insert after #meta-seen-by (matches template ordering)
665        const seenBy = metaBlock.querySelector('#meta-seen-by');
666        if (seenBy) seenBy.after(metaLinks);
667        else metaBlock.prepend(metaLinks);
668    }
669
670    const container = metaLinks.querySelector('.flex');
671    if (!container) return;
672
673    const displayText = link.itemNumber
674        ? `${link.itemNumber} — ${link.itemTitle || 'Untitled'}`
675        : (link.itemTitle || 'Untitled');
676
677    // Build the link pill (same markup as tpl-message-detail-body {{for links}})
678    const html = $.templates('#tpl-message-link-pill').render({
679        id: link.id,
680        itemType: link.itemType,
681        itemId: link.itemId,
682        displayText
683    });
684    container.insertAdjacentHTML('beforeend', html);
685    const pill = container.lastElementChild;
686    afterRender(pill);
687}
688
689// ========================================
690// CREATE-AND-LINK: TASK (Calendar Event)
691// ========================================
692
693/**
694 * Open the "create task from message" modal pre-filled with the message subject
695 * + a stripped excerpt of the body, then create a calendar event via calendarApi
696 * and auto-link it to the source message via messagesApi.createLink.
697 *
698 * Ported from legacy `cmdMessageAddTask` — the kebab "Add Task" / "Link Message
699 * To → Task → Create New" entry point.
700 *
701 * @param {number} projectId
702 * @param {number|null} messageId - The message that's seeding this task. Null = standalone.
703 * @param {{ signal?: AbortSignal, prefill?: { title?: string, description?: string } }} [opts]
704 */
705export async function showCreateTaskAndLink(projectId, messageId, { signal, prefill, onCreated } = {}) {
706    await ensureMessageTemplates();
707
708    // Default scheduling: one hour from now, snapped to the next quarter-hour.
709    const now = new Date();
710    now.setMinutes(now.getMinutes() + 60);
711    const quarterHour = Math.ceil(now.getMinutes() / 15) * 15;
712    now.setMinutes(quarterHour % 60);
713    if (quarterHour >= 60) now.setHours(now.getHours() + 1);
714
715    const pad = (n) => String(n).padStart(2, '0');
716    const yyyy = now.getFullYear();
717    const mm = pad(now.getMonth() + 1);
718    const dd = pad(now.getDate());
719    const dateDefault = `${yyyy}-${mm}-${dd}`;
720    const timeDefault = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
721
722    const modal = await showModal('#tpl-create-task-from-message-modal', {
723        title: prefill?.title ? prefill.title.substring(0, 200) : '',
724        description: prefill?.description || '',
725        dateDefault,
726        timeDefault,
727    }, { signal });
728
729    const titleEl = modal.element.querySelector('#new-task-title');
730    const descriptionEl = modal.element.querySelector('#new-task-description');
731    const dateEl = modal.element.querySelector('#new-task-date');
732    const timeEl = modal.element.querySelector('#new-task-time');
733    const priorityEl = modal.element.querySelector('#new-task-priority');
734    const saveBtn = modal.element.querySelector('#btn-create-task-and-link');
735
736    titleEl?.focus();
737    titleEl?.select();
738
739    saveBtn?.addEventListener('click', async () => {
740        if (!validateForm(modal.element)) return;
741
742        const title = titleEl?.value?.trim();
743        if (!title) return;
744
745        // Combine date + time into a single DateTime the backend stores.
746        const dateVal = dateEl?.value || dateDefault;
747        const timeVal = timeEl?.value || '09:00';
748        const eventStart = new Date(`${dateVal}T${timeVal}:00`);
749        if (isNaN(eventStart.getTime())) {
750            window.showToast?.('Invalid date / time', 'error');
751            return;
752        }
753
754        setLoading(saveBtn, true, 'Creating...');
755        try {
756            // type_code "PROJECT" + type_id = projectId is the legacy convention for
757            // a calendar task scoped to a project (vs personal / company-wide).
758            const eventData = {
759                title,
760                description: descriptionEl?.value?.trim() || null,
761                eventDateStart: eventStart.toISOString(),
762                eventStartTime: timeVal,
763                priority: priorityEl?.value || 'Medium',
764                type: 'Task',
765                typeCode: 'PROJECT',
766                typeId: projectId,
767            };
768
769            const result = await calendarApi.create(eventData);
770
771            // calendarApi.create returns either a wrapped { data } envelope or the
772            // raw entity depending on ToHttpResult shape — handle both.
773            const newEvent = result?.data ?? result;
774            const newTaskId = newEvent?.calendarEventId ?? newEvent?.CalendarEventId ?? newEvent?.id;
775
776            if (!newTaskId) {
777                throw new Error('Task was created but no ID was returned');
778            }
779
780            // Callers not tied to the message center (external email "Create Task") use
781            // onCreated to record their own follow-up link.
782            if (onCreated) {
783                try { await onCreated(newTaskId, { title }); }
784                catch (hookErr) { console.error('onCreated hook failed', hookErr); }
785            }
786
787            // Auto-link the source message to the new task. Best-effort — if the
788            // link insert fails, the task still exists and the user can link manually.
789            if (messageId) {
790                try {
791                    // The created item is a calendar event (calendarEventId), so link it as
792                    // CALENDARTASK — that resolves via the becc_pm_calendar_events JOIN in
793                    // GetLinksAsync and routes to /calendar/event/{id}. Linking as 'TASK'
794                    // (→ PROJECTTASK) would store the event id against becc_pm_projecttasks,
795                    // which finds nothing → dead, untitled pill. The link PICKER's 'TASK'
796                    // option still returns real project tasks and stays PROJECTTASK.
797                    const newLink = await messagesApi.createLink(messageId, 'CALENDARTASK', newTaskId);
798                    window.showToast?.('Task created & linked', 'success', { description: title });
799                    appendLinkPill(newLink);
800                } catch (linkErr) {
801                    if (linkErr.name === 'AbortError') return;
802                    window.showToast?.('Task created — linking failed', 'warning', {
803                        description: 'You can link it manually from the kebab menu.',
804                    });
805                }
806            } else {
807                window.showToast?.('Task created', 'success', { description: title });
808            }
809
810            modal.close();
811        } catch (err) {
812            if (err.name === 'AbortError') return;
813            setLoading(saveBtn, false);
814            window.showToast?.('Failed to create task', 'error', { description: err.message });
815        }
816    });
817}

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.