echos-ocr/src/lib/monday.ts

381 lines
11 KiB
TypeScript
Raw Normal View History

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;
}
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<string, unknown>).date) {
const inner = String((val as Record<string, unknown>).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<string, string> = {
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();
// 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<string, unknown>,
columnMap: Record<string, unknown>
): Record<string, unknown> {
const values: Record<string, unknown> = {};
const columnTypes = ((columnMap._columnTypes as Record<string, string>) ?? {});
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<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
}
}
`);
}