import { getAirtableApi } from "@automate.ax/integration-contracts/airtable" import * as z from "zod" import { defineAction } from "../../automation/actions" import { airtableAccountRequirement, airtablePathSegment } from "./lib" const AIRTABLE_ID_SCHEMA = z.string().trim().min(1) const AIRTABLE_COMMENT_TARGET_SCHEMA = z.object({ /** Stable Airtable base ID. */ baseId: AIRTABLE_ID_SCHEMA, /** Stable Airtable record ID. */ recordId: AIRTABLE_ID_SCHEMA, /** Airtable table name or ID. */ table: AIRTABLE_ID_SCHEMA, }) const AIRTABLE_THUMBNAIL_SCHEMA = z.object({ height: z.number().optional(), url: z.url(), width: z.number().optional(), }) const AIRTABLE_COMMENT_ATTACHMENT_SCHEMA = z.object({ filename: z.string(), height: z.number().optional(), id: z.string(), size: z.number().optional(), thumbnails: z .object({ full: AIRTABLE_THUMBNAIL_SCHEMA.optional(), large: AIRTABLE_THUMBNAIL_SCHEMA.optional(), small: AIRTABLE_THUMBNAIL_SCHEMA.optional(), }) .optional(), type: z.string().optional(), url: z.url(), width: z.number().optional(), }) const AIRTABLE_COMMENT_USER_SCHEMA = z.object({ email: z.email(), id: z.string(), name: z.string().optional(), }) const AIRTABLE_MENTIONED_USER_SCHEMA = z.object({ displayName: z.string(), email: z.email(), id: z.string(), type: z.literal("user"), }) const AIRTABLE_COMMENT_RESPONSE_SCHEMA = z.object({ attachments: AIRTABLE_COMMENT_ATTACHMENT_SCHEMA.array().optional(), author: AIRTABLE_COMMENT_USER_SCHEMA, createdTime: z.iso.datetime({ offset: true }), id: z.string(), lastUpdatedTime: z.iso.datetime({ offset: true }).nullable(), mentioned: z.record(z.string(), AIRTABLE_MENTIONED_USER_SCHEMA).optional(), parentCommentId: z.string().optional(), reactions: z .object({ emoji: z.object({ unicodeCharacter: z.string() }), reactingUser: z.object({ email: z.email(), name: z.string().optional(), userId: z.string(), }), }) .array() .optional(), text: z.string(), }) const AIRTABLE_COMMENT_SCHEMA = AIRTABLE_COMMENT_RESPONSE_SCHEMA.extend({ /** Time at which Airtable created the comment. */ createdTime: z.date(), /** Time of the latest edit, or null when never edited. */ lastUpdatedTime: z.date().nullable(), }) /** Lists every comment and reply on one Airtable record. */ export const listAirtableRecordComments = defineAction( "List Airtable record comments", ) .describe("Lists record comments and threaded replies newest first.") .account("airtable", airtableAccountRequirement("data.recordComments:read")) .input( AIRTABLE_COMMENT_TARGET_SCHEMA.extend({ /** Maximum comments to return across all pages. */ maxComments: z.number().int().positive().optional(), /** Number of comments requested per provider page. */ pageSize: z.number().int().min(1).max(100).optional(), }), ) .output( z.object({ /** Number of returned comments. */ count: z.number().int().nonnegative(), /** Comments ordered newest first. */ comments: AIRTABLE_COMMENT_SCHEMA.array(), }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const api = getAirtableApi(account.secret) const comments: z.output[] = [] let offset: string | undefined do { const url = new URL( `https://api.airtable.com/v0/${commentCollectionPath(input)}`, ) url.searchParams.set( "pageSize", String( Math.min( input.pageSize ?? 100, input.maxComments === undefined ? 100 : input.maxComments - comments.length, ), ), ) if (offset) url.searchParams.set("offset", offset) const page = z .object({ comments: AIRTABLE_COMMENT_RESPONSE_SCHEMA.array(), offset: z.string().nullable(), }) .parse( await ( await api.request( `${url.pathname.replace(/^\/v0\//, "")}${url.search}`, ) ).json(), ) comments.push(...page.comments.map(toAirtableComment)) offset = page.offset ?? undefined } while ( offset && (input.maxComments === undefined || comments.length < input.maxComments) ) const limitedComments = comments.slice(0, input.maxComments) return { comments: limitedComments, count: limitedComments.length } }) /** Creates a comment or threaded reply on one Airtable record. */ export const createAirtableRecordComment = defineAction( "Create Airtable record comment", ) .describe("Creates a record comment or threaded reply.") .account("airtable", airtableAccountRequirement("data.recordComments:write")) .input( AIRTABLE_COMMENT_TARGET_SCHEMA.extend({ /** Parent comment ID when creating a threaded reply. */ parentCommentId: AIRTABLE_ID_SCHEMA.optional(), /** Comment text, including Airtable mention syntax when needed. */ text: z.string().min(1), }), ) .output(AIRTABLE_COMMENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input: { baseId, recordId, table, ...body } }) => toAirtableComment( AIRTABLE_COMMENT_RESPONSE_SCHEMA.parse( await ( await getAirtableApi(account.secret).request( commentCollectionPath({ baseId, recordId, table }), { body: JSON.stringify(body), method: "POST" }, ) ).json(), ), ), ) /** Updates one caller-owned Airtable record comment. */ export const updateAirtableRecordComment = defineAction( "Update Airtable record comment", ) .describe("Updates the text of a caller-owned record comment.") .account("airtable", airtableAccountRequirement("data.recordComments:write")) .input( AIRTABLE_COMMENT_TARGET_SCHEMA.extend({ /** Stable Airtable comment ID. */ commentId: AIRTABLE_ID_SCHEMA, /** Replacement comment text. */ text: z.string().min(1), }), ) .output(AIRTABLE_COMMENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input: { commentId, text, ...target } }) => toAirtableComment( AIRTABLE_COMMENT_RESPONSE_SCHEMA.parse( await ( await getAirtableApi(account.secret).request( `${commentCollectionPath(target)}/${airtablePathSegment(commentId)}`, { body: JSON.stringify({ text }), method: "PATCH" }, ) ).json(), ), ), ) /** Deletes one caller-owned Airtable record comment. */ export const deleteAirtableRecordComment = defineAction( "Delete Airtable record comment", ) .describe("Deletes a caller-owned record comment.") .account("airtable", airtableAccountRequirement("data.recordComments:write")) .input( AIRTABLE_COMMENT_TARGET_SCHEMA.extend({ /** Stable Airtable comment ID. */ commentId: AIRTABLE_ID_SCHEMA, }), ) .output(z.object({ deleted: z.literal(true), id: z.string() })) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input: { commentId, ...target } }) => z .object({ deleted: z.literal(true), id: z.string() }) .parse( await ( await getAirtableApi(account.secret).request( `${commentCollectionPath(target)}/${airtablePathSegment(commentId)}`, { method: "DELETE" }, ) ).json(), ), ) /** * Builds the provider collection path for one record's comments. * * @param target - Stable base, table, and record identifiers. * @param target.baseId - Stable Airtable base ID. * @param target.recordId - Stable Airtable record ID. * @param target.table - Airtable table name or ID. */ function commentCollectionPath(target: { baseId: string recordId: string table: string }) { return `${airtablePathSegment(target.baseId)}/${airtablePathSegment(target.table)}/${airtablePathSegment(target.recordId)}/comments` } /** * Maps provider timestamp strings to the public comment contract. * * @param comment - Parsed provider comment. */ function toAirtableComment( comment: z.output, ) { return { ...comment, createdTime: new Date(comment.createdTime), lastUpdatedTime: comment.lastUpdatedTime ? new Date(comment.lastUpdatedTime) : null, } }