import { getAirtableApi } from "@automate.ax/integration-contracts/airtable" import * as z from "zod" import { defineAction } from "../../automation/actions" import { integrationScope as scope } from "../../automation/integrations" import { airtableAccountRequirement, airtablePathSegment } from "./lib" const AIRTABLE_ID_SCHEMA = z.string().trim().min(1) const AIRTABLE_DESCRIPTION_SCHEMA = z.string().max(20_000) const AIRTABLE_PERMISSION_LEVEL_SCHEMA = z.enum([ "none", "read", "comment", "edit", "create", "owner", ]) const AIRTABLE_VIEW_TYPE_SCHEMA = z.enum([ "grid", "form", "calendar", "gallery", "kanban", "timeline", "block", ]) const AIRTABLE_DATE_DEPENDENCY_SETTINGS_SCHEMA = z.object({ durationFieldId: z.string(), endDateFieldId: z.string(), holidays: z.iso.date().array(), isEnabled: z.boolean(), isForwardOnly: z.boolean().nullable().optional(), predecessorFieldId: z.string().nullable(), reschedulingMode: z.enum(["flexible", "fixed", "none"]), shouldSkipWeekendsAndHolidays: z.boolean(), startDateFieldId: z.string(), }) const AIRTABLE_FIELD_INPUT_SCHEMA = z.object({ /** Optional field description. */ description: AIRTABLE_DESCRIPTION_SCHEMA.optional(), /** Field name, unique within its table ignoring case. */ name: z.string().trim().min(1), /** Options documented by Airtable for the selected field type. */ options: z.record(z.string(), z.json()).optional(), /** Airtable field type. */ type: z.string().trim().min(1), }) const AIRTABLE_FIELD_RESPONSE_SCHEMA = z.looseObject({ description: z.string().nullable().optional(), id: z.string(), name: z.string(), options: z.record(z.string(), z.json()).optional(), type: z.string().optional(), }) const AIRTABLE_FIELD_SCHEMA = z.object({ /** Optional field description. */ description: z.string().optional(), /** Stable Airtable field ID. */ id: z.string(), /** Field name. */ name: z.string(), /** Provider-specific configuration for the field type. */ options: z.record(z.string(), z.json()).optional(), /** Airtable field type when returned by the provider. */ type: z.string().optional(), }) const AIRTABLE_VIEW_RESPONSE_SCHEMA = z.looseObject({ id: z.string(), name: z.string(), personalForUserId: z.string().optional(), type: AIRTABLE_VIEW_TYPE_SCHEMA, visibleFieldIds: z.string().array().optional(), }) const AIRTABLE_VIEW_SCHEMA = z.object({ /** Stable Airtable view ID. */ id: z.string(), /** View name. */ name: z.string(), /** User that owns a personal view. */ personalForUserId: z.string().optional(), /** Airtable view type. */ type: AIRTABLE_VIEW_TYPE_SCHEMA, /** Visible field IDs when explicitly requested for a grid view. */ visibleFieldIds: z.string().array().optional(), }) const AIRTABLE_TABLE_INPUT_SCHEMA = z.object({ /** Optional table description. */ description: AIRTABLE_DESCRIPTION_SCHEMA.optional(), /** Initial fields. The first field becomes the primary field. */ fields: AIRTABLE_FIELD_INPUT_SCHEMA.array().min(1), /** Table name. */ name: z.string().trim().min(1), }) const AIRTABLE_TABLE_RESPONSE_SCHEMA = z.looseObject({ dateDependencySettings: AIRTABLE_DATE_DEPENDENCY_SETTINGS_SCHEMA.optional(), description: z.string().nullable().optional(), fields: AIRTABLE_FIELD_RESPONSE_SCHEMA.array(), id: z.string(), name: z.string(), primaryFieldId: z.string(), views: AIRTABLE_VIEW_RESPONSE_SCHEMA.array(), }) const AIRTABLE_TABLE_SCHEMA = z.object({ /** Date dependency configuration when enabled for this table. */ dateDependencySettings: AIRTABLE_DATE_DEPENDENCY_SETTINGS_SCHEMA.optional(), /** Optional table description. */ description: z.string().optional(), /** Table fields. */ fields: AIRTABLE_FIELD_SCHEMA.array(), /** Stable Airtable table ID. */ id: z.string(), /** Table name. */ name: z.string(), /** Stable ID of the primary field. */ primaryFieldId: z.string(), /** Table views. */ views: AIRTABLE_VIEW_SCHEMA.array(), }) const AIRTABLE_USER_COLLABORATOR_RESPONSE_SCHEMA = z.looseObject({ createdTime: z.iso.datetime({ offset: true }), email: z.email(), grantedByUserId: z.string(), permissionLevel: AIRTABLE_PERMISSION_LEVEL_SCHEMA, userId: z.string(), }) const AIRTABLE_GROUP_COLLABORATOR_RESPONSE_SCHEMA = z.looseObject({ createdTime: z.iso.datetime({ offset: true }), grantedByUserId: z.string(), groupId: z.string(), name: z.string(), permissionLevel: AIRTABLE_PERMISSION_LEVEL_SCHEMA, }) const AIRTABLE_BASE_COLLABORATORS_RESPONSE_SCHEMA = z.looseObject({ createdTime: z.iso.datetime({ offset: true }), groupCollaborators: z .looseObject({ baseCollaborators: AIRTABLE_GROUP_COLLABORATOR_RESPONSE_SCHEMA.array(), workspaceCollaborators: AIRTABLE_GROUP_COLLABORATOR_RESPONSE_SCHEMA.array(), }) .optional(), id: z.string(), individualCollaborators: z .looseObject({ baseCollaborators: AIRTABLE_USER_COLLABORATOR_RESPONSE_SCHEMA.array(), workspaceCollaborators: AIRTABLE_USER_COLLABORATOR_RESPONSE_SCHEMA.array(), }) .optional(), name: z.string(), permissionLevel: AIRTABLE_PERMISSION_LEVEL_SCHEMA, workspaceId: z.string(), }) const AIRTABLE_USER_COLLABORATOR_SCHEMA = z.object({ createdTime: z.date(), email: z.email(), grantedByUserId: z.string(), permissionLevel: AIRTABLE_PERMISSION_LEVEL_SCHEMA, userId: z.string(), }) const AIRTABLE_GROUP_COLLABORATOR_SCHEMA = z.object({ createdTime: z.date(), grantedByUserId: z.string(), groupId: z.string(), name: z.string(), permissionLevel: AIRTABLE_PERMISSION_LEVEL_SCHEMA, }) /** Creates an Airtable base with its initial tables and fields. */ export const createAirtableBase = defineAction("Create Airtable base") .describe("Creates a base with initial tables in one workspace.") .account("airtable", airtableAccountRequirement("schema.bases:write")) .input( z.object({ /** Base name. */ name: z.string().trim().min(1), /** Initial tables. */ tables: AIRTABLE_TABLE_INPUT_SCHEMA.array().min(1), /** Stable Airtable workspace ID. */ workspaceId: AIRTABLE_ID_SCHEMA, }), ) .output( z.object({ /** Stable ID of the created base. */ id: z.string(), /** Created table schemas. */ tables: AIRTABLE_TABLE_SCHEMA.array(), }), ) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const response = z .looseObject({ id: z.string(), tables: AIRTABLE_TABLE_RESPONSE_SCHEMA.array(), }) .parse( await ( await getAirtableApi(account.secret).request("meta/bases", { body: JSON.stringify(input), method: "POST", }) ).json(), ) return { id: response.id, tables: response.tables.map(toAirtableTable) } }) /** Creates a table with its initial fields in an existing Airtable base. */ export const createAirtableTable = defineAction("Create Airtable table") .describe("Creates a table and its initial fields in a base.") .account("airtable", airtableAccountRequirement("schema.bases:write")) .input( AIRTABLE_TABLE_INPUT_SCHEMA.extend({ /** Stable Airtable base ID. */ baseId: AIRTABLE_ID_SCHEMA, }), ) .output(AIRTABLE_TABLE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input: { baseId, ...body } }) => toAirtableTable( AIRTABLE_TABLE_RESPONSE_SCHEMA.parse( await ( await getAirtableApi(account.secret).request( `meta/bases/${airtablePathSegment(baseId)}/tables`, { body: JSON.stringify(body), method: "POST" }, ) ).json(), ), ), ) /** Updates an Airtable table's metadata or date dependency settings. */ export const updateAirtableTable = defineAction("Update Airtable table") .describe("Updates a table's metadata or date dependency settings.") .account("airtable", airtableAccountRequirement("schema.bases:write")) .input( z.object({ /** Stable Airtable base ID. */ baseId: AIRTABLE_ID_SCHEMA, /** New table description. */ description: AIRTABLE_DESCRIPTION_SCHEMA.optional(), /** Date dependency configuration for this table. */ dateDependencySettings: AIRTABLE_DATE_DEPENDENCY_SETTINGS_SCHEMA.optional(), /** New table name. */ name: z.string().trim().min(1).optional(), /** Airtable table name or ID. */ table: AIRTABLE_ID_SCHEMA, }), ) .output(AIRTABLE_TABLE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input: { baseId, table, ...body } }) => toAirtableTable( AIRTABLE_TABLE_RESPONSE_SCHEMA.parse( await ( await getAirtableApi(account.secret).request( `meta/bases/${airtablePathSegment(baseId)}/tables/${airtablePathSegment(table)}`, { body: JSON.stringify(body), method: "PATCH" }, ) ).json(), ), ), ) /** Creates one field in an Airtable table. */ export const createAirtableField = defineAction("Create Airtable field") .describe("Creates a field in an existing table.") .account("airtable", airtableAccountRequirement("schema.bases:write")) .input( AIRTABLE_FIELD_INPUT_SCHEMA.extend({ /** Stable Airtable base ID. */ baseId: AIRTABLE_ID_SCHEMA, /** Stable Airtable table ID. */ tableId: AIRTABLE_ID_SCHEMA, }), ) .output(AIRTABLE_FIELD_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input: { baseId, tableId, ...body } }) => toAirtableField( AIRTABLE_FIELD_RESPONSE_SCHEMA.parse( await ( await getAirtableApi(account.secret).request( `meta/bases/${airtablePathSegment(baseId)}/tables/${airtablePathSegment(tableId)}/fields`, { body: JSON.stringify(body), method: "POST" }, ) ).json(), ), ), ) /** Updates one Airtable field's name, description, or options. */ export const updateAirtableField = defineAction("Update Airtable field") .describe("Updates a field definition in an existing table.") .account("airtable", airtableAccountRequirement("schema.bases:write")) .input( z.object({ /** Stable Airtable base ID. */ baseId: AIRTABLE_ID_SCHEMA, /** New field description. */ description: AIRTABLE_DESCRIPTION_SCHEMA.optional(), /** Stable Airtable field ID. */ fieldId: AIRTABLE_ID_SCHEMA, /** New field name. */ name: z.string().trim().min(1).optional(), /** New options documented for the field type. */ options: z.record(z.string(), z.json()).optional(), /** Stable Airtable table ID. */ tableId: AIRTABLE_ID_SCHEMA, }), ) .output(AIRTABLE_FIELD_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input: { baseId, fieldId, tableId, ...body } }) => toAirtableField( AIRTABLE_FIELD_RESPONSE_SCHEMA.parse( await ( await getAirtableApi(account.secret).request( `meta/bases/${airtablePathSegment(baseId)}/tables/${airtablePathSegment(tableId)}/fields/${airtablePathSegment(fieldId)}`, { body: JSON.stringify(body), method: "PATCH" }, ) ).json(), ), ), ) /** Lists views in an Airtable base. */ export const listAirtableViews = defineAction("List Airtable views") .describe("Lists base views and optionally their visible grid fields.") .account("airtable", airtableAccountRequirement("workspacesAndBases:read")) .input( z.object({ /** Stable Airtable base ID. */ baseId: AIRTABLE_ID_SCHEMA, /** Include visible field IDs for grid views. */ includeVisibleFieldIds: z.boolean().optional(), }), ) .output(z.object({ views: AIRTABLE_VIEW_SCHEMA.array() })) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => z .object({ views: AIRTABLE_VIEW_RESPONSE_SCHEMA.array() }) .parse( await ( await getAirtableApi(account.secret).request( viewPath(input.baseId, undefined, input.includeVisibleFieldIds), ) ).json(), ), ) /** Gets one Airtable view by stable ID. */ export const getAirtableView = defineAction("Get Airtable view") .describe("Gets one view and optionally its visible grid fields.") .account("airtable", airtableAccountRequirement("workspacesAndBases:read")) .input( z.object({ /** Stable Airtable base ID. */ baseId: AIRTABLE_ID_SCHEMA, /** Include visible field IDs for a grid view. */ includeVisibleFieldIds: z.boolean().optional(), /** Stable Airtable view ID. */ viewId: AIRTABLE_ID_SCHEMA, }), ) .output(AIRTABLE_VIEW_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => AIRTABLE_VIEW_RESPONSE_SCHEMA.parse( await ( await getAirtableApi(account.secret).request( viewPath(input.baseId, input.viewId, input.includeVisibleFieldIds), ) ).json(), ), ) /** Gets base metadata and explicit individual and group collaborators. */ export const getAirtableBaseCollaborators = defineAction( "Get Airtable base collaborators", ) .describe("Gets base metadata and explicit collaborators.") .account( "airtable", airtableAccountRequirement( scope.and("schema.bases:read", "workspacesAndBases:read"), ), ) .input(z.object({ baseId: AIRTABLE_ID_SCHEMA })) .output( z.object({ createdTime: z.date(), groupCollaborators: z .object({ baseCollaborators: AIRTABLE_GROUP_COLLABORATOR_SCHEMA.array(), workspaceCollaborators: AIRTABLE_GROUP_COLLABORATOR_SCHEMA.array(), }) .optional(), id: z.string(), individualCollaborators: z .object({ baseCollaborators: AIRTABLE_USER_COLLABORATOR_SCHEMA.array(), workspaceCollaborators: AIRTABLE_USER_COLLABORATOR_SCHEMA.array(), }) .optional(), name: z.string(), permissionLevel: AIRTABLE_PERMISSION_LEVEL_SCHEMA, workspaceId: z.string(), }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const base = AIRTABLE_BASE_COLLABORATORS_RESPONSE_SCHEMA.parse( await ( await getAirtableApi(account.secret).request( `meta/bases/${airtablePathSegment(input.baseId)}?include%5B%5D=collaborators`, ) ).json(), ) return { ...base, createdTime: new Date(base.createdTime), groupCollaborators: mapCollaborators(base.groupCollaborators), individualCollaborators: mapCollaborators(base.individualCollaborators), } }) /** * Removes provider nulls from the public optional field description. * * @param field - Parsed provider field. */ function toAirtableField( field: z.output, ) { return { ...field, description: field.description ?? undefined } } /** * Maps one provider table response to the public table contract. * * @param table - Parsed provider table. */ function toAirtableTable( table: z.output, ) { return { ...table, description: table.description ?? undefined, fields: table.fields.map(toAirtableField), } } /** * Builds one view metadata path and optional include query. * * @param baseId - Stable Airtable base ID. * @param viewId - Optional stable Airtable view ID. * @param includeVisibleFieldIds - Whether to request visible grid fields. */ function viewPath( baseId: string, viewId: string | undefined, includeVisibleFieldIds: boolean | undefined, ) { const url = new URL( `https://api.airtable.com/v0/meta/bases/${airtablePathSegment(baseId)}/views${viewId ? `/${airtablePathSegment(viewId)}` : ""}`, ) if (includeVisibleFieldIds) { url.searchParams.append("include[]", "visibleFieldIds") } return `${url.pathname.replace(/^\/v0\//, "")}${url.search}` } /** * Maps optional collaborator groups to public timestamp values. * * @param collaborators - Provider collaborator groups. */ function mapCollaborators( collaborators: | { baseCollaborators: T[] workspaceCollaborators: T[] } | undefined, ) { return collaborators ? { baseCollaborators: collaborators.baseCollaborators.map(mapCollaborator), workspaceCollaborators: collaborators.workspaceCollaborators.map(mapCollaborator), } : undefined } /** * Maps one collaborator timestamp to a Date. * * @param collaborator - Provider collaborator. */ function mapCollaborator(collaborator: T) { return { ...collaborator, createdTime: new Date(collaborator.createdTime) } }