const MONDAY_API = "https://api.monday.com/v2"; const MONDAY_FILE_API = "https://api.monday.com/v2/file"; type MondayColumn = { id: string; title: string; type: string }; async function gql(token: string, query: string, variables?: Record) { const body = JSON.stringify({ query, variables }); console.log("[monday gql] request:", body.slice(0, 500)); const res = await fetch(MONDAY_API, { method: "POST", headers: { "Content-Type": "application/json", Authorization: token, "API-Version": "2024-10", }, body, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Monday.com API ${res.status}: ${text}`); } const json = await res.json(); if (json.errors?.length) { const err = json.errors[0]; const detail = err.extensions ? ` (${JSON.stringify(err.extensions)})` : ""; console.error("[monday gql] GraphQL error:", JSON.stringify(json.errors)); throw new Error(`Monday.com GraphQL: ${err.message}${detail}`); } if (json.error_message) { console.error("[monday gql] API error:", json.error_message, json.error_code); throw new Error(`Monday.com: ${json.error_message} (${json.error_code || "unknown"})`); } return json.data; } export async function fetchBoardColumns(token: string, boardId: string): Promise { const data = await gql(token, ` query ($boardId: [ID!]!) { boards(ids: $boardId) { columns { id title type } } } `, { boardId: [boardId] }); return data?.boards?.[0]?.columns ?? []; } export async function createItem( token: string, boardId: string, itemName: string, columnValues: Record ): Promise { const data = await gql(token, ` mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) { create_item( board_id: $boardId, item_name: $itemName, column_values: $columnValues, create_labels_if_missing: true ) { id } } `, { boardId, itemName, columnValues: JSON.stringify(columnValues), }); return String(data.create_item.id); } export async function updateItem( token: string, boardId: string, itemId: string, columnValues: Record ): Promise { await gql(token, ` mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { change_multiple_column_values( board_id: $boardId, item_id: $itemId, column_values: $columnValues, create_labels_if_missing: true ) { id } } `, { boardId, itemId, columnValues: JSON.stringify(columnValues), }); } export async function uploadFileToItem( token: string, itemId: string, columnId: string, fileBuffer: Buffer, fileName: string ): Promise { const query = `mutation ($file: File!) { add_file_to_column(file: $file, item_id: ${itemId}, column_id: "${columnId}") { id } }`; const form = new FormData(); form.append("query", query); form.append("variables[file]", new Blob([new Uint8Array(fileBuffer)]), fileName); const res = await fetch(MONDAY_FILE_API, { method: "POST", headers: { Authorization: token }, body: form, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Monday.com file upload ${res.status}: ${text}`); } } type ItemColumnValue = { id: string; text: string; value: string | null }; export async function readItem( token: string, itemId: string ): Promise<{ name: string; columnValues: ItemColumnValue[] } | null> { const data = await gql(token, ` query ($itemId: [ID!]!) { items(ids: $itemId) { name column_values { id text value } } } `, { itemId: [itemId] }); const item = data?.items?.[0]; if (!item) return null; return { name: item.name, columnValues: item.column_values.map((cv: { id: string; text: string; value: string | null }) => ({ id: cv.id, text: cv.text, value: cv.value, })), }; } function toCleanString(val: unknown): string | null { if (val === null || val === undefined) return null; if (Array.isArray(val)) { const joined = val.filter(Boolean).join(", "); return joined || null; } if (typeof val === "object") { const s = JSON.stringify(val); return s === "{}" || s === "[]" ? null : s; } const str = String(val).trim(); if (str === "" || str === "null" || str === "undefined" || str === "[object Object]") return null; return str; } const DROPDOWN_NORMALIZATIONS: [RegExp, string][] = [ [/expressing my faith in jesus/i, "Baptism"], [/i want to be baptized/i, "Baptism"], [/learning more about becoming a partner/i, "Next Steps"], [/attend next steps/i, "Next Steps"], ]; function normalizeDropdownLabel(label: string): string { for (const [pattern, replacement] of DROPDOWN_NORMALIZATIONS) { if (pattern.test(label)) return replacement; } return label; } function formatForColumnType(colType: string, val: unknown): unknown | null { switch (colType) { case "boolean": case "checkbox": { const isChecked = val === true || val === "true"; return { checked: isChecked ? "true" : "false" }; } case "color": case "status": { const str = toCleanString(val); return str ? { label: str } : null; } case "dropdown": { const str = toCleanString(val); if (!str) return null; const labels = str.split(",").map((s) => normalizeDropdownLabel(s.trim())).filter(Boolean); return labels.length > 0 ? { labels } : null; } case "date": { const str = toCleanString(val); if (!str) return null; if (typeof val === "object" && !Array.isArray(val) && (val as Record).date) { const inner = String((val as Record).date); const iso = parseToISO(inner); return iso ? { date: iso } : null; } const iso = parseToISO(str); return iso ? { date: iso } : null; } case "numeric": case "numbers": { const str = toCleanString(val); if (!str) return null; const num = parseFloat(str); return isNaN(num) ? null : String(num); } case "email": { const str = toCleanString(val); return str ? { email: str, text: str } : null; } case "phone": { const str = toCleanString(val); return str ? { phone: str, countryShortName: "US" } : null; } case "link": { const str = toCleanString(val); return str ? { url: str, text: str } : null; } default: { return toCleanString(val); } } } const MONTH_MAP: Record = { jan: "01", january: "01", feb: "02", february: "02", mar: "03", march: "03", apr: "04", april: "04", may: "05", jun: "06", june: "06", jul: "07", july: "07", aug: "08", august: "08", sep: "09", september: "09", oct: "10", october: "10", nov: "11", november: "11", dec: "12", december: "12", }; function normalizeYear(y: string): string { if (y.length === 4) return y; const n = parseInt(y); return n > 50 ? `19${y.padStart(2, "0")}` : `20${y.padStart(2, "0")}`; } function parseToISO(dateStr: string): string | null { const s = dateStr.replace(/\s+/g, " ").trim(); // Full ISO datetime e.g. "2026-04-06T00:00:00.000Z" const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})T/); if (isoFull) return `${isoFull[1]}-${isoFull[2]}-${isoFull[3]}`; // MM/DD/YYYY or DD/MM/YYYY (all-numeric) const numParts = s.match(/^(\d{1,2})\s*[/\-.]\s*(\d{1,2})\s*[/\-.]\s*(\d{2,4})$/); if (numParts) { let [, a, b, y] = numParts; y = normalizeYear(y); const aNum = parseInt(a), bNum = parseInt(b); let month: string, day: string; if (aNum > 12 && bNum <= 12) { day = a; month = b; } else { month = a; day = b; } return `${y}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; } // DD/Mon/YYYY e.g. "20/May/1999", "08/JUN/26" const dmyNamed = s.match(/^(\d{1,2})\s*[/\-.]\s*([A-Za-z]+)\s*[/\-.]\s*(\d{2,4})$/); if (dmyNamed) { const [, d, mName, y] = dmyNamed; const month = MONTH_MAP[mName.toLowerCase()]; if (month) return `${normalizeYear(y)}-${month}-${d.padStart(2, "0")}`; } // "Month DD, YY(YY)" e.g. "july 20, 02", "Feb 19, 2016", "October 5, 1990" const mdyNamed = s.match(/^([A-Za-z]+)\s+(\d{1,2}),?\s*(\d{2,4})$/); if (mdyNamed) { const [, mName, d, y] = mdyNamed; const month = MONTH_MAP[mName.toLowerCase()]; if (month) return `${normalizeYear(y)}-${month}-${d.padStart(2, "0")}`; } // "Mon / DD / YYYY" with spaces e.g. "Oct / 02 / 2004" const spacedNamed = s.match(/^([A-Za-z]+)\s*[/\-.]\s*(\d{1,2})\s*[/\-.]\s*(\d{2,4})$/); if (spacedNamed) { const [, mName, d, y] = spacedNamed; const month = MONTH_MAP[mName.toLowerCase()]; if (month) return `${normalizeYear(y)}-${month}-${d.padStart(2, "0")}`; } // YYYY-MM-DD already ISO const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (iso) return s; // Last resort: try native Date parsing const d = new Date(s); if (!isNaN(d.getTime()) && d.getFullYear() > 1900) { const yyyy = String(d.getFullYear()); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); return `${yyyy}-${mm}-${dd}`; } return null; } export function mapCardToColumnValues( card: Record, columnMap: Record ): Record { const values: Record = {}; const columnTypes = ((columnMap._columnTypes as Record) ?? {}); console.log("[mapCardToColumnValues] _columnTypes keys:", Object.keys(columnTypes).length, "sample:", JSON.stringify(Object.entries(columnTypes).slice(0, 5))); for (const [cardField, rawColId] of Object.entries(columnMap)) { if (!rawColId || !cardField || cardField.startsWith("_") || typeof rawColId !== "string") continue; const colId = rawColId; const val = card[cardField]; if (val === null || val === undefined) continue; const colType = columnTypes[colId] || "text"; const formatted = formatForColumnType(colType, val); if (formatted !== null) { values[colId] = formatted; } } return values; } export function mapItemToCardFields( columnValues: ItemColumnValue[], columnMap: Record ): Record { const reverseMap: Record = {}; for (const [cardField, colId] of Object.entries(columnMap)) { if (cardField.startsWith("_") || typeof colId !== "string") continue; reverseMap[colId] = cardField; } const cardData: Record = {}; for (const cv of columnValues) { const cardField = reverseMap[cv.id]; if (cardField && cv.text) { cardData[cardField] = cv.text; } } return cardData; } export async function createWebhookSubscription( token: string, boardId: string, callbackUrl: string ): Promise { const escapedUrl = callbackUrl.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const data = await gql(token, ` mutation { create_webhook(board_id: ${boardId}, url: "${escapedUrl}", event: change_column_value) { id board_id } } `); return String(data.create_webhook.id); } export async function deleteWebhookSubscription( token: string, webhookId: string ): Promise { await gql(token, ` mutation { delete_webhook(id: ${webhookId}) { id } } `); }