The root cause was sending raw text to checkbox, status, dropdown, and
date columns. Monday.com requires specific JSON structures per type.
- Store column types in _columnTypes when Fetch Columns is clicked
- Format boolean/checkbox: {"checked": "true"/"false"}
- Format status/color: {"label": "value"}
- Format dropdown: {"labels": ["val1", "val2"]}
- Format date: parse MM/DD/YYYY to {"date": "YYYY-MM-DD"}
- Format email/phone/link with proper nested structures
- Text columns pass through as plain strings
Made-with: Cursor
286 lines
7.9 KiB
TypeScript
286 lines
7.9 KiB
TypeScript
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<string, unknown>) {
|
|
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<MondayColumn[]> {
|
|
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<string, unknown>
|
|
): Promise<string> {
|
|
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<string, unknown>
|
|
): Promise<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
|
|
function formatForColumnType(colType: string, val: unknown): unknown | null {
|
|
switch (colType) {
|
|
case "boolean": {
|
|
const isChecked = val === true || val === "true";
|
|
return { checked: isChecked ? "true" : "false" };
|
|
}
|
|
|
|
case "color": {
|
|
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) => s.trim()).filter(Boolean);
|
|
return labels.length > 0 ? { labels } : null;
|
|
}
|
|
|
|
case "date": {
|
|
const str = toCleanString(val);
|
|
if (!str) return null;
|
|
const iso = parseToISO(str);
|
|
return iso ? { date: iso } : str;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseToISO(dateStr: string): string | null {
|
|
const parts = dateStr.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})$/);
|
|
if (!parts) return null;
|
|
let [, m, d, y] = parts;
|
|
if (y.length === 2) y = parseInt(y) > 50 ? `19${y}` : `20${y}`;
|
|
const month = m.padStart(2, "0");
|
|
const day = d.padStart(2, "0");
|
|
return `${y}-${month}-${day}`;
|
|
}
|
|
|
|
export function mapCardToColumnValues(
|
|
card: Record<string, unknown>,
|
|
columnMap: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
const values: Record<string, unknown> = {};
|
|
const columnTypes = ((columnMap._columnTypes as Record<string, string>) ?? {});
|
|
|
|
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<string, unknown>
|
|
): Record<string, string> {
|
|
const reverseMap: Record<string, string> = {};
|
|
for (const [cardField, colId] of Object.entries(columnMap)) {
|
|
if (cardField.startsWith("_") || typeof colId !== "string") continue;
|
|
reverseMap[colId] = cardField;
|
|
}
|
|
|
|
const cardData: Record<string, string> = {};
|
|
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<string> {
|
|
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<void> {
|
|
await gql(token, `
|
|
mutation {
|
|
delete_webhook(id: ${webhookId}) {
|
|
id
|
|
}
|
|
}
|
|
`);
|
|
}
|