diff --git a/.changeset/gentle-brooms-sing.md b/.changeset/gentle-brooms-sing.md new file mode 100644 index 000000000..1dbcf4a5b --- /dev/null +++ b/.changeset/gentle-brooms-sing.md @@ -0,0 +1,12 @@ +--- +"@trigger.dev/integration-kit": patch +"@trigger.dev/airtable": patch +"@trigger.dev/shopify": patch +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +"@trigger.dev/cli": patch +--- + +- Simplify `Webhook Triggers` and use the new HTTP Endpoints +- Add a `Key-Value Store` for use in and outside of Jobs +- Add a `@trigger.dev/shopify` package diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index 9bb978c28..d38d35c4f 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -100,7 +100,7 @@ export function PageInfoProperty({ }: { icon?: string | React.ReactNode; label?: string; - value: React.ReactNode; + value?: React.ReactNode; to?: string; }) { if (to === undefined) { @@ -121,17 +121,18 @@ function PageInfoPropertyContent({ }: { icon?: string | React.ReactNode; label?: string; - value: React.ReactNode; + value?: React.ReactNode; }) { return (
{icon && typeof icon === "string" ? : icon} {label && ( - {label}: + {label} + {value && ":"} )} - {value} + {value && {value}}
); } diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index da2e3c70d..00c701a30 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -8,9 +8,10 @@ export type TabsProps = { to: string; }[]; className?: string; + layoutId: string }; -export function Tabs({ tabs, className }: TabsProps) { +export function Tabs({ tabs, className, layoutId }: TabsProps) { return (
{tabs.map((tab, index) => ( @@ -26,7 +27,7 @@ export function Tabs({ tabs, className }: TabsProps) { {tab.label} {isActive || isPending ? ( - + ) : (
)} diff --git a/apps/webapp/app/components/runs/WebhookDeliveryRunsTable.tsx b/apps/webapp/app/components/runs/WebhookDeliveryRunsTable.tsx new file mode 100644 index 000000000..46160052c --- /dev/null +++ b/apps/webapp/app/components/runs/WebhookDeliveryRunsTable.tsx @@ -0,0 +1,126 @@ +import { StopIcon } from "@heroicons/react/24/outline"; +import { CheckIcon } from "@heroicons/react/24/solid"; +import { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { formatDuration } from "~/utils"; +import { EnvironmentLabel } from "../environments/EnvironmentLabel"; +import { DateTime } from "../primitives/DateTime"; +import { Paragraph } from "../primitives/Paragraph"; +import { Spinner } from "../primitives/Spinner"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "../primitives/Table"; +import { RunStatus } from "./RunStatuses"; + +type RunTableItem = { + id: string; + number: number; + environment: { + type: RuntimeEnvironmentType; + }; + error: string | null; + createdAt: Date | null; + deliveredAt: Date | null; + verified: boolean; +}; + +type RunsTableProps = { + total: number; + hasFilters: boolean; + runs: RunTableItem[]; + isLoading?: boolean; + runsParentPath: string; +}; + +export function WebhookDeliveryRunsTable({ + total, + hasFilters, + runs, + isLoading = false, + runsParentPath, +}: RunsTableProps) { + return ( + + + + Run + Env + Status + Last Error + Started + Duration + Verified + Created at + + + + {total === 0 && !hasFilters ? ( + + + + ) : runs.length === 0 ? ( + + + + ) : ( + runs.map((run) => { + return ( + + #{run.number} + + + + + + + {run.error?.slice(0, 30) ?? "–"} + {run.createdAt ? : "–"} + + {formatDuration(run.createdAt, run.deliveredAt, { + style: "short", + })} + + + {run.verified ? ( + + ) : ( + + )} + + {run.createdAt ? : "–"} + + ); + }) + )} + {isLoading && ( + + Loading… + + )} + +
+ ); +} +function NoRuns({ title }: { title: string }) { + return ( +
+ {title} +
+ ); +} diff --git a/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts b/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts index fe792483c..886b4192d 100644 --- a/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts +++ b/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts @@ -1,11 +1,9 @@ -import { TriggerHttpEndpoint } from "@trigger.dev/database"; import { z } from "zod"; import { PrismaClient, prisma } from "~/db.server"; -import { Project } from "~/models/project.server"; -import { User } from "~/models/user.server"; import { sortEnvironments } from "~/services/environmentSort.server"; import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService"; import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { projectPath } from "~/utils/pathBuilder"; export class HttpEndpointPresenter { #prismaClient: PrismaClient; @@ -17,10 +15,12 @@ export class HttpEndpointPresenter { public async call({ userId, projectSlug, + organizationSlug, httpEndpointKey, }: { userId: string; projectSlug: string; + organizationSlug: string; httpEndpointKey: string; }) { const httpEndpoint = await this.#prismaClient.triggerHttpEndpoint.findFirst({ @@ -57,6 +57,12 @@ export class HttpEndpointPresenter { }, }, }, + webhook: { + select: { + id: true, + key: true, + }, + }, }, where: { key: httpEndpointKey, @@ -138,11 +144,15 @@ export class HttpEndpointPresenter { ?.webhookUrl, })); + const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug }); + return { httpEndpoint: { ...httpEndpoint, - httpEndpointEnvironments, + webhookLink: httpEndpoint.webhook + ? `${projectRootPath}/triggers/webhooks/${httpEndpoint.webhook.id}` + : undefined, }, environments: relevantEnvironments, unconfiguredEnvironments: relevantEnvironments.filter( diff --git a/apps/webapp/app/presenters/WebhookDeliveryListPresenter.server.ts b/apps/webapp/app/presenters/WebhookDeliveryListPresenter.server.ts new file mode 100644 index 000000000..3aec19238 --- /dev/null +++ b/apps/webapp/app/presenters/WebhookDeliveryListPresenter.server.ts @@ -0,0 +1,117 @@ +import { PrismaClient, prisma } from "~/db.server"; +import { Direction } from "./RunListPresenter.server"; + +type RunListOptions = { + userId: string; + webhookId: string; + direction?: Direction; + cursor?: string; +}; + +const PAGE_SIZE = 20; + +export type WebhookDeliveryList = Awaited>; + +export class WebhookDeliveryListPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ userId, webhookId, direction = "forward", cursor }: RunListOptions) { + const directionMultiplier = direction === "forward" ? 1 : -1; + + const runs = await this.#prismaClient.webhookRequestDelivery.findMany({ + select: { + id: true, + number: true, + createdAt: true, + deliveredAt: true, + verified: true, + error: true, + environment: { + select: { + type: true, + slug: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + }, + }, + where: { + webhookId, + environment: { + OR: [ + { + orgMember: null, + }, + { + orgMember: { + userId, + }, + }, + ], + }, + }, + orderBy: [{ id: "desc" }], + //take an extra page to tell if there are more + take: directionMultiplier * (PAGE_SIZE + 1), + //skip the cursor if there is one + skip: cursor ? 1 : 0, + cursor: cursor + ? { + id: cursor, + } + : undefined, + }); + + const hasMore = runs.length > PAGE_SIZE; + + //get cursors for next and previous pages + let next: string | undefined; + let previous: string | undefined; + switch (direction) { + case "forward": + previous = cursor ? runs.at(0)?.id : undefined; + if (hasMore) { + next = runs[PAGE_SIZE - 1]?.id; + } + break; + case "backward": + if (hasMore) { + previous = runs[1]?.id; + next = runs[PAGE_SIZE]?.id; + } else { + next = runs[PAGE_SIZE - 1]?.id; + } + break; + } + + const runsToReturn = + direction === "backward" && hasMore ? runs.slice(1, PAGE_SIZE + 1) : runs.slice(0, PAGE_SIZE); + + return { + runs: runsToReturn.map((run) => ({ + id: run.id, + number: run.number, + createdAt: run.createdAt, + deliveredAt: run.deliveredAt, + verified: run.verified, + error: run.error, + environment: { + type: run.environment.type, + slug: run.environment.slug, + userId: run.environment.orgMember?.userId, + }, + })), + pagination: { + next, + previous, + }, + }; + } +} diff --git a/apps/webapp/app/presenters/WebhookDeliveryPresenter.server.ts b/apps/webapp/app/presenters/WebhookDeliveryPresenter.server.ts new file mode 100644 index 000000000..5acd8a639 --- /dev/null +++ b/apps/webapp/app/presenters/WebhookDeliveryPresenter.server.ts @@ -0,0 +1,96 @@ +import { User, Webhook } from "@trigger.dev/database"; +import { PrismaClient, prisma } from "~/db.server"; +import { Organization } from "~/models/organization.server"; +import { Project } from "~/models/project.server"; +import { Direction } from "./RunListPresenter.server"; +import { organizationPath, projectPath } from "~/utils/pathBuilder"; +import { WebhookDeliveryListPresenter } from "./WebhookDeliveryListPresenter.server"; + +export class WebhookDeliveryPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + organizationSlug, + webhookId, + direction = "forward", + cursor, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + organizationSlug: Organization["slug"]; + webhookId: Webhook["id"]; + direction?: Direction; + cursor?: string; + }) { + const webhook = await this.#prismaClient.webhook.findUnique({ + select: { + id: true, + key: true, + active: true, + integration: { + select: { + id: true, + title: true, + slug: true, + definitionId: true, + setupStatus: true, + definition: { + select: { + icon: true, + }, + }, + }, + }, + httpEndpoint: { + select: { + key: true, + }, + }, + createdAt: true, + updatedAt: true, + params: true, + }, + where: { + id: webhookId, + }, + }); + + if (!webhook) { + throw new Error("Webhook source not found"); + } + + const deliveryListPresenter = new WebhookDeliveryListPresenter(this.#prismaClient); + + const orgRootPath = organizationPath({ slug: organizationSlug }); + const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug }); + + const requestDeliveries = await deliveryListPresenter.call({ + userId, + webhookId: webhook.id, + direction, + cursor, + }); + + return { + webhook: { + id: webhook.id, + key: webhook.key, + active: webhook.active, + integration: webhook.integration, + integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`, + httpEndpoint: webhook.httpEndpoint, + httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`, + createdAt: webhook.createdAt, + updatedAt: webhook.updatedAt, + params: webhook.params, + requestDeliveries, + }, + }; + } +} diff --git a/apps/webapp/app/presenters/WebhookSourcePresenter.server.ts b/apps/webapp/app/presenters/WebhookSourcePresenter.server.ts new file mode 100644 index 000000000..bc8244905 --- /dev/null +++ b/apps/webapp/app/presenters/WebhookSourcePresenter.server.ts @@ -0,0 +1,106 @@ +import { User, Webhook } from "@trigger.dev/database"; +import { PrismaClient, prisma } from "~/db.server"; +import { Organization } from "~/models/organization.server"; +import { Project } from "~/models/project.server"; +import { Direction, RunListPresenter } from "./RunListPresenter.server"; +import { organizationPath, projectPath } from "~/utils/pathBuilder"; + +export class WebhookSourcePresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + organizationSlug, + webhookId, + direction = "forward", + cursor, + getDeliveryRuns = false, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + organizationSlug: Organization["slug"]; + webhookId: Webhook["id"]; + direction?: Direction; + cursor?: string; + getDeliveryRuns?: boolean; + }) { + const webhook = await this.#prismaClient.webhook.findUnique({ + select: { + id: true, + key: true, + active: true, + integration: { + select: { + id: true, + title: true, + slug: true, + definitionId: true, + setupStatus: true, + definition: { + select: { + icon: true, + }, + }, + }, + }, + httpEndpoint: { + select: { + key: true, + }, + }, + createdAt: true, + updatedAt: true, + params: true, + }, + where: { + id: webhookId, + }, + }); + + if (!webhook) { + throw new Error("Webhook source not found"); + } + + const runListPresenter = new RunListPresenter(this.#prismaClient); + const jobSlug = getDeliveryRuns + ? getDeliveryJobSlug(webhook.key) + : getRegistrationJobSlug(webhook.key); + + const runList = await runListPresenter.call({ + userId, + jobSlug, + organizationSlug, + projectSlug, + direction, + cursor, + }); + + const orgRootPath = organizationPath({ slug: organizationSlug }); + const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug }); + + return { + trigger: { + id: webhook.id, + key: webhook.key, + active: webhook.active, + integration: webhook.integration, + integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`, + httpEndpoint: webhook.httpEndpoint, + httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`, + createdAt: webhook.createdAt, + updatedAt: webhook.updatedAt, + params: webhook.params, + runList, + }, + }; + } +} + +const getRegistrationJobSlug = (key: string) => `webhook.register.${key}`; + +const getDeliveryJobSlug = (key: string) => `webhook.deliver.${key}`; diff --git a/apps/webapp/app/presenters/WebhookTriggersPresenter.server.ts b/apps/webapp/app/presenters/WebhookTriggersPresenter.server.ts new file mode 100644 index 000000000..a9e76cae5 --- /dev/null +++ b/apps/webapp/app/presenters/WebhookTriggersPresenter.server.ts @@ -0,0 +1,71 @@ +import { Organization, User } from "@trigger.dev/database"; +import { PrismaClient, prisma } from "~/db.server"; +import { Project } from "~/models/project.server"; + +export class WebhookTriggersPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + organizationSlug, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + organizationSlug: Organization["slug"]; + }) { + const webhooks = await this.#prismaClient.webhook.findMany({ + select: { + id: true, + key: true, + active: true, + params: true, + integration: { + select: { + id: true, + title: true, + slug: true, + definitionId: true, + setupStatus: true, + definition: { + select: { + icon: true, + }, + }, + }, + }, + webhookEnvironments: { + select: { + id: true, + environment: { + select: { + type: true + } + } + } + }, + createdAt: true, + updatedAt: true, + }, + where: { + project: { + slug: projectSlug, + organization: { + slug: organizationSlug, + members: { + some: { + userId, + }, + }, + }, + }, + }, + }); + + return { webhooks }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route.tsx index e622d02c1..a3b1fcc0b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route.tsx @@ -113,7 +113,7 @@ export default function Integrations() { /> - + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.http-endpoints.$httpEndpointParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.http-endpoints.$httpEndpointParam/route.tsx index 595687d17..4a719b382 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.http-endpoints.$httpEndpointParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.http-endpoints.$httpEndpointParam/route.tsx @@ -14,6 +14,9 @@ import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help"; import { PageButtons, PageHeader, + PageInfoGroup, + PageInfoProperty, + PageInfoRow, PageTitle, PageTitleRow, } from "~/components/primitives/PageHeader"; @@ -38,13 +41,15 @@ import { HttpEndpointParamSchema, docsPath, projectHttpEndpointsPath } from "~/u export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { projectParam, httpEndpointParam } = HttpEndpointParamSchema.parse(params); + const { projectParam, organizationSlug, httpEndpointParam } = + HttpEndpointParamSchema.parse(params); const presenter = new HttpEndpointPresenter(); try { const result = await presenter.call({ userId, projectSlug: projectParam, + organizationSlug, httpEndpointKey: httpEndpointParam, }); @@ -98,6 +103,17 @@ export default function Page() { + {httpEndpoint.webhook && ( + + + + + + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx index 3801568a5..0992803bc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination.tsx @@ -1,9 +1,16 @@ import { useLocation } from "@remix-run/react"; import { LinkButton } from "~/components/primitives/Buttons"; import { Direction, RunList } from "~/presenters/RunListPresenter.server"; +import { WebhookDeliveryList } from "~/presenters/WebhookDeliveryListPresenter.server"; import { cn } from "~/utils/cn"; -export function ListPagination({ list, className }: { list: RunList; className?: string }) { +export function ListPagination({ + list, + className, +}: { + list: RunList | WebhookDeliveryList; + className?: string; +}) { return (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx index dffd6cd79..4c999c639 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx @@ -154,6 +154,7 @@ export default function Job() { )} { + const user = await requireUser(request); + const { organizationSlug, projectParam } = ProjectParamSchema.parse(params); + + const presenter = new WebhookTriggersPresenter(); + const data = await presenter.call({ + userId: user.id, + organizationSlug, + projectSlug: projectParam, + }); + + return typedjson(data); +}; + +export const handle: Handle = { + breadcrumb: (match) => ( + + ), +}; + +export default function Integrations() { + const { webhooks } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + + return ( + <> + + A Webhook Trigger runs a Job when it receives a matching payload at a registered HTTP Endpoint. + + + + + + Key + Integration + Properties + Environment + Active + Go to page + + + + {webhooks.length > 0 ? ( + webhooks.map((w) => { + const path = webhookTriggerPath(organization, project, w); + return ( + + {w.key} + +
+ + +
+
+ + {w.params && ( + + {Object.entries(w.params).map(([label, value], index) => ( + + ))} + + } + content={ +
+ {Object.entries(w.params).map(([label, value], index) => ( + + ))} +
+ } + /> + )} +
+ +
+ {w.webhookEnvironments.map((env) => ( + + ))} +
+
+ + {w.active ? ( + + ) : ( + + )} + + +
+ ); + }) + ) : ( + + No External triggers + + )} +
+
+ + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers/route.tsx index 7830ff1e4..7cb142564 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers/route.tsx @@ -17,6 +17,7 @@ import { docsPath, projectScheduledTriggersPath, projectTriggersPath, + projectWebhookTriggersPath, trimTrailingSlash, } from "~/utils/pathBuilder"; @@ -45,6 +46,7 @@ export default function Page() { A Trigger is what starts a Job Run. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam._index/route.tsx new file mode 100644 index 000000000..d3af8ec23 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam._index/route.tsx @@ -0,0 +1,181 @@ +import { json } from "@remix-run/node"; +import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { Fragment } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { BreadcrumbLink } from "~/components/navigation/Breadcrumb"; +import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon"; +import { Callout, variantClasses } from "~/components/primitives/Callout"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { RunsTable } from "~/components/runs/RunsTable"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useTypedMatchData } from "~/hooks/useTypedMatchData"; +import { requireUser, requireUserId } from "~/services/session.server"; +import { Handle } from "~/utils/handle"; +import { + TriggerSourceParamSchema, + projectTriggersPath, + externalTriggerPath, + trimTrailingSlash, + webhookTriggerRunsParentPath, + projectWebhookTriggersPath, +} from "~/utils/pathBuilder"; +import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination"; +import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route"; +import { Button } from "~/components/primitives/Buttons"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { cn } from "~/utils/cn"; +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { z } from "zod"; +import { ActivateSourceService } from "~/services/sources/activateSource.server"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params); + + const url = new URL(request.url); + const s = Object.fromEntries(url.searchParams.entries()); + const searchParams = RunListSearchSchema.parse(s); + + const presenter = new WebhookSourcePresenter(); + const { trigger } = await presenter.call({ + userId: user.id, + organizationSlug, + projectSlug: projectParam, + webhookId: triggerParam, + direction: searchParams.direction, + cursor: searchParams.cursor, + }); + + if (!trigger) { + throw new Response("Trigger not found", { + status: 404, + statusText: "Not Found", + }); + } + + return typedjson({ trigger }); +}; + +const schema = z.object({ + jobId: z.string(), +}); + +/* export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params); + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value) { + return json(submission); + } + + try { + const service = new ActivateSourceService(); + + const result = await service.call(triggerParam); + + return redirectWithSuccessMessage( + externalTriggerPath({ slug: organizationSlug }, { slug: projectParam }, { id: triggerParam }), + request, + `Retrying registration now` + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } +}; */ + +export const handle: Handle = { + //this one is complicated because we render outside the parent route (using triggers_ in the path) + breadcrumb: (match, matches) => { + const data = useTypedMatchData(match); + if (!data) return null; + + const org = useOrganization(matches); + const project = useProject(matches); + + return ( + + + + + + + + ); + }, +}; + +export default function Page() { + const { trigger } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const navigation = useNavigation(); + const lastSubmission = useActionData(); + + const [form, { jobId }] = useForm({ + id: "trigger-registration-retry", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + }); + + const isLoading = navigation.state === "submitting" && navigation.formData !== undefined; + + return ( + <> + + Webhook Triggers need to be registered with the external service. You can see the list + of attempted registrations below. + + + {!trigger.active && +
+ + + Registration hasn't succeeded yet, check the runs below. + + {/* + */} + +
} + + {trigger.runList ? ( + <> + + + + + ) : ( + No registration runs found + )} + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam.delivery/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam.delivery/route.tsx new file mode 100644 index 000000000..d30726215 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam.delivery/route.tsx @@ -0,0 +1,108 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { Fragment } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { BreadcrumbLink } from "~/components/navigation/Breadcrumb"; +import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon"; +import { Callout } from "~/components/primitives/Callout"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useTypedMatchData } from "~/hooks/useTypedMatchData"; +import { requireUser } from "~/services/session.server"; +import { Handle } from "~/utils/handle"; +import { + TriggerSourceParamSchema, + projectTriggersPath, + projectWebhookTriggersPath, + trimTrailingSlash, + webhookTriggerDeliveryRunsParentPath, + webhookTriggerPath, +} from "~/utils/pathBuilder"; +import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination"; +import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route"; +import { WebhookDeliveryPresenter } from "~/presenters/WebhookDeliveryPresenter.server"; +import { WebhookDeliveryRunsTable } from "~/components/runs/WebhookDeliveryRunsTable"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params); + + const url = new URL(request.url); + const s = Object.fromEntries(url.searchParams.entries()); + const searchParams = RunListSearchSchema.parse(s); + + const presenter = new WebhookDeliveryPresenter(); + const { webhook } = await presenter.call({ + userId: user.id, + organizationSlug, + projectSlug: projectParam, + webhookId: triggerParam, + direction: searchParams.direction, + cursor: searchParams.cursor, + }); + + if (!webhook) { + throw new Response("Trigger not found", { + status: 404, + statusText: "Not Found", + }); + } + + return typedjson({ webhook }); +}; + +export const handle: Handle = { + //this one is complicated because we render outside the parent route (using triggers_ in the path) + breadcrumb: (match, matches) => { + const data = useTypedMatchData(match); + if (!data) return null; + + const org = useOrganization(matches); + const project = useProject(matches); + + return ( + + + + + + + + + + ); + }, +}; + +export default function Page() { + const { webhook } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + + return ( + <> + + Webhook payloads are delivered to clients for validation and event generation. You can see + the list of attempted deliveries below. + + + {webhook.requestDeliveries ? ( + <> + + + + + ) : ( + No registration runs found + )} + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam/route.tsx new file mode 100644 index 000000000..66aee145b --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam/route.tsx @@ -0,0 +1,109 @@ +import { Outlet } from "@remix-run/react"; +import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { NamedIcon } from "~/components/primitives/NamedIcon"; +import { + PageHeader, + PageInfoGroup, + PageInfoProperty, + PageInfoRow, + PageTabs, + PageTitle, + PageTitleRow, +} from "~/components/primitives/PageHeader"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { requireUser } from "~/services/session.server"; +import { + TriggerSourceParamSchema, + projectWebhookTriggersPath, + webhookDeliveryPath, + webhookTriggerPath, +} from "~/utils/pathBuilder"; +import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route"; +import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params); + + const url = new URL(request.url); + const s = Object.fromEntries(url.searchParams.entries()); + const searchParams = RunListSearchSchema.parse(s); + + const presenter = new WebhookSourcePresenter(); + const { trigger } = await presenter.call({ + userId: user.id, + organizationSlug, + projectSlug: projectParam, + webhookId: triggerParam, + direction: searchParams.direction, + cursor: searchParams.cursor, + }); + + if (!trigger) { + throw new Response("Trigger not found", { + status: 404, + statusText: "Not Found", + }); + } + + return typedjson({ trigger }); +}; + +export default function Page() { + const { trigger } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + + return ( + + + + + + + + + + + + + + + +
+ +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.completed/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.completed/route.tsx new file mode 100644 index 000000000..6a7574a85 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.completed/route.tsx @@ -0,0 +1,20 @@ +import { useTypedRouteLoaderData } from "remix-typedjson"; +import { RunCompletedDetail } from "~/components/run/RunCompletedDetail"; +import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route"; + +function useTriggerRegisterRun() { + const routeMatch = useTypedRouteLoaderData( + "routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam" + ); + + if (!routeMatch || !routeMatch.run) { + throw new Error("No run found"); + } + + return routeMatch.run; +} + +export default function RunCompletedPage() { + const run = useTriggerRegisterRun(); + return ; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.stream/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.stream/route.tsx new file mode 100644 index 000000000..287ba86a4 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.stream/route.tsx @@ -0,0 +1,13 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { RunStreamPresenter } from "~/presenters/RunStreamPresenter.server"; +import { requireUserId } from "~/services/session.server"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + await requireUserId(request); + + const { runParam } = z.object({ runParam: z.string() }).parse(params); + + const presenter = new RunStreamPresenter(); + return presenter.call({ request, runId: runParam }); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx new file mode 100644 index 000000000..1e07e6deb --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx @@ -0,0 +1,35 @@ +import { Await, useLoaderData } from "@remix-run/react"; +import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime"; +import { Suspense } from "react"; +import { Spinner } from "~/components/primitives/Spinner"; +import { TaskDetail } from "~/components/run/TaskDetail"; +import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { TriggerSourceRunTaskParamsSchema } from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params); + + const presenter = new TaskDetailsPresenter(); + const taskPromise = presenter.call({ + userId, + id: taskParam, + }); + + return defer({ + taskPromise, + }); +}; + +export default function Page() { + const { taskPromise } = useLoaderData(); + + return ( + }> + Error loading task!

}> + {(resolvedTask) => resolvedTask && } +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.trigger/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.trigger/route.tsx new file mode 100644 index 000000000..e186e2ba0 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam.trigger/route.tsx @@ -0,0 +1,34 @@ +import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { TriggerDetail } from "~/components/run/TriggerDetail"; +import { TriggerDetailsPresenter } from "~/presenters/TriggerDetailsPresenter.server"; +import { TriggerSourceRunParamsSchema } from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { runParam } = TriggerSourceRunParamsSchema.parse(params); + + const presenter = new TriggerDetailsPresenter(); + const trigger = await presenter.call(runParam); + + if (!trigger) { + throw new Response(null, { + status: 404, + }); + } + + return typedjson({ + trigger, + }); +}; + +export default function Page() { + const { trigger } = useTypedLoaderData(); + + return ( + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route.tsx new file mode 100644 index 000000000..facd0ec4e --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route.tsx @@ -0,0 +1,135 @@ +import { useRevalidator } from "@remix-run/react"; +import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { Fragment, useEffect } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { useEventSource } from "remix-utils/sse/react"; +import { BreadcrumbLink } from "~/components/navigation/Breadcrumb"; +import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon"; +import { RunOverview } from "~/components/run/RunOverview"; +import { prisma } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useTypedMatchData } from "~/hooks/useTypedMatchData"; +import { RunPresenter } from "~/presenters/RunPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { Handle } from "~/utils/handle"; +import { + TriggerSourceRunParamsSchema, + projectWebhookTriggersPath, + trimTrailingSlash, + webhookTriggerPath, + webhookTriggerRunPath, + webhookTriggerRunStreamingPath, + webhookTriggerRunsParentPath, +} from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { runParam, triggerParam } = TriggerSourceRunParamsSchema.parse(params); + + const presenter = new RunPresenter(); + const run = await presenter.call({ + userId, + id: runParam, + }); + + const trigger = await prisma.webhook.findUnique({ + select: { + id: true, + key: true, + integration: { + select: { + id: true, + title: true, + slug: true, + definitionId: true, + setupStatus: true, + }, + }, + }, + where: { + id: triggerParam, + }, + }); + + if (!run || !trigger) { + throw new Response(null, { + status: 404, + }); + } + + return typedjson({ + run, + trigger, + }); +}; + +export const handle: Handle = { + breadcrumb: (match, matches) => { + const data = useTypedMatchData(match); + if (!data) return null; + + const org = useOrganization(matches); + const project = useProject(matches); + + return ( + + + + + + + + + + {data && data.run && ( + + )} + + ); + }, +}; + +export default function Page() { + const { run, trigger } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + + const revalidator = useRevalidator(); + const events = useEventSource( + webhookTriggerRunStreamingPath(organization, project, trigger, run), + { + event: "message", + } + ); + useEffect(() => { + if (events !== null) { + revalidator.revalidate(); + } + // WARNING Don't put the revalidator in the useEffect deps array or bad things will happen + }, [events]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.completed/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.completed/route.tsx new file mode 100644 index 000000000..dd70b1b88 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.completed/route.tsx @@ -0,0 +1,20 @@ +import { useTypedRouteLoaderData } from "remix-typedjson"; +import { RunCompletedDetail } from "~/components/run/RunCompletedDetail"; +import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route"; + +function useTriggerRegisterRun() { + const routeMatch = useTypedRouteLoaderData( + "routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam" + ); + + if (!routeMatch || !routeMatch.run) { + throw new Error("No run found"); + } + + return routeMatch.run; +} + +export default function RunCompletedPage() { + const run = useTriggerRegisterRun(); + return ; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.stream/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.stream/route.tsx new file mode 100644 index 000000000..287ba86a4 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.stream/route.tsx @@ -0,0 +1,13 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { RunStreamPresenter } from "~/presenters/RunStreamPresenter.server"; +import { requireUserId } from "~/services/session.server"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + await requireUserId(request); + + const { runParam } = z.object({ runParam: z.string() }).parse(params); + + const presenter = new RunStreamPresenter(); + return presenter.call({ request, runId: runParam }); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.tasks.$taskParam/route.tsx new file mode 100644 index 000000000..1e07e6deb --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.tasks.$taskParam/route.tsx @@ -0,0 +1,35 @@ +import { Await, useLoaderData } from "@remix-run/react"; +import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime"; +import { Suspense } from "react"; +import { Spinner } from "~/components/primitives/Spinner"; +import { TaskDetail } from "~/components/run/TaskDetail"; +import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { TriggerSourceRunTaskParamsSchema } from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params); + + const presenter = new TaskDetailsPresenter(); + const taskPromise = presenter.call({ + userId, + id: taskParam, + }); + + return defer({ + taskPromise, + }); +}; + +export default function Page() { + const { taskPromise } = useLoaderData(); + + return ( + }> + Error loading task!

}> + {(resolvedTask) => resolvedTask && } +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.trigger/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.trigger/route.tsx new file mode 100644 index 000000000..478dfbe77 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam.trigger/route.tsx @@ -0,0 +1,34 @@ +import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { TriggerDetail } from "~/components/run/TriggerDetail"; +import { TriggerDetailsPresenter } from "~/presenters/TriggerDetailsPresenter.server"; +import { TriggerSourceRunParamsSchema } from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { runParam } = TriggerSourceRunParamsSchema.parse(params); + + const presenter = new TriggerDetailsPresenter(); + const trigger = await presenter.call(runParam); + + if (!trigger) { + throw new Response(null, { + status: 404, + }); + } + + return typedjson({ + trigger, + }); +}; + +export default function Page() { + const { trigger } = useTypedLoaderData(); + + return ( + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route.tsx new file mode 100644 index 000000000..f7fc7eb0f --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route.tsx @@ -0,0 +1,132 @@ +import { useRevalidator } from "@remix-run/react"; +import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { Fragment, useEffect } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { useEventSource } from "remix-utils/sse/react"; +import { BreadcrumbLink } from "~/components/navigation/Breadcrumb"; +import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon"; +import { RunOverview } from "~/components/run/RunOverview"; +import { prisma } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useTypedMatchData } from "~/hooks/useTypedMatchData"; +import { RunPresenter } from "~/presenters/RunPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { Handle } from "~/utils/handle"; +import { + TriggerSourceRunParamsSchema, + projectWebhookTriggersPath, + trimTrailingSlash, + webhookDeliveryPath, + webhookTriggerDeliveryRunPath, + webhookTriggerDeliveryRunsParentPath, + webhookTriggerPath, + webhookTriggerRunStreamingPath, +} from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { runParam, triggerParam } = TriggerSourceRunParamsSchema.parse(params); + + const presenter = new RunPresenter(); + const run = await presenter.call({ + userId, + id: runParam, + }); + + const trigger = await prisma.webhook.findUnique({ + select: { + id: true, + integration: { + select: { + id: true, + title: true, + slug: true, + definitionId: true, + setupStatus: true, + }, + }, + }, + where: { + id: triggerParam, + }, + }); + + if (!run || !trigger) { + throw new Response(null, { + status: 404, + }); + } + + return typedjson({ + run, + trigger, + }); +}; + +export const handle: Handle = { + breadcrumb: (match, matches) => { + const data = useTypedMatchData(match); + if (!data) return null; + + const org = useOrganization(matches); + const project = useProject(matches); + + return ( + + + + + + + + + + {data && data.run && ( + + )} + + ); + }, +}; + +export default function Page() { + const { run, trigger } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + + const revalidator = useRevalidator(); + const events = useEventSource( + webhookTriggerRunStreamingPath(organization, project, trigger, run), + { + event: "message", + } + ); + useEffect(() => { + if (events !== null) { + revalidator.revalidate(); + } + // WARNING Don't put the revalidator in the useEffect deps array or bad things will happen + }, [events]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + ); +} diff --git a/apps/webapp/app/routes/api.v1.store.$key.ts b/apps/webapp/app/routes/api.v1.store.$key.ts new file mode 100644 index 000000000..2350cf029 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.store.$key.ts @@ -0,0 +1,156 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { assertExhaustive } from "@trigger.dev/core"; +import { z } from "zod"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { KeyValueStore } from "~/services/store/keyValueStore.server"; + +const ParamsSchema = z.object({ + key: z.string(), +}); + +const MAX_BODY_BYTE_LENGTH = 256 * 1024; + +export async function action({ request, params }: ActionFunctionArgs) { + logger.info("Key-value store action", { url: request.url }); + + const ActionMethodSchema = z.enum(["DELETE", "PUT"]); + + const parsedMethod = ActionMethodSchema.safeParse(request.method.toUpperCase()); + + if (!parsedMethod.success) { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + logger.info("Invalid params", { params }); + + return json({ error: "Invalid params" }, { status: 400 }); + } + + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const authenticatedEnv = authenticationResult.environment; + + const store = new KeyValueStore(authenticatedEnv); + + const { key } = parsedParams.data; + + try { + switch (parsedMethod.data) { + case "DELETE": { + const deleted = await store.delete(key); + + return json({ action: "DELETE", key, deleted }); + } + case "PUT": { + const value = await request.text(); + + const serializedValueBytes = value.length; + + if (serializedValueBytes > MAX_BODY_BYTE_LENGTH) { + logger.info("Max request body size exceeded", { serializedValueBytes }); + + return json( + { error: `Max request body size exceeded: ${MAX_BODY_BYTE_LENGTH} bytes` }, + { status: 413 } + ); + } + + const setValue = await store.set(key, value); + + return json({ action: "SET", key, value: setValue }); + } + default: { + assertExhaustive(parsedMethod.data); + } + } + } catch (error) { + if (error instanceof Error) { + logger.error("Error peforming key-value store action", { + method: parsedMethod.data, + url: request.url, + error: error.message, + }); + + return json({ error: error.message }, { status: 400 }); + } + + return json({ error: "Something went wrong" }, { status: 500 }); + } +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + logger.info("Key-value store loader", { url: request.url }); + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + logger.info("Invalid params", { params }); + + return json({ error: "Invalid params" }, { status: 400 }); + } + + const ActionMethodSchema = z.enum(["GET", "HEAD"]); + + const parsedMethod = ActionMethodSchema.safeParse(request.method.toUpperCase()); + + if (!parsedMethod.success) { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const authenticatedEnv = authenticationResult.environment; + + const store = new KeyValueStore(authenticatedEnv); + + const { key } = parsedParams.data; + + try { + switch (parsedMethod.data) { + case "GET": { + const value = await store.get(key); + + return json({ action: "GET", key, value }); + } + case "HEAD": { + const has = await store.has(key); + + if (!has) { + return new Response("Key not found", { status: 404 }); + } + + return new Response("Key found", { status: 200 }); + } + default: { + assertExhaustive(parsedMethod.data); + } + } + } catch (error) { + if (error instanceof Error) { + logger.error("Error peforming key-value store action", { + method: parsedMethod.data, + url: request.url, + error: error.message, + }); + + return json({ error: error.message }, { status: 400 }); + } + + return json({ error: "Something went wrong" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.webhooks.$key.ts b/apps/webapp/app/routes/api.v1.webhooks.$key.ts new file mode 100644 index 000000000..572898aaa --- /dev/null +++ b/apps/webapp/app/routes/api.v1.webhooks.$key.ts @@ -0,0 +1,70 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { UpdateWebhookBodySchema } from "@trigger.dev/core"; +import { z } from "zod"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { UpdateWebhookService } from "~/services/sources/updateWebhook.server"; + +const ParamsSchema = z.object({ + key: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + logger.info("Updating webhook", { url: request.url }); + + // Ensure this is a POST request + if (request.method.toUpperCase() !== "PUT") { + return { status: 405, body: "Method Not Allowed" }; + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + logger.info("Invalid params", { params }); + + return json({ error: "Invalid params" }, { status: 400 }); + } + + // Next authenticate the request + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const authenticatedEnv = authenticationResult.environment; + + // Now parse the request body + const anyBody = await request.json(); + + const body = UpdateWebhookBodySchema.safeParse(anyBody); + + if (!body.success) { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const service = new UpdateWebhookService(); + + try { + const source = await service.call({ + environment: authenticatedEnv, + payload: body.data, + key: parsedParams.data.key, + }); + + return json(source); + } catch (error) { + if (error instanceof Error) { + logger.error("Error updating webhook", { + url: request.url, + error: error.message, + }); + + return json({ error: error.message }, { status: 400 }); + } + + return json({ error: "Something went wrong" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/services/endpointApi.server.ts b/apps/webapp/app/services/endpointApi.server.ts index 33967d374..1f64fbbdc 100644 --- a/apps/webapp/app/services/endpointApi.server.ts +++ b/apps/webapp/app/services/endpointApi.server.ts @@ -18,6 +18,7 @@ import { RunNotification, ValidateResponse, ValidateResponseSchema, + WebhookDeliveryResponseSchema, } from "@trigger.dev/core"; import { performance } from "node:perf_hooks"; import { safeBodyFromResponse, safeParseBodyFromResponse } from "~/utils/json"; @@ -253,6 +254,45 @@ export class EndpointApi { return HttpSourceResponseSchema.parse(anyBody); } + async deliverWebhookRequest(options: { + key: string; + secret: string; + params: any; + request: HttpSourceRequest; + }) { + const response = await safeFetch(this.url, { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "x-trigger-api-key": this.apiKey, + "x-trigger-action": "DELIVER_WEBHOOK_REQUEST", + "x-ts-key": options.key, + "x-ts-secret": options.secret, + "x-ts-params": JSON.stringify(options.params ?? {}), + "x-ts-http-url": options.request.url, + "x-ts-http-method": options.request.method, + "x-ts-http-headers": JSON.stringify(options.request.headers), + }, + body: options.request.rawBody, + }); + + if (!response) { + throw new Error(`Could not connect to endpoint ${this.url}`); + } + + if (!response.ok) { + throw new Error(`Could not connect to endpoint ${this.url}. Status code: ${response.status}`); + } + + const anyBody = await response.json(); + + logger.debug("deliverWebhookRequest() response from endpoint", { + body: anyBody, + }); + + return WebhookDeliveryResponseSchema.parse(anyBody); + } + async deliverHttpEndpointRequestForResponse(options: { key: string; secret: string; diff --git a/apps/webapp/app/services/endpoints/performEndpointIndexService.ts b/apps/webapp/app/services/endpoints/performEndpointIndexService.ts index 775625eb8..6c31280d7 100644 --- a/apps/webapp/app/services/endpoints/performEndpointIndexService.ts +++ b/apps/webapp/app/services/endpoints/performEndpointIndexService.ts @@ -1,6 +1,4 @@ -import type { EndpointIndexSource } from "@trigger.dev/database"; import { PrismaClient, prisma } from "~/db.server"; -import { findEndpoint } from "~/models/endpoint.server"; import { EndpointApi } from "../endpointApi.server"; import { RegisterJobService } from "../jobs/registerJob.server"; import { logger } from "../logger.server"; @@ -14,6 +12,7 @@ import { safeBodyFromResponse } from "~/utils/json"; import { fromZodError } from "zod-validation-error"; import { IndexEndpointStats } from "@trigger.dev/core"; import { RegisterHttpEndpointService } from "../triggers/registerHttpEndpoint.server"; +import { RegisterWebhookService } from "../triggers/registerWebhook.server"; export class PerformEndpointIndexService { #prismaClient: PrismaClient; @@ -24,6 +23,7 @@ export class PerformEndpointIndexService { #registerDynamicTriggerService = new RegisterDynamicTriggerService(); #registerDynamicScheduleService = new RegisterDynamicScheduleService(); #registerHttpEndpointService = new RegisterHttpEndpointService(); + #registerWebhookService = new RegisterWebhookService(); constructor(prismaClient: PrismaClient = prisma) { this.#prismaClient = prismaClient; @@ -128,7 +128,8 @@ export class PerformEndpointIndexService { }); } - const { jobs, sources, dynamicTriggers, dynamicSchedules, httpEndpoints } = bodyResult.data; + const { jobs, sources, dynamicTriggers, dynamicSchedules, httpEndpoints, webhooks } = + bodyResult.data; const { "trigger-version": triggerVersion, "trigger-sdk-version": triggerSdkVersion } = headerResult.data; const { endpoint } = endpointIndex; @@ -151,6 +152,7 @@ export class PerformEndpointIndexService { const indexStats: IndexEndpointStats = { jobs: 0, sources: 0, + webhooks: 0, dynamicTriggers: 0, dynamicSchedules: 0, disabledJobs: 0, @@ -318,6 +320,21 @@ export class PerformEndpointIndexService { } } + if (webhooks) { + for (const webhook of webhooks) { + try { + await this.#registerWebhookService.call(endpoint, webhook); + indexStats.webhooks++; + } catch (error) { + logger.error("Failed to register webhook", { + endpointId: endpoint.id, + webhook, + error, + }); + } + } + } + logger.debug("Endpoint indexing complete", { endpointId: endpoint.id, indexStats, @@ -336,6 +353,7 @@ export class PerformEndpointIndexService { data: { jobs, sources, + webhooks, dynamicTriggers, dynamicSchedules, httpEndpoints, diff --git a/apps/webapp/app/services/externalApis/integrationCatalog.server.ts b/apps/webapp/app/services/externalApis/integrationCatalog.server.ts index 3c13f154a..0ec27874e 100644 --- a/apps/webapp/app/services/externalApis/integrationCatalog.server.ts +++ b/apps/webapp/app/services/externalApis/integrationCatalog.server.ts @@ -6,6 +6,7 @@ import { plain } from "./integrations/plain"; import { replicate } from "./integrations/replicate"; import { resend } from "./integrations/resend"; import { sendgrid } from "./integrations/sendgrid"; +import { shopify } from "./integrations/shopify"; import { slack } from "./integrations/slack"; import { stripe } from "./integrations/stripe"; import { supabase, supabaseManagement } from "./integrations/supabase"; @@ -40,6 +41,7 @@ export const integrationCatalog = new IntegrationCatalog({ plain, replicate, resend, + shopify, slack, stripe, supabaseManagement, diff --git a/apps/webapp/app/services/externalApis/integrations/linear.ts b/apps/webapp/app/services/externalApis/integrations/linear.ts index bb1266b2b..841ded653 100644 --- a/apps/webapp/app/services/externalApis/integrations/linear.ts +++ b/apps/webapp/app/services/externalApis/integrations/linear.ts @@ -7,7 +7,7 @@ function usageSample(hasApiKey: boolean): HelpSample { import { Linear } from "@trigger.dev/linear"; const linear = new Linear({ - id: "__SLUG__",${hasApiKey ? ",\n apiKey: process.env.LINEAR_API_KEY!" : ""} + id: "__SLUG__",${hasApiKey ? "\n apiKey: process.env.LINEAR_API_KEY!," : ""} }); client.defineJob({ diff --git a/apps/webapp/app/services/externalApis/integrations/replicate.ts b/apps/webapp/app/services/externalApis/integrations/replicate.ts index 74f20cdaf..095a043e5 100644 --- a/apps/webapp/app/services/externalApis/integrations/replicate.ts +++ b/apps/webapp/app/services/externalApis/integrations/replicate.ts @@ -9,7 +9,7 @@ function usageSample(hasApiKey: boolean): HelpSample { import { Replicate } from "@trigger.dev/replicate"; const replicate = new Replicate({ - id: "__SLUG__",${hasApiKey ? `,\n ${apiKeyPropertyName}: process.env.REPLICATE_API_KEY!` : ""} + id: "__SLUG__",${hasApiKey ? `\n ${apiKeyPropertyName}: process.env.REPLICATE_API_KEY!,` : ""} }); client.defineJob({ diff --git a/apps/webapp/app/services/externalApis/integrations/shopify.ts b/apps/webapp/app/services/externalApis/integrations/shopify.ts new file mode 100644 index 000000000..07c4408ec --- /dev/null +++ b/apps/webapp/app/services/externalApis/integrations/shopify.ts @@ -0,0 +1,55 @@ +import type { HelpSample, Integration } from "../types"; + +function usageSample(hasApiKey: boolean): HelpSample { + const apiKeyPropertyName = "apiKey"; + + return { + title: "Using the client", + code: ` +import { Shopify } from "@trigger.dev/shopify"; + +const shopify = new Shopify({ + id: "__SLUG__",${hasApiKey ? `\n ${apiKeyPropertyName}: process.env.SHOPIFY_API_KEY!,` : ""} + apiSecretKey: process.env.SHOPIFY_API_SECRET_KEY!, + adminAccessToken: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!, + hostName: process.env.SHOPIFY_SHOP_DOMAIN!, +}); + +client.defineJob({ + id: "shopify-create-product", + name: "Shopify: Create Product", + version: "0.1.0", + integrations: { shopify }, + trigger: eventTrigger({ + name: "shopify.product.create", + schema: z.object({ + title: z.string(), + }), + }), + run: async (payload, io, ctx) => { + const product = await io.shopify.rest.Product.save("create-product", { + fromData: { + title: payload.title, + }, + }); + + await io.logger.info(\`Created product \${product.id}: \${product.title}\`); + }, +}); + `, + }; +} + +export const shopify: Integration = { + identifier: "shopify", + name: "Shopify", + packageName: "@trigger.dev/shopify@latest", + authenticationMethods: { + apikey: { + type: "apikey", + help: { + samples: [usageSample(true)], + }, + }, + }, +}; diff --git a/apps/webapp/app/services/httpendpoint/HandleHttpEndpointService.ts b/apps/webapp/app/services/httpendpoint/HandleHttpEndpointService.ts index 7c9f196c8..0a3cab774 100644 --- a/apps/webapp/app/services/httpendpoint/HandleHttpEndpointService.ts +++ b/apps/webapp/app/services/httpendpoint/HandleHttpEndpointService.ts @@ -1,16 +1,21 @@ -import { PrismaClient, TriggerHttpEndpoint } from "@trigger.dev/database"; +import { PrismaClient, RuntimeEnvironment, Webhook } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requestUrl } from "~/utils/requestUrl.server"; import { logger } from "../logger.server"; import { json } from "@remix-run/server-runtime"; -import { RequestFilterSchema, requestFilterMatches } from "@trigger.dev/core"; +import { + RequestFilterSchema, + WebhookContextMetadataSchema, + requestFilterMatches, +} from "@trigger.dev/core"; import { EndpointApi } from "../endpointApi.server"; import { IngestSendEvent } from "../events/ingestSendEvent.server"; import { getSecretStore } from "../secrets/secretStore.server"; import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest"; import { ulid } from "../ulid.server"; import { env } from "~/env.server"; +import { HandleWebhookRequestService } from "../sources/handleWebhookRequest.server"; export const HttpEndpointParamsSchema = z.object({ httpEndpointId: z.string(), @@ -34,6 +39,7 @@ export class HandleHttpEndpointService { }, include: { secretReference: true, + webhook: true, project: { include: { environments: { @@ -108,6 +114,7 @@ export class HandleHttpEndpointService { //get the secret const secretStore = getSecretStore(httpEndpoint.secretReference.provider); let secret: string | undefined; + try { const secretData = await secretStore.getSecretOrThrow( z.object({ secret: z.string() }), @@ -119,6 +126,7 @@ export class HandleHttpEndpointService { logger.error("Getting secret threw", { error }); return json({ error: true, message: "Could not retrieve secret" }, { status: 404 }); } + if (!secret) { logger.error("Could not find secret", { httpEndpointId: httpEndpoint.id, @@ -132,16 +140,22 @@ export class HandleHttpEndpointService { const callClientImmediately = immediateResponseFilter.data ? await requestFilterMatches(request, immediateResponseFilter.data) : false; + let httpResponse: Response | undefined; + if (callClientImmediately) { logger.info("Calling client immediately", { httpEndpointId: httpEndpoint.id, environmentId: environment.id, immediateResponseFilter: immediateResponseFilter.data, }); + const clonedRequest = request.clone(); + const client = new EndpointApi(environment.apiKey, httpEndpointEnvironment.endpoint.url); + const httpRequest = await createHttpSourceRequest(clonedRequest); + const { response, parser } = await client.deliverHttpEndpointRequestForResponse({ key: httpEndpoint.key, secret: secret, @@ -149,7 +163,9 @@ export class HandleHttpEndpointService { }); const responseJson = await response.json(); + const parsedResponseResult = parser.safeParse(responseJson); + if (!parsedResponseResult.success) { logger.error("Could not parse response from client", { httpEndpointId: httpEndpoint.id, @@ -157,6 +173,7 @@ export class HandleHttpEndpointService { responseJson, errors: parsedResponseResult.error, }); + return json( { error: true, message: "Could not parse response from client" }, { status: 500 } @@ -164,6 +181,7 @@ export class HandleHttpEndpointService { } const endpointResponse = parsedResponseResult.data; + httpResponse = new Response(endpointResponse.body, { status: endpointResponse.status, headers: endpointResponse.headers, @@ -186,11 +204,17 @@ export class HandleHttpEndpointService { return httpResponse; } + if (httpEndpoint.webhook) { + return await this.#handleWebhookRequest(request, environment, httpEndpoint.webhook, secret); + } + const ingestService = new IngestSendEvent(); + let rawBody: string | undefined; try { rawBody = await request.text(); } catch (e) {} + const url = requestUrl(request); const event = { headers: Object.fromEntries(request.headers) as Record, @@ -223,6 +247,44 @@ export class HandleHttpEndpointService { }) ); } + + async #handleWebhookRequest( + request: Request, + environment: RuntimeEnvironment, + webhook: Webhook, + secret: string + ) { + const webhookEnvironment = await this.#prismaClient.webhookEnvironment.findUnique({ + where: { + environmentId_webhookId: { + environmentId: environment.id, + webhookId: webhook.id, + }, + }, + include: { + endpoint: true, + }, + }); + + if (!webhookEnvironment) { + logger.debug("Could not find webhook environment", { + webhookId: webhook.id, + environmentId: environment.id, + }); + return json({ error: true, message: "Could not find webhook environment" }, { status: 404 }); + } + + const rawContext = { + secret, + config: webhookEnvironment.config, + params: webhook.params, + }; + const webhookContextMetadata = WebhookContextMetadataSchema.parse(rawContext); + + const service = new HandleWebhookRequestService(this.#prismaClient); + + return await service.call(webhookEnvironment.id, request, webhookContextMetadata); + } } type GetHttpEndpointUrlParams = { diff --git a/apps/webapp/app/services/jobs/registerJob.server.ts b/apps/webapp/app/services/jobs/registerJob.server.ts index bee4dccec..63aeb5cb5 100644 --- a/apps/webapp/app/services/jobs/registerJob.server.ts +++ b/apps/webapp/app/services/jobs/registerJob.server.ts @@ -3,6 +3,7 @@ import { JobMetadata, SCHEDULED_EVENT, TriggerMetadata, + assertExhaustive, } from "@trigger.dev/core"; import type { Endpoint, Integration, Job, JobIntegration, JobVersion } from "@trigger.dev/database"; import { DEFAULT_MAX_CONCURRENT_RUNS } from "~/consts"; @@ -638,7 +639,3 @@ export class RegisterJobService { }); } } - -function assertExhaustive(x: never): never { - throw new Error("Unexpected object: " + x); -} diff --git a/apps/webapp/app/services/sources/deliverWebhookRequest.server.ts b/apps/webapp/app/services/sources/deliverWebhookRequest.server.ts new file mode 100644 index 000000000..f821ecce4 --- /dev/null +++ b/apps/webapp/app/services/sources/deliverWebhookRequest.server.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; +import type { PrismaClient } from "~/db.server"; +import { prisma } from "~/db.server"; +import { EndpointApi } from "../endpointApi.server"; +import { getSecretStore } from "../secrets/secretStore.server"; + +export class DeliverWebhookRequestService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call(id: string) { + const requestDelivery = await this.#prismaClient.webhookRequestDelivery.findUniqueOrThrow({ + where: { + id, + }, + include: { + webhook: { + include: { + integration: { + include: { + connections: true, + }, + }, + httpEndpoint: { + include: { + secretReference: true, + }, + }, + }, + }, + webhookEnvironment: { + include: { + environment: { + include: { + organization: true, + project: true, + }, + }, + }, + }, + endpoint: true, + }, + }); + + if (!requestDelivery.webhookEnvironment.active) { + return; + } + + const { secretReference } = requestDelivery.webhook.httpEndpoint; + + const secretStore = getSecretStore(secretReference.provider); + + const secret = await secretStore.getSecret( + z.object({ + secret: z.string(), + }), + secretReference.key + ); + + if (!secret) { + throw new Error(`Secret not found for ${requestDelivery.webhook.key}`); + } + + const clientApi = new EndpointApi( + requestDelivery.webhookEnvironment.environment.apiKey, + requestDelivery.endpoint.url + ); + + const { response, verified, error } = await clientApi.deliverWebhookRequest({ + key: requestDelivery.webhook.key, + secret: secret.secret, + params: requestDelivery.webhook.params, + request: { + url: requestDelivery.url, + method: requestDelivery.method, + headers: requestDelivery.headers as Record, + rawBody: requestDelivery.body, + }, + }); + + await this.#prismaClient.webhookRequestDelivery.update({ + where: { + id, + }, + data: { + deliveredAt: new Date(), + verified, + error, + }, + }); + + return response; + } +} diff --git a/apps/webapp/app/services/sources/handleWebhookRequest.server.ts b/apps/webapp/app/services/sources/handleWebhookRequest.server.ts new file mode 100644 index 000000000..d8265864e --- /dev/null +++ b/apps/webapp/app/services/sources/handleWebhookRequest.server.ts @@ -0,0 +1,85 @@ +import type { PrismaClient } from "~/db.server"; +import { prisma } from "~/db.server"; +import { workerQueue } from "../worker.server"; +import { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest"; +import { WebhookContextMetadata } from "@trigger.dev/core"; +import { createHash } from "crypto"; + +export class HandleWebhookRequestService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call(id: string, request: Request, metadata: WebhookContextMetadata) { + const webhookEnvironment = await this.#prismaClient.webhookEnvironment.findUnique({ + where: { + id, + }, + include: { + endpoint: true, + environment: true, + }, + }); + + if (!webhookEnvironment) { + return { status: 404 }; + } + + if (!webhookEnvironment.active) { + return { status: 200 }; + } + + const webhookRequest = await createHttpSourceRequest(request); + + const lockId = webhookIdToLockId(webhookEnvironment.webhookId); + + await this.#prismaClient.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`; + + const counter = await tx.webhookDeliveryCounter.upsert({ + where: { webhookId: webhookEnvironment.id }, + update: { lastNumber: { increment: 1 } }, + create: { webhookId: webhookEnvironment.id, lastNumber: 1 }, + select: { lastNumber: true }, + }); + + const delivery = await tx.webhookRequestDelivery.create({ + data: { + number: counter.lastNumber, + webhookId: webhookEnvironment.webhookId, + webhookEnvironmentId: webhookEnvironment.id, + endpointId: webhookEnvironment.endpointId, + environmentId: webhookEnvironment.environmentId, + url: webhookRequest.url, + method: webhookRequest.method, + headers: webhookRequest.headers, + body: webhookRequest.rawBody, + }, + }); + + await workerQueue.enqueue( + "deliverWebhookRequest", + { + id: delivery.id, + }, + { + tx, + maxAttempts: + webhookEnvironment.environment.type === RuntimeEnvironmentType.DEVELOPMENT + ? 1 + : undefined, + } + ); + }); + + return { status: 200 }; + } +} + +function webhookIdToLockId(webhookId: string): number { + // Convert webhookId to a unique lock identifier + return parseInt(createHash("sha256").update(webhookId).digest("hex").slice(0, 8), 16); +} diff --git a/apps/webapp/app/services/sources/updateWebhook.server.ts b/apps/webapp/app/services/sources/updateWebhook.server.ts new file mode 100644 index 000000000..ec2e04b90 --- /dev/null +++ b/apps/webapp/app/services/sources/updateWebhook.server.ts @@ -0,0 +1,62 @@ +import type { TriggerSource, UpdateWebhookBody } from "@trigger.dev/core"; +import type { RuntimeEnvironment } from "@trigger.dev/database"; +import type { PrismaClient } from "~/db.server"; +import { prisma } from "~/db.server"; + +export class UpdateWebhookService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + environment, + payload, + key, + }: { + environment: RuntimeEnvironment; + payload: UpdateWebhookBody; + key: string; + }): Promise { + const webhook = await this.#prismaClient.webhook.findUniqueOrThrow({ + where: { + key_projectId: { + key, + projectId: environment.projectId, + }, + }, + }); + + await this.#prismaClient.webhook.update({ + where: { + key_projectId: { + key, + projectId: environment.projectId, + }, + }, + data: { + active: payload.active, + webhookEnvironments: { + update: { + where: { + environmentId_webhookId: { + environmentId: environment.id, + webhookId: webhook.id, + }, + }, + data: { + active: payload.active, + config: payload.active ? payload.config : undefined, + }, + }, + }, + }, + }); + + return { + id: webhook.id, + key: webhook.key, + }; + } +} diff --git a/apps/webapp/app/services/store/keyValueStore.server.ts b/apps/webapp/app/services/store/keyValueStore.server.ts new file mode 100644 index 000000000..8f8f13d14 --- /dev/null +++ b/apps/webapp/app/services/store/keyValueStore.server.ts @@ -0,0 +1,96 @@ +import { RuntimeEnvironment } from "@trigger.dev/database"; +import type { AsyncMap } from "@trigger.dev/core"; +import type { PrismaClient } from "~/db.server"; +import { prisma } from "~/db.server"; +import { logger } from "../logger.server"; + +export class KeyValueStore implements AsyncMap { + #prismaClient: PrismaClient; + + constructor(private environment: RuntimeEnvironment, prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + async delete(key: string): Promise { + try { + await this.#prismaClient.keyValueItem.delete({ + select: { + id: true, + }, + where: { + environmentId_key: { + key, + environmentId: this.environment.id, + }, + }, + }); + + return true; + } catch (error) { + return false; + } + } + + async get(key: string): Promise { + const keyValueItem = await this.#prismaClient.keyValueItem.findUnique({ + select: { + value: true, + }, + where: { + environmentId_key: { + key, + environmentId: this.environment.id, + }, + }, + }); + + if (!keyValueItem) { + logger.debug("KeyValueStore.get() key not found", { key, environment: this.environment.id }); + return undefined; + } + + return keyValueItem.value.toString(); + } + + async has(key: string): Promise { + const keyValueItem = await this.#prismaClient.keyValueItem.findUnique({ + select: { + id: true, + }, + where: { + environmentId_key: { + key, + environmentId: this.environment.id, + }, + }, + }); + + return !!keyValueItem; + } + + async set(key: string, value: TValue): Promise { + const valueBuffer = Buffer.from(value); + + await this.#prismaClient.keyValueItem.upsert({ + select: { + value: true, + }, + where: { + environmentId_key: { + key, + environmentId: this.environment.id, + }, + }, + create: { + key, + environmentId: this.environment.id, + value: valueBuffer, + }, + update: { + value: valueBuffer, + }, + }); + + return value; + } +} diff --git a/apps/webapp/app/services/triggers/registerWebhook.server.ts b/apps/webapp/app/services/triggers/registerWebhook.server.ts new file mode 100644 index 000000000..f5e004f86 --- /dev/null +++ b/apps/webapp/app/services/triggers/registerWebhook.server.ts @@ -0,0 +1,169 @@ +import { REGISTER_WEBHOOK, WebhookMetadata } from "@trigger.dev/core"; +import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server"; +import { ExtendedEndpoint, findEndpoint } from "~/models/endpoint.server"; +import { IngestSendEvent } from "../events/ingestSendEvent.server"; +import { Prisma, WebhookEnvironment } from "@trigger.dev/database"; +import { ulid } from "../ulid.server"; +import { getSecretStore } from "../secrets/secretStore.server"; +import { z } from "zod"; +import { httpEndpointUrl } from "../httpendpoint/HandleHttpEndpointService"; +import { isEqual } from "ohash"; + +type ExtendedWebhook = Prisma.WebhookGetPayload<{ + include: { + httpEndpoint: { + include: { + secretReference: true; + }; + }; + }; +}>; + +export class RegisterWebhookService { + #prismaClient: PrismaClientOrTransaction; + + constructor(prismaClient: PrismaClientOrTransaction = prisma) { + this.#prismaClient = prismaClient; + } + + public async call( + endpointIdOrEndpoint: string | ExtendedEndpoint, + webhookMetadata: WebhookMetadata + ) { + const endpoint = + typeof endpointIdOrEndpoint === "string" + ? await findEndpoint(endpointIdOrEndpoint) + : endpointIdOrEndpoint; + + const upsertResult = await this.#upsertWebhook(endpoint, webhookMetadata); + + if (!upsertResult) { + return; + } + + const { webhook, webhookEnvironment } = upsertResult; + const { config, desiredConfig } = webhookEnvironment; + + if (webhook.active && isEqual(config, desiredConfig)) { + return; + } + + return await this.#activateWebhook(endpoint, webhook, webhookEnvironment); + } + + async #upsertWebhook(endpoint: ExtendedEndpoint, webhookMetadata: WebhookMetadata) { + return await $transaction(this.#prismaClient, async (tx) => { + const webhook = await tx.webhook.upsert({ + where: { + key_projectId: { + key: webhookMetadata.key, + projectId: endpoint.projectId, + }, + }, + create: { + key: webhookMetadata.key, + params: webhookMetadata.params, + httpEndpoint: { + connect: { + key_projectId: { + key: webhookMetadata.httpEndpoint.id, + projectId: endpoint.projectId, + }, + }, + }, + project: { + connect: { + id: endpoint.projectId, + }, + }, + integration: { + connect: { + organizationId_slug: { + organizationId: endpoint.organizationId, + slug: webhookMetadata.integration.id, + }, + }, + }, + }, + update: { + key: webhookMetadata.key, + params: webhookMetadata.params, + }, + include: { + httpEndpoint: { + include: { + secretReference: true, + }, + }, + }, + }); + + const webhookEnvironment = await tx.webhookEnvironment.upsert({ + where: { + environmentId_webhookId: { + environmentId: endpoint.environmentId, + webhookId: webhook.id, + }, + }, + create: { + desiredConfig: webhookMetadata.config, + webhook: { + connect: { + id: webhook.id, + }, + }, + environment: { + connect: { + id: endpoint.environmentId, + }, + }, + endpoint: { + connect: { + id: endpoint.id, + }, + }, + }, + update: { + desiredConfig: webhookMetadata.config, + }, + }); + + return { webhook, webhookEnvironment }; + }); + } + + async #activateWebhook( + endpoint: ExtendedEndpoint, + webhook: ExtendedWebhook, + webhookEnvironment: WebhookEnvironment + ) { + const { httpEndpoint } = webhook; + + const secretStore = getSecretStore(httpEndpoint.secretReference.provider); + + const secretData = await secretStore.getSecretOrThrow( + z.object({ secret: z.string() }), + httpEndpoint.secretReference.key + ); + + const ingestService = new IngestSendEvent(); + + await ingestService.call(endpoint.environment, { + id: ulid(), + name: `${REGISTER_WEBHOOK}.${webhook.key}`, + payload: { + active: webhook.active, + url: httpEndpointUrl({ + httpEndpointId: httpEndpoint.id, + environment: endpoint.environment, + }), + secret: secretData.secret, + params: webhook.params, + config: { + current: webhookEnvironment.config ?? {}, + desired: webhookEnvironment.desiredConfig ?? {}, + }, + }, + }); + } +} diff --git a/apps/webapp/app/services/worker.server.ts b/apps/webapp/app/services/worker.server.ts index 79f299648..eae2ba59e 100644 --- a/apps/webapp/app/services/worker.server.ts +++ b/apps/webapp/app/services/worker.server.ts @@ -26,6 +26,7 @@ import { DeliverRunSubscriptionsService } from "./runs/deliverRunSubscriptions.s import { ResumeTaskService } from "./tasks/resumeTask.server"; import { ExpireDispatcherService } from "./dispatchers/expireDispatcher.server"; import { InvokeEphemeralDispatcherService } from "./dispatchers/invokeEphemeralEventDispatcher.server"; +import { DeliverWebhookRequestService } from "./sources/deliverWebhookRequest.server"; const workerCatalog = { indexEndpoint: z.object({ @@ -43,6 +44,7 @@ const workerCatalog = { id: z.string(), }), deliverHttpSourceRequest: z.object({ id: z.string() }), + deliverWebhookRequest: z.object({ id: z.string() }), refreshOAuthToken: z.object({ organizationId: z.string(), connectionId: z.string(), @@ -293,6 +295,16 @@ function getWorkerQueue() { await service.call(payload.id); }, }, + deliverWebhookRequest: { + priority: 1, // smaller number = higher priority + maxAttempts: 14, + queueName: (payload) => `webhooks:${payload.id}`, + handler: async (payload, job) => { + const service = new DeliverWebhookRequestService(); + + await service.call(payload.id); + }, + }, startRun: { priority: 0, // smaller number = higher priority maxAttempts: 4, diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 241eea6c5..8683d4ff5 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -1,4 +1,9 @@ -import type { Integration, TriggerHttpEndpoint, TriggerSource } from "@trigger.dev/database"; +import type { + Integration, + TriggerHttpEndpoint, + TriggerSource, + Webhook, +} from "@trigger.dev/database"; import { z } from "zod"; import { Job } from "~/models/job.server"; import type { Organization } from "~/models/organization.server"; @@ -10,6 +15,7 @@ export type JobForPath = Pick; export type RunForPath = Pick; export type IntegrationForPath = Pick; export type TriggerForPath = Pick; +export type WebhookForPath = Pick; export type HttpEndpointForPath = Pick; export const OrganizationParamsSchema = z.object({ @@ -273,6 +279,82 @@ function triggerSourceParam(trigger: TriggerForPath) { return trigger.id; } +export function projectWebhookTriggersPath(organization: OrgForPath, project: ProjectForPath) { + return `${projectTriggersPath(organization, project)}/webhooks`; +} + +export function webhookTriggerPath( + organization: OrgForPath, + project: ProjectForPath, + webhook: WebhookForPath +) { + return `${projectTriggersPath(organization, project)}/webhooks/${webhookSourceParam(webhook)}`; +} + +export function webhookTriggerRunsParentPath( + organization: OrgForPath, + project: ProjectForPath, + webhook: WebhookForPath +) { + return `${webhookTriggerPath(organization, project, webhook)}/runs`; +} + +export function webhookTriggerRunPath( + organization: OrgForPath, + project: ProjectForPath, + webhook: WebhookForPath, + run: RunForPath +) { + return `${webhookTriggerRunsParentPath(organization, project, webhook)}/${run.id}`; +} + +export function webhookTriggerRunStreamingPath( + organization: OrgForPath, + project: ProjectForPath, + webhook: WebhookForPath, + run: RunForPath +) { + return `${webhookTriggerRunPath(organization, project, webhook, run)}/stream`; +} + +export function webhookDeliveryPath( + organization: OrgForPath, + project: ProjectForPath, + webhook: WebhookForPath +) { + return `${webhookTriggerPath(organization, project, webhook)}/delivery`; +} + +export function webhookTriggerDeliveryRunsParentPath( + organization: OrgForPath, + project: ProjectForPath, + webhook: WebhookForPath +) { + return `${webhookTriggerRunsParentPath(organization, project, webhook)}/delivery`; +} + +export function webhookTriggerDeliveryRunPath( + organization: OrgForPath, + project: ProjectForPath, + webhook: WebhookForPath, + run: RunForPath +) { + return `${webhookTriggerDeliveryRunsParentPath(organization, project, webhook)}/${run.id}`; +} + +export function webhookTriggerDeliveryRunStreamingPath( + organization: OrgForPath, + project: ProjectForPath, + webhook: WebhookForPath, + run: RunForPath +) { + return `${webhookTriggerDeliveryRunPath(organization, project, webhook, run)}/stream`; +} + +function webhookSourceParam(webhook: WebhookForPath) { + return webhook.id; +} + // Job export function jobPath(organization: OrgForPath, project: ProjectForPath, job: JobForPath) { return `${projectPath(organization, project)}/jobs/${jobParam(job)}`; diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 50ad62218..82c60d8f0 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -91,6 +91,7 @@ "marked": "^4.0.18", "morgan": "^1.10.0", "nanoid": "^3.3.4", + "ohash": "^1.1.3", "postcss-import": "^14.1.0", "posthog-js": "^1.83.0", "posthog-node": "^3.1.1", diff --git a/docs/integrations/apis/shopify-tasks.mdx b/docs/integrations/apis/shopify-tasks.mdx new file mode 100644 index 000000000..d2ee78191 --- /dev/null +++ b/docs/integrations/apis/shopify-tasks.mdx @@ -0,0 +1,229 @@ +--- +title: Shopify Tasks +sidebarTitle: Tasks +--- + +Tasks are executed after the job is triggered and are the main building blocks of a job. You can string together as many tasks as you want. + +--- + +## Tasks + +All tasks are using Shopify's [REST Resources](https://github.com/Shopify/shopify-api-js/blob/ff0900cd383712e6362b6dd3370d8ab11caabd3d/packages/shopify-api/docs/guides/rest-resources.md) and can be used with the same general pattern: + +```ts +await io.shopify.rest..("cacheKey", params) +``` + + + Should be a stable and unique cache key inside the `run()`. See + [resumability](/documentation/concepts/resumability) for more information. + + + Resource-specific parameters. + + +### `all()` + +Fetch all resources of a given type. + +```ts +await io.shopify.rest.Variant.all("get-all-variants", {, + autoPaginate: true, // Pagination helper, disabled by default + product_id: 123456 // Optional, resource-specific parameter +}) +``` + +### `count()` + +Fetch the number of resources of a given type. + +```ts +await io.shopify.rest.Product.count("count-products", { + product_type: "amazing stuff" // Optional, resource-specific parameters +}) +``` + +### `find()` + +Fetch a single resource by its ID. + +```ts +await io.shopify.rest.Product.find("find-product", { + id: 123456 +}) +``` + +### `save()` + +Create or update a resource of a given type. The resource will be created if no ID is specified. + +```ts +// Create a product +await io.shopify.rest.Product.save("create-product", { + fromData: { + title: "Some Product", + }, +}) + +// Update a product +await io.shopify.rest.Product.save("update-product", { + fromData: { + id: 123456 + title: "New Product Name", + }, +}) +``` + +### `delete()` + +Delete an existing resource. + +```ts +await io.shopify.rest.Product.delete("delete-product", { + id: 123456 +}) +``` + +## Resources + +This is a list of REST Resources that can be used directly as Tasks. They all implement the same methods described above. For resources with non-standard methods, you will have to use the raw Shopify API Client instead - please see the end of this page for further instructions. + +- [Article](https://shopify.dev/docs/api/admin-rest/2023-07/resources/article) +- [Blog](https://shopify.dev/docs/api/admin-rest/2023-07/resources/blog) +- [Collect](https://shopify.dev/docs/api/admin-rest/2023-07/resources/collect) +- [Country](https://shopify.dev/docs/api/admin-rest/2023-07/resources/country) +- [CustomCollection](https://shopify.dev/docs/api/admin-rest/2023-07/resources/customcollection) +- [Customer](https://shopify.dev/docs/api/admin-rest/2023-07/resources/customer) +- [DiscountCode](https://shopify.dev/docs/api/admin-rest/2023-07/resources/discountCode) +- [DraftOrder](https://shopify.dev/docs/api/admin-rest/2023-07/resources/draftOrder) +- [Image](https://shopify.dev/docs/api/admin-rest/2023-07/resources/image) +- [MarketingEvent](https://shopify.dev/docs/api/admin-rest/2023-07/resources/marketingevent) +- [MetaField](https://shopify.dev/docs/api/admin-rest/2023-07/resources/metafield) +- [Order](https://shopify.dev/docs/api/admin-rest/2023-07/resources/order) +- [Page](https://shopify.dev/docs/api/admin-rest/2023-07/resources/page) +- [PriceRule](https://shopify.dev/docs/api/admin-rest/2023-07/resources/pricerule) +- [Product](https://shopify.dev/docs/api/admin-rest/2023-07/resources/product) +- [Redirect](https://shopify.dev/docs/api/admin-rest/2023-07/resources/redirect) +- [ScriptTag](https://shopify.dev/docs/api/admin-rest/2023-07/resources/scripttag) +- [SmartCollection](https://shopify.dev/docs/api/admin-rest/2023-07/resources/smartcollection) +- [Variant](https://shopify.dev/docs/api/admin-rest/2023-07/resources/variant) +- [Webhook](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook) + + +## Example usage + +In this example we'll create some products in response to a customer sign-up, count them all before and after, and do a few other things too. + +```ts +client.defineJob({ + id: "shopify-integration-on-customer-created", + name: "Shopify Integration - On Customer Created", + version: "1.0.0", + integrations: { + shopify + }, + trigger: shopify.on("customers/create"), + run: async (payload, io, ctx) => { + const pre = await io.shopify.rest.Product.count("count-products"); + + const firstName = payload.first_name; + + // Create a customized product + const productOne = await io.shopify.rest.Product.save("create-product-one", { + fromData: { + title: `${firstName}'s Teapot`, + }, + }); + + // ..and another one + const productTwo = await io.shopify.rest.Product.save("create-product-two", { + fromData: { + title: `${firstName}'s Mug`, + }, + }); + + const post = await io.shopify.rest.Product.count("count-products-again"); + + await io.logger.info(`Created products: ${post.count - pre.count}`) + + // Use our fancy pagination helper + const allProducts = await io.shopify.rest.Product.all("get-all-products", { + limit: 1, + autoPaginate: true, + }); + + + const productNames = allProducts.data.map(p => p.title).join(", ") + + await io.logger.info(`All product names: ${productNames}`) + + const foundOne = await io.shopify.rest.Product.find("find-product", { + id: productOne.id, + }); + + if (foundOne) { + // Get those variants, because we can + await io.shopify.rest.Variant.all("get-all-variants", { + product_id: foundOne.id, + }); + + // Maybe that teapot was a bit too much + await io.shopify.rest.Product.delete("delete-product", { + id: foundOne.id, + }); + } + + return { + message: `Hi, ${firstName}! Bet you'll love this item: ${productTwo.title}` + } + }, +}); +``` + +## Using the underlying Shopify API Client + +You can access the [Shopify API Client instance](https://github.com/Shopify/shopify-api-js/blob/ff0900cd383712e6362b6dd3370d8ab11caabd3d/packages/shopify-api/docs/reference/shopifyApi.md) by using the `runTask` method on the integration: + +```ts +const shopify = new Shopify({ + id: "shopify", +}); + +client.defineJob({ + id: "shopify-example-1", + name: "Shopify Example 1", + version: "0.1.0", + trigger: eventTrigger({ + name: "shopify.example", + }), + integrations: { + shopify, + }, + run: async (payload, io, ctx) => { + const newProduct = await io.shopify.runTask( + "create-product", + async (client, task, io, session) => { + // We create a session for you to pass to the client + const product = new client.rest.Product({ session }); + + product.title = "Rick's Amazing Teapot"; + product.body_html = "What a great teapot!"; + product.vendor = "Astley Inc."; + product.product_type = "Teapot"; + product.status = "active"; + + // This will create the product and update the object + await product.save({ update: true }); + + return product; + } + ); + + return { + status: 418, + statusText: `I'm ${newProduct.title}`, + }; + }, +}); +``` diff --git a/docs/integrations/apis/shopify-triggers.mdx b/docs/integrations/apis/shopify-triggers.mdx new file mode 100644 index 000000000..9e630dc2b --- /dev/null +++ b/docs/integrations/apis/shopify-triggers.mdx @@ -0,0 +1,852 @@ +--- +title: Shopify Triggers & Events +sidebarTitle: Triggers & Events +--- + +You can use these triggers to start a job when a Shopify event occurs. + +--- + +## Triggers + +All triggers can be create folllowing the same pattern: + +```ts +shopify.on("topic") +``` + + + The webhook topic you want to subscribe to. Generally a pattern of `/`. + + +### Helpers + +The `filter()` method returns a new trigger with the applied payload filter: + +```ts +const trigger = shopify.on("topic").filter(filter) +``` + + + A filter to apply to the event. See our [EventFilter guide](/documentation/guides/event-filter). + + +## Events + +What follows is a small selection of webhook topics and associated payloads. A complete list of possible topic names and payloads can be found [here](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics). + + +### `fulfillments/create` + +Occurs when a fulfillment is created. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-fulfillments-create). + +```ts usage.ts +client.defineJob({ + id: "", + name: "", + version: "0.1.0", + trigger: shopify.on("fulfillments/create"), + run: async (payload, io, ctx) => { + // Add tasks here + }, +}); +``` + + +```json +{ + "id": 123456, + "order_id": 820982911946154500, + "status": "pending", + "created_at": "2021-12-31T19:00:00-05:00", + "service": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "tracking_company": "UPS", + "shipment_status": null, + "location_id": null, + "origin_address": null, + "email": "jon@example.com", + "destination": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "line_items": [ + { + "id": 866550311766439000, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "0.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "discount_allocations": [], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "tax_lines": [] + }, + { + "id": 141249953214522980, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "5.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + }, + "discount_allocations": [ + { + "amount": "5.00", + "discount_application_index": 0, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + }, + { + "amount": "5.00", + "discount_application_index": 2, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + } + ], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "tax_lines": [] + } + ], + "tracking_number": "1z827wk74630", + "tracking_numbers": ["1z827wk74630"], + "tracking_url": "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630", + "tracking_urls": [ + "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630" + ], + "receipt": {}, + "name": "#9999.1", + "admin_graphql_api_id": "gid://shopify/Fulfillment/123456" +} +``` + + +### `inventory_items/update` + +Occurs when an inventory item is updated. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-inventory-items-update). + +```ts usage.ts +client.defineJob({ + id: "", + name: "", + version: "0.1.0", + trigger: shopify.on("inventory_items/update"), + run: async (payload, io, ctx) => { + // Add tasks here + }, +}); +``` + + +```json +{ + "id": 271878346596884000, + "sku": "example-sku", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "requires_shipping": true, + "cost": null, + "country_code_of_origin": null, + "province_code_of_origin": null, + "harmonized_system_code": null, + "tracked": true, + "country_harmonized_system_codes": [], + "admin_graphql_api_id": "gid://shopify/InventoryItem/271878346596884015" +} +``` + + +### `orders/delete` + +Occurs when a order is deleted. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-orders-delete). + +```ts usage.ts +client.defineJob({ + id: "", + name: "", + version: "0.1.0", + trigger: shopify.on("orders/delete"), + run: async (payload, io, ctx) => { + // Add tasks here + }, +}); +``` + + +```json +{ + "id": 820982911946154500 +} +``` + + +### `orders/paid` + +Occurs when an order is paid. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-orders-paid). + +```ts usage.ts +client.defineJob({ + id: "", + name: "", + version: "0.1.0", + trigger: shopify.on("orders/paid"), + run: async (payload, io, ctx) => { + // Add tasks here + }, +}); +``` + + +```json +{ + "id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/Order/820982911946154508", + "app_id": null, + "browser_ip": null, + "buyer_accepts_marketing": true, + "cancel_reason": "customer", + "cancelled_at": "2021-12-31T19:00:00-05:00", + "cart_token": null, + "checkout_id": null, + "checkout_token": null, + "client_details": null, + "closed_at": null, + "confirmation_number": null, + "confirmed": false, + "contact_email": "jon@example.com", + "created_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "current_subtotal_price": "398.00", + "current_subtotal_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_additional_fees_set": null, + "current_total_discounts": "0.00", + "current_total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "current_total_duties_set": null, + "current_total_price": "398.00", + "current_total_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_tax": "0.00", + "current_total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "customer_locale": "en", + "device_id": null, + "discount_codes": [], + "email": "jon@example.com", + "estimated_taxes": false, + "financial_status": "voided", + "fulfillment_status": "pending", + "landing_site": null, + "landing_site_ref": null, + "location_id": null, + "merchant_of_record_app_id": null, + "name": "#9999", + "note": null, + "note_attributes": [], + "number": 234, + "order_number": 1234, + "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg", + "original_total_additional_fees_set": null, + "original_total_duties_set": null, + "payment_gateway_names": [ + "visa", + "bogus" + ], + "phone": null, + "po_number": null, + "presentment_currency": "USD", + "processed_at": null, + "reference": null, + "referring_site": null, + "source_identifier": null, + "source_name": "web", + "source_url": null, + "subtotal_price": "388.00", + "subtotal_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "tags": "", + "tax_exempt": false, + "tax_lines": [], + "taxes_included": false, + "test": true, + "token": "123456abcd", + "total_discounts": "20.00", + "total_discounts_set": { + "shop_money": { + "amount": "20.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "20.00", + "currency_code": "USD" + } + }, + "total_line_items_price": "398.00", + "total_line_items_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "total_outstanding": "398.00", + "total_price": "388.00", + "total_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "total_shipping_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_tax": "0.00", + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "total_tip_received": "0.00", + "total_weight": 0, + "updated_at": "2021-12-31T19:00:00-05:00", + "user_id": null, + "billing_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 115310627314723950, + "email": "john@example.com", + "accepts_marketing": false, + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "state": "disabled", + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "phone": null, + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "tags": "", + "currency": "USD", + "accepts_marketing_updated_at": null, + "marketing_opt_in_level": null, + "tax_exemptions": [], + "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954", + "default_address": { + "id": 715243470612851200, + "customer_id": 115310627314723950, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + }, + "discount_applications": [], + "fulfillments": [], + "line_items": [ + { + "id": 866550311766439000, + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "attributed_staffs": [ + { + "id": "gid://shopify/StaffMember/902541635", + "quantity": 1 + } + ], + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + }, + { + "id": 141249953214522980, + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "attributed_staffs": [], + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + } + ], + "payment_terms": null, + "refunds": [], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "shipping_lines": [ + { + "id": 271878346596884000, + "carrier_identifier": null, + "code": null, + "discounted_price": "10.00", + "discounted_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "phone": null, + "price": "10.00", + "price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "requested_fulfillment_service_id": null, + "source": "shopify", + "title": "Generic Shipping", + "tax_lines": [], + "discount_allocations": [] + } + ] +} +``` + + +### `products/create` + +Occurs when a product is created. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-products-create). + +```ts usage.ts +client.defineJob({ + id: "", + name: "", + version: "0.1.0", + trigger: shopify.on("products/create"), + run: async (payload, io, ctx) => { + // Add tasks here + }, +}); +``` + + +```json +{ + "admin_graphql_api_id": "gid://shopify/Product/788032119674292922", + "body_html": "An example T-Shirt", + "created_at": null, + "handle": "example-t-shirt", + "id": 788032119674292900, + "product_type": "Shirts", + "published_at": "2021-12-31T19:00:00-05:00", + "template_suffix": null, + "title": "Example T-Shirt", + "updated_at": "2021-12-31T19:00:00-05:00", + "vendor": "Acme", + "status": "active", + "published_scope": "web", + "tags": "example, mens, t-shirt", + "variants": [ + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922", + "barcode": null, + "compare_at_price": "24.99", + "created_at": null, + "fulfillment_service": "manual", + "id": 642667041472714000, + "inventory_management": "shopify", + "inventory_policy": "deny", + "position": 0, + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-s", + "taxable": true, + "title": "", + "updated_at": null, + "option1": "Small", + "option2": null, + "option3": null, + "grams": 200, + "image_id": null, + "weight": 200, + "weight_unit": "g", + "inventory_item_id": null, + "inventory_quantity": 75, + "old_inventory_quantity": 75, + "requires_shipping": true + }, + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/757650484644203962", + "barcode": null, + "compare_at_price": "24.99", + "created_at": null, + "fulfillment_service": "manual", + "id": 757650484644203900, + "inventory_management": "shopify", + "inventory_policy": "deny", + "position": 0, + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-m", + "taxable": true, + "title": "", + "updated_at": null, + "option1": "Medium", + "option2": null, + "option3": null, + "grams": 200, + "image_id": null, + "weight": 200, + "weight_unit": "g", + "inventory_item_id": null, + "inventory_quantity": 50, + "old_inventory_quantity": 50, + "requires_shipping": true + } + ], + "options": [], + "images": [], + "image": null +} +``` + + +### `products/delete` + +Occurs when a product is deleted. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-products-delete). + +```ts usage.ts +client.defineJob({ + id: "", + name: "", + version: "0.1.0", + trigger: shopify.on("products/delete"), + run: async (payload, io, ctx) => { + // Add tasks here + }, +}); +``` + + +```json +{ + "id": 1 +} +``` + + +### `subscription_billing_attempts/failure` + +Occurs when a subscription billing attempt has failed. [Official Shopify docs](https://shopify.dev/docs/api/admin-rest/2023-07/resources/webhook#event-topics-subscription-billing-attempts-failure). + +```ts usage.ts +client.defineJob({ + id: "", + name: "", + version: "0.1.0", + trigger: shopify.on("subscription_billing_attempts/failure"), + run: async (payload, io, ctx) => { + // Add tasks here + }, +}); +``` + + +```json +{ + "id": null, + "admin_graphql_api_id": null, + "idempotency_key": "9a453d81-d41d-403e-806f-714dee215ff9", + "order_id": 1, + "admin_graphql_api_order_id": "gid://shopify/Order/1", + "subscription_contract_id": 9251185925, + "admin_graphql_api_subscription_contract_id": "gid://shopify/SubscriptionContract/9251185925", + "ready": true, + "error_message": null, + "error_code": null +} +``` + diff --git a/docs/integrations/apis/shopify.mdx b/docs/integrations/apis/shopify.mdx new file mode 100644 index 000000000..91d6617a6 --- /dev/null +++ b/docs/integrations/apis/shopify.mdx @@ -0,0 +1,75 @@ +--- +title: Shopify overview & authentication +sidebarTitle: Overview & authentication +--- + +## Overview + +Our Shopify integration allows you to create triggers and tasks that interact with Shopify. Trigger jobs when events happen, like when a new product is added to a shop, or when an order is paid for, etc. You can also perform tasks like creating products, editing variants, getting information about an order, and a lot more. + +{/* + Check out pre-built Shopify jobs in our showcase. + */} + +## Installing the Shopify packages + +To get started with our Shopify integration, you need to install the `@trigger.dev/shopify` packages. You can do this using `npm`, `pnpm`, or `yarn`: + + + +```bash npm +npm install @trigger.dev/shopify@latest +``` + +```bash pnpm +pnpm install @trigger.dev/shopify@latest +``` + +```bash yarn +yarn add @trigger.dev/shopify@latest +``` + + + +## Authentication + +You can use Personal Access Tokens to authenticate with Shopify and get started with building custom apps. + +### Personal Access Tokens + +To create the tokens on Shopify, login and [follow the instructions](https://help.shopify.com/en/manual/apps/app-types/custom-apps#create-and-install-a-custom-app). + +The [required scopes](https://shopify.dev/docs/api/usage/access-scopes#authenticated-access-scopes) depend on the tasks you wish to perform and which webhooks you intend to receive. Webhooks will generally need read access to the respective Shopify resource. + +Additionally, you will also have to provide your shop domain. + +```ts my-job.ts +import { Shopify } from "@trigger.dev/shopify"; + +//create Shopify client using a token +const shopify = new Shopify({ + id: "shopify", + adminAccessToken: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!, + apiKey: process.env.SHOPIFY_API_KEY!, + apiSecretKey: process.env.SHOPIFY_API_SECRET_KEY!, + hostName: process.env.SHOPIFY_SHOP_DOMAIN!, +}); +... +``` + +## Triggers and Tasks + +Once you have set up a Shopify client, you can use it to create triggers and tasks. + + + + Trigger Jobs when events happen in Shopify, like a deleted product or a paid order. + + + Perform Tasks such as creating new variants, or editing orders, and more. + + diff --git a/docs/integrations/introduction.mdx b/docs/integrations/introduction.mdx index 05fa73abe..a4bab4508 100644 --- a/docs/integrations/introduction.mdx +++ b/docs/integrations/introduction.mdx @@ -42,6 +42,7 @@ Navigate the menu or select Integrations from the table below. | [Replicate](/integrations/apis/replicate) | Run machine learning tasks easily at scale | N/A | ✅ | | [Resend](/integrations/apis/resend) | Send emails using Resend | 🕘 | ✅ | | [SendGrid](/integrations/apis/sendgrid) | Send emails using SendGrid | 🕘 | ✅ | +| [Shopify](/integrations/apis/shopify) | Interact with the Shopify Admin API | ✅ | ✅ | | [Slack](/integrations/apis/slack) | Send Slack messages | 🕘 | ✅ | | [Stripe](/integrations/apis/stripe) | Interact with the Stripe API | ✅ | ✅ | | [Supabase](/integrations/apis/supabase) | Interact with your projects and databases | ✅ | ✅ | diff --git a/docs/mint.json b/docs/mint.json index 4964b6061..4dd57930c 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -290,6 +290,14 @@ "group": "Resend", "pages": ["integrations/apis/resend", "integrations/apis/resend-tasks"] }, + { + "group": "Shopify", + "pages": [ + "integrations/apis/shopify", + "integrations/apis/shopify-triggers", + "integrations/apis/shopify-tasks" + ] + }, { "group": "Slack", "pages": ["integrations/apis/slack", "integrations/apis/slack-tasks"] @@ -323,6 +331,7 @@ "sdk/triggerclient/instancemethods/getevent", "sdk/triggerclient/instancemethods/cancel-event", "sdk/triggerclient/instancemethods/cancel-runs-for-event", + "sdk/triggerclient/store", "sdk/triggerclient/instancemethods/getruns", "sdk/triggerclient/instancemethods/getrun", "sdk/triggerclient/instancemethods/define-job", @@ -343,6 +352,7 @@ "sdk/io/runtask", "sdk/io/sendevent", "sdk/io/sendevents", + "sdk/io/store", "sdk/io/wait", "sdk/io/wait-for-event", "sdk/io/wait-for-request", diff --git a/docs/sdk/io/overview.mdx b/docs/sdk/io/overview.mdx index 2a45d0b47..4f8e82692 100644 --- a/docs/sdk/io/overview.mdx +++ b/docs/sdk/io/overview.mdx @@ -20,6 +20,10 @@ View [the Integrations documentation](/integrations) for information on how to u Used to send log messages to the [Run log](/documentation/guides/viewing-runs). +### [store](/sdk/io/store) + +Exposes namespaced [Key-Value Stores](/sdk/io/store) you can access inside of your Jobs. + ## Instance methods ### [runTask()](/sdk/io/runtask) diff --git a/docs/sdk/io/store.mdx b/docs/sdk/io/store.mdx new file mode 100644 index 000000000..b6a80b2f5 --- /dev/null +++ b/docs/sdk/io/store.mdx @@ -0,0 +1,114 @@ +--- +title: "store" +sidebarTitle: "store" +description: "Exposes namespaced **Key-Value Stores** you can access inside of your Jobs." +--- + + + Only use this for small values - there's a **256KB** per-item size limit. + + +## Namespaces + +- `store.env` to access and store data within the **Environment** +- `store.job` to access and store data within the **Job** +- `store.run` to access and store data within the **Run** + +## Methods + +### `delete()` + +Deletes an item from the Key-Value Store. + + + + + The `key` of the item to delete. + + +#### Returns + +A `Promise` that resolves when the item has been deleted. + +```ts +await client.store.env.delete("cacheKey", "key") +``` + +### `has()` + +Checks if an item exists in the Key-Value Store. + + + + + The `key` of the item to check existence of. + + +#### Returns + +A `Promise` that resolves to a `boolean` value indicating existence. + +```ts +const exists = await client.store.env.has("cacheKey", "key") +``` + +### `get()` + +Retrieves an item from the Key-Value Store. + + + + + The `key` of the item to retrieve. + + +#### Returns + +A `Promise` that resolves to the stored value or `undefined` if missing. + +```ts +const val = await client.store.env.get("cacheKey", "key") +``` + +### `set()` + +Stores an item in the Key-Value Store. + + + + + The `key` of the item to store. + + + + The serializable `value` to store. + + +```ts +const val = await client.store.env.set("cacheKey", "key", "value") +``` + +#### Returns + +A `Promise` that resolves to the stored value. + + +```ts Example +await client.store.env.set("key", "foo") +await client.store.job.set("key", "bar") +await client.store.run.set("key", "baz") + +await client.store.env.get("key") // "foo" +await client.store.job.get("key") // "bar" +await client.store.run.get("key") // "baz" + +await client.store.env.has("key") // true +await client.store.job.has("missing") // false +await client.store.run.has("key") // true + +// cleanup +await client.store.env.delete("key") +await client.store.job.delete("key") +await client.store.run.delete("key") +``` + diff --git a/docs/sdk/triggerclient/overview.mdx b/docs/sdk/triggerclient/overview.mdx index dcc5d4f36..319b6c61f 100644 --- a/docs/sdk/triggerclient/overview.mdx +++ b/docs/sdk/triggerclient/overview.mdx @@ -30,6 +30,10 @@ Creates a new TriggerClient object. Is used to uniquely identify the client. + + Exposes namespaced [Key-Value Stores](/sdk/triggerclient/instancemethods/store) you can access in and outside of your Jobs. + + ## Instance methods #### [sendEvent()](/sdk/triggerclient/instancemethods/sendevent) diff --git a/docs/sdk/triggerclient/store.mdx b/docs/sdk/triggerclient/store.mdx new file mode 100644 index 000000000..c834dc126 --- /dev/null +++ b/docs/sdk/triggerclient/store.mdx @@ -0,0 +1,105 @@ +--- +title: "store" +sidebarTitle: "store" +description: "Exposes namespaced **Key-Value Stores** you can access in and outside of your Jobs." +--- + + + Only use this for small values - there's a **256KB** per-item size limit. + + +## Namespaces + +- `store.env` to access and store data across your **Environment** + +## Methods + +### `delete()` + +Deletes an item from the Key-Value Store. + + + The `key` of the item to delete. + + +#### Returns + +A `Promise` that resolves when the item has been deleted. + +```ts +await client.store.env.delete("key") +``` + +### `has()` + +Checks if an item exists in the Key-Value Store. + + + The `key` of the item to check existence of. + + +#### Returns + +A `Promise` that resolves to a `boolean` value indicating existence. + +```ts +const exists = await client.store.env.has("key") +``` + +### `get()` + +Retrieves an item from the Key-Value Store. + + + The `key` of the item to retrieve. + + +#### Returns + +A `Promise` that resolves to the stored value or `undefined` if missing. + +```ts +const val = await client.store.env.get("key") +``` + +### `set()` + +Stores an item in the Key-Value Store. + + + The `key` of the item to store. + + + + The serializable `value` to store. + + +```ts +const val = await client.store.env.set("key", "value") +``` + +#### Returns + +A `Promise` that resolves to the stored value. + + +```ts Example +// returns: "value" +await client.store.env.set("key", "value") + +// returns: true +await client.store.env.has("key") + +// returns: "value" +await client.store.env.get("key") + +// returns: void +await client.store.env.delete("key") + +// returns: false +await client.store.env.has("key") + +// returns: { foo: "bar" } +await client.store.env.set("obj", { foo: "bar" }) +``` + diff --git a/docs/sdk/verify-request-signature.mdx b/docs/sdk/verify-request-signature.mdx index 106a476ee..7cbf53b6a 100644 --- a/docs/sdk/verify-request-signature.mdx +++ b/docs/sdk/verify-request-signature.mdx @@ -41,6 +41,9 @@ const caldotcom = client.defineHttpEndpoint({ The name of the header that contains the signature. E.g. `X-Cal-Signature-256`. + + The header encoding. Defaults to `hex`. + The secret that you use to hash the payload. For HttpEndpoints this will usually originally come from the Trigger.dev dashboard and should be stored in an environment variable. diff --git a/integrations/airtable/src/index.ts b/integrations/airtable/src/index.ts index bc9ff2402..691cb8793 100644 --- a/integrations/airtable/src/index.ts +++ b/integrations/airtable/src/index.ts @@ -10,9 +10,16 @@ import { type RunTaskOptions, type TriggerIntegration, } from "@trigger.dev/sdk"; -import AirtableSDK from "airtable"; +import AirtableSDK, { Error as AirtableApiError } from "airtable"; import { Base } from "./base"; -import { Webhooks, createWebhookEventSource } from "./webhooks"; +import * as events from "./events"; +import { + WebhookChangeType, + WebhookDataType, + Webhooks, + createWebhookSource, + createWebhookTrigger, +} from "./webhooks"; export * from "./types"; export * from "./base"; @@ -57,7 +64,7 @@ export class Airtable implements TriggerIntegration { } get source() { - return createWebhookEventSource(this); + return createWebhookSource(this); } cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) { @@ -105,7 +112,7 @@ export class Airtable implements TriggerIntegration { ...(options ?? {}), connectionKey: this._connectionKey, }, - errorCallback + errorCallback ?? onError ); } @@ -120,8 +127,8 @@ export class Airtable implements TriggerIntegration { // changeTypes?: WebhookChangeType[]; // dataTypes?: WebhookDataType[]; // }) { - // return createTrigger(this.source, events.onTableChanged, params, { - // changeTypes: params.changeTypes, + // return createWebhookTrigger(this.source, events.onTableChanged, params, { + // changeTypes: params.changeTypes ?? ["add", "remove", "update"], // dataTypes: ["tableData", "tableFields", "tableMetadata"], // }); // } @@ -130,3 +137,37 @@ export class Airtable implements TriggerIntegration { return new Webhooks(this.runTask.bind(this)); } } + +function isAirtableApiError(error: unknown): error is AirtableApiError { + if (typeof error !== "object" || error === null) { + return false; + } + + const airtableError = error as AirtableApiError; + + return ( + typeof airtableError.error === "string" && + typeof airtableError.message === "string" && + typeof airtableError.statusCode === "number" + ); +} + +export function onError(error: unknown): ReturnType { + if (!isAirtableApiError(error)) { + return; + } + + if (error.statusCode === 429) { + // see: https://airtable.com/developers/web/api/rate-limits + return { + retryAt: new Date(Date.now() + 30 * 1000), + }; + } + + if (error.statusCode >= 400 && error.statusCode < 500) { + // see: https://airtable.com/developers/web/api/errors#user-error-codes + return { + skipRetrying: true, + }; + } +} diff --git a/integrations/airtable/src/webhooks.ts b/integrations/airtable/src/webhooks.ts index 786d9ef7e..98ca0c2c3 100644 --- a/integrations/airtable/src/webhooks.ts +++ b/integrations/airtable/src/webhooks.ts @@ -1,16 +1,11 @@ -import { - EventFilter, - ExternalSource, - ExternalSourceTrigger, - HandlerEvent, - IntegrationTaskKey, - Logger, -} from "@trigger.dev/sdk"; -import AirtableSDK from "airtable"; +import { EventFilter, IntegrationTaskKey, verifyRequestSignature } from "@trigger.dev/sdk"; +import AirtableSDK, { Error as AirtableApiError } from "airtable"; import { z } from "zod"; import * as events from "./events"; import { Airtable, AirtableRunTask } from "./index"; import { ListWebhooksResponse, ListWebhooksResponseSchema } from "./schemas"; +import { WebhookSource, WebhookTrigger } from "@trigger.dev/sdk/triggers/webhook"; +import { registerJobNamespace } from "@trigger.dev/integration-kit/webhooks"; const WebhookFromSourceSchema = z.union([ z.literal("formSubmission"), @@ -25,17 +20,21 @@ const WebhookFromSourceSchema = z.union([ ]); type WebhookFromSource = z.infer; + const WebhookDataTypeSchema = z.union([ z.literal("tableData"), z.literal("tableFields"), z.literal("tableMetadata"), ]); + export type WebhookDataType = z.infer; + const WebhookChangeTypeSchema = z.union([ z.literal("add"), z.literal("remove"), z.literal("update"), ]); + export type WebhookChangeType = z.infer; type WebhookSpecification = { filters: { @@ -46,6 +45,31 @@ type WebhookSpecification = { }; }; +const AirtableErrorBodySchema = z + .union([ + z.object({ + error: z.string(), + }), + z.object({ + error: z.object({ + type: z.string(), + message: z.string().optional(), + }), + }), + ]) + .transform((body) => { + if (typeof body.error === "string") { + return { + type: body.error, + }; + } else { + return { + type: body.error.type, + message: body.error.message, + }; + } + }); + const apiUrl = "https://api.airtable.com/v0/bases"; export class Webhooks { @@ -62,7 +86,6 @@ export class Webhooks { return this.runTask( key, async (client, task, io) => { - // create webhook const response = await fetch(`${apiUrl}/${baseId}/webhooks`, { method: "POST", headers: { @@ -85,14 +108,7 @@ export class Webhooks { }); if (!response.ok) { - const errorText = await response - .text() - .then((t) => t) - .catch((e) => "No body"); - - throw new Error( - `Failed to create webhook: ${response.status} ${response.statusText}\n${errorText}` - ); + await handleWebhookError(response, "WEBHOOK_CREATE"); } const webhook = await response.json(); @@ -114,7 +130,6 @@ export class Webhooks { return this.runTask( key, async (client, task, io) => { - // create webhook const response = await fetch(`${apiUrl}/${baseId}/webhooks`, { headers: { Authorization: `Bearer ${client._apiKey}`, @@ -123,7 +138,7 @@ export class Webhooks { }); if (!response.ok) { - throw new Error(`Failed to list webhooks: ${response.statusText}`); + await handleWebhookError(response, "WEBHOOK_LIST"); } const webhook = await response.json(); @@ -143,7 +158,6 @@ export class Webhooks { return this.runTask( key, async (client, task, io) => { - // create webhook const response = await fetch(`${apiUrl}/${baseId}/webhooks/${webhookId}`, { method: "DELETE", headers: { @@ -153,7 +167,7 @@ export class Webhooks { }); if (!response.ok) { - throw new Error(`Failed to delete webhook: ${response.statusText}`); + await handleWebhookError(response, "WEBHOOK_DELETE"); } }, { @@ -187,26 +201,26 @@ export type TriggerParams = { filter?: EventFilter; }; -type CreateTriggersResult = ExternalSourceTrigger< +type CreateWebhookTriggersResult = WebhookTrigger< TEventSpecification, - ReturnType + ReturnType >; -export function createTrigger( - source: ReturnType, +export function createWebhookTrigger( + source: ReturnType, event: TEventSpecification, params: TriggerParams, - options: { + config: { dataTypes: WebhookDataType[]; changeTypes?: WebhookChangeType[]; fromSources?: WebhookFromSource[]; } -): CreateTriggersResult { - return new ExternalSourceTrigger({ +): CreateWebhookTriggersResult { + return new WebhookTrigger({ event, params, source, - options, + config, }); } @@ -232,21 +246,39 @@ const WebhookListDataSchema = z.object({ type WebhookListData = z.infer; -export function createWebhookEventSource( +const getSpecification = (config: Record, params: any): WebhookSpecification => { + return { + filters: { + dataTypes: config.dataTypes as WebhookDataType[], + changeTypes: config.changeTypes + ? (config.changeTypes as WebhookChangeType[]) + : ["add", "remove", "update"], + fromSources: (config.fromSources ?? [ + "client", + "anonymousUser", + "formSubmission", + ]) as WebhookFromSource[], + recordChangeScope: params?.tableId, + }, + }; +}; + +export function createWebhookSource( integration: Airtable -): ExternalSource< +): WebhookSource< Airtable, { baseId: string; tableId?: string }, - "HTTP", { dataTypes: WebhookDataType[]; fromSources?: WebhookFromSource[] } > { - return new ExternalSource("HTTP", { + return new WebhookSource({ id: "airtable.webhook", - schema: z.object({ baseId: z.string(), tableId: z.string().optional() }), - optionSchema: z.object({ - dataTypes: z.array(WebhookDataTypeSchema), - fromSources: z.array(WebhookFromSourceSchema).optional(), - }), + schemas: { + params: z.object({ baseId: z.string(), tableId: z.string().optional() }), + config: z.object({ + dataTypes: z.array(WebhookDataTypeSchema), + fromSources: z.array(WebhookFromSourceSchema).optional(), + }), + }, version: "0.1.0", integration, filter: (params, options) => ({ @@ -256,83 +288,89 @@ export function createWebhookEventSource( }), key: (params) => `airtable.webhook.${params.baseId}${params.tableId ? `.${params.tableId}` : ""}`, - handler: webhookHandler, - register: async (event, io, ctx) => { - const { params, source: httpSource, options } = event; - - const webhookData = WebhookRegistrationDataSchema.safeParse(httpSource.data); - - const registeredOptions = { - event: options.event.desired, - dataTypes: options.dataTypes.desired, - fromSources: options.fromSources?.desired, - }; - - const specification: WebhookSpecification = { - filters: { - dataTypes: options.dataTypes.desired as WebhookDataType[], - changeTypes: options.event.desired as WebhookChangeType[], - fromSources: (options.fromSources?.desired ?? [ - "client", - "anonymousUser", - "formSubmission", - ]) as WebhookFromSource[], - recordChangeScope: params.tableId, - }, - }; - - if (httpSource.active && webhookData.success) { - const hasMissingOptions = Object.values(options).some( - (option) => option.missing.length > 0 - ); - if (!hasMissingOptions) return; - - const updatedWebhook = await io.integration.webhooks().update("update-webhook", { - baseId: params.baseId, - url: httpSource.url, - webhookId: webhookData.data.id, - options: specification, + crud: { + create: async ({ io, ctx }) => { + const webhook = await io.integration.webhooks().create("create-webhook", { + url: ctx.url, + baseId: ctx.params?.baseId, + options: getSpecification(ctx.config.desired, ctx.params), }); - return { - data: WebhookRegistrationDataSchema.parse(updatedWebhook), - options: registeredOptions, - }; - } + await io.store.job.set("set-id", "webhook-id", webhook.id); + await io.store.job.set("set-secret", "webhook-secret-base64", webhook.macSecretBase64); + }, + read: async ({ io, ctx }) => { + const listResponse = await io.integration.webhooks().list("list-webhooks", { + baseId: ctx.params?.baseId, + }); - const listResponse = await io.integration.webhooks().list("list-webhooks", { - baseId: params.baseId, - }); + const existingWebhook = listResponse.webhooks.find((w) => w.notificationUrl === ctx.url); - const existingWebhook = listResponse.webhooks.find( - (w) => w.notificationUrl === httpSource.url + if (!existingWebhook) { + return await io.store.job.delete("delete-stale-webhook-id", "webhook-id"); + } + + await io.store.job.set("set-webhook-id", "webhook-id", existingWebhook.id); + }, + delete: async ({ io, ctx }) => { + const webhookId = await io.store.job.get("get-webhook-id", "webhook-id"); + + await io.integration.webhooks().delete("delete-webhook", { + baseId: ctx.params?.baseId, + webhookId, + }); + }, + }, + verify: async ({ request, client, ctx }) => { + // TODO: should pass namespaced store instead, e.g. client.store.webhookRegistration.get() + const secretBase64 = await client.store.env.get( + `${registerJobNamespace(ctx.key)}:webhook-secret-base64` ); - if (existingWebhook) { - const updatedWebhook = await io.integration.webhooks().update("update-webhook", { - baseId: params.baseId, - url: httpSource.url, - webhookId: existingWebhook.id, - options: specification, - }); + return await verifyRequestSignature({ + request, + headerName: "x-airtable-content-mac", + secret: Buffer.from(secretBase64, "base64"), + algorithm: "sha256", + }); + }, + generateEvents: async ({ request, client, ctx }) => { + console.log("[@trigger.dev/airtable] Handling webhook payload"); - return { - data: WebhookRegistrationDataSchema.parse(updatedWebhook), - options: registeredOptions, - }; + const webhookPayload = ReceivedPayload.parse(await request.json()); + + const webhookId = await client.store.env.get( + `${registerJobNamespace(ctx.key)}:webhook-id` + ); + + const cursorKey = `cursor-${webhookId}`; + const cursor = await client.store.env.get(cursorKey); + + // TODO: get auth back + const airtable = integration.createClient(); + + const response = await getAllPayloads( + webhookPayload.base.id, + webhookPayload.webhook.id, + airtable, + cursor + ); + + if (!response) { + return console.log("[@trigger.dev/airtable] No payload fetch response, nothing to do!"); } - const webhook = await io.integration.webhooks().create("create-webhook", { - url: httpSource.url, - baseId: params.baseId, - options: specification, - }); + await client.store.env.set(cursorKey, response.cursor); - return { - data: WebhookRegistrationDataSchema.parse(webhook), - secret: webhook.macSecretBase64, - options: registeredOptions, - }; + const eventsFromResponse = response.payloads.map((payload) => ({ + id: `${payload.timestamp.getTime()}-${payload.baseTransactionNumber}`, + payload, + source: "airtable.com", + name: "changed", + timestamp: payload.timestamp, + })); + + await client.sendEvents(eventsFromResponse); }, }); } @@ -348,67 +386,6 @@ const ReceivedPayload = z.object({ timestamp: z.coerce.date(), }); -const SourceMetadataSchema = z - .object({ - cursor: z.number().optional(), - }) - .optional(); - -async function webhookHandler(event: HandlerEvent<"HTTP">, logger: Logger, integration: Airtable) { - logger.debug("[@trigger.dev/airtable] Handling webhook payload"); - - const client = integration.createClient(event.source.auth); - - const { rawEvent: request, source } = event; - - if (!request.body) { - logger.debug("[@trigger.dev/airtable] No body found"); - return { events: [] }; - } - - const rawBody = await request.text(); - - const signature = request.headers.get("X-Airtable-Content-MAC"); - - if (!signature) { - logger.error("[@trigger.dev/airtable] Error validating webhook signature, no signature found"); - throw Error("[@trigger.dev/airtable] No signature found"); - } - - const hmac = require("crypto").createHmac("sha256", source.secret); - hmac.update(rawBody, "ascii"); - const expectedContentHmac = "hmac-sha256=" + hmac.digest("hex"); - - if (signature !== expectedContentHmac) { - logger.error("[@trigger.dev/airtable] Error validating webhook signature, they don't match"); - } - - const webhookPayload = ReceivedPayload.parse(JSON.parse(rawBody)); - const parsedMetadata = SourceMetadataSchema.parse(source.metadata); - - //fetch the actual payloads - const response = await getAllPayloads( - webhookPayload.base.id, - webhookPayload.webhook.id, - client, - parsedMetadata?.cursor - ); - - return { - events: response - ? response.payloads.map((payload) => ({ - id: `${payload.timestamp}-${payload.baseTransactionNumber}`, - payload: payload, - source: "airtable.com", - name: "changed", - timestamp: payload.timestamp, - context: {}, - })) - : [], - metadata: response?.cursor ? { cursor: response.cursor } : undefined, - }; -} - async function getAllPayloads( baseId: string, webhookId: string, @@ -458,3 +435,21 @@ async function getPayload( const webhook = await response.json(); return ListWebhooksResponseSchema.parse(webhook); } + +async function handleWebhookError(response: Response, errorType: string) { + const rawErrorBody = await response.json(); + + const parsedErrorBody = AirtableErrorBodySchema.safeParse(rawErrorBody); + + if (!parsedErrorBody.success) { + throw new AirtableApiError( + `${errorType}_PARSE_ERROR`, + `${response.statusText}:\n${rawErrorBody}`, + response.status + ); + } + + const { type, message } = parsedErrorBody.data; + + throw new AirtableApiError(type, message ?? response.statusText, response.status); +} diff --git a/integrations/shopify/README.md b/integrations/shopify/README.md new file mode 100644 index 000000000..5553be780 --- /dev/null +++ b/integrations/shopify/README.md @@ -0,0 +1 @@ +# @trigger.dev/shopify \ No newline at end of file diff --git a/integrations/shopify/package.json b/integrations/shopify/package.json new file mode 100644 index 000000000..128aa440d --- /dev/null +++ b/integrations/shopify/package.json @@ -0,0 +1,38 @@ +{ + "name": "@trigger.dev/shopify", + "version": "2.2.7", + "description": "Trigger.dev integration for @shopify/shopify-api", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/index.js", + "dist/index.d.ts", + "dist/index.js.map" + ], + "devDependencies": { + "@trigger.dev/tsconfig": "workspace:*", + "@trigger.dev/tsup": "workspace:*", + "@types/node": "16.x", + "rimraf": "^3.0.2", + "tsup": "7.1.x", + "typescript": "4.9.4" + }, + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && npm run build:tsup", + "build:tsup": "tsup", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@shopify/shopify-api": "^8.0.2", + "@trigger.dev/sdk": "workspace:^2.2.6", + "@trigger.dev/integration-kit": "workspace:^2.2.6", + "zod": "3.22.3" + }, + "engines": { + "node": ">=16.8.0" + } +} diff --git a/integrations/shopify/src/events.ts b/integrations/shopify/src/events.ts new file mode 100644 index 000000000..cf919daaf --- /dev/null +++ b/integrations/shopify/src/events.ts @@ -0,0 +1,18 @@ +import { basicProperties, eventSpec } from "./utils"; +import { ShopifyExamples, ShopifyPayloads, shopifyExample } from "./payload-examples"; +import { Nullable } from "@trigger.dev/integration-kit/types"; +import { Prettify } from "@trigger.dev/integration-kit"; + +type ShopifyThis = Prettify< + Nullable & { + [key: string]: any; + } +>; + +export const shopifyEvent = [0]>(topic: TTopic) => { + return eventSpec>({ + topic, + examples: [shopifyExample(topic)], + runProperties: (payload) => basicProperties(payload), + }); +}; diff --git a/integrations/shopify/src/index.ts b/integrations/shopify/src/index.ts new file mode 100644 index 000000000..f0e94eb3a --- /dev/null +++ b/integrations/shopify/src/index.ts @@ -0,0 +1,238 @@ +import { + TriggerIntegration, + RunTaskOptions, + IO, + IOTask, + IntegrationTaskKey, + RunTaskErrorCallback, + Json, + retry, + ConnectionAuth, +} from "@trigger.dev/sdk"; +import { OmitIndexSignature } from "@trigger.dev/integration-kit/types"; + +import { + ApiVersion, + HttpRetriableError, + HttpThrottlingError, + LATEST_API_VERSION, + LogSeverity, + Session, + shopifyApi, + ShopifyError, +} from "@shopify/shopify-api"; + +// this has to be updated manually with each LATEST_API_VERSION bump +import { restResources, type RestResources } from "@shopify/shopify-api/rest/admin/2023-10"; +import "@shopify/shopify-api/adapters/node"; + +import { ApiScope } from "./schemas"; +import { createWebhookEventCatalog, WebhookEventCatalog } from "./triggers"; +import { Webhooks, createWebhookEventSource } from "./webhooks"; +import { Rest, restProxy } from "./rest"; +import { GetWebhookParams } from "@trigger.dev/sdk/triggers/webhook"; + +export type ShopifyRestResources = OmitIndexSignature; + +export type ShopifyIntegrationOptions = { + id: string; + apiKey: string; + apiSecretKey: string; + apiVersion?: ApiVersion; + adminAccessToken: string; + hostName: string; + restResources?: RestResources; + scopes?: ApiScope[]; + session?: Session; +}; + +export type ShopifyRunTask = InstanceType["runTask"]; + +type EventNamesFromCatalog> = + TEventCatalog extends WebhookEventCatalog ? keyof U : never; + +export class Shopify implements TriggerIntegration { + private _options: ShopifyIntegrationOptions; + + private _client?: ReturnType<(typeof this)["createClient"]>; + private _io?: IO; + private _connectionKey?: string; + private _session?: Session; + private _shopDomain: string; + + constructor(private options: ShopifyIntegrationOptions) { + if (Object.keys(options).includes("apiKey") && !options.apiKey) { + throw `Can't create Shopify integration (${options.id}) as apiKey was undefined`; + } + + this._options = options; + this._shopDomain = this._options.hostName.replace("http://", "").replace("https://", ""); + } + + get authSource() { + return this._options.apiKey ? "LOCAL" : "HOSTED"; + } + + get id() { + return this.options.id; + } + + get metadata() { + return { id: "shopify", name: "Shopify" }; + } + + get #source() { + return createWebhookEventSource(this); + } + + get #eventCatalog() { + return createWebhookEventCatalog(this.#source); + } + + cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) { + const shopify = new Shopify(this._options); + + const client = this.createClient(auth); + + const session = client.session.customAppSession(client.config.hostName); + session.accessToken = client.config.adminApiAccessToken; + + shopify._io = io; + shopify._connectionKey = connectionKey; + shopify._client = client; + shopify._session = this._options.session ?? session; + + return shopify; + } + + createClient(auth?: ConnectionAuth) { + // oauth + // if (auth) { + // return shopifyApi({ + // apiKey: this._options.apiKey, + // apiSecretKey: auth.accessToken, + // adminApiAccessToken: this._options.adminAccessToken, + // apiVersion: this._options.apiVersion ?? LATEST_API_VERSION, + // hostName: this._shopDomain, + // scopes: auth.scopes, + // restResources: this._options.restResources ?? restResources, + // isCustomStoreApp: false, + // isEmbeddedApp: true, + // logger: { + // level: LogSeverity.Warning, + // }, + // }); + // } + + // apiKey auth + if (this._options.apiKey) { + return shopifyApi({ + apiKey: this._options.apiKey, + apiSecretKey: this._options.apiSecretKey, + adminApiAccessToken: this._options.adminAccessToken, + apiVersion: this._options.apiVersion ?? LATEST_API_VERSION, + hostName: this._shopDomain, + scopes: this._options.scopes, + restResources: this._options.restResources ?? restResources, + isCustomStoreApp: true, + isEmbeddedApp: false, + logger: { + level: LogSeverity.Warning, + }, + }); + } + + throw new Error("No auth"); + } + + runTask | void>( + key: IntegrationTaskKey, + callback: ( + client: ReturnType, + task: IOTask, + io: IO, + session: Session + ) => Promise, + options?: RunTaskOptions, + errorCallback?: RunTaskErrorCallback + ): Promise { + if (!this._io) throw new Error("No IO"); + if (!this._connectionKey) throw new Error("No connection key"); + + return this._io.runTask( + key, + (task, io) => { + if (!this._client) throw new Error("No client"); + if (!this._session) throw new Error("No session"); + return callback(this._client, task, io, this._session); + }, + { + icon: "shopify", + retry: retry.standardBackoff, + ...(options ?? {}), + connectionKey: this._connectionKey, + }, + errorCallback ?? onError + ); + } + + /** + * Creates a webhook trigger. + */ + on>>( + name: TName + // additional params have been disabled, see WebhookSource schema + // params?: Omit>, "topic"> + ) { + return this.#eventCatalog.on(name, { topic: name }); + } + + get #webhooks() { + return new Webhooks(this.runTask.bind(this)); + } + + get rest() { + if (!this._session) { + throw new Error("No session"); + } + + return restProxy( + new Rest(this.runTask.bind(this), this._session), + this._session, + this.runTask.bind(this) + ); + } +} + +export function onError(error: unknown): ReturnType { + if (!(error instanceof ShopifyError)) { + return; + } + + if (!(error instanceof HttpRetriableError)) { + return { + skipRetrying: true, + }; + } + + if (!(error instanceof HttpThrottlingError)) { + return; + } + + const retryAfter = error.response.retryAfter; + + if (retryAfter) { + const retryAfterMs = Number(retryAfter) * 1000; + + if (Number.isNaN(retryAfterMs)) { + return; + } + + const resetDate = new Date(Date.now() + retryAfterMs); + + return { + retryAt: resetDate, + error, + }; + } +} diff --git a/integrations/shopify/src/payload-examples/AppSubscriptionsUpdate.json b/integrations/shopify/src/payload-examples/AppSubscriptionsUpdate.json new file mode 100644 index 000000000..7db2507e0 --- /dev/null +++ b/integrations/shopify/src/payload-examples/AppSubscriptionsUpdate.json @@ -0,0 +1,12 @@ +{ + "app_subscription": { + "admin_graphql_api_id": "gid://shopify/AppSubscription/1029266999", + "name": "Webhook Test", + "status": "PENDING", + "admin_graphql_api_shop_id": "gid://shopify/Shop/548380009", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "capped_amount": "20.0" + } +} diff --git a/integrations/shopify/src/payload-examples/AppUninstalled.json b/integrations/shopify/src/payload-examples/AppUninstalled.json new file mode 100644 index 000000000..04a25b206 --- /dev/null +++ b/integrations/shopify/src/payload-examples/AppUninstalled.json @@ -0,0 +1,56 @@ +{ + "id": 548380009, + "name": "Super Toys", + "email": "super@supertoys.com", + "domain": null, + "province": "Tennessee", + "country": "US", + "address1": "190 MacLaren Street", + "zip": "37178", + "city": "Houston", + "source": null, + "phone": "3213213210", + "latitude": null, + "longitude": null, + "primary_locale": "en", + "address2": null, + "created_at": null, + "updated_at": null, + "country_code": "US", + "country_name": "United States", + "currency": "USD", + "customer_email": "super@supertoys.com", + "timezone": "(GMT-05:00) Eastern Time (US & Canada)", + "iana_timezone": null, + "shop_owner": "John Smith", + "money_format": "${{amount}}", + "money_with_currency_format": "${{amount}} USD", + "weight_unit": "kg", + "province_code": "TN", + "taxes_included": null, + "auto_configure_tax_inclusivity": null, + "tax_shipping": null, + "county_taxes": null, + "plan_display_name": "Shopify Plus", + "plan_name": "enterprise", + "has_discounts": false, + "has_gift_cards": true, + "myshopify_domain": null, + "google_apps_domain": null, + "google_apps_login_enabled": null, + "money_in_emails_format": "${{amount}}", + "money_with_currency_in_emails_format": "${{amount}} USD", + "eligible_for_payments": true, + "requires_extra_payments_agreement": false, + "password_enabled": null, + "has_storefront": true, + "finances": true, + "primary_location_id": 655441491, + "checkout_api_supported": true, + "multi_location_enabled": true, + "setup_required": false, + "pre_launch_enabled": false, + "enabled_presentment_currencies": ["USD"], + "transactional_sms_disabled": false, + "marketing_sms_consent_enabled_at_checkout": false +} diff --git a/integrations/shopify/src/payload-examples/BulkOperationsFinish.json b/integrations/shopify/src/payload-examples/BulkOperationsFinish.json new file mode 100644 index 000000000..af7dd7a36 --- /dev/null +++ b/integrations/shopify/src/payload-examples/BulkOperationsFinish.json @@ -0,0 +1,8 @@ +{ + "admin_graphql_api_id": "gid://shopify/BulkOperation/147595010", + "completed_at": "2023-10-10T11:30:21-04:00", + "created_at": "2023-10-10T11:30:21-04:00", + "error_code": null, + "status": "completed", + "type": "query" +} diff --git a/integrations/shopify/src/payload-examples/CartsCreate.json b/integrations/shopify/src/payload-examples/CartsCreate.json new file mode 100644 index 000000000..ab96fe08b --- /dev/null +++ b/integrations/shopify/src/payload-examples/CartsCreate.json @@ -0,0 +1,80 @@ +{ + "id": "eeafa272cebfd4b22385bc4b645e762c", + "token": "eeafa272cebfd4b22385bc4b645e762c", + "line_items": [ + { + "id": 704912205188288500, + "properties": {}, + "quantity": 3, + "variant_id": 704912205188288500, + "key": "704912205188288575:33f11f7a1ec7d93b826de33bb54de37b", + "discounted_price": "19.99", + "discounts": [], + "gift_card": false, + "grams": 200, + "line_price": "59.97", + "original_line_price": "59.97", + "original_price": "19.99", + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-s", + "taxable": true, + "title": "Example T-Shirt", + "total_discount": "0.00", + "vendor": "Acme", + "discounted_price_set": { + "shop_money": { + "amount": "19.99", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "19.99", + "currency_code": "USD" + } + }, + "line_price_set": { + "shop_money": { + "amount": "59.97", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "59.97", + "currency_code": "USD" + } + }, + "original_line_price_set": { + "shop_money": { + "amount": "59.97", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "59.97", + "currency_code": "USD" + } + }, + "price_set": { + "shop_money": { + "amount": "19.99", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "19.99", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.0", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.0", + "currency_code": "USD" + } + } + } + ], + "note": null, + "updated_at": "2022-01-01T00:00:00.000Z", + "created_at": "2022-01-01T00:00:00.000Z" +} diff --git a/integrations/shopify/src/payload-examples/CheckoutsCreate.json b/integrations/shopify/src/payload-examples/CheckoutsCreate.json new file mode 100644 index 000000000..9d7376c6d --- /dev/null +++ b/integrations/shopify/src/payload-examples/CheckoutsCreate.json @@ -0,0 +1,188 @@ +{ + "id": 981820079255243500, + "token": "123123123", + "cart_token": "eeafa272cebfd4b22385bc4b645e762c", + "email": "example@email.com", + "gateway": null, + "buyer_accepts_marketing": false, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "landing_site": null, + "note": null, + "note_attributes": [], + "referring_site": null, + "shipping_lines": [], + "taxes_included": false, + "total_weight": 907, + "currency": "USD", + "completed_at": null, + "closed_at": null, + "user_id": null, + "location_id": null, + "source_identifier": null, + "source_url": null, + "device_id": null, + "phone": null, + "customer_locale": null, + "line_items": [ + { + "applied_discounts": [], + "discount_allocations": [], + "key": "ae31d3cc0703817acfd14bfc2ddca48b", + "destination_location_id": 938998353, + "fulfillment_service": "manual", + "gift_card": false, + "grams": 454, + "origin_location_id": 938998352, + "presentment_title": "IPod Nano - 8GB", + "presentment_variant_title": "", + "product_id": 632910392, + "properties": null, + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "tax_lines": [], + "taxable": true, + "title": "IPod Nano - 8GB", + "variant_id": null, + "variant_title": "", + "variant_price": null, + "vendor": "Apple", + "user_id": null, + "unit_price_measurement": null, + "rank": null, + "compare_at_price": null, + "line_price": "199.00", + "price": "199.00" + }, + { + "applied_discounts": [], + "discount_allocations": [], + "key": "ae31d3cc0703817acfd14bfc2ddca48b", + "destination_location_id": 938998353, + "fulfillment_service": "manual", + "gift_card": false, + "grams": 454, + "origin_location_id": 938998352, + "presentment_title": "IPod Nano - 8GB", + "presentment_variant_title": "", + "product_id": 632910392, + "properties": null, + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "tax_lines": [], + "taxable": true, + "title": "IPod Nano - 8GB", + "variant_id": null, + "variant_title": "", + "variant_price": null, + "vendor": "Apple", + "user_id": null, + "unit_price_measurement": null, + "rank": null, + "compare_at_price": null, + "line_price": "199.00", + "price": "199.00" + } + ], + "name": "#981820079255243537", + "source": null, + "abandoned_checkout_url": "https://checkout.local/548380009/checkouts/123123123/recover?key=example-secret-token", + "discount_codes": [], + "tax_lines": [], + "source_name": "web", + "presentment_currency": "USD", + "buyer_accepts_sms_marketing": false, + "sms_marketing_phone": null, + "total_discounts": "0.00", + "total_line_items_price": "398.00", + "total_price": "398.00", + "total_tax": "0.00", + "subtotal_price": "398.00", + "total_duties": null, + "billing_address": { + "first_name": "Bob", + "address1": "123 Billing Street", + "phone": "555-555-BILL", + "city": "Billtown", + "zip": "K2P0B0", + "province": "Kentucky", + "country": "United States", + "last_name": "Biller", + "address2": null, + "company": "My Company", + "latitude": null, + "longitude": null, + "name": "Bob Biller", + "country_code": "US", + "province_code": "KY" + }, + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "K2P0S0", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 603851970716743400, + "email": "john@example.com", + "accepts_marketing": false, + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "accepts_marketing_updated_at": null, + "marketing_opt_in_level": null, + "tax_exemptions": [], + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/603851970716743426", + "default_address": { + "id": null, + "customer_id": 603851970716743400, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + } +} diff --git a/integrations/shopify/src/payload-examples/CheckoutsDelete.json b/integrations/shopify/src/payload-examples/CheckoutsDelete.json new file mode 100644 index 000000000..5d80d4bd6 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CheckoutsDelete.json @@ -0,0 +1,12 @@ +{ + "id": 981820079255243500, + "presentment_currency": "USD", + "buyer_accepts_sms_marketing": false, + "sms_marketing_phone": null, + "total_discounts": "0.00", + "total_line_items_price": "398.00", + "total_price": "398.00", + "total_tax": "0.00", + "subtotal_price": "398.00", + "total_duties": null +} diff --git a/integrations/shopify/src/payload-examples/CollectionListingsAdd.json b/integrations/shopify/src/payload-examples/CollectionListingsAdd.json new file mode 100644 index 000000000..9da3913d2 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CollectionListingsAdd.json @@ -0,0 +1,13 @@ +{ + "collection_listing": { + "collection_id": 408372092144951400, + "updated_at": null, + "body_html": "Some HTML", + "default_product_image": null, + "handle": "mynewcollection", + "image": null, + "title": "My New Collection", + "sort_order": null, + "published_at": "2021-12-31T19:00:00-05:00" + } +} diff --git a/integrations/shopify/src/payload-examples/CollectionListingsRemove.json b/integrations/shopify/src/payload-examples/CollectionListingsRemove.json new file mode 100644 index 000000000..b91509459 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CollectionListingsRemove.json @@ -0,0 +1,5 @@ +{ + "collection_listing": { + "collection_id": 408372092144951400 + } +} diff --git a/integrations/shopify/src/payload-examples/CollectionsCreate.json b/integrations/shopify/src/payload-examples/CollectionsCreate.json new file mode 100644 index 000000000..94c175f71 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CollectionsCreate.json @@ -0,0 +1,12 @@ +{ + "id": 408372092144951400, + "handle": "mynewcollection", + "title": "My New Collection", + "updated_at": "2021-12-31T19:00:00-05:00", + "body_html": "Some HTML", + "published_at": "2021-12-31T16:00:00-05:00", + "sort_order": null, + "template_suffix": null, + "published_scope": "web", + "admin_graphql_api_id": "gid://shopify/Collection/408372092144951419" +} diff --git a/integrations/shopify/src/payload-examples/CollectionsDelete.json b/integrations/shopify/src/payload-examples/CollectionsDelete.json new file mode 100644 index 000000000..f7d859015 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CollectionsDelete.json @@ -0,0 +1,5 @@ +{ + "id": 408372092144951400, + "published_scope": "web", + "admin_graphql_api_id": "gid://shopify/Collection/408372092144951419" +} diff --git a/integrations/shopify/src/payload-examples/CompaniesCreate.json b/integrations/shopify/src/payload-examples/CompaniesCreate.json new file mode 100644 index 000000000..f9314324e --- /dev/null +++ b/integrations/shopify/src/payload-examples/CompaniesCreate.json @@ -0,0 +1,10 @@ +{ + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" +} diff --git a/integrations/shopify/src/payload-examples/CompanyContactsCreate.json b/integrations/shopify/src/payload-examples/CompanyContactsCreate.json new file mode 100644 index 000000000..dca8c6497 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CompanyContactsCreate.json @@ -0,0 +1,18 @@ +{ + "customer_admin_graphql_api_id": "gid://shopify/Customer/12123842227812391", + "title": "Buyer", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951419", + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + } +} diff --git a/integrations/shopify/src/payload-examples/CompanyLocationsCreate.json b/integrations/shopify/src/payload-examples/CompanyLocationsCreate.json new file mode 100644 index 000000000..17e1f8290 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CompanyLocationsCreate.json @@ -0,0 +1,57 @@ +{ + "name": "Montreal", + "external_id": "123456789", + "phone": "555-555-5555", + "locale": "en", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "note": "Head Office Location", + "buyer_experience_configuration": null, + "admin_graphql_api_id": "gid://shopify/CompanyLocation/408372092144951419", + "tax_exemptions": ["CA_BC_CONTRACTOR_EXEMPTION", "CA_BC_RESELLER_EXEMPTION"], + "company": { + "name": "Example Company", + "note": "This is an example company", + "external_id": "123456789", + "main_contact_admin_graphql_api_id": "gid://shopify/CompanyContact/408372092144951652", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "customer_since": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "billing_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "shipping_address": { + "address1": "175 Sherbrooke Street West", + "city": "Montreal", + "province": "Quebec", + "country": "Canada", + "zip": "H3A 0G4", + "recipient": "Adam Felix", + "address2": null, + "phone": "+49738001239", + "zone_code": "QC", + "country_code": "CA", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/CompanyAddress/141016871799219115", + "company_admin_graphql_api_id": "gid://shopify/Company/408372092144951419" + }, + "tax_registration": { + "tax_id": "1214214141" + } +} diff --git a/integrations/shopify/src/payload-examples/CustomerGroupsCreate.json b/integrations/shopify/src/payload-examples/CustomerGroupsCreate.json new file mode 100644 index 000000000..128ef9d69 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CustomerGroupsCreate.json @@ -0,0 +1,7 @@ +{ + "id": 239443597569284770, + "name": "Repeat Customers", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "query": "orders_count:>1" +} diff --git a/integrations/shopify/src/payload-examples/CustomerPaymentMethodsCreate.json b/integrations/shopify/src/payload-examples/CustomerPaymentMethodsCreate.json new file mode 100644 index 000000000..8ef7560aa --- /dev/null +++ b/integrations/shopify/src/payload-examples/CustomerPaymentMethodsCreate.json @@ -0,0 +1,14 @@ +{ + "admin_graphql_api_id": "gid://shopify/CustomerPaymentMethod/0eccccc666aac73efcd31094ddc4ebf0", + "token": "0eccccc666aac73efcd31094ddc4ebf0", + "customer_id": 82850125, + "admin_graphql_api_customer_id": "gid://shopify/Customer/82850125", + "instrument_type": "CustomerCreditCard", + "payment_instrument": { + "last_digits": "4242", + "month": 8, + "year": 2060, + "name": "Jim Smith", + "brand": "Visa" + } +} diff --git a/integrations/shopify/src/payload-examples/CustomersCreate.json b/integrations/shopify/src/payload-examples/CustomersCreate.json new file mode 100644 index 000000000..2c36ced46 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CustomersCreate.json @@ -0,0 +1,28 @@ +{ + "id": 706405506930370000, + "email": "bob@biller.com", + "accepts_marketing": true, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "first_name": "Bob", + "last_name": "Biller", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": "This customer loves ice cream", + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "addresses": [], + "accepts_marketing_updated_at": "2021-12-31T19:00:00-05:00", + "marketing_opt_in_level": null, + "tax_exemptions": [], + "email_marketing_consent": null, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084" +} diff --git a/integrations/shopify/src/payload-examples/CustomersDelete.json b/integrations/shopify/src/payload-examples/CustomersDelete.json new file mode 100644 index 000000000..11011e812 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CustomersDelete.json @@ -0,0 +1,11 @@ +{ + "id": 706405506930370000, + "phone": null, + "addresses": [], + "accepts_marketing_updated_at": "2021-12-31T19:00:00-05:00", + "marketing_opt_in_level": null, + "tax_exemptions": [], + "email_marketing_consent": null, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084" +} diff --git a/integrations/shopify/src/payload-examples/CustomersEmailMarketingConsentUpdate.json b/integrations/shopify/src/payload-examples/CustomersEmailMarketingConsentUpdate.json new file mode 100644 index 000000000..eb654cb36 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CustomersEmailMarketingConsentUpdate.json @@ -0,0 +1,9 @@ +{ + "customer_id": 706405506930370000, + "email_address": null, + "email_marketing_consent": { + "state": null, + "opt_in_level": null, + "consent_updated_at": null + } +} diff --git a/integrations/shopify/src/payload-examples/CustomersMarketingConsentUpdate.json b/integrations/shopify/src/payload-examples/CustomersMarketingConsentUpdate.json new file mode 100644 index 000000000..929756adf --- /dev/null +++ b/integrations/shopify/src/payload-examples/CustomersMarketingConsentUpdate.json @@ -0,0 +1,10 @@ +{ + "id": 706405506930370000, + "phone": null, + "sms_marketing_consent": { + "state": null, + "opt_in_level": null, + "consent_updated_at": null, + "consent_collected_from": "other" + } +} diff --git a/integrations/shopify/src/payload-examples/CustomersMerge.json b/integrations/shopify/src/payload-examples/CustomersMerge.json new file mode 100644 index 000000000..9a0a6c4f5 --- /dev/null +++ b/integrations/shopify/src/payload-examples/CustomersMerge.json @@ -0,0 +1,13 @@ +{ + "admin_graphql_api_customer_kept_id": "gid://shopify/Customer/1", + "admin_graphql_api_customer_deleted_id": "gid://shopify/Customer/2", + "admin_graphql_api_job_id": null, + "status": "failed", + "errors": [ + { + "customer_ids": [1], + "field": "merge_in_progress", + "message": "John Doe is currently being merged." + } + ] +} diff --git a/integrations/shopify/src/payload-examples/Deleted.json b/integrations/shopify/src/payload-examples/Deleted.json new file mode 100644 index 000000000..51b90fbe7 --- /dev/null +++ b/integrations/shopify/src/payload-examples/Deleted.json @@ -0,0 +1,3 @@ +{ + "id": 788032119674292900 +} diff --git a/integrations/shopify/src/payload-examples/DeliveryProfile.json b/integrations/shopify/src/payload-examples/DeliveryProfile.json new file mode 100644 index 000000000..2572ae5fe --- /dev/null +++ b/integrations/shopify/src/payload-examples/DeliveryProfile.json @@ -0,0 +1,3 @@ +{ + "id": 1 +} diff --git a/integrations/shopify/src/payload-examples/DisputesCreate.json b/integrations/shopify/src/payload-examples/DisputesCreate.json new file mode 100644 index 000000000..158b2ba54 --- /dev/null +++ b/integrations/shopify/src/payload-examples/DisputesCreate.json @@ -0,0 +1,14 @@ +{ + "id": 285332461850802050, + "order_id": 820982911946154500, + "type": "chargeback", + "amount": "11.50", + "currency": "CAD", + "reason": "fraudulent", + "network_reason_code": "4837", + "status": "under_review", + "evidence_due_by": "2021-12-30T19:00:00-05:00", + "evidence_sent_on": null, + "finalized_on": null, + "initiated_at": "2021-12-31T19:00:00-05:00" +} diff --git a/integrations/shopify/src/payload-examples/DomainsCreate.json b/integrations/shopify/src/payload-examples/DomainsCreate.json new file mode 100644 index 000000000..77de13505 --- /dev/null +++ b/integrations/shopify/src/payload-examples/DomainsCreate.json @@ -0,0 +1,10 @@ +{ + "id": 690933842, + "host": "jsmith.myshopify.com", + "ssl_enabled": true, + "localization": { + "country": null, + "default_locale": "en", + "alternate_locales": [] + } +} diff --git a/integrations/shopify/src/payload-examples/DraftOrdersCreate.json b/integrations/shopify/src/payload-examples/DraftOrdersCreate.json new file mode 100644 index 000000000..bb7967a1e --- /dev/null +++ b/integrations/shopify/src/payload-examples/DraftOrdersCreate.json @@ -0,0 +1,231 @@ +{ + "id": 890612572568261600, + "note": null, + "email": "jon@doe.ca", + "taxes_included": false, + "currency": "USD", + "invoice_sent_at": null, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "tax_exempt": false, + "completed_at": null, + "name": "#D234", + "status": "open", + "line_items": [ + { + "id": 994118531, + "variant_id": 808950810, + "product_id": 632910392, + "title": "IPod Nano - 8GB", + "variant_title": "Pink", + "sku": "IPOD2008PINK", + "vendor": "Apple", + "quantity": 3, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "fulfillment_service": "manual", + "grams": 567, + "tax_lines": [], + "applied_discount": null, + "name": "IPod Nano - 8GB - Pink", + "properties": [], + "custom": false, + "price": "199.00", + "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/994118531" + }, + { + "id": 994118533, + "variant_id": 808950810, + "product_id": 632910392, + "title": "IPod Nano - 8GB", + "variant_title": "Pink", + "sku": "IPOD2008PINK", + "vendor": "Apple", + "quantity": 1, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "fulfillment_service": "manual", + "grams": 567, + "tax_lines": [], + "applied_discount": null, + "name": "IPod Nano - 8GB - Pink", + "properties": [], + "custom": false, + "price": "199.00", + "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/994118533" + }, + { + "id": 994118535, + "variant_id": 457924702, + "product_id": 632910392, + "title": "IPod Nano - 8GB", + "variant_title": "Black", + "sku": "IPOD2008BLACK", + "vendor": "Apple", + "quantity": 10, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "fulfillment_service": "manual", + "grams": 567, + "tax_lines": [], + "applied_discount": { + "description": "bulk discount", + "value": "10.0", + "title": "Bulk Discount", + "amount": "199.00", + "value_type": "percentage" + }, + "name": "IPod Nano - 8GB - Black", + "properties": [], + "custom": false, + "price": "199.00", + "admin_graphql_api_id": "gid://shopify/DraftOrderLineItem/994118535" + } + ], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40150", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "billing_address": { + "first_name": "Bob", + "address1": "123 Billing Street", + "phone": "555-555-BILL", + "city": "Billtown", + "zip": "K2P0B0", + "province": "Kentucky", + "country": "United States", + "last_name": "Biller", + "address2": null, + "company": "My Company", + "latitude": null, + "longitude": null, + "name": "Bob Biller", + "country_code": "US", + "province_code": "KY" + }, + "invoice_url": "https://jsmith.myshopify.com/548380009/invoices/abcd1234abcd1234abcd1234abcd1234", + "applied_discount": { + "description": "loyalty", + "value": "50.0", + "title": "Loyalty", + "amount": "50.00", + "value_type": "fixed_amount" + }, + "order_id": null, + "shipping_line": { + "title": "Generic Shipping", + "custom": true, + "handle": null, + "price": "10.00" + }, + "tax_lines": [ + { + "rate": 0.06, + "title": "State tax", + "price": "35.82" + }, + { + "rate": 0.06, + "title": "State tax", + "price": "11.94" + }, + { + "rate": 0.06, + "title": "State tax", + "price": "107.46" + } + ], + "tags": "", + "note_attributes": [], + "total_price": "2702.22", + "subtotal_price": "2537.00", + "total_tax": "0.00", + "payment_terms": { + "id": 706405506930370000, + "payment_terms_name": "Net 7", + "payment_terms_type": "net", + "due_in_days": 7, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_schedules": [ + { + "id": 606405506930370000, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_terms_id": 706405506930370000, + "issued_at": "2021-01-01T00:00:00-05:00", + "due_at": "2021-01-02T00:00:00-05:00", + "completed_at": "2021-01-02T00:00:00-05:00", + "amount": "10.00", + "currency": "USD" + } + ] + }, + "admin_graphql_api_id": "gid://shopify/DraftOrder/890612572568261625", + "customer": { + "id": 706405506930370000, + "email": "john@doe.ca", + "accepts_marketing": false, + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "orders_count": 0, + "state": "disabled", + "total_spent": "0.00", + "last_order_id": null, + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "tags": "", + "last_order_name": null, + "currency": "USD", + "phone": null, + "accepts_marketing_updated_at": null, + "marketing_opt_in_level": null, + "tax_exemptions": [], + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084", + "default_address": { + "id": null, + "customer_id": 706405506930370000, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + } +} diff --git a/integrations/shopify/src/payload-examples/DraftOrdersDelete.json b/integrations/shopify/src/payload-examples/DraftOrdersDelete.json new file mode 100644 index 000000000..fbfb96a34 --- /dev/null +++ b/integrations/shopify/src/payload-examples/DraftOrdersDelete.json @@ -0,0 +1,25 @@ +{ + "id": 890612572568261600, + "payment_terms": { + "id": 706405506930370000, + "payment_terms_name": "Net 7", + "payment_terms_type": "net", + "due_in_days": 7, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_schedules": [ + { + "id": 606405506930370000, + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "payment_terms_id": 706405506930370000, + "issued_at": "2021-01-01T00:00:00-05:00", + "due_at": "2021-01-02T00:00:00-05:00", + "completed_at": "2021-01-02T00:00:00-05:00", + "amount": "10.00", + "currency": "USD" + } + ] + }, + "admin_graphql_api_id": "gid://shopify/DraftOrder/890612572568261625" +} diff --git a/integrations/shopify/src/payload-examples/Fulfillment.json b/integrations/shopify/src/payload-examples/Fulfillment.json new file mode 100644 index 000000000..1954e5e70 --- /dev/null +++ b/integrations/shopify/src/payload-examples/Fulfillment.json @@ -0,0 +1,164 @@ +{ + "id": 123456, + "order_id": 820982911946154500, + "status": "pending", + "created_at": "2021-12-31T19:00:00-05:00", + "service": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "tracking_company": "UPS", + "shipment_status": null, + "location_id": null, + "origin_address": null, + "email": "jon@example.com", + "destination": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "line_items": [ + { + "id": 866550311766439000, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "0.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "discount_allocations": [], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "tax_lines": [] + }, + { + "id": 141249953214522980, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "5.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + }, + "discount_allocations": [ + { + "amount": "5.00", + "discount_application_index": 0, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + }, + { + "amount": "5.00", + "discount_application_index": 2, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + } + ], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "tax_lines": [] + } + ], + "tracking_number": "1z827wk74630", + "tracking_numbers": ["1z827wk74630"], + "tracking_url": "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630", + "tracking_urls": [ + "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=1z827wk74630" + ], + "receipt": {}, + "name": "#9999.1", + "admin_graphql_api_id": "gid://shopify/Fulfillment/123456" +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentEventsCreate.json b/integrations/shopify/src/payload-examples/FulfillmentEventsCreate.json new file mode 100644 index 000000000..fcc207fe8 --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentEventsCreate.json @@ -0,0 +1,20 @@ +{ + "id": 1234567, + "fulfillment_id": 123456, + "status": "in_transit", + "message": "Item is now in transit", + "happened_at": "2021-12-31T19:00:00-05:00", + "city": null, + "province": null, + "country": "CA", + "zip": null, + "address1": null, + "latitude": null, + "longitude": null, + "shop_id": 548380009, + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "estimated_delivery_at": null, + "order_id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/FulfillmentEvent/1234567" +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestAccepted.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestAccepted.json new file mode 100644 index 000000000..a972b443d --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestAccepted.json @@ -0,0 +1,7 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "closed" + }, + "message": "Order has not been shipped yet." +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestRejected.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestRejected.json new file mode 100644 index 000000000..88d8781da --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestRejected.json @@ -0,0 +1,8 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "in_progress", + "request_status": "cancellation_rejected" + }, + "message": "Order has already been shipped." +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestSubmitted.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestSubmitted.json new file mode 100644 index 000000000..1d61a3bf5 --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancellationRequestSubmitted.json @@ -0,0 +1,11 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "in_progress", + "request_status": "cancellation_request" + }, + "fulfillment_order_merchant_request": { + "id": "gid://shopify/FulfillmentOrderMerchantRequest/1", + "message": "Customer cancelled their order" + } +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersCancelled.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancelled.json new file mode 100644 index 000000000..43d0819ea --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersCancelled.json @@ -0,0 +1,10 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "cancelled" + }, + "replacement_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/2", + "status": "open" + } +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestAccepted.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestAccepted.json new file mode 100644 index 000000000..75557c0c8 --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestAccepted.json @@ -0,0 +1,8 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "in_progress", + "request_status": "accepted" + }, + "message": "We will ship the item tomorrow." +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestRejected.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestRejected.json new file mode 100644 index 000000000..2e94513cd --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestRejected.json @@ -0,0 +1,8 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "request_status": "rejected" + }, + "message": "Can't fulfill due to no inventory on product." +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestSubmitted.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestSubmitted.json new file mode 100644 index 000000000..98b35320d --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentRequestSubmitted.json @@ -0,0 +1,16 @@ +{ + "original_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "request_status": "unsubmitted" + }, + "submitted_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "request_status": "unsubmitted" + }, + "fulfillment_order_merchant_request": { + "id": "gid://shopify/FulfillmentOrderMerchantRequest/1", + "message": "Fragile" + } +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentServiceFailedToComplete.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentServiceFailedToComplete.json new file mode 100644 index 000000000..97b32f2f7 --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersFulfillmentServiceFailedToComplete.json @@ -0,0 +1,7 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "closed" + }, + "message": "We broke the last item." +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersHoldReleased.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersHoldReleased.json new file mode 100644 index 000000000..ca9ffb6dd --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersHoldReleased.json @@ -0,0 +1,6 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open" + } +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForLocalDelivery.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForLocalDelivery.json new file mode 100644 index 000000000..79ca58d34 --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForLocalDelivery.json @@ -0,0 +1,10 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "preparable": true, + "delivery_method": { + "method_type": "local" + } + } +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForPickup.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForPickup.json new file mode 100644 index 000000000..39d73d9a3 --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersLineItemsPreparedForPickup.json @@ -0,0 +1,10 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open", + "preparable": true, + "delivery_method": { + "method_type": "pickup" + } + } +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersMoved.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersMoved.json new file mode 100644 index 000000000..a7c45a4e7 --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersMoved.json @@ -0,0 +1,22 @@ +{ + "original_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "closed", + "assigned_location_id": "gid://shopify/Location/0" + }, + "moved_fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/2", + "status": "open", + "assigned_location_id": "gid://shopify/Location/1" + }, + "destination_location_id": "gid://shopify/Location/1", + "fulfillment_order_line_items_requested": [ + { + "id": "gid://shopify/FulfillmentOrderLineItem/1", + "quantity": 1 + } + ], + "source_location": { + "id": "gid://shopify/Location/0" + } +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersOrderRoutingComplete.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersOrderRoutingComplete.json new file mode 100644 index 000000000..ca9ffb6dd --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersOrderRoutingComplete.json @@ -0,0 +1,6 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open" + } +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersPlacedOnHold.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersPlacedOnHold.json new file mode 100644 index 000000000..7ac3294cc --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersPlacedOnHold.json @@ -0,0 +1,14 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "on_hold", + "fulfillment_holds": [ + { + "reason": "other", + "reason_notes": "example" + } + ] + }, + "remaining_fulfillment_order": null, + "held_fulfillment_order_line_items": [] +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersRescheduled.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersRescheduled.json new file mode 100644 index 000000000..ade0a48b4 --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersRescheduled.json @@ -0,0 +1,7 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "scheduled", + "fulfill_at": "2021-12-31T19:00:00-05:00" + } +} diff --git a/integrations/shopify/src/payload-examples/FulfillmentOrdersScheduledFulfillmentOrderReady.json b/integrations/shopify/src/payload-examples/FulfillmentOrdersScheduledFulfillmentOrderReady.json new file mode 100644 index 000000000..ca9ffb6dd --- /dev/null +++ b/integrations/shopify/src/payload-examples/FulfillmentOrdersScheduledFulfillmentOrderReady.json @@ -0,0 +1,6 @@ +{ + "fulfillment_order": { + "id": "gid://shopify/FulfillmentOrder/1", + "status": "open" + } +} diff --git a/integrations/shopify/src/payload-examples/InventoryItem.json b/integrations/shopify/src/payload-examples/InventoryItem.json new file mode 100644 index 000000000..9a2c8c3c4 --- /dev/null +++ b/integrations/shopify/src/payload-examples/InventoryItem.json @@ -0,0 +1,14 @@ +{ + "id": 271878346596884000, + "sku": "example-sku", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "requires_shipping": true, + "cost": null, + "country_code_of_origin": null, + "province_code_of_origin": null, + "harmonized_system_code": null, + "tracked": true, + "country_harmonized_system_codes": [], + "admin_graphql_api_id": "gid://shopify/InventoryItem/271878346596884015" +} diff --git a/integrations/shopify/src/payload-examples/InventoryItemDeleted.json b/integrations/shopify/src/payload-examples/InventoryItemDeleted.json new file mode 100644 index 000000000..62cb6e62f --- /dev/null +++ b/integrations/shopify/src/payload-examples/InventoryItemDeleted.json @@ -0,0 +1,8 @@ +{ + "id": 271878346596884000, + "country_code_of_origin": null, + "province_code_of_origin": null, + "harmonized_system_code": null, + "country_harmonized_system_codes": [], + "admin_graphql_api_id": "gid://shopify/InventoryItem/271878346596884015" +} diff --git a/integrations/shopify/src/payload-examples/InventoryLevel.json b/integrations/shopify/src/payload-examples/InventoryLevel.json new file mode 100644 index 000000000..574d37e3b --- /dev/null +++ b/integrations/shopify/src/payload-examples/InventoryLevel.json @@ -0,0 +1,7 @@ +{ + "inventory_item_id": 271878346596884000, + "location_id": 24826418, + "available": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "admin_graphql_api_id": "gid://shopify/InventoryLevel/24826418?inventory_item_id=271878346596884015" +} diff --git a/integrations/shopify/src/payload-examples/InventoryLevelDisconnected.json b/integrations/shopify/src/payload-examples/InventoryLevelDisconnected.json new file mode 100644 index 000000000..1648c183c --- /dev/null +++ b/integrations/shopify/src/payload-examples/InventoryLevelDisconnected.json @@ -0,0 +1,4 @@ +{ + "inventory_item_id": 271878346596884000, + "location_id": 24826418 +} diff --git a/integrations/shopify/src/payload-examples/Location.json b/integrations/shopify/src/payload-examples/Location.json new file mode 100644 index 000000000..26ca46f62 --- /dev/null +++ b/integrations/shopify/src/payload-examples/Location.json @@ -0,0 +1,19 @@ +{ + "id": 866550311766439000, + "name": "Example Shop", + "address1": "34 Example Street", + "address2": "Next to example", + "city": "ottawa", + "zip": "k1n5t5", + "province": "ontario", + "country": "CA", + "phone": "555-555-5555", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "country_code": "CA", + "country_name": "Canada", + "province_code": "ON", + "legacy": false, + "active": true, + "admin_graphql_api_id": "gid://shopify/Location/866550311766439020" +} diff --git a/integrations/shopify/src/payload-examples/Market.json b/integrations/shopify/src/payload-examples/Market.json new file mode 100644 index 000000000..d5aa1a632 --- /dev/null +++ b/integrations/shopify/src/payload-examples/Market.json @@ -0,0 +1,10 @@ +{ + "id": 188558248, + "name": "United States", + "enabled": true, + "regions": [ + { + "country_code": "US" + } + ] +} diff --git a/integrations/shopify/src/payload-examples/Order.json b/integrations/shopify/src/payload-examples/Order.json new file mode 100644 index 000000000..7bd5dba92 --- /dev/null +++ b/integrations/shopify/src/payload-examples/Order.json @@ -0,0 +1,392 @@ +{ + "id": 820982911946154500, + "admin_graphql_api_id": "gid://shopify/Order/820982911946154508", + "app_id": null, + "browser_ip": null, + "buyer_accepts_marketing": true, + "cancel_reason": "customer", + "cancelled_at": "2021-12-31T19:00:00-05:00", + "cart_token": null, + "checkout_id": null, + "checkout_token": null, + "client_details": null, + "closed_at": null, + "confirmation_number": null, + "confirmed": false, + "contact_email": "jon@example.com", + "created_at": "2021-12-31T19:00:00-05:00", + "currency": "USD", + "current_subtotal_price": "398.00", + "current_subtotal_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_additional_fees_set": null, + "current_total_discounts": "0.00", + "current_total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "current_total_duties_set": null, + "current_total_price": "398.00", + "current_total_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "current_total_tax": "0.00", + "current_total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "customer_locale": "en", + "device_id": null, + "discount_codes": [], + "email": "jon@example.com", + "estimated_taxes": false, + "financial_status": "voided", + "fulfillment_status": "pending", + "landing_site": null, + "landing_site_ref": null, + "location_id": null, + "merchant_of_record_app_id": null, + "name": "#9999", + "note": null, + "note_attributes": [], + "number": 234, + "order_number": 1234, + "order_status_url": "https://jsmith.myshopify.com/548380009/orders/123456abcd/authenticate?key=abcdefg", + "original_total_additional_fees_set": null, + "original_total_duties_set": null, + "payment_gateway_names": ["visa", "bogus"], + "phone": null, + "po_number": null, + "presentment_currency": "USD", + "processed_at": null, + "reference": null, + "referring_site": null, + "source_identifier": null, + "source_name": "web", + "source_url": null, + "subtotal_price": "388.00", + "subtotal_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "tags": "", + "tax_exempt": false, + "tax_lines": [], + "taxes_included": false, + "test": true, + "token": "123456abcd", + "total_discounts": "20.00", + "total_discounts_set": { + "shop_money": { + "amount": "20.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "20.00", + "currency_code": "USD" + } + }, + "total_line_items_price": "398.00", + "total_line_items_price_set": { + "shop_money": { + "amount": "398.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "398.00", + "currency_code": "USD" + } + }, + "total_outstanding": "398.00", + "total_price": "388.00", + "total_price_set": { + "shop_money": { + "amount": "388.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "388.00", + "currency_code": "USD" + } + }, + "total_shipping_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_tax": "0.00", + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "total_tip_received": "0.00", + "total_weight": 0, + "updated_at": "2021-12-31T19:00:00-05:00", + "user_id": null, + "billing_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 115310627314723950, + "email": "john@example.com", + "accepts_marketing": false, + "created_at": null, + "updated_at": null, + "first_name": "John", + "last_name": "Smith", + "state": "disabled", + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "phone": null, + "email_marketing_consent": { + "state": "not_subscribed", + "opt_in_level": null, + "consent_updated_at": null + }, + "sms_marketing_consent": null, + "tags": "", + "currency": "USD", + "accepts_marketing_updated_at": null, + "marketing_opt_in_level": null, + "tax_exemptions": [], + "admin_graphql_api_id": "gid://shopify/Customer/115310627314723954", + "default_address": { + "id": 715243470612851200, + "customer_id": 115310627314723950, + "first_name": null, + "last_name": null, + "company": null, + "address1": "123 Elm St.", + "address2": null, + "city": "Ottawa", + "province": "Ontario", + "country": "Canada", + "zip": "K2H7A8", + "phone": "123-123-1234", + "name": "", + "province_code": "ON", + "country_code": "CA", + "country_name": "Canada", + "default": true + } + }, + "discount_applications": [], + "fulfillments": [], + "line_items": [ + { + "id": 866550311766439000, + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "attributed_staffs": [ + { + "id": "gid://shopify/StaffMember/902541635", + "quantity": 1 + } + ], + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + }, + { + "id": 141249953214522980, + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "attributed_staffs": [], + "fulfillable_quantity": 1, + "fulfillment_service": "manual", + "fulfillment_status": null, + "gift_card": false, + "grams": 567, + "name": "IPod Nano - 8GB", + "price": "199.00", + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "product_exists": true, + "product_id": 632910392, + "properties": [], + "quantity": 1, + "requires_shipping": true, + "sku": "IPOD2008PINK", + "taxable": true, + "title": "IPod Nano - 8GB", + "total_discount": "0.00", + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "variant_id": 808950810, + "variant_inventory_management": "shopify", + "variant_title": null, + "vendor": null, + "tax_lines": [], + "duties": [], + "discount_allocations": [] + } + ], + "payment_terms": null, + "refunds": [], + "shipping_address": { + "first_name": "Steve", + "address1": "123 Shipping Street", + "phone": "555-555-SHIP", + "city": "Shippington", + "zip": "40003", + "province": "Kentucky", + "country": "United States", + "last_name": "Shipper", + "address2": null, + "company": "Shipping Company", + "latitude": null, + "longitude": null, + "name": "Steve Shipper", + "country_code": "US", + "province_code": "KY" + }, + "shipping_lines": [ + { + "id": 271878346596884000, + "carrier_identifier": null, + "code": null, + "discounted_price": "10.00", + "discounted_price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "phone": null, + "price": "10.00", + "price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "requested_fulfillment_service_id": null, + "source": "shopify", + "title": "Generic Shipping", + "tax_lines": [], + "discount_allocations": [] + } + ] +} diff --git a/integrations/shopify/src/payload-examples/OrderEdited.json b/integrations/shopify/src/payload-examples/OrderEdited.json new file mode 100644 index 000000000..d1e71f923 --- /dev/null +++ b/integrations/shopify/src/payload-examples/OrderEdited.json @@ -0,0 +1,34 @@ +{ + "order_edit": { + "id": 78912328793123780, + "app_id": null, + "created_at": "2021-12-31T19:00:00-05:00", + "notify_customer": false, + "order_id": 820982911946154500, + "staff_note": "", + "user_id": null, + "line_items": { + "additions": [ + { + "id": 78643924236718240, + "delta": 1 + } + ], + "removals": [ + { + "id": 866550311766439000, + "delta": 1 + } + ] + }, + "discounts": { + "line_item": { + "additions": [], + "removals": [] + } + }, + "shipping_lines": { + "additions": [] + } + } +} diff --git a/integrations/shopify/src/payload-examples/OrderTransactionsCreate.json b/integrations/shopify/src/payload-examples/OrderTransactionsCreate.json new file mode 100644 index 000000000..0f62f2e87 --- /dev/null +++ b/integrations/shopify/src/payload-examples/OrderTransactionsCreate.json @@ -0,0 +1,45 @@ +{ + "id": 120560818172775260, + "order_id": 820982911946154500, + "kind": "authorization", + "gateway": "visa", + "status": "success", + "message": null, + "created_at": "2021-12-31T19:00:00-05:00", + "test": false, + "authorization": "1001", + "location_id": null, + "user_id": null, + "parent_id": null, + "processed_at": null, + "device_id": null, + "error_code": null, + "source_name": "web", + "payment_details": { + "credit_card_bin": null, + "avs_result_code": null, + "cvv_result_code": null, + "credit_card_number": "•••• •••• •••• 1234", + "credit_card_company": "Visa", + "buyer_action_info": null, + "credit_card_name": null, + "credit_card_wallet": null, + "credit_card_expiration_month": null, + "credit_card_expiration_year": null + }, + "receipt": {}, + "amount": "403.00", + "currency": "USD", + "payment_id": "#9999.1", + "total_unsettled_set": { + "presentment_money": { + "amount": "403.0", + "currency": "USD" + }, + "shop_money": { + "amount": "403.0", + "currency": "USD" + } + }, + "admin_graphql_api_id": "gid://shopify/OrderTransaction/120560818172775265" +} diff --git a/integrations/shopify/src/payload-examples/PaymentSchedule.json b/integrations/shopify/src/payload-examples/PaymentSchedule.json new file mode 100644 index 000000000..02242e89c --- /dev/null +++ b/integrations/shopify/src/payload-examples/PaymentSchedule.json @@ -0,0 +1,12 @@ +{ + "id": 606405506930370000, + "payment_terms_id": 706405506930370000, + "amount": "10.00", + "currency": "USD", + "issued_at": "2021-01-01T00:00:00-05:00", + "due_at": "2021-01-02T00:00:00-05:00", + "completed_at": "2021-01-02T00:00:00-05:00", + "created_at": "2021-01-01T00:00:00-05:00", + "updated_at": "2021-01-01T00:00:01-05:00", + "admin_graphql_api_id": "gid://shopify/PaymentSchedule/606405506930370084" +} diff --git a/integrations/shopify/src/payload-examples/Product.json b/integrations/shopify/src/payload-examples/Product.json new file mode 100644 index 000000000..5ac128848 --- /dev/null +++ b/integrations/shopify/src/payload-examples/Product.json @@ -0,0 +1,77 @@ +{ + "admin_graphql_api_id": "gid://shopify/Product/788032119674292922", + "body_html": "An example T-Shirt", + "created_at": "2021-12-31T19:00:00-05:00", + "handle": "example-t-shirt", + "id": 788032119674292900, + "product_type": "Shirts", + "published_at": "2021-12-31T19:00:00-05:00", + "template_suffix": null, + "title": "Example T-Shirt", + "updated_at": "2021-12-31T19:00:00-05:00", + "vendor": "Acme", + "status": "active", + "published_scope": "web", + "tags": "example, mens, t-shirt", + "variants": [ + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/642667041472713922", + "barcode": null, + "compare_at_price": "24.99", + "created_at": null, + "fulfillment_service": "manual", + "id": 642667041472714000, + "inventory_management": "shopify", + "inventory_policy": "deny", + "position": 0, + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-s", + "taxable": true, + "title": "", + "updated_at": null, + "option1": "Small", + "option2": null, + "option3": null, + "grams": 200, + "image_id": null, + "weight": 200, + "weight_unit": "g", + "inventory_item_id": null, + "inventory_quantity": 75, + "old_inventory_quantity": 75, + "requires_shipping": true + }, + { + "admin_graphql_api_id": "gid://shopify/ProductVariant/757650484644203962", + "barcode": null, + "compare_at_price": "24.99", + "created_at": null, + "fulfillment_service": "manual", + "id": 757650484644203900, + "inventory_management": "shopify", + "inventory_policy": "deny", + "position": 0, + "price": "19.99", + "product_id": 788032119674292900, + "sku": "example-shirt-m", + "taxable": true, + "title": "", + "updated_at": null, + "option1": "Medium", + "option2": null, + "option3": null, + "grams": 200, + "image_id": null, + "weight": 200, + "weight_unit": "g", + "inventory_item_id": null, + "inventory_quantity": 50, + "old_inventory_quantity": 50, + "requires_shipping": true + } + ], + "options": [], + "images": [], + "image": null +} diff --git a/integrations/shopify/src/payload-examples/ProductFeedCreated.json b/integrations/shopify/src/payload-examples/ProductFeedCreated.json new file mode 100644 index 000000000..a1c844643 --- /dev/null +++ b/integrations/shopify/src/payload-examples/ProductFeedCreated.json @@ -0,0 +1,6 @@ +{ + "id": "gid://shopify/ProductFeed/", + "country": "CA", + "language": "EN", + "status": "" +} diff --git a/integrations/shopify/src/payload-examples/ProductFeedFullSync.json b/integrations/shopify/src/payload-examples/ProductFeedFullSync.json new file mode 100644 index 000000000..cf3f3afd8 --- /dev/null +++ b/integrations/shopify/src/payload-examples/ProductFeedFullSync.json @@ -0,0 +1,22 @@ +{ + "metadata": { + "action": "CREATE", + "type": "FULL", + "resource": "PRODUCT", + "fullSyncId": "gid://shopify/ProductFullSync/11235", + "truncatedFields": [], + "occurred_at": "2022-01-01T00:00:00.000Z" + }, + "productFeed": { + "id": "gid://shopify/ProductFeed/12345", + "shop_id": "gid://shopify/Shop/12345", + "language": "EN", + "country": "CA" + }, + "fullSync": { + "createdAt": "2021-12-31 19:00:00 -0500", + "errorCode": "", + "status": "completed", + "count": 10 + } +} diff --git a/integrations/shopify/src/payload-examples/ProductFeedIncrementalSync.json b/integrations/shopify/src/payload-examples/ProductFeedIncrementalSync.json new file mode 100644 index 000000000..dd0fb9e89 --- /dev/null +++ b/integrations/shopify/src/payload-examples/ProductFeedIncrementalSync.json @@ -0,0 +1,83 @@ +{ + "metadata": { + "action": "CREATE", + "type": "INCREMENTAL", + "resource": "PRODUCT", + "truncatedFields": [], + "occured_at": "2021-12-31T19:00:00-05:00" + }, + "productFeed": { + "id": "gid://shopify/ProductFeed/12345", + "shop_id": "gid://shopify/Shop/12345", + "language": "EN", + "country": "CA" + }, + "product": { + "id": "gid://shopify/Product/12345", + "title": "Coffee", + "description": "The best coffee in the world", + "onlineStoreUrl": "https://example.com/products/coffee", + "updatedAt": "2021-12-31T19:00:00-05:00", + "productType": "Coffee", + "vendor": "Cawfee Inc", + "handle": "", + "isPublished": true, + "publishedAt": "2021-12-31T19:00:00-05:00", + "images": { + "edges": [ + { + "node": { + "id": "gid://shopify/ProductImage/394", + "url": "https://cdn.shopify.com/s/files/1/0262/9117/5446/products/IMG_0022.jpg?v=1675101331", + "height": 3024, + "width": 4032 + } + } + ] + }, + "options": [ + { + "name": "Title", + "values": ["151cm", "155cm", "158cm"] + } + ], + "variants": { + "edges": [ + { + "node": { + "id": "gid://shopify/ProductVariant/1", + "title": "151cm", + "price": { + "amount": "100.00", + "currencyCode": "CAD" + }, + "compareAtPrice": null, + "barcode": null, + "weight": 2.3, + "weightUnit": "KILOGRAMS", + "requireShipping": true, + "image": null, + "selectedOptions": [ + { + "name": "Title", + "value": "151cm" + } + ] + } + } + ] + }, + "seo": { + "title": "seo title", + "description": "seo description" + }, + "metafields": [ + { + "namespace": "inventory", + "key": "is_counting_pieces", + "value": "true", + "type": "boolean" + } + ] + } +} diff --git a/integrations/shopify/src/payload-examples/ProductFeedsCreate.json b/integrations/shopify/src/payload-examples/ProductFeedsCreate.json new file mode 100644 index 000000000..a1c844643 --- /dev/null +++ b/integrations/shopify/src/payload-examples/ProductFeedsCreate.json @@ -0,0 +1,6 @@ +{ + "id": "gid://shopify/ProductFeed/", + "country": "CA", + "language": "EN", + "status": "" +} diff --git a/integrations/shopify/src/payload-examples/ProductFeedsFullSync.json b/integrations/shopify/src/payload-examples/ProductFeedsFullSync.json new file mode 100644 index 000000000..cf3f3afd8 --- /dev/null +++ b/integrations/shopify/src/payload-examples/ProductFeedsFullSync.json @@ -0,0 +1,22 @@ +{ + "metadata": { + "action": "CREATE", + "type": "FULL", + "resource": "PRODUCT", + "fullSyncId": "gid://shopify/ProductFullSync/11235", + "truncatedFields": [], + "occurred_at": "2022-01-01T00:00:00.000Z" + }, + "productFeed": { + "id": "gid://shopify/ProductFeed/12345", + "shop_id": "gid://shopify/Shop/12345", + "language": "EN", + "country": "CA" + }, + "fullSync": { + "createdAt": "2021-12-31 19:00:00 -0500", + "errorCode": "", + "status": "completed", + "count": 10 + } +} diff --git a/integrations/shopify/src/payload-examples/ProductFeedsIncrementalSync.json b/integrations/shopify/src/payload-examples/ProductFeedsIncrementalSync.json new file mode 100644 index 000000000..dd0fb9e89 --- /dev/null +++ b/integrations/shopify/src/payload-examples/ProductFeedsIncrementalSync.json @@ -0,0 +1,83 @@ +{ + "metadata": { + "action": "CREATE", + "type": "INCREMENTAL", + "resource": "PRODUCT", + "truncatedFields": [], + "occured_at": "2021-12-31T19:00:00-05:00" + }, + "productFeed": { + "id": "gid://shopify/ProductFeed/12345", + "shop_id": "gid://shopify/Shop/12345", + "language": "EN", + "country": "CA" + }, + "product": { + "id": "gid://shopify/Product/12345", + "title": "Coffee", + "description": "The best coffee in the world", + "onlineStoreUrl": "https://example.com/products/coffee", + "updatedAt": "2021-12-31T19:00:00-05:00", + "productType": "Coffee", + "vendor": "Cawfee Inc", + "handle": "", + "isPublished": true, + "publishedAt": "2021-12-31T19:00:00-05:00", + "images": { + "edges": [ + { + "node": { + "id": "gid://shopify/ProductImage/394", + "url": "https://cdn.shopify.com/s/files/1/0262/9117/5446/products/IMG_0022.jpg?v=1675101331", + "height": 3024, + "width": 4032 + } + } + ] + }, + "options": [ + { + "name": "Title", + "values": ["151cm", "155cm", "158cm"] + } + ], + "variants": { + "edges": [ + { + "node": { + "id": "gid://shopify/ProductVariant/1", + "title": "151cm", + "price": { + "amount": "100.00", + "currencyCode": "CAD" + }, + "compareAtPrice": null, + "barcode": null, + "weight": 2.3, + "weightUnit": "KILOGRAMS", + "requireShipping": true, + "image": null, + "selectedOptions": [ + { + "name": "Title", + "value": "151cm" + } + ] + } + } + ] + }, + "seo": { + "title": "seo title", + "description": "seo description" + }, + "metafields": [ + { + "namespace": "inventory", + "key": "is_counting_pieces", + "value": "true", + "type": "boolean" + } + ] + } +} diff --git a/integrations/shopify/src/payload-examples/ProductListing.json b/integrations/shopify/src/payload-examples/ProductListing.json new file mode 100644 index 000000000..45c753849 --- /dev/null +++ b/integrations/shopify/src/payload-examples/ProductListing.json @@ -0,0 +1,99 @@ +{ + "product_listing": { + "product_id": 788032119674292900, + "created_at": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "body_html": "An example T-Shirt", + "handle": "example-t-shirt", + "product_type": "Shirts", + "title": "Example T-Shirt", + "vendor": "Acme", + "available": false, + "tags": "example, mens, t-shirt", + "published_at": "2021-12-31T19:00:00-05:00", + "variants": [ + { + "id": 642667041472714000, + "title": "", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Small" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": "example-shirt-s", + "barcode": null, + "taxable": true, + "position": 0, + "available": false, + "inventory_policy": "deny", + "inventory_quantity": 0, + "inventory_management": "shopify", + "fulfillment_service": "manual", + "weight": 200, + "weight_unit": "g", + "image_id": null, + "created_at": null, + "updated_at": null + }, + { + "id": 757650484644203900, + "title": "", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Medium" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": "example-shirt-m", + "barcode": null, + "taxable": true, + "position": 0, + "available": false, + "inventory_policy": "deny", + "inventory_quantity": 0, + "inventory_management": "shopify", + "fulfillment_service": "manual", + "weight": 200, + "weight_unit": "g", + "image_id": null, + "created_at": null, + "updated_at": null + } + ], + "images": [ + { + "id": 539438707724640960, + "created_at": null, + "position": 0, + "updated_at": null, + "product_id": 788032119674292900, + "src": "//cdn.shopify.com/shopifycloud/shopify/assets/shopify_shirt-39bb555874ecaeed0a1170417d58bbcf792f7ceb56acfe758384f788710ba635.png", + "variant_ids": [], + "width": 323, + "height": 434 + } + ], + "options": [ + { + "id": 527050010214937800, + "name": "Title", + "product_id": 788032119674292900, + "position": 1, + "values": ["Small", "Medium"] + } + ] + } +} diff --git a/integrations/shopify/src/payload-examples/ProductListingRemoved.json b/integrations/shopify/src/payload-examples/ProductListingRemoved.json new file mode 100644 index 000000000..9726e17cb --- /dev/null +++ b/integrations/shopify/src/payload-examples/ProductListingRemoved.json @@ -0,0 +1,5 @@ +{ + "product_listing": { + "product_id": 788032119674292900 + } +} diff --git a/integrations/shopify/src/payload-examples/Refund.json b/integrations/shopify/src/payload-examples/Refund.json new file mode 100644 index 000000000..49e7ebeef --- /dev/null +++ b/integrations/shopify/src/payload-examples/Refund.json @@ -0,0 +1,207 @@ +{ + "id": 890088186047892400, + "order_id": 820982911946154500, + "created_at": null, + "note": "Things were damaged", + "user_id": 548380009, + "processed_at": null, + "restock": false, + "duties": [], + "total_duties_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "return": null, + "admin_graphql_api_id": "gid://shopify/Refund/890088186047892319", + "refund_line_items": [ + { + "id": 866550311766439000, + "quantity": 1, + "line_item_id": 866550311766439000, + "location_id": null, + "restock_type": "no_restock", + "subtotal": 199, + "total_tax": 0, + "subtotal_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "line_item": { + "id": 866550311766439000, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "0.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "discount_allocations": [], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/866550311766439020", + "tax_lines": [] + } + }, + { + "id": 141249953214523040, + "quantity": 1, + "line_item_id": 141249953214522980, + "location_id": null, + "restock_type": "no_restock", + "subtotal": 199, + "total_tax": 0, + "subtotal_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "line_item": { + "id": 141249953214522980, + "variant_id": 808950810, + "title": "IPod Nano - 8GB", + "quantity": 1, + "sku": "IPOD2008PINK", + "variant_title": null, + "vendor": null, + "fulfillment_service": "manual", + "product_id": 632910392, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "IPod Nano - 8GB", + "variant_inventory_management": "shopify", + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 567, + "price": "199.00", + "total_discount": "5.00", + "fulfillment_status": null, + "price_set": { + "shop_money": { + "amount": "199.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "199.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + }, + "discount_allocations": [ + { + "amount": "5.00", + "discount_application_index": 0, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + }, + { + "amount": "5.00", + "discount_application_index": 2, + "amount_set": { + "shop_money": { + "amount": "5.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "5.00", + "currency_code": "USD" + } + } + } + ], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/141249953214522974", + "tax_lines": [] + } + } + ], + "transactions": [], + "order_adjustments": [] +} diff --git a/integrations/shopify/src/payload-examples/ScheduledProductListingsAdd.json b/integrations/shopify/src/payload-examples/ScheduledProductListingsAdd.json new file mode 100644 index 000000000..78be9170b --- /dev/null +++ b/integrations/shopify/src/payload-examples/ScheduledProductListingsAdd.json @@ -0,0 +1,99 @@ +{ + "scheduled_product_listing": { + "product_id": 788032119674292900, + "created_at": null, + "updated_at": "2021-12-31T19:00:00-05:00", + "body_html": "An example T-Shirt", + "handle": "example-t-shirt", + "product_type": "Shirts", + "title": "Example T-Shirt", + "vendor": "Acme", + "available": false, + "tags": "example, mens, t-shirt", + "variants": [ + { + "id": 642667041472714000, + "title": "", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Small" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": "example-shirt-s", + "barcode": null, + "taxable": true, + "position": 0, + "available": false, + "inventory_policy": "deny", + "inventory_quantity": 0, + "inventory_management": "shopify", + "fulfillment_service": "manual", + "weight": 200, + "weight_unit": "g", + "image_id": null, + "created_at": null, + "updated_at": null + }, + { + "id": 757650484644203900, + "title": "", + "option_values": [ + { + "option_id": 527050010214937800, + "name": "Title", + "value": "Medium" + } + ], + "price": "19.99", + "formatted_price": "$19.99", + "compare_at_price": "24.99", + "grams": 200, + "requires_shipping": true, + "sku": "example-shirt-m", + "barcode": null, + "taxable": true, + "position": 0, + "available": false, + "inventory_policy": "deny", + "inventory_quantity": 0, + "inventory_management": "shopify", + "fulfillment_service": "manual", + "weight": 200, + "weight_unit": "g", + "image_id": null, + "created_at": null, + "updated_at": null + } + ], + "publish_at": null, + "images": [ + { + "id": 539438707724640960, + "created_at": null, + "position": 0, + "updated_at": null, + "product_id": 788032119674292900, + "src": "//cdn.shopify.com/shopifycloud/shopify/assets/shopify_shirt-39bb555874ecaeed0a1170417d58bbcf792f7ceb56acfe758384f788710ba635.png", + "variant_ids": [], + "width": 323, + "height": 434 + } + ], + "options": [ + { + "id": 527050010214937800, + "name": "Title", + "product_id": 788032119674292900, + "position": 1, + "values": ["Small", "Medium"] + } + ] + } +} diff --git a/integrations/shopify/src/payload-examples/ScheduledProductListingsRemove.json b/integrations/shopify/src/payload-examples/ScheduledProductListingsRemove.json new file mode 100644 index 000000000..8dcbd3533 --- /dev/null +++ b/integrations/shopify/src/payload-examples/ScheduledProductListingsRemove.json @@ -0,0 +1,5 @@ +{ + "scheduled_product_listing": { + "product_id": 788032119674292900 + } +} diff --git a/integrations/shopify/src/payload-examples/SellingPlanGroup.json b/integrations/shopify/src/payload-examples/SellingPlanGroup.json new file mode 100644 index 000000000..6d81d4cb2 --- /dev/null +++ b/integrations/shopify/src/payload-examples/SellingPlanGroup.json @@ -0,0 +1,36 @@ +{ + "admin_graphql_api_id": "gid://shopify/SellingPlanGroup/1039518989", + "id": 1039518989, + "name": "Subscribe & Save", + "merchant_code": "sub-n-save", + "admin_graphql_api_app": "gid://shopify/App/2525000003", + "app_id": null, + "description": null, + "options": ["Delivery every"], + "position": null, + "summary": "1 delivery frequency, discount", + "selling_plans": [ + { + "name": "Pay every month deliver every month", + "options": ["month"], + "position": null, + "description": null, + "billing_policy": { + "interval": "month", + "interval_count": 1, + "min_cycles": null, + "max_cycles": null + }, + "delivery_policy": { + "interval": "month", + "interval_count": 1, + "anchors": [], + "cutoff": null, + "pre_anchor_behavior": "asap" + }, + "pricing_policies": [] + } + ], + "product_variants": [], + "products": [] +} diff --git a/integrations/shopify/src/payload-examples/SellingPlanGroupDeleted.json b/integrations/shopify/src/payload-examples/SellingPlanGroupDeleted.json new file mode 100644 index 000000000..b4ddaf197 --- /dev/null +++ b/integrations/shopify/src/payload-examples/SellingPlanGroupDeleted.json @@ -0,0 +1,4 @@ +{ + "admin_graphql_api_id": "gid://shopify/SellingPlanGroup/1039518994", + "id": 1039518994 +} diff --git a/integrations/shopify/src/payload-examples/Shop.json b/integrations/shopify/src/payload-examples/Shop.json new file mode 100644 index 000000000..04a25b206 --- /dev/null +++ b/integrations/shopify/src/payload-examples/Shop.json @@ -0,0 +1,56 @@ +{ + "id": 548380009, + "name": "Super Toys", + "email": "super@supertoys.com", + "domain": null, + "province": "Tennessee", + "country": "US", + "address1": "190 MacLaren Street", + "zip": "37178", + "city": "Houston", + "source": null, + "phone": "3213213210", + "latitude": null, + "longitude": null, + "primary_locale": "en", + "address2": null, + "created_at": null, + "updated_at": null, + "country_code": "US", + "country_name": "United States", + "currency": "USD", + "customer_email": "super@supertoys.com", + "timezone": "(GMT-05:00) Eastern Time (US & Canada)", + "iana_timezone": null, + "shop_owner": "John Smith", + "money_format": "${{amount}}", + "money_with_currency_format": "${{amount}} USD", + "weight_unit": "kg", + "province_code": "TN", + "taxes_included": null, + "auto_configure_tax_inclusivity": null, + "tax_shipping": null, + "county_taxes": null, + "plan_display_name": "Shopify Plus", + "plan_name": "enterprise", + "has_discounts": false, + "has_gift_cards": true, + "myshopify_domain": null, + "google_apps_domain": null, + "google_apps_login_enabled": null, + "money_in_emails_format": "${{amount}}", + "money_with_currency_in_emails_format": "${{amount}} USD", + "eligible_for_payments": true, + "requires_extra_payments_agreement": false, + "password_enabled": null, + "has_storefront": true, + "finances": true, + "primary_location_id": 655441491, + "checkout_api_supported": true, + "multi_location_enabled": true, + "setup_required": false, + "pre_launch_enabled": false, + "enabled_presentment_currencies": ["USD"], + "transactional_sms_disabled": false, + "marketing_sms_consent_enabled_at_checkout": false +} diff --git a/integrations/shopify/src/payload-examples/ShopLocale.json b/integrations/shopify/src/payload-examples/ShopLocale.json new file mode 100644 index 000000000..6d139d27c --- /dev/null +++ b/integrations/shopify/src/payload-examples/ShopLocale.json @@ -0,0 +1,4 @@ +{ + "locale": "fr-CA", + "published": true +} diff --git a/integrations/shopify/src/payload-examples/SubscriptionBillingAttempt.json b/integrations/shopify/src/payload-examples/SubscriptionBillingAttempt.json new file mode 100644 index 000000000..30747f16c --- /dev/null +++ b/integrations/shopify/src/payload-examples/SubscriptionBillingAttempt.json @@ -0,0 +1,12 @@ +{ + "id": null, + "admin_graphql_api_id": null, + "idempotency_key": "9a453d81-d41d-403e-806f-714dee215ff9", + "order_id": 1, + "admin_graphql_api_order_id": "gid://shopify/Order/1", + "subscription_contract_id": 9251185925, + "admin_graphql_api_subscription_contract_id": "gid://shopify/SubscriptionContract/9251185925", + "ready": true, + "error_message": null, + "error_code": null +} diff --git a/integrations/shopify/src/payload-examples/SubscriptionBillingCycle.json b/integrations/shopify/src/payload-examples/SubscriptionBillingCycle.json new file mode 100644 index 000000000..30f5825c3 --- /dev/null +++ b/integrations/shopify/src/payload-examples/SubscriptionBillingCycle.json @@ -0,0 +1,10 @@ +{ + "subscription_contract_id": 8641781273, + "cycle_start_at": "2022-10-01T00:00:00-04:00", + "cycle_end_at": "2022-11-01T00:00:00-04:00", + "cycle_index": 1, + "contract_edit": null, + "billing_attempt_expected_date": "2022-11-01T00:00:00-04:00", + "skipped": false, + "edited": false +} diff --git a/integrations/shopify/src/payload-examples/SubscriptionContract.json b/integrations/shopify/src/payload-examples/SubscriptionContract.json new file mode 100644 index 000000000..994cc2937 --- /dev/null +++ b/integrations/shopify/src/payload-examples/SubscriptionContract.json @@ -0,0 +1,21 @@ +{ + "admin_graphql_api_id": "gid://shopify/SubscriptionContract/6097830498", + "id": 6097830498, + "billing_policy": { + "interval": "week", + "interval_count": 4, + "min_cycles": 1, + "max_cycles": 2 + }, + "currency_code": "USD", + "customer_id": 1, + "admin_graphql_api_customer_id": "gid://shopify/Customer/1", + "delivery_policy": { + "interval": "week", + "interval_count": 2 + }, + "status": "active", + "admin_graphql_api_origin_order_id": "gid://shopify/Order/1", + "origin_order_id": 1, + "revision_id": "6399800706" +} diff --git a/integrations/shopify/src/payload-examples/TenderTransaction.json b/integrations/shopify/src/payload-examples/TenderTransaction.json new file mode 100644 index 000000000..50cdea8a1 --- /dev/null +++ b/integrations/shopify/src/payload-examples/TenderTransaction.json @@ -0,0 +1,12 @@ +{ + "id": 220982911946154500, + "order_id": 820982911946154500, + "amount": "403.00", + "currency": "USD", + "user_id": null, + "test": false, + "processed_at": null, + "remote_reference": "1001", + "payment_details": null, + "payment_method": "unknown" +} diff --git a/integrations/shopify/src/payload-examples/Theme.json b/integrations/shopify/src/payload-examples/Theme.json new file mode 100644 index 000000000..69a55ece3 --- /dev/null +++ b/integrations/shopify/src/payload-examples/Theme.json @@ -0,0 +1,11 @@ +{ + "id": 512162865275216960, + "name": "Comfort", + "created_at": "2021-12-31T19:00:00-05:00", + "updated_at": "2021-12-31T19:00:00-05:00", + "role": "main", + "theme_store_id": 1234, + "previewable": true, + "processing": false, + "admin_graphql_api_id": "gid://shopify/Theme/512162865275216980" +} diff --git a/integrations/shopify/src/payload-examples/index.ts b/integrations/shopify/src/payload-examples/index.ts new file mode 100644 index 000000000..707a98a09 --- /dev/null +++ b/integrations/shopify/src/payload-examples/index.ts @@ -0,0 +1,224 @@ +import { TypedEventSpecificationExample } from "@trigger.dev/sdk"; +import { slugifyId } from "@trigger.dev/sdk/utils"; + +import AppUninstalled from "./AppUninstalled.json"; +import AppSubscriptionsUpdate from "./AppSubscriptionsUpdate.json"; +import BulkOperationsFinish from "./BulkOperationsFinish.json"; +import CartsCreate from "./CartsCreate.json"; +import CheckoutsCreate from "./CheckoutsCreate.json"; +import CheckoutsDelete from "./CheckoutsDelete.json"; +import CollectionListingsAdd from "./CollectionListingsAdd.json"; +import CollectionListingsRemove from "./CollectionListingsRemove.json"; +import CollectionsCreate from "./CollectionsCreate.json"; +import CollectionsDelete from "./CollectionsDelete.json"; +import CompaniesCreate from "./CompaniesCreate.json"; +import CompanyContactsCreate from "./CompanyContactsCreate.json"; +import CompanyLocationsCreate from "./CompanyLocationsCreate.json"; +import CustomerGroupsCreate from "./CustomerGroupsCreate.json"; +import CustomerPaymentMethodsCreate from "./CustomerPaymentMethodsCreate.json"; +import CustomersCreate from "./CustomersCreate.json"; +import CustomersDelete from "./CustomersDelete.json"; +import CustomersMerge from "./CustomersMerge.json"; +import CustomersEmailMarketingConsentUpdate from "./CustomersEmailMarketingConsentUpdate.json"; +import CustomersMarketingConsentUpdate from "./CustomersMarketingConsentUpdate.json"; +import DisputesCreate from "./DisputesCreate.json"; +import DomainsCreate from "./DomainsCreate.json"; +import DraftOrdersCreate from "./DraftOrdersCreate.json"; +import DraftOrdersDelete from "./DraftOrdersDelete.json"; +import FulfillmentEventsCreate from "./FulfillmentEventsCreate.json"; +import FulfillmentOrdersCancellationRequestAccepted from "./FulfillmentOrdersCancellationRequestAccepted.json"; +import FulfillmentOrdersCancellationRequestRejected from "./FulfillmentOrdersCancellationRequestRejected.json"; +import FulfillmentOrdersCancellationRequestSubmitted from "./FulfillmentOrdersCancellationRequestSubmitted.json"; +import FulfillmentOrdersCancelled from "./FulfillmentOrdersCancelled.json"; +import FulfillmentOrdersFulfillmentRequestAccepted from "./FulfillmentOrdersFulfillmentRequestAccepted.json"; +import FulfillmentOrdersFulfillmentRequestRejected from "./FulfillmentOrdersFulfillmentRequestRejected.json"; +import FulfillmentOrdersFulfillmentRequestSubmitted from "./FulfillmentOrdersFulfillmentRequestSubmitted.json"; +import FulfillmentOrdersFulfillmentServiceFailedToComplete from "./FulfillmentOrdersFulfillmentServiceFailedToComplete.json"; +import FulfillmentOrdersHoldReleased from "./FulfillmentOrdersHoldReleased.json"; +import FulfillmentOrdersLineItemsPreparedForLocalDelivery from "./FulfillmentOrdersLineItemsPreparedForLocalDelivery.json"; +import FulfillmentOrdersLineItemsPreparedForPickup from "./FulfillmentOrdersLineItemsPreparedForPickup.json"; +import FulfillmentOrdersMoved from "./FulfillmentOrdersMoved.json"; +import FulfillmentOrdersOrderRoutingComplete from "./FulfillmentOrdersOrderRoutingComplete.json"; +import FulfillmentOrdersPlacedOnHold from "./FulfillmentOrdersPlacedOnHold.json"; +import FulfillmentOrdersRescheduled from "./FulfillmentOrdersRescheduled.json"; +import FulfillmentOrdersScheduledFulfillmentOrderReady from "./FulfillmentOrdersScheduledFulfillmentOrderReady.json"; +import OrderTransactionsCreate from "./OrderTransactionsCreate.json"; +import ProductFeedsCreate from "./ProductFeedsCreate.json"; +import ProductFeedsFullSync from "./ProductFeedsFullSync.json"; +import ProductFeedsIncrementalSync from "./ProductFeedsIncrementalSync.json"; +import ScheduledProductListingsAdd from "./ScheduledProductListingsAdd.json"; +import ScheduledProductListingsRemove from "./ScheduledProductListingsRemove.json"; +import Deleted from "./Deleted.json"; +import DeliveryProfile from "./DeliveryProfile.json"; +import Fulfillment from "./Fulfillment.json"; +import InventoryItem from "./InventoryItem.json"; +import InventoryItemDeleted from "./InventoryItemDeleted.json"; +import InventoryLevel from "./InventoryLevel.json"; +import InventoryLevelDisconnected from "./InventoryLevelDisconnected.json"; +import Location from "./Location.json"; +import Market from "./Market.json"; +import Order from "./Order.json"; +import OrderEdited from "./OrderEdited.json"; +import PaymentSchedule from "./PaymentSchedule.json"; +import Product from "./Product.json"; +import ProductListing from "./ProductListing.json"; +import ProductListingRemoved from "./ProductListingRemoved.json"; +import Refund from "./Refund.json"; +import SellingPlanGroup from "./SellingPlanGroup.json"; +import SellingPlanGroupDeleted from "./SellingPlanGroupDeleted.json"; +import Shop from "./Shop.json"; +import ShopLocale from "./ShopLocale.json"; +import SubscriptionBillingAttempt from "./SubscriptionBillingAttempt.json"; +import SubscriptionBillingCycle from "./SubscriptionBillingCycle.json"; +import SubscriptionContract from "./SubscriptionContract.json"; +import TenderTransaction from "./TenderTransaction.json"; +import Theme from "./Theme.json"; + +const example = (name: string, payload: TEvent): TypedEventSpecificationExample => { + return { + id: slugifyId(name), + name, + payload, + }; +}; + +export const shopifyPayloads = { + "app/uninstalled": AppUninstalled, + "app_subscriptions/update": AppSubscriptionsUpdate, + "bulk_operations/finish": BulkOperationsFinish, + "carts/create": CartsCreate, + "carts/update": CartsCreate, + "checkouts/create": CheckoutsCreate, + "checkouts/delete": CheckoutsDelete, + "checkouts/update": CheckoutsCreate, + "collection_listings/add": CollectionListingsAdd, + "collection_listings/remove": CollectionListingsRemove, + "collection_listings/update": CollectionListingsAdd, + "collections/create": CollectionsCreate, + "collections/delete": CollectionsDelete, + "collections/update": CollectionsCreate, + "companies/create": CompaniesCreate, + "companies/delete": CompaniesCreate, + "companies/update": CompaniesCreate, + "company_contact_roles/assign": {}, + "company_contact_roles/revoke": {}, + "company_contacts/create": CompanyContactsCreate, + "company_contacts/delete": CompanyContactsCreate, + "company_contacts/update": CompanyContactsCreate, + "company_locations/create": CompanyLocationsCreate, + "company_locations/delete": CompanyLocationsCreate, + "company_locations/update": CompanyLocationsCreate, + "customer_groups/create": CustomerGroupsCreate, + "customer_groups/delete": Deleted, + "customer_groups/update": CustomerGroupsCreate, + "customer_payment_methods/create": CustomerPaymentMethodsCreate, + "customer_payment_methods/revoke": CustomerPaymentMethodsCreate, + "customer_payment_methods/update": CustomerPaymentMethodsCreate, + "customers/create": CustomersCreate, + "customers/delete": CustomersDelete, + "customers/disable": CustomersCreate, + "customers/enable": CustomersCreate, + "customers/merge": CustomersMerge, + "customers/update": CustomersCreate, + "customers_email_marketing_consent/update": CustomersEmailMarketingConsentUpdate, + "customers_marketing_consent/update": CustomersMarketingConsentUpdate, + "disputes/create": DisputesCreate, + "disputes/update": DisputesCreate, + "domains/create": DomainsCreate, + "domains/destroy": DomainsCreate, + "domains/update": DomainsCreate, + "draft_orders/create": DraftOrdersCreate, + "draft_orders/delete": DraftOrdersDelete, + "draft_orders/update": DraftOrdersCreate, + "fulfillment_events/create": FulfillmentEventsCreate, + "fulfillment_events/delete": FulfillmentEventsCreate, + "fulfillment_orders/cancellation_request_accepted": FulfillmentOrdersCancellationRequestAccepted, + "fulfillment_orders/cancellation_request_rejected": FulfillmentOrdersCancellationRequestRejected, + "fulfillment_orders/cancellation_request_submitted": + FulfillmentOrdersCancellationRequestSubmitted, + "fulfillment_orders/cancelled": FulfillmentOrdersCancelled, + "fulfillment_orders/fulfillment_request_accepted": FulfillmentOrdersFulfillmentRequestAccepted, + "fulfillment_orders/fulfillment_request_rejected": FulfillmentOrdersFulfillmentRequestRejected, + "fulfillment_orders/fulfillment_request_submitted": FulfillmentOrdersFulfillmentRequestSubmitted, + "fulfillment_orders/fulfillment_service_failed_to_complete": + FulfillmentOrdersFulfillmentServiceFailedToComplete, + "fulfillment_orders/hold_released": FulfillmentOrdersHoldReleased, + "fulfillment_orders/line_items_prepared_for_local_delivery": + FulfillmentOrdersLineItemsPreparedForLocalDelivery, + "fulfillment_orders/line_items_prepared_for_pickup": FulfillmentOrdersLineItemsPreparedForPickup, + "fulfillment_orders/moved": FulfillmentOrdersMoved, + "fulfillment_orders/order_routing_complete": FulfillmentOrdersOrderRoutingComplete, + "fulfillment_orders/placed_on_hold": FulfillmentOrdersPlacedOnHold, + "fulfillment_orders/rescheduled": FulfillmentOrdersRescheduled, + "fulfillment_orders/scheduled_fulfillment_order_ready": + FulfillmentOrdersScheduledFulfillmentOrderReady, + "fulfillments/create": Fulfillment, + "fulfillments/update": Fulfillment, + "inventory_items/create": InventoryItem, + "inventory_items/delete": InventoryItemDeleted, + "inventory_items/update": InventoryItem, + "inventory_levels/connect": InventoryLevel, + "inventory_levels/disconnect": InventoryLevelDisconnected, + "inventory_levels/update": InventoryLevel, + "locales/create": ShopLocale, + "locales/update": ShopLocale, + "locations/activate": Location, + "locations/create": Location, + "locations/deactivate": Location, + "locations/delete": Deleted, + "locations/update": Location, + "markets/create": Market, + "markets/delete": Deleted, + "markets/update": Market, + "order_transactions/create": OrderTransactionsCreate, + "orders/cancelled": Order, + "orders/create": Order, + "orders/delete": Deleted, + "orders/edited": OrderEdited, + "orders/fulfilled": Order, + "orders/paid": Order, + "orders/partially_fulfilled": Order, + "orders/updated": Order, + "payment_schedules/due": PaymentSchedule, + "product_feeds/create": ProductFeedsCreate, + "product_feeds/full_sync": ProductFeedsFullSync, + "product_feeds/incremental_sync": ProductFeedsIncrementalSync, + "product_listings/add": ProductListing, + "product_listings/remove": ProductListingRemoved, + "product_listings/update": ProductListing, + "products/create": Product, + "products/delete": Deleted, + "products/update": Product, + "profiles/create": DeliveryProfile, + "profiles/delete": Deleted, + "profiles/update": DeliveryProfile, + "refunds/create": Refund, + "scheduled_product_listings/add": ScheduledProductListingsAdd, + "scheduled_product_listings/remove": ScheduledProductListingsRemove, + "scheduled_product_listings/update": ScheduledProductListingsAdd, + "selling_plan_groups/create": SellingPlanGroup, + "selling_plan_groups/delete": SellingPlanGroupDeleted, + "selling_plan_groups/update": SellingPlanGroup, + "shop/update": Shop, + "subscription_billing_attempts/challenged": SubscriptionBillingAttempt, + "subscription_billing_attempts/failure": SubscriptionBillingAttempt, + "subscription_billing_attempts/success": SubscriptionBillingAttempt, + "subscription_billing_cycle_edits/create": SubscriptionBillingCycle, + "subscription_billing_cycle_edits/delete": Deleted, + "subscription_billing_cycle_edits/update": SubscriptionBillingCycle, + "subscription_contracts/create": SubscriptionContract, + "subscription_contracts/update": SubscriptionContract, + "tender_transactions/create": TenderTransaction, + "themes/create": Theme, + "themes/delete": Deleted, + "themes/publish": Theme, + "themes/update": Theme, +}; + +export type ShopifyPayloads = typeof shopifyPayloads; + +export const shopifyExample = ( + name: TName +): TypedEventSpecificationExample => example(name, shopifyPayloads[name]); + +export type ShopifyExamples = typeof shopifyExample; diff --git a/integrations/shopify/src/rest.ts b/integrations/shopify/src/rest.ts new file mode 100644 index 000000000..e308be559 --- /dev/null +++ b/integrations/shopify/src/rest.ts @@ -0,0 +1,311 @@ +import { ShopifyRestResources, ShopifyRunTask } from "./index"; +import { basicProperties, serializeShopifyResource } from "./utils"; +import { + RecursiveShopifySerializer, + ResourcesWithStandardMethods, + ShopifyInputType, +} from "./types"; +import { PageInfo, Session } from "@shopify/shopify-api"; +import { OmitIndexSignature, Optional, SomeNonNullable } from "@trigger.dev/integration-kit/types"; +import { z } from "zod"; + +type AllReturnType = Promise<{ + data: RecursiveShopifySerializer>["data"]>; + pageInfo?: PageInfo; +}>; + +type CountReturnType = Promise<{ count: number }>; + +type DeleteReturnType = Promise; + +type SaveReturnType< + TResource extends ShopifyRestResources[ResourcesWithStandardMethods], + TUpdate extends boolean, + TFromData extends any, +> = Promise< + TUpdate extends true + ? SomeNonNullable, "id"> + : TFromData +>; + +type HasOptionalSession = { + session?: Session; +}; + +type WithRequiredSession = T & { + session: Session; +}; + +export class Resource< + TResourceType extends ResourcesWithStandardMethods, + TResource extends ShopifyRestResources[TResourceType] = ShopifyRestResources[TResourceType], +> { + constructor( + private runTask: ShopifyRunTask, + private session: Session, + private resourceType: TResourceType + ) {} + + #withSession(params: TParams): WithRequiredSession { + const { session, ...paramsWithoutSession } = params; + + return { + session: session ?? this.session, + ...paramsWithoutSession, + } as WithRequiredSession; + } + + /** + * Fetch a single resource by its ID. + */ + async find(key: string, params: Optional[0], "session">) { + return this.runTask( + key, + async (client, task, io) => { + const abc = this.#withSession(params ?? {}); + const resource = await client.rest[this.resourceType].find(this.#withSession(params)); + + return serializeShopifyResource(resource); + }, + { + name: `Find ${this.resourceType}`, + params, + properties: basicProperties(params), + } + ); + } + + async #allSinglePage( + key: string, + pageNumber: number, + params?: Optional[0], "session"> + ): AllReturnType { + const { session, ...paramsWithoutSession } = params ?? {}; + + return this.runTask( + `${key}-page-${String(pageNumber)}`, + async (client, task, io) => { + const allResponse = await client.rest[this.resourceType].all( + this.#withSession(params ?? {}) + ); + + task.outputProperties = [ + { + label: `${this.resourceType}s`, + text: String(allResponse.data.length), + }, + ]; + + return { + data: serializeShopifyResource(allResponse.data) as Awaited< + AllReturnType + >["data"], + pageInfo: allResponse.pageInfo, + }; + }, + { + name: `Get All ${this.resourceType}s`, + params: paramsWithoutSession, + properties: [ + { + label: "Page Number", + text: String(pageNumber), + }, + ], + } + ); + } + + /** + * Fetch all resources of a given type. + */ + async all( + key: string, + params?: Optional[0]>, "session"> & { + autoPaginate?: boolean; + limit?: number; + } + ): AllReturnType { + return this.runTask( + key, + async (client, task, io) => { + let pageNumber = 0; + + const { data, pageInfo: firstPageInfo } = await this.#allSinglePage( + key, + pageNumber++, + params + ); + + let pageInfo = firstPageInfo; + + if (params?.autoPaginate && pageInfo) { + while (pageInfo.nextPage) { + const { data: moreData, pageInfo: morePageInfo } = await this.#allSinglePage( + key, + pageNumber++, + { + ...params, + ...pageInfo.nextPage.query, + } + ); + + data.push(...(moreData as any)); + + pageInfo.nextPage = morePageInfo?.nextPage; + } + } + + task.outputProperties = [ + { + label: `Total ${this.resourceType}s`, + text: String(data.length), + }, + ]; + + return { data, pageInfo }; + }, + { + name: `Get All ${this.resourceType}s`, + params, + properties: [ + { + label: "Auto Paginate", + text: String(!!params?.autoPaginate), + }, + ...(params?.limit + ? [ + { + label: "Limit", + text: String(params.limit), + }, + ] + : []), + ], + } + ); + } + + /** + * Fetch the number of resources of a given type. + */ + async count( + key: string, + params?: Optional[0]>, "session"> + ): CountReturnType { + return this.runTask( + key, + async (client, task, io) => { + const countResponse = await client.rest[this.resourceType].count( + this.#withSession(params ?? {}) + ); + + const CountResponseSchema = z.object({ + count: z.number(), + }); + + const parsed = CountResponseSchema.safeParse(countResponse); + + if (!parsed.success) { + return JSON.parse(JSON.stringify(countResponse)); + } + + task.outputProperties = [ + { + label: "Total", + text: String(parsed.data.count), + }, + ]; + + return parsed.data; + }, + { + name: `Count ${this.resourceType}s`, + params, + } + ); + } + + /** + * Create or update a resource of a given type. The resource will be created if no ID is specified. + */ + async save( + key: string, + params: { + update?: TUpdate; + fromData: TFromData; + session?: Session; + } + ): SaveReturnType { + return this.runTask( + key, + async (client, task, io) => { + const resource = new client.rest[this.resourceType](this.#withSession(params)); + + // mutate resource object with upserted data by default + await resource.save({ update: params.update ?? true }); + + return JSON.parse(JSON.stringify(resource)); + }, + { + name: `Upsert ${this.resourceType}`, + params, + properties: [ + ...(params.fromData.id ? basicProperties({ id: params.fromData.id }) : []), + { + label: "Action", + text: params.fromData.id ? "Update" : "Create", + }, + ], + } + ); + } + + /** + * Delete an existing resource. + */ + async delete( + key: string, + params: Optional[0], "session"> + ): DeleteReturnType { + return this.runTask( + key, + async (client, task, io) => { + await client.rest[this.resourceType].delete(this.#withSession(params)); + return; + }, + { + name: `Delete ${this.resourceType}`, + params, + properties: basicProperties(params), + } + ); + } +} + +export class Rest { + constructor( + private runTask: ShopifyRunTask, + private session: Session + ) {} +} + +interface MergeProxyConstructor { + new , TResult extends Record>( + target: TTarget, + handler: ProxyHandler + ): TTarget & TResult; +} + +type ResourceMap = { + [KResourceType in ResourcesWithStandardMethods]: Resource; +}; + +const RestProxy = Proxy as MergeProxyConstructor; + +export const restProxy = (rest: Rest, session: Session, runTask: ShopifyRunTask) => + new RestProxy(rest, { + get: (target, resourceType, receiver) => { + return new Resource(runTask, session, resourceType as ResourcesWithStandardMethods); + }, + }); diff --git a/integrations/shopify/src/schemas.ts b/integrations/shopify/src/schemas.ts new file mode 100644 index 000000000..5381ad16f --- /dev/null +++ b/integrations/shopify/src/schemas.ts @@ -0,0 +1,261 @@ +import { z } from "zod"; + +export const ApiVersionSchema = z.enum([ + "2022-10", + "2023-01", + "2023-04", + "2023-07", + "2023-10", + "unstable", +]); + +export const ApiScopeSchema = z.enum([ + "read_all_orders", + "read_assigned_fulfillment_orders", + "write_assigned_fulfillment_orders", + "read_cart_transforms", + "write_cart_transforms", + "read_checkouts", + "write_checkouts", + "read_checkout_branding_settings", + "write_checkout_branding_settings", + "read_content", + "write_content", + "read_customer_merge", + "write_customer_merge", + "read_customers", + "write_customers", + "read_customer_payment_methods", + "read_discounts", + "write_discounts", + "read_draft_orders", + "write_draft_orders", + "read_files", + "write_files", + "read_fulfillments", + "write_fulfillments", + "read_gift_cards", + "write_gift_cards", + "read_inventory", + "write_inventory", + "read_legal_policies", + "read_locales", + "write_locales", + "read_locations", + "read_markets", + "write_markets", + "read_metaobject_definitions", + "write_metaobject_definitions", + "read_metaobjects", + "write_metaobjects", + "read_marketing_events", + "write_marketing_events", + "read_merchant_approval_signals", + "read_merchant_managed_fulfillment_orders", + "write_merchant_managed_fulfillment_orders", + "read_orders", + "write_orders", + "read_payment_mandate", + "write_payment_mandate", + "read_payment_terms", + "write_payment_terms", + "read_price_rules", + "write_price_rules", + "read_products", + "write_products", + "read_product_listings", + "read_publications", + "write_publications", + "read_purchase_options", + "write_purchase_options", + "read_reports", + "write_reports", + "read_resource_feedbacks", + "write_resource_feedbacks", + "read_script_tags", + "write_script_tags", + "read_shipping", + "write_shipping", + "read_shopify_payments_disputes", + "read_shopify_payments_payouts", + "read_own_subscription_contracts", + "write_own_subscription_contracts", + "read_returns", + "write_returns", + "read_themes", + "write_themes", + "read_translations", + "write_translations", + "read_third_party_fulfillment_orders", + "write_third_party_fulfillment_orders", + "read_users", + "read_order_edits", + "write_order_edits", + "write_payment_gateways", + "write_payment_sessions", + "write_pixels", + "read_customer_events", +]); + +export type ApiScope = z.infer; + +export const WebhookTopicSchema = z.enum([ + "app/uninstalled", + "app_subscriptions/update", + "bulk_operations/finish", + "carts/create", + "carts/update", + "checkouts/create", + "checkouts/delete", + "checkouts/update", + "collection_listings/add", + "collection_listings/remove", + "collection_listings/update", + "collections/create", + "collections/delete", + "collections/update", + "companies/create", + "companies/delete", + "companies/update", + "company_contact_roles/assign", + "company_contact_roles/revoke", + "company_contacts/create", + "company_contacts/delete", + "company_contacts/update", + "company_locations/create", + "company_locations/delete", + "company_locations/update", + "customer_groups/create", + "customer_groups/delete", + "customer_groups/update", + "customer_payment_methods/create", + "customer_payment_methods/revoke", + "customer_payment_methods/update", + "customers/create", + "customers/delete", + "customers/disable", + "customers/enable", + "customers/merge", + "customers/update", + "customers_email_marketing_consent/update", + "customers_marketing_consent/update", + "disputes/create", + "disputes/update", + "domains/create", + "domains/destroy", + "domains/update", + "draft_orders/create", + "draft_orders/delete", + "draft_orders/update", + "fulfillment_events/create", + "fulfillment_events/delete", + "fulfillment_orders/cancellation_request_accepted", + "fulfillment_orders/cancellation_request_rejected", + "fulfillment_orders/cancellation_request_submitted", + "fulfillment_orders/cancelled", + "fulfillment_orders/fulfillment_request_accepted", + "fulfillment_orders/fulfillment_request_rejected", + "fulfillment_orders/fulfillment_request_submitted", + "fulfillment_orders/fulfillment_service_failed_to_complete", + "fulfillment_orders/hold_released", + "fulfillment_orders/line_items_prepared_for_local_delivery", + "fulfillment_orders/line_items_prepared_for_pickup", + "fulfillment_orders/moved", + "fulfillment_orders/order_routing_complete", + "fulfillment_orders/placed_on_hold", + "fulfillment_orders/rescheduled", + "fulfillment_orders/scheduled_fulfillment_order_ready", + "fulfillments/create", + "fulfillments/update", + "inventory_items/create", + "inventory_items/delete", + "inventory_items/update", + "inventory_levels/connect", + "inventory_levels/disconnect", + "inventory_levels/update", + "locales/create", + "locales/update", + "locations/activate", + "locations/create", + "locations/deactivate", + "locations/delete", + "locations/update", + "markets/create", + "markets/delete", + "markets/update", + "order_transactions/create", + "orders/cancelled", + "orders/create", + "orders/delete", + "orders/edited", + "orders/fulfilled", + "orders/paid", + "orders/partially_fulfilled", + "orders/updated", + "payment_schedules/due", + "product_feeds/create", + "product_feeds/full_sync", + "product_feeds/incremental_sync", + "product_listings/add", + "product_listings/remove", + "product_listings/update", + "products/create", + "products/delete", + "products/update", + "profiles/create", + "profiles/delete", + "profiles/update", + "refunds/create", + "scheduled_product_listings/add", + "scheduled_product_listings/remove", + "scheduled_product_listings/update", + "selling_plan_groups/create", + "selling_plan_groups/delete", + "selling_plan_groups/update", + "shop/update", + "subscription_billing_attempts/challenged", + "subscription_billing_attempts/failure", + "subscription_billing_attempts/success", + "subscription_billing_cycle_edits/create", + "subscription_billing_cycle_edits/delete", + "subscription_billing_cycle_edits/update", + "subscription_contracts/create", + "subscription_contracts/update", + "tender_transactions/create", + "themes/create", + "themes/delete", + "themes/publish", + "themes/update", +]); + +export type WebhookTopic = z.infer; + +export const WebhookHeaderSchema = z.object({ + "x-shopify-topic": WebhookTopicSchema.or(z.string()), + "x-shopify-webhook-id": z.string(), + "x-shopify-api-version": ApiVersionSchema.or(z.string()), + "x-shopify-hmac-sha256": z.string(), + "x-shopify-shop-domain": z.string(), + "x-shopify-triggered-at": z.coerce.date(), +}); + +export const WebhookSubscriptionSchema = z.object({ + address: z.string(), + api_version: ApiVersionSchema, + created_at: z.coerce.date(), + fields: z.string().array(), + format: z.enum(["json", "xml"]), + id: z.number(), + metafield_namespaces: z.string().array(), + private_metafield_namespaces: z.string().array(), + topic: WebhookTopicSchema, + updated_at: z.coerce.date(), +}); + +export type WebhookSubscription = z.infer; + +export const WebhookSubscriptionDataSchema = z.object({ + webhook: WebhookSubscriptionSchema, +}); + +export type WebhookSubscriptionData = z.infer; diff --git a/integrations/shopify/src/triggers.ts b/integrations/shopify/src/triggers.ts new file mode 100644 index 000000000..af6f11ab5 --- /dev/null +++ b/integrations/shopify/src/triggers.ts @@ -0,0 +1,209 @@ +import { shopifyEvent } from "./events"; +import { EventSpecification } from "@trigger.dev/sdk"; +import { GetWebhookParams, WebhookSource, WebhookTrigger } from "@trigger.dev/sdk/triggers/webhook"; +import { createWebhookEventSource } from "./webhooks"; + +const shopifyEvents = { + "app/uninstalled": shopifyEvent("app/uninstalled"), + "app_subscriptions/update": shopifyEvent("app_subscriptions/update"), + "bulk_operations/finish": shopifyEvent("bulk_operations/finish"), + "carts/create": shopifyEvent("carts/create"), + "carts/update": shopifyEvent("carts/update"), + "checkouts/create": shopifyEvent("checkouts/create"), + "checkouts/delete": shopifyEvent("checkouts/delete"), + "checkouts/update": shopifyEvent("checkouts/update"), + "collection_listings/add": shopifyEvent("collection_listings/add"), + "collection_listings/remove": shopifyEvent("collection_listings/remove"), + "collection_listings/update": shopifyEvent("collection_listings/update"), + "collections/create": shopifyEvent("collections/create"), + "collections/delete": shopifyEvent("collections/delete"), + "collections/update": shopifyEvent("collections/update"), + "companies/create": shopifyEvent("companies/create"), + "companies/delete": shopifyEvent("companies/delete"), + "companies/update": shopifyEvent("companies/update"), + "company_contact_roles/assign": shopifyEvent("company_contact_roles/assign"), + "company_contact_roles/revoke": shopifyEvent("company_contact_roles/revoke"), + "company_contacts/create": shopifyEvent("company_contacts/create"), + "company_contacts/delete": shopifyEvent("company_contacts/delete"), + "company_contacts/update": shopifyEvent("company_contacts/update"), + "company_locations/create": shopifyEvent("company_locations/create"), + "company_locations/delete": shopifyEvent("company_locations/delete"), + "company_locations/update": shopifyEvent("company_locations/update"), + "customer_groups/create": shopifyEvent("customer_groups/create"), + "customer_groups/delete": shopifyEvent("customer_groups/delete"), + "customer_groups/update": shopifyEvent("customer_groups/update"), + "customer_payment_methods/create": shopifyEvent("customer_payment_methods/create"), + "customer_payment_methods/revoke": shopifyEvent("customer_payment_methods/revoke"), + "customer_payment_methods/update": shopifyEvent("customer_payment_methods/update"), + "customers/create": shopifyEvent("customers/create"), + "customers/delete": shopifyEvent("customers/delete"), + "customers/disable": shopifyEvent("customers/disable"), + "customers/enable": shopifyEvent("customers/enable"), + "customers/merge": shopifyEvent("customers/merge"), + "customers/update": shopifyEvent("customers/update"), + "customers_email_marketing_consent/update": shopifyEvent( + "customers_email_marketing_consent/update" + ), + "customers_marketing_consent/update": shopifyEvent("customers_marketing_consent/update"), + "disputes/create": shopifyEvent("disputes/create"), + "disputes/update": shopifyEvent("disputes/update"), + "domains/create": shopifyEvent("domains/create"), + "domains/destroy": shopifyEvent("domains/destroy"), + "domains/update": shopifyEvent("domains/update"), + "draft_orders/create": shopifyEvent("draft_orders/create"), + "draft_orders/delete": shopifyEvent("draft_orders/delete"), + "draft_orders/update": shopifyEvent("draft_orders/update"), + "fulfillment_events/create": shopifyEvent("fulfillment_events/create"), + "fulfillment_events/delete": shopifyEvent("fulfillment_events/delete"), + "fulfillment_orders/cancellation_request_accepted": shopifyEvent( + "fulfillment_orders/cancellation_request_accepted" + ), + "fulfillment_orders/cancellation_request_rejected": shopifyEvent( + "fulfillment_orders/cancellation_request_rejected" + ), + "fulfillment_orders/cancellation_request_submitted": shopifyEvent( + "fulfillment_orders/cancellation_request_submitted" + ), + "fulfillment_orders/cancelled": shopifyEvent("fulfillment_orders/cancelled"), + "fulfillment_orders/fulfillment_request_accepted": shopifyEvent( + "fulfillment_orders/fulfillment_request_accepted" + ), + "fulfillment_orders/fulfillment_request_rejected": shopifyEvent( + "fulfillment_orders/fulfillment_request_rejected" + ), + "fulfillment_orders/fulfillment_request_submitted": shopifyEvent( + "fulfillment_orders/fulfillment_request_submitted" + ), + "fulfillment_orders/fulfillment_service_failed_to_complete": shopifyEvent( + "fulfillment_orders/fulfillment_service_failed_to_complete" + ), + "fulfillment_orders/hold_released": shopifyEvent("fulfillment_orders/hold_released"), + "fulfillment_orders/line_items_prepared_for_local_delivery": shopifyEvent( + "fulfillment_orders/line_items_prepared_for_local_delivery" + ), + "fulfillment_orders/line_items_prepared_for_pickup": shopifyEvent( + "fulfillment_orders/line_items_prepared_for_pickup" + ), + "fulfillment_orders/moved": shopifyEvent("fulfillment_orders/moved"), + "fulfillment_orders/order_routing_complete": shopifyEvent( + "fulfillment_orders/order_routing_complete" + ), + "fulfillment_orders/placed_on_hold": shopifyEvent("fulfillment_orders/placed_on_hold"), + "fulfillment_orders/rescheduled": shopifyEvent("fulfillment_orders/rescheduled"), + "fulfillment_orders/scheduled_fulfillment_order_ready": shopifyEvent( + "fulfillment_orders/scheduled_fulfillment_order_ready" + ), + "fulfillments/create": shopifyEvent("fulfillments/create"), + "fulfillments/update": shopifyEvent("fulfillments/update"), + "inventory_items/create": shopifyEvent("inventory_items/create"), + "inventory_items/delete": shopifyEvent("inventory_items/delete"), + "inventory_items/update": shopifyEvent("inventory_items/update"), + "inventory_levels/connect": shopifyEvent("inventory_levels/connect"), + "inventory_levels/disconnect": shopifyEvent("inventory_levels/disconnect"), + "inventory_levels/update": shopifyEvent("inventory_levels/update"), + "locales/create": shopifyEvent("locales/create"), + "locales/update": shopifyEvent("locales/update"), + "locations/activate": shopifyEvent("locations/activate"), + "locations/create": shopifyEvent("locations/create"), + "locations/deactivate": shopifyEvent("locations/deactivate"), + "locations/delete": shopifyEvent("locations/delete"), + "locations/update": shopifyEvent("locations/update"), + "markets/create": shopifyEvent("markets/create"), + "markets/delete": shopifyEvent("markets/delete"), + "markets/update": shopifyEvent("markets/update"), + "order_transactions/create": shopifyEvent("order_transactions/create"), + "orders/cancelled": shopifyEvent("orders/cancelled"), + "orders/create": shopifyEvent("orders/create"), + "orders/delete": shopifyEvent("orders/delete"), + "orders/edited": shopifyEvent("orders/edited"), + "orders/fulfilled": shopifyEvent("orders/fulfilled"), + "orders/paid": shopifyEvent("orders/paid"), + "orders/partially_fulfilled": shopifyEvent("orders/partially_fulfilled"), + "orders/updated": shopifyEvent("orders/updated"), + "payment_schedules/due": shopifyEvent("payment_schedules/due"), + "product_feeds/create": shopifyEvent("product_feeds/create"), + "product_feeds/full_sync": shopifyEvent("product_feeds/full_sync"), + "product_feeds/incremental_sync": shopifyEvent("product_feeds/incremental_sync"), + "product_listings/add": shopifyEvent("product_listings/add"), + "product_listings/remove": shopifyEvent("product_listings/remove"), + "product_listings/update": shopifyEvent("product_listings/update"), + "products/create": shopifyEvent("products/create"), + "products/delete": shopifyEvent("products/delete"), + "products/update": shopifyEvent("products/update"), + "profiles/create": shopifyEvent("profiles/create"), + "profiles/delete": shopifyEvent("profiles/delete"), + "profiles/update": shopifyEvent("profiles/update"), + "refunds/create": shopifyEvent("refunds/create"), + "scheduled_product_listings/add": shopifyEvent("scheduled_product_listings/add"), + "scheduled_product_listings/remove": shopifyEvent("scheduled_product_listings/remove"), + "scheduled_product_listings/update": shopifyEvent("scheduled_product_listings/update"), + "selling_plan_groups/create": shopifyEvent("selling_plan_groups/create"), + "selling_plan_groups/delete": shopifyEvent("selling_plan_groups/delete"), + "selling_plan_groups/update": shopifyEvent("selling_plan_groups/update"), + "shop/update": shopifyEvent("shop/update"), + "subscription_billing_attempts/challenged": shopifyEvent( + "subscription_billing_attempts/challenged" + ), + "subscription_billing_attempts/failure": shopifyEvent("subscription_billing_attempts/failure"), + "subscription_billing_attempts/success": shopifyEvent("subscription_billing_attempts/success"), + "subscription_billing_cycle_edits/create": shopifyEvent( + "subscription_billing_cycle_edits/create" + ), + "subscription_billing_cycle_edits/delete": shopifyEvent( + "subscription_billing_cycle_edits/delete" + ), + "subscription_billing_cycle_edits/update": shopifyEvent( + "subscription_billing_cycle_edits/update" + ), + "subscription_contracts/create": shopifyEvent("subscription_contracts/create"), + "subscription_contracts/update": shopifyEvent("subscription_contracts/update"), + "tender_transactions/create": shopifyEvent("tender_transactions/create"), + "themes/create": shopifyEvent("themes/create"), + "themes/delete": shopifyEvent("themes/delete"), + "themes/publish": shopifyEvent("themes/publish"), + "themes/update": shopifyEvent("themes/update"), +}; + +type WebhookCatalogOptions< + TEvents extends Record>, + TSource extends WebhookSource, +> = { + id: string; + events: TEvents; + source: TSource; +}; + +export class WebhookEventCatalog< + TEvents extends Record>, + TSource extends WebhookSource, +> { + constructor(private options: WebhookCatalogOptions) {} + + get events() { + return this.options.events; + } + + get source() { + return this.options.source; + } + + on>( + name: TName, + params: TParams + ): WebhookTrigger { + return new WebhookTrigger({ + event: this.events[name], + params, + source: this.source, + config: {}, + }); + } +} + +export function createWebhookEventCatalog(source: ReturnType) { + return new WebhookEventCatalog({ + id: "shopify", + events: shopifyEvents, + source, + }); +} diff --git a/integrations/shopify/src/types.ts b/integrations/shopify/src/types.ts new file mode 100644 index 000000000..1fc7f99ab --- /dev/null +++ b/integrations/shopify/src/types.ts @@ -0,0 +1,62 @@ +import { + ObjectNonNullable, + OmitFunctions, + OmitIndexSignature, + OmitValues, + Prettify, +} from "@trigger.dev/integration-kit"; +import { ShopifyRestResources } from "./index"; +import { WebhookTopic } from "./schemas"; + +type OmitNonSerializable = Omit>, "session">; + +export type SerializedShopifyResource = Prettify< + TNonNullable extends true ? ObjectNonNullable> : OmitNonSerializable +>; + +export type RecursiveShopifySerializer = T extends object + ? T extends Array + ? Array> + : SerializedShopifyResource + : T; + +export type ShopifyReturnType< + TPayload extends Omit, + K extends unknown = unknown, +> = Promise< + Awaited>> +>; + +export type AwaitNested = Omit & { + [key in K]: Awaited; +}; + +export type ShopifyResource = InstanceType< + ShopifyRestResources[TResource] +>; + +export type ShopifyWebhookPayload = { + [K in keyof OmitIndexSignature]: Prettify< + SerializedShopifyResource> + >; +}; + +export type ShopifyInputType = { + [K in keyof OmitIndexSignature]: Prettify< + Partial, false>> + > & { id?: number }; +}; + +type ResourceHasStandardMethods = { + [K in keyof ShopifyRestResources]: "find" extends keyof ShopifyRestResources[K] + ? ShopifyRestResources[K]["find"] extends (...args: any) => any + ? Parameters[0] extends { id: string | number } + ? "all" | "count" | "delete" extends keyof ShopifyRestResources[K] + ? true + : false + : false + : false + : false; +}; + +export type ResourcesWithStandardMethods = keyof OmitValues; diff --git a/integrations/shopify/src/utils.ts b/integrations/shopify/src/utils.ts new file mode 100644 index 000000000..011b40db4 --- /dev/null +++ b/integrations/shopify/src/utils.ts @@ -0,0 +1,41 @@ +import { Base } from "@shopify/shopify-api/rest/base"; +import { RecursiveShopifySerializer } from "./types"; +import { WebhookTopic } from "./schemas"; +import { DisplayProperty, EventSpecificationExample } from "@trigger.dev/sdk"; +import { EventSpecification } from "@trigger.dev/sdk"; +import { titleCase } from "@trigger.dev/integration-kit"; + +export const basicProperties = (payload: Record) => { + return payload.id ? [{ label: "ID", text: String(payload.id) }] : []; +}; + +export const serializeShopifyResource = ( + resource: TResource +): RecursiveShopifySerializer => { + return JSON.parse(JSON.stringify(resource)); +}; + +const topicToTitle = (topic: WebhookTopic) => { + const prettyTopic = titleCase(topic.replace("_", " ").replace("/", " ")); + return `On ${prettyTopic}`; +}; + +export const eventSpec = ({ + topic, + examples, + runProperties, +}: { + topic: WebhookTopic; + examples?: EventSpecificationExample[]; + runProperties?: (payload: TEvent) => DisplayProperty[]; +}): EventSpecification => { + return { + name: topic, + title: topicToTitle(topic), + source: "shopify.com", + icon: "shopify", + examples, + parsePayload: (payload) => payload as TEvent, + runProperties, + }; +}; diff --git a/integrations/shopify/src/webhooks.ts b/integrations/shopify/src/webhooks.ts new file mode 100644 index 000000000..32144e72f --- /dev/null +++ b/integrations/shopify/src/webhooks.ts @@ -0,0 +1,177 @@ +import { IntegrationTaskKey, verifyRequestSignature } from "@trigger.dev/sdk"; +import { z } from "zod"; +import { Shopify, ShopifyRunTask } from "./index"; +import { + WebhookHeaderSchema, + WebhookSubscription, + WebhookSubscriptionDataSchema, + WebhookTopic, + WebhookTopicSchema, +} from "./schemas"; +import { WebhookSource } from "@trigger.dev/sdk/triggers/webhook"; +import { registerJobNamespace } from "@trigger.dev/integration-kit/webhooks"; + +export class Webhooks { + constructor(private runTask: ShopifyRunTask) {} + + #apiUrl(client: NonNullable) { + const { apiVersion, hostName } = client.config; + return new URL(`/admin/api/${apiVersion}/`, `https://${hostName}`); + } + + // just here as an example if we ever want better platform support + #createWithFetch( + key: IntegrationTaskKey, + params: { + topic: WebhookTopic; + address: string; + fields?: string[]; + } + ): Promise { + return this.runTask( + key, + async (client, task, io) => { + const resource = { + webhook: { + topic: params.topic, + address: params.address, + fields: params.fields, + }, + }; + + const request = new Request(new URL("webhooks.json", this.#apiUrl(client)), { + method: "POST", + headers: { + "X-Shopify-Access-Token": client.config.adminApiAccessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(resource), + }); + + const response = await fetch(request.clone()); + + if (!response.ok) { + await handleWebhookError("WEBHOOK_CREATE", request, response); + } + + const webhook = await response.json(); + const parsed = WebhookSubscriptionDataSchema.parse(webhook); + + return parsed.webhook; + }, + { + name: "Create Webhook with Fetch", + params, + properties: [ + { label: "Webhook URL", text: params.address }, + { label: "Topic", text: params.topic }, + ], + } + ); + } +} + +export function createWebhookEventSource(integration: Shopify) { + return new WebhookSource({ + id: "shopify", + schemas: { + params: z.object({ + topic: WebhookTopicSchema, + // disabled for now, doesn't seem useful and complicates things + // fields: z.string().array().optional(), + }), + // config: z.record(z.string().array()), + }, + version: "0.1.0", + integration, + key: (params) => params.topic, + crud: { + create: async ({ io, ctx }) => { + const webhook = await io.integration.rest.Webhook.save("create-webhook", { + fromData: { + address: ctx.url, + topic: ctx.params.topic, + // fields: ctx.params.fields, + }, + }); + + const clientSecret = await io.integration.runTask( + "get-client-secret", + async (client) => client.config.apiSecretKey + ); + + await io.store.job.set("set-id", "webhook-id", webhook.id); + await io.store.job.set("set-secret", "webhook-secret", clientSecret); + }, + delete: async ({ io, ctx }) => { + const webhookId = await io.store.job.get("get-webhook-id", "webhook-id"); + + await io.integration.rest.Webhook.delete("delete-webhook", { + id: webhookId, + }); + + await io.store.job.delete("delete-webhook-id", "webhook-id"); + }, + update: async ({ io, ctx }) => { + const webhookId = await io.store.job.get("get-webhook-id", "webhook-id"); + + await io.integration.rest.Webhook.save("update-webhook", { + fromData: { + id: webhookId, + address: ctx.url, + topic: ctx.params.topic, + // fields: ctx.params.fields, + }, + }); + }, + }, + verify: async ({ request, client, ctx }) => { + // TODO: should pass namespaced store instead, e.g. client.store.webhookRegistration.get() + const clientSecret = await client.store.env.get( + `${registerJobNamespace(ctx.key)}:webhook-secret` + ); + + return await verifyRequestSignature({ + request, + headerName: "x-shopify-hmac-sha256", + headerEncoding: "base64", + secret: clientSecret, + algorithm: "sha256", + }); + }, + generateEvents: async ({ request, client }) => { + const headers = WebhookHeaderSchema.parse(Object.fromEntries(request.headers)); + + const topic = headers["x-shopify-topic"]; + const triggeredAt = headers["x-shopify-triggered-at"]; + const idempotencyKey = headers["x-shopify-webhook-id"]; + + await client.sendEvent({ + id: idempotencyKey, + payload: await request.json(), + source: "shopify.com", + name: topic, + timestamp: triggeredAt, + }); + }, + }); +} + +export class ShopifyApiError extends Error { + constructor( + message: string, + readonly request: Request, + readonly response: Response + ) { + super(message); + this.name = "ShopifyApiError"; + } +} + +async function handleWebhookError(errorType: string, request: Request, response: Response) { + const body = await response.clone().text(); + + const message = `[${errorType}] ${response.status} - ${response.statusText} - body: "${body}"`; + + throw new ShopifyApiError(message, request, response); +} diff --git a/integrations/shopify/tsconfig.json b/integrations/shopify/tsconfig.json new file mode 100644 index 000000000..26ae70a15 --- /dev/null +++ b/integrations/shopify/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@trigger.dev/tsconfig/integration.json", + "include": ["./src/**/*.ts", "tsup.config.ts"] +} diff --git a/integrations/shopify/tsup.config.ts b/integrations/shopify/tsup.config.ts new file mode 100644 index 000000000..3071b229a --- /dev/null +++ b/integrations/shopify/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig, deepMergeOptions, integrationOptions } from "@trigger.dev/tsup"; + +const options = deepMergeOptions(integrationOptions, { + // extend base config here +}); + +export default defineConfig(options); diff --git a/packages/cli/src/templates/integration/events.js.j2 b/packages/cli/src/templates/integration/events.js.j2 index 77d100907..f9c85a313 100644 --- a/packages/cli/src/templates/integration/events.js.j2 +++ b/packages/cli/src/templates/integration/events.js.j2 @@ -14,8 +14,8 @@ import { onCommentProperties, onIssueProperties, updatedFromProperties } from ". export const onComment: EventSpecification> = { name: "Comment", title: "On Comment", - source: "linear.app", - icon: "linear", + source: "{{ identifier }}.com", + icon: "{{ identifier }}", examples: [commentCreated, commentRemoved, commentUpdated], parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, runProperties: (payload) => [ @@ -28,8 +28,8 @@ export const onComment: EventSpecification> = { name: "Comment", title: "On Comment Created", - source: "linear.app", - icon: "linear", + source: "{{ identifier }}.com", + icon: "{{ identifier }}", filter: { action: ["create"], }, @@ -41,8 +41,8 @@ export const onCommentCreated: EventSpecification> = { name: "Comment", title: "On Comment Removed", - source: "linear.app", - icon: "linear", + source: "{{ identifier }}.com", + icon: "{{ identifier }}", filter: { action: ["remove"], }, @@ -54,8 +54,8 @@ export const onCommentRemoved: EventSpecification> = { name: "Comment", title: "On Comment Updated", - source: "linear.app", - icon: "linear", + source: "{{ identifier }}.com", + icon: "{{ identifier }}", filter: { action: ["update"], }, @@ -67,8 +67,8 @@ export const onCommentUpdated: EventSpecification> = { name: "Issue", title: "On Issue", - source: "linear.app", - icon: "linear", + source: "{{ identifier }}.com", + icon: "{{ identifier }}", examples: [issueCreated, issueRemoved, issueUpdated], parsePayload: (payload) => payload as Get{{ identifier | capitalize }}Payload, runProperties: (payload) => [ @@ -81,8 +81,8 @@ export const onIssue: EventSpecification> = { name: "Issue", title: "On Issue Created", - source: "linear.app", - icon: "linear", + source: "{{ identifier }}.com", + icon: "{{ identifier }}", filter: { action: ["create"], }, @@ -94,8 +94,8 @@ export const onIssueCreated: EventSpecification> = { name: "Issue", title: "On Issue Removed", - source: "linear.app", - icon: "linear", + source: "{{ identifier }}.com", + icon: "{{ identifier }}", filter: { action: ["remove"], }, @@ -107,8 +107,8 @@ export const onIssueRemoved: EventSpecification> = { name: "Issue", title: "On Issue Updated", - source: "linear.app", - icon: "linear", + source: "{{ identifier }}.com", + icon: "{{ identifier }}", filter: { action: ["update"], }, diff --git a/packages/cli/src/templates/integration/index.js.j2 b/packages/cli/src/templates/integration/index.js.j2 index 289de3ee1..09e376d2c 100644 --- a/packages/cli/src/templates/integration/index.js.j2 +++ b/packages/cli/src/templates/integration/index.js.j2 @@ -19,7 +19,7 @@ import { Models } from "./models"; export type {{ identifier | capitalize }}IntegrationOptions = { id: string; - {{ apiKeyPropertyName }}: string; + {{ apiKeyPropertyName }}?: string; }; export type {{ identifier | capitalize }}RunTask = InstanceType["runTask"]; @@ -32,6 +32,7 @@ export class {{ identifier | capitalize }}{{ " " }} implements TriggerIntegratio constructor(private options: {{ identifier | capitalize }}IntegrationOptions) { if (Object.keys(options).includes("{{ apiKeyPropertyName }}") && !options.{{ apiKeyPropertyName }}) { + {# FIXME: For some reason the spaces after the variables vanish #} throw `Can't create {{ identifier | capitalize }} integration (${options.id}) as {{ apiKeyPropertyName }} was undefined`; } diff --git a/packages/cli/src/templates/integration/models.js.j2 b/packages/cli/src/templates/integration/models.js.j2 index 0d67c7952..29edda503 100644 --- a/packages/cli/src/templates/integration/models.js.j2 +++ b/packages/cli/src/templates/integration/models.js.j2 @@ -1,12 +1,12 @@ import { IntegrationTaskKey } from "@trigger.dev/sdk"; import { Model, ModelVersion } from "{{ sdkPackage }}"; -import { {{ capitalizedIdentifier }}RunTask } from "./index"; +import { {{ identifier | capitalize }}RunTask } from "./index"; import { modelProperties } from "./utils"; -import { {{ capitalizedIdentifier }}ReturnType } from "./types"; +import { {{ identifier | capitalize }}ReturnType } from "./types"; export class Models { - constructor(private runTask: {{ capitalizedIdentifier }}RunTask) {} + constructor(private runTask: {{ identifier | capitalize }}RunTask) {} get( key: IntegrationTaskKey, @@ -14,7 +14,7 @@ export class Models { model_owner: string; model_name: string; } - ): {{ capitalizedIdentifier }}ReturnType { + ): {{ identifier | capitalize }}ReturnType { return this.runTask( key, (client) => { @@ -34,7 +34,7 @@ export class Models { } class Versions { - constructor(private runTask: {{ capitalizedIdentifier }}RunTask) {} + constructor(private runTask: {{ identifier | capitalize }}RunTask) {} get( key: IntegrationTaskKey, @@ -43,7 +43,7 @@ class Versions { model_name: string; version_id: string; } - ): {{ capitalizedIdentifier }}ReturnType { + ): {{ identifier | capitalize }}ReturnType { return this.runTask( key, (client) => { @@ -63,7 +63,7 @@ class Versions { model_owner: string; model_name: string; } - ): {{ capitalizedIdentifier }}ReturnType { + ): {{ identifier | capitalize }}ReturnType { return this.runTask( key, (client) => { diff --git a/packages/cli/src/templates/integration/package.json.j2 b/packages/cli/src/templates/integration/package.json.j2 index 6e1391c90..308b6b436 100644 --- a/packages/cli/src/templates/integration/package.json.j2 +++ b/packages/cli/src/templates/integration/package.json.j2 @@ -32,7 +32,7 @@ "{{ latestVersion.name }}": "^{{ latestVersion.version }}", "{{ sdkVersion.name }}": "{{ sdkVersion.version }}", "{{ integrationKitVersion.name }}": "{{ integrationKitVersion.version }}", - "zod": "3.21.4" + "zod": "3.22.3" }, "engines": { "node": ">=16.8.0" diff --git a/packages/cli/src/templates/integration/webhooks.js.j2 b/packages/cli/src/templates/integration/webhooks.js.j2 index 9bb55e514..10d0dd18a 100644 --- a/packages/cli/src/templates/integration/webhooks.js.j2 +++ b/packages/cli/src/templates/integration/webhooks.js.j2 @@ -131,7 +131,6 @@ type {{ identifier | capitalize }}Events = (typeof events)[keyof typeof events]; export type TriggerParams = { teamId?: string; - filter?: EventFilter; }; type CreateTriggersResult = ExternalSourceTrigger< diff --git a/packages/core/src/schemas/api.ts b/packages/core/src/schemas/api.ts index 582e2aceb..e207c420c 100644 --- a/packages/core/src/schemas/api.ts +++ b/packages/core/src/schemas/api.ts @@ -37,6 +37,18 @@ export const UpdateTriggerSourceBodyV2Schema = z.object({ }); export type UpdateTriggerSourceBodyV2 = z.infer; +export const UpdateWebhookBodySchema = z.discriminatedUnion("active", [ + z.object({ + active: z.literal(false), + }), + z.object({ + active: z.literal(true), + config: z.record(z.string().array()), + }), +]); + +export type UpdateWebhookBody = z.infer; + export const RegisterHTTPTriggerSourceBodySchema = z.object({ type: z.literal("HTTP"), url: z.string().url(), @@ -56,6 +68,36 @@ export const RegisterSourceChannelBodySchema = z.discriminatedUnion("type", [ RegisterSQSTriggerSourceBodySchema, ]); +export const REGISTER_WEBHOOK = "dev.trigger.webhook.register"; +export const DELIVER_WEBHOOK_REQUEST = "dev.trigger.webhook.deliver"; + +export const RegisterWebhookSourceSchema = z.object({ + key: z.string(), + params: z.any(), + config: z.any(), + active: z.boolean(), + secret: z.string(), + url: z.string(), + data: DeserializedJsonSchema.optional(), + clientId: z.string().optional(), +}); + +export type RegisterWebhookSource = z.infer; + +export const RegisterWebhookPayloadSchema = z.object({ + active: z.boolean(), + params: z.any().optional(), + config: z.object({ + current: z.record(z.string().array()), + desired: z.record(z.string().array()), + }), + // from HTTP Endpoint + url: z.string(), + secret: z.string(), +}); + +export type RegisterWebhookPayload = z.infer; + export const REGISTER_SOURCE_EVENT_V1 = "dev.trigger.source.register"; export const REGISTER_SOURCE_EVENT_V2 = "dev.trigger.source.register.v2"; @@ -173,6 +215,18 @@ export const HttpEndpointRequestHeadersSchema = z.object({ "x-ts-http-headers": z.string().transform((s) => z.record(z.string()).parse(JSON.parse(s))), }); +export const WebhookSourceRequestHeadersSchema = z.object({ + "x-ts-key": z.string(), + "x-ts-dynamic-id": z.string().optional(), + "x-ts-secret": z.string(), + "x-ts-params": z.string().transform((s) => JSON.parse(s)), + "x-ts-http-url": z.string(), + "x-ts-http-method": z.string(), + "x-ts-http-headers": z.string().transform((s) => z.record(z.string()).parse(JSON.parse(s))), +}); + +export type WebhookSourceRequestHeaders = z.output; + export const PongSuccessResponseSchema = z.object({ ok: z.literal(true), triggerVersion: z.string().optional(), @@ -275,6 +329,26 @@ const SourceMetadataSchema = z.preprocess( type SourceMetadata = Prettify>; +export const WebhookMetadataSchema = z.object({ + key: z.string(), + params: z.any(), + config: z.record(z.array(z.string())), + integration: IntegrationConfigSchema, + httpEndpoint: z.object({ + id: z.string(), + }), +}); + +export type WebhookMetadata = z.infer; + +export const WebhookContextMetadataSchema = z.object({ + params: z.any(), + config: z.record(z.string().array()), + secret: z.string(), +}); + +export type WebhookContextMetadata = z.infer; + export const DynamicTriggerEndpointMetadataSchema = z.object({ id: z.string(), jobs: z.array(JobMetadataSchema.pick({ id: true, version: true })), @@ -306,6 +380,7 @@ export type HttpEndpointMetadata = z.infer; export const IndexEndpointResponseSchema = z.object({ jobs: z.array(JobMetadataSchema), sources: z.array(SourceMetadataSchema), + webhooks: z.array(WebhookMetadataSchema), dynamicTriggers: z.array(DynamicTriggerEndpointMetadataSchema), dynamicSchedules: z.array(RegisterDynamicSchedulePayloadSchema), httpEndpoints: z.array(HttpEndpointMetadataSchema).optional(), @@ -323,6 +398,7 @@ export type EndpointIndexError = z.infer; const IndexEndpointStatsSchema = z.object({ jobs: z.number(), sources: z.number(), + webhooks: z.number(), dynamicTriggers: z.number(), dynamicSchedules: z.number(), disabledJobs: z.number().default(0), @@ -901,6 +977,14 @@ export const HttpSourceResponseSchema = z.object({ metadata: HttpSourceResponseMetadataSchema.optional(), }); +export const WebhookDeliveryResponseSchema = z.object({ + response: NormalizedResponseSchema, + verified: z.boolean(), + error: z.string().optional(), +}); + +export type WebhookDeliveryResponse = z.infer; + export const RegisterTriggerBodySchemaV1 = z.object({ rule: EventRuleSchema, source: SourceMetadataV1Schema, @@ -1028,3 +1112,28 @@ export const EphemeralEventDispatcherResponseBodySchema = z.object({ export type EphemeralEventDispatcherResponseBody = z.infer< typeof EphemeralEventDispatcherResponseBodySchema >; + +export const KeyValueStoreResponseBodySchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("DELETE"), + key: z.string(), + deleted: z.boolean(), + }), + z.object({ + action: z.literal("GET"), + key: z.string(), + value: z.string().optional(), + }), + z.object({ + action: z.literal("HAS"), + key: z.string(), + has: z.boolean(), + }), + z.object({ + action: z.literal("SET"), + key: z.string(), + value: z.string().optional(), + }), +]); + +export type KeyValueStoreResponseBody = z.infer; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7d0697bf6..ae413ce93 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2,3 +2,10 @@ export type Prettify = { [K in keyof T]: T[K]; } & {}; + +export interface AsyncMap { + delete: (key: string) => Promise; + get: (key: string) => Promise; + has: (key: string) => Promise; + set: (key: string, value: any) => Promise; +} diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 7dd77cf5f..6af17bb49 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -31,3 +31,7 @@ export function deepMergeFilters(...filters: EventFilter[]): EventFilter { return result; } + +export function assertExhaustive(x: never): never { + throw new Error("Unexpected object: " + x); +} diff --git a/packages/database/prisma/migrations/20231115134828_add_events_schema_and_tables/migration.sql b/packages/database/prisma/migrations/20231115134828_add_events_schema_and_tables/migration.sql index 5bcd79f8b..923e2c6f0 100644 --- a/packages/database/prisma/migrations/20231115134828_add_events_schema_and_tables/migration.sql +++ b/packages/database/prisma/migrations/20231115134828_add_events_schema_and_tables/migration.sql @@ -1,5 +1,7 @@ CREATE SCHEMA IF NOT EXISTS triggerdotdev_events; +DROP TABLE IF EXISTS triggerdotdev_events.run_executions; + CREATE TABLE triggerdotdev_events.run_executions ( id SERIAL PRIMARY KEY, organization_id TEXT NOT NULL, diff --git a/packages/database/prisma/migrations/20231115142936_add_webhook_source/migration.sql b/packages/database/prisma/migrations/20231115142936_add_webhook_source/migration.sql new file mode 100644 index 000000000..e13a18332 --- /dev/null +++ b/packages/database/prisma/migrations/20231115142936_add_webhook_source/migration.sql @@ -0,0 +1,56 @@ +-- CreateTable +CREATE TABLE "Webhook" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "params" JSONB, + "config" JSONB, + "desiredConfig" JSONB, + "httpEndpointId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "integrationId" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "KeyValueItem" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + "environmentId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "KeyValueItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Webhook_httpEndpointId_key" ON "Webhook"("httpEndpointId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Webhook_key_projectId_key" ON "Webhook"("key", "projectId"); + +-- CreateIndex +CREATE INDEX "KeyValueItem_key_idx" ON "KeyValueItem" USING HASH ("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "KeyValueItem_environmentId_key_key" ON "KeyValueItem"("environmentId", "key"); + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_httpEndpointId_fkey" FOREIGN KEY ("httpEndpointId") REFERENCES "TriggerHttpEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_integrationId_fkey" FOREIGN KEY ("integrationId") REFERENCES "Integration"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "KeyValueItem" ADD CONSTRAINT "KeyValueItem_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20231120163155_add_webhook_environment/migration.sql b/packages/database/prisma/migrations/20231120163155_add_webhook_environment/migration.sql new file mode 100644 index 000000000..7b00f8967 --- /dev/null +++ b/packages/database/prisma/migrations/20231120163155_add_webhook_environment/migration.sql @@ -0,0 +1,38 @@ +/* + Warnings: + + - You are about to drop the column `config` on the `Webhook` table. All the data in the column will be lost. + - You are about to drop the column `desiredConfig` on the `Webhook` table. All the data in the column will be lost. + - You are about to drop the column `environmentId` on the `Webhook` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Webhook" DROP CONSTRAINT "Webhook_environmentId_fkey"; + +-- AlterTable +ALTER TABLE "Webhook" DROP COLUMN "config", +DROP COLUMN "desiredConfig", +DROP COLUMN "environmentId"; + +-- CreateTable +CREATE TABLE "WebhookEnvironment" ( + "id" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT false, + "config" JSONB, + "desiredConfig" JSONB, + "environmentId" TEXT NOT NULL, + "webhookId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WebhookEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WebhookEnvironment_environmentId_webhookId_key" ON "WebhookEnvironment"("environmentId", "webhookId"); + +-- AddForeignKey +ALTER TABLE "WebhookEnvironment" ADD CONSTRAINT "WebhookEnvironment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WebhookEnvironment" ADD CONSTRAINT "WebhookEnvironment_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20231121155237_change_kv_value_to_bytes/migration.sql b/packages/database/prisma/migrations/20231121155237_change_kv_value_to_bytes/migration.sql new file mode 100644 index 000000000..83af82ba1 --- /dev/null +++ b/packages/database/prisma/migrations/20231121155237_change_kv_value_to_bytes/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Changed the type of `value` on the `KeyValueItem` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- AlterTable +ALTER TABLE "KeyValueItem" DROP COLUMN "value", +ADD COLUMN "value" BYTEA NOT NULL; diff --git a/packages/database/prisma/migrations/20231122091927_add_webhook_request_delivery/migration.sql b/packages/database/prisma/migrations/20231122091927_add_webhook_request_delivery/migration.sql new file mode 100644 index 000000000..ef6ec17f3 --- /dev/null +++ b/packages/database/prisma/migrations/20231122091927_add_webhook_request_delivery/migration.sql @@ -0,0 +1,39 @@ +/* + Warnings: + + - Added the required column `endpointId` to the `WebhookEnvironment` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "WebhookEnvironment" ADD COLUMN "endpointId" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "WebhookRequestDelivery" ( + "id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "method" TEXT NOT NULL, + "headers" JSONB NOT NULL, + "body" BYTEA, + "verified" BOOLEAN NOT NULL DEFAULT false, + "error" TEXT, + "webhookId" TEXT NOT NULL, + "webhookEnvironmentId" TEXT NOT NULL, + "endpointId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deliveredAt" TIMESTAMP(3), + + CONSTRAINT "WebhookRequestDelivery_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "WebhookEnvironment" ADD CONSTRAINT "WebhookEnvironment_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "Endpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WebhookRequestDelivery" ADD CONSTRAINT "WebhookRequestDelivery_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WebhookRequestDelivery" ADD CONSTRAINT "WebhookRequestDelivery_webhookEnvironmentId_fkey" FOREIGN KEY ("webhookEnvironmentId") REFERENCES "WebhookEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WebhookRequestDelivery" ADD CONSTRAINT "WebhookRequestDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "Endpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20231122105932_add_env_to_webhook_delivery/migration.sql b/packages/database/prisma/migrations/20231122105932_add_env_to_webhook_delivery/migration.sql new file mode 100644 index 000000000..d715fba55 --- /dev/null +++ b/packages/database/prisma/migrations/20231122105932_add_env_to_webhook_delivery/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `environmentId` to the `WebhookRequestDelivery` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "WebhookRequestDelivery" ADD COLUMN "environmentId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "WebhookRequestDelivery" ADD CONSTRAINT "WebhookRequestDelivery_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20231122151126_add_delivery_numbers/migration.sql b/packages/database/prisma/migrations/20231122151126_add_delivery_numbers/migration.sql new file mode 100644 index 000000000..ecd79ef78 --- /dev/null +++ b/packages/database/prisma/migrations/20231122151126_add_delivery_numbers/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - Added the required column `number` to the `WebhookRequestDelivery` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "WebhookRequestDelivery" ADD COLUMN "number" INTEGER NOT NULL; + +-- CreateTable +CREATE TABLE "WebhookDeliveryCounter" ( + "webhookId" TEXT NOT NULL, + "lastNumber" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "WebhookDeliveryCounter_pkey" PRIMARY KEY ("webhookId") +); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 46b459d4c..45ca92ebc 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -181,6 +181,7 @@ model Integration { connections IntegrationConnection[] jobIntegrations JobIntegration[] sources TriggerSource[] + webhooks Webhook[] missingConnections MissingConnection[] RunConnection RunConnection[] @@ -323,6 +324,9 @@ model RuntimeEnvironment { scheduleSources ScheduleSource[] ExternalAccount ExternalAccount[] httpEndpointEnvironments TriggerHttpEndpointEnvironment[] + keyValueItems KeyValueItem[] + webhookEnvironments WebhookEnvironment[] + webhookRequestDeliveries WebhookRequestDelivery[] @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@ -354,6 +358,7 @@ model Project { runs JobRun[] sources TriggerSource[] httpEndpoints TriggerHttpEndpoint[] + webhooks Webhook[] } model Endpoint { @@ -386,10 +391,12 @@ model Endpoint { jobVersions JobVersion[] jobRuns JobRun[] httpRequestDeliveries HttpSourceRequestDelivery[] + webhookRequestDeliveries WebhookRequestDelivery[] dynamictriggers DynamicTrigger[] sources TriggerSource[] indexings EndpointIndex[] httpEndpointEnvironments TriggerHttpEndpointEnvironment[] + webhookEnvironments WebhookEnvironment[] @@unique([environmentId, slug]) } @@ -1088,6 +1095,93 @@ model TriggerSourceOption { @@unique([name, value, sourceId]) } +model Webhook { + id String @id @default(cuid()) + + active Boolean @default(false) + + key String + params Json? + + webhookEnvironments WebhookEnvironment[] + requestDeliveries WebhookRequestDelivery[] + + httpEndpoint TriggerHttpEndpoint @relation(fields: [httpEndpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + httpEndpointId String @unique + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + integrationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([key, projectId]) +} + +model WebhookEnvironment { + id String @id @default(cuid()) + + active Boolean @default(false) + + config Json? + desiredConfig Json? + + requestDeliveries WebhookRequestDelivery[] + + endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + endpointId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade, onUpdate: Cascade) + webhookId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([environmentId, webhookId]) +} + +model WebhookRequestDelivery { + id String @id @default(cuid()) + number Int + + url String + method String + headers Json + + body Bytes? + + verified Boolean @default(false) + error String? + + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade, onUpdate: Cascade) + webhookId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + webhookEnvironment WebhookEnvironment @relation(fields: [webhookEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + webhookEnvironmentId String + + endpoint Endpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + endpointId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + deliveredAt DateTime? +} + +model WebhookDeliveryCounter { + webhookId String @id + lastNumber Int @default(0) +} + model DynamicTriggerRegistration { id String @id @default(cuid()) @@ -1173,6 +1267,8 @@ model TriggerHttpEndpoint { icon String? properties Json? + webhook Webhook? + secretReference SecretReference @relation(fields: [secretReferenceId], references: [id], onDelete: Cascade, onUpdate: Cascade) secretReferenceId String @@ -1219,6 +1315,22 @@ model TriggerHttpEndpointEnvironment { @@unique([endpointId, httpEndpointId]) } +model KeyValueItem { + id String @id @default(cuid()) + + key String + value Bytes + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([environmentId, key]) + @@index([key], type: Hash) +} + model MissingConnection { id String @id @default(cuid()) diff --git a/packages/integration-kit/src/types.ts b/packages/integration-kit/src/types.ts index 20d24ef32..c57c4795f 100644 --- a/packages/integration-kit/src/types.ts +++ b/packages/integration-kit/src/types.ts @@ -1,3 +1,45 @@ -import type { FetchRetryOptions, FetchTimeoutOptions } from "@trigger.dev/core"; +export type { FetchRetryOptions, FetchTimeoutOptions } from "@trigger.dev/core"; -export type { FetchRetryOptions, FetchTimeoutOptions }; +export type Nullable = T extends Record + ? { + [K in keyof T]: T[K] | null; + } + : T | null; + +export type ObjectNonNullable = { + [K in keyof T]: K extends TKeys ? NonNullable : T[K]; +}; + +export type SomeNonNullable, TSome extends keyof T> = { + [K in keyof T]: K extends TSome ? NonNullable : T[K]; +}; + +export type SomeNullable, TSome extends keyof T> = { + [K in keyof T]: K extends TSome ? T[K] | null : T[K]; +}; + +type FunctionKeys = { + [K in keyof T]: T[K] extends Function ? K : never; +}[keyof T]; + +export type OmitFunctions = { + [K in keyof T as Exclude>]: T[K]; +}; + +export type OmitIndexSignature = { + [K in keyof T as {} extends Record ? never : K]: T[K]; +}; + +type ObjectEntry = [keyof T, T[keyof T]]; + +export type ObjectEntries = Array>; + +export type OmitValues, TValue extends any> = { + [K in keyof TRecord as TRecord[K] extends TValue ? never : K]: TRecord[K]; +}; + +export type Optional, TOptional extends keyof TRecord> = Omit< + TRecord, + TOptional +> & + Partial>; diff --git a/packages/integration-kit/src/utils.ts b/packages/integration-kit/src/utils.ts index cba3d7f25..27717e811 100644 --- a/packages/integration-kit/src/utils.ts +++ b/packages/integration-kit/src/utils.ts @@ -1,3 +1,28 @@ import { calculateResetAt } from "@trigger.dev/core"; +import { ObjectEntries } from "./types"; export const calculateResetAtUtil = calculateResetAt; + +export const entries = (object: T): ObjectEntries => { + return Object.entries(object) as ObjectEntries; +}; + +// see: https://github.com/sindresorhus/ts-extras +export const fromEntries = Object.fromEntries as < + Key extends PropertyKey, + Entries extends ReadonlyArray, +>( + values: Entries +) => { + [K in Extract[0]]: Extract< + Entries[number], + readonly [K, unknown] + >[1]; +}; + +export function titleCase(original: string): string { + return original + .split(" ") + .map((word) => word[0].toUpperCase() + word.slice(1)) + .join(" "); +} diff --git a/packages/integration-kit/src/webhooks.ts b/packages/integration-kit/src/webhooks.ts index 8a4e67944..8d0b586f7 100644 --- a/packages/integration-kit/src/webhooks.ts +++ b/packages/integration-kit/src/webhooks.ts @@ -14,3 +14,5 @@ export function safeParseBody(body: any) { return body; } + +export const registerJobNamespace = (webhookKey: string) => `job:webhook.register.${webhookKey}`; diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 66f6f0d6e..652f29e50 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -59,4 +59,4 @@ "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/packages/trigger-sdk/src/apiClient.ts b/packages/trigger-sdk/src/apiClient.ts index bbe494245..18327326e 100644 --- a/packages/trigger-sdk/src/apiClient.ts +++ b/packages/trigger-sdk/src/apiClient.ts @@ -2,7 +2,6 @@ import { ApiEventLog, ApiEventLogSchema, CancelRunsForEventSchema, - CompleteTaskBodyInput, ConnectionAuthSchema, FailTaskBodyInput, GetEventSchema, @@ -36,9 +35,15 @@ import { CompleteTaskBodyV2Input, EphemeralEventDispatcherRequestBody, EphemeralEventDispatcherResponseBodySchema, + UpdateWebhookBody, + KeyValueStoreResponseBodySchema, + KeyValueStoreResponseBody, + assertExhaustive, + HttpMethod, } from "@trigger.dev/core"; import { z } from "zod"; +import { KeyValueStoreClient } from "./store/keyValueStoreClient"; export type ApiClientOptions = { apiKey?: string; @@ -73,12 +78,15 @@ export class ApiClient { #apiUrl: string; #options: ApiClientOptions; #logger: Logger; + #storeClient: KeyValueStoreClient; constructor(options: ApiClientOptions) { this.#options = options; this.#apiUrl = this.#options.apiUrl ?? process.env.TRIGGER_API_URL ?? "https://api.trigger.dev"; this.#logger = new Logger("trigger.dev", this.#options.logLevel); + + this.#storeClient = new KeyValueStoreClient(this.#queryKeyValueStore.bind(this)); } async registerEndpoint(options: { url: string; name: string }): Promise { @@ -309,6 +317,25 @@ export class ApiClient { return response; } + async updateWebhook(key: string, webhookData: UpdateWebhookBody): Promise { + const apiKey = await this.#apiKey(); + + this.#logger.debug("activating webhook", { + webhookData, + }); + + const response = await zodfetch(TriggerSourceSchema, `${this.#apiUrl}/api/v1/webhooks/${key}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(webhookData), + }); + + return response; + } + async registerTrigger( client: string, id: string, @@ -550,6 +577,88 @@ export class ApiClient { return response; } + get store() { + return this.#storeClient; + } + + async #queryKeyValueStore( + action: KeyValueStoreResponseBody["action"], + data: { + key: string; + value?: string; + } + ): Promise { + const apiKey = await this.#apiKey(); + + this.#logger.debug("accessing key-value store", { + action, + data, + }); + + const STORE_URL = `${this.#apiUrl}/api/v1/store/${data.key}`; + + const authHeader: HeadersInit = { + Authorization: `Bearer ${apiKey}`, + }; + + let requestInit: RequestInit | undefined; + + switch (action) { + case "DELETE": { + requestInit = { + method: "DELETE", + headers: authHeader, + }; + + break; + } + case "GET": { + requestInit = { + method: "GET", + headers: authHeader, + }; + + break; + } + case "HAS": { + const headResponse = await fetchHead(STORE_URL, { + headers: authHeader, + }); + + return { + action: "HAS", + key: data.key, + has: !!headResponse.ok, + }; + } + case "SET": { + const MAX_BODY_BYTE_LENGTH = 256 * 1024; + + if ((data.value?.length ?? 0) > MAX_BODY_BYTE_LENGTH) { + throw new Error(`Max request body size exceeded: ${MAX_BODY_BYTE_LENGTH} bytes`); + } + + requestInit = { + method: "PUT", + headers: { + ...authHeader, + "Content-Type": "text/plain", + }, + body: data.value, + }; + + break; + } + default: { + assertExhaustive(action); + } + } + + const response = await zodfetch(KeyValueStoreResponseBodySchema, STORE_URL, requestInit); + + return response; + } + async #apiKey() { const apiKey = getApiKey(this.#options.apiKey); @@ -716,6 +825,29 @@ async function zodfetchWithVersions< }; } +async function fetchHead( + url: string, + requestInitWithoutMethod?: Omit, + retryCount = 0 +): Promise { + const requestInit: RequestInit = { + ...requestInitWithoutMethod, + method: "HEAD", + }; + const response = await fetch(url, { ...requestInit, cache: "no-cache" }); + + if (response.status >= 500 && retryCount < 6) { + // retry with exponential backoff and jitter + const delay = exponentialBackoff(retryCount + 1, 2, 50, 1150, 50); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + return fetchHead(url, requestInitWithoutMethod, retryCount + 1); + } + + return response; +} + async function zodfetch( schema: TResponseSchema, url: string, diff --git a/packages/trigger-sdk/src/httpEndpoint.ts b/packages/trigger-sdk/src/httpEndpoint.ts index 788fd40a1..4f5698e2f 100644 --- a/packages/trigger-sdk/src/httpEndpoint.ts +++ b/packages/trigger-sdk/src/httpEndpoint.ts @@ -124,7 +124,7 @@ type RespondWith = { handler: (request: Request, verify: () => Promise) => Promise; }; -type VerifyCallback = (request: Request) => Promise; +export type VerifyCallback = (request: Request) => Promise; export type EndpointOptions = { /** Used to uniquely identify the HTTP Endpoint inside your Project. */ diff --git a/packages/trigger-sdk/src/io.ts b/packages/trigger-sdk/src/io.ts index a01facfe4..43624411f 100644 --- a/packages/trigger-sdk/src/io.ts +++ b/packages/trigger-sdk/src/io.ts @@ -18,6 +18,7 @@ import { SendEventOptions, ServerTask, UpdateTriggerSourceBodyV2, + UpdateWebhookBody, supportsFeature, } from "@trigger.dev/core"; import { BloomFilter } from "@trigger.dev/core-backend"; @@ -51,11 +52,13 @@ import { waitForEventSchema, } from "./types"; import { z } from "zod"; +import { KeyValueStore } from "./store/keyValueStore"; export type IOTask = ServerTask; export type IOOptions = { id: string; + jobId: string; apiClient: ApiClient; client: TriggerClient; context: TriggerContext; @@ -120,6 +123,7 @@ export type BackgroundFetchResponse = { export class IO { private _id: string; + private _jobId: string; private _apiClient: ApiClient; private _triggerClient: TriggerClient; private _logger: Logger; @@ -138,12 +142,17 @@ export class IO { private _outputSerializer: OutputSerializer = new JSONOutputSerializer(); private _visitedCacheKeys: Set = new Set(); + private _envStore: KeyValueStore; + private _jobStore: KeyValueStore; + private _runStore: KeyValueStore; + get stats() { return this._stats; } constructor(options: IOOptions) { this._id = options.id; + this._jobId = options.jobId; this._apiClient = options.apiClient; this._triggerClient = options.client; this._logger = options.logger ?? new Logger("trigger.dev", options.logLevel); @@ -153,6 +162,10 @@ export class IO { this._timeOrigin = options.timeOrigin; this._executionTimeout = options.executionTimeout; + this._envStore = new KeyValueStore(options.apiClient); + this._jobStore = new KeyValueStore(options.apiClient, "job", options.jobId); + this._runStore = new KeyValueStore(options.apiClient, "run", options.id); + this._stats = { initialCachedTasks: 0, lazyLoadedCachedTasks: 0, @@ -714,8 +727,9 @@ export class IO { return await this._triggerClient.sendEvent(event, options); }, { - name: "sendEvent", + name: "Send Event", params: { event, options }, + icon: "send", properties: [ { label: "name", @@ -740,8 +754,9 @@ export class IO { return await this._triggerClient.sendEvents(events, options); }, { - name: "sendEvents", + name: "Send Multiple Events", params: { events, options }, + icon: "send", properties: [ { label: "Total Events", @@ -824,6 +839,26 @@ export class IO { ); } + async updateWebhook(cacheKey: string | any[], options: { key: string } & UpdateWebhookBody) { + return this.runTask( + cacheKey, + async (task) => { + return await this._apiClient.updateWebhook(options.key, options); + }, + { + name: "Update Webhook Source", + icon: "refresh", + properties: [ + { + label: "key", + text: options.key, + }, + ], + params: options, + } + ); + } + /** `io.registerInterval()` allows you to register a [DynamicSchedule](https://trigger.dev/docs/sdk/dynamicschedule) that will trigger any jobs it's attached to on a regular interval. * @param cacheKey Should be a stable and unique key inside the `run()`. See [resumability](https://trigger.dev/docs/documentation/concepts/resumability) for more information. * @param dynamicSchedule The [DynamicSchedule](https://trigger.dev/docs/sdk/dynamicschedule) to register a new schedule on. @@ -1387,6 +1422,14 @@ export class IO { } } + get store() { + return { + env: this._envStore, + job: this._jobStore, + run: this._runStore, + }; + } + #addToCachedTasks(task: ServerTask) { this._cachedTasks.set(task.idempotencyKey, task); } diff --git a/packages/trigger-sdk/src/security.ts b/packages/trigger-sdk/src/security.ts index 663437d6b..eaf1da0af 100644 --- a/packages/trigger-sdk/src/security.ts +++ b/packages/trigger-sdk/src/security.ts @@ -1,10 +1,12 @@ import crypto from "crypto"; +import type { BinaryToTextEncoding, BinaryLike, KeyObject } from "crypto"; import { VerifyResult } from "./types"; /** Easily verify webhook payloads when they're using common signing methods. */ export async function verifyRequestSignature({ request, headerName, + headerEncoding = "hex", secret, algorithm, }: { @@ -12,9 +14,11 @@ export async function verifyRequestSignature({ request: Request; /** The name of the header that contains the signature. E.g. `X-Cal-Signature-256`. */ headerName: string; + /** The header encoding. Defaults to `hex`. */ + headerEncoding?: BinaryToTextEncoding; /** The secret that you use to hash the payload. For HttpEndpoints this will usually originally come from the Trigger.dev dashboard and should be stored in an environment variable. */ - secret: string; + secret: BinaryLike | KeyObject; /** The hashing algorithm that was used to create the signature. Currently only `sha256` is supported. */ algorithm: "sha256"; @@ -33,7 +37,7 @@ export async function verifyRequestSignature({ switch (algorithm) { case "sha256": - const success = verifyHmacSha256(headerValue, secret, await request.text()); + const success = verifyHmacSha256(headerValue, headerEncoding, secret, await request.text()); if (success) { return { @@ -47,9 +51,14 @@ export async function verifyRequestSignature({ } } -export function verifyHmacSha256(headerValue: string, secret: string, body: string): boolean { - const bodyDigest = crypto.createHmac("sha256", secret).update(body).digest("hex"); - const signature = headerValue?.replace("sha256=", "") ?? ""; +export function verifyHmacSha256( + headerValue: string, + headerEncoding: BinaryToTextEncoding, + secret: BinaryLike | KeyObject, + body: string +): boolean { + const bodyDigest = crypto.createHmac("sha256", secret).update(body).digest(headerEncoding); + const signature = headerValue?.replace("hmac-sha256=", "").replace("sha256=", "") ?? ""; return signature === bodyDigest; } diff --git a/packages/trigger-sdk/src/store/keyValueStore.ts b/packages/trigger-sdk/src/store/keyValueStore.ts new file mode 100644 index 000000000..6d112eeef --- /dev/null +++ b/packages/trigger-sdk/src/store/keyValueStore.ts @@ -0,0 +1,196 @@ +import { ApiClient } from "../apiClient"; +import { Json } from "../io"; +import { runLocalStorage } from "../runLocalStorage"; + +export class KeyValueStore { + constructor( + private apiClient: ApiClient, + private type: string | null = null, + private namespace: string = "" + ) {} + + #namespacedKey(key: string) { + const parts = []; + + if (this.type) { + parts.push(this.type); + } + + if (this.namespace) { + parts.push(this.namespace); + } + + parts.push(key); + + return parts.join(":"); + } + + #sharedProperties(key: string) { + return [ + { + label: "namespace", + text: this.type ?? "env", + }, + { + label: "key", + text: key, + }, + ]; + } + + async delete(cacheKey: string | any[], key: string): Promise; + async delete(key: string): Promise; + async delete(param1: string | any[], param2?: string): Promise { + const runStore = runLocalStorage.getStore(); + + if (!runStore) { + if (typeof param1 !== "string") { + throw new Error( + "Please use the store without a cacheKey when accessing from outside a run." + ); + } + + return await this.apiClient.store.delete(this.#namespacedKey(param1)); + } + + const { io } = runStore; + + if (!param2) { + throw new Error("Please provide a non-empty key when accessing the store from inside a run."); + } + + return await io.runTask( + param1, + async (task) => { + return await this.apiClient.store.delete(this.#namespacedKey(param2)); + }, + { + name: "Key-Value Store Delete", + icon: "database-minus", + params: { key: param2 }, + properties: this.#sharedProperties(param2), + style: { style: "minimal" }, + } + ); + } + + async get = any>(cacheKey: string | any[], key: string): Promise; + async get = any>(key: string): Promise; + async get = any>(param1: string | any[], param2?: string): Promise { + const runStore = runLocalStorage.getStore(); + + if (!runStore) { + if (typeof param1 !== "string") { + throw new Error( + "Please use the store without a cacheKey when accessing from outside a run." + ); + } + + return await this.apiClient.store.get(this.#namespacedKey(param1)); + } + + const { io } = runStore; + + if (!param2) { + throw new Error("Please provide a non-empty key when accessing the store from inside a run."); + } + + return await io.runTask( + param1, + async (task) => { + return await this.apiClient.store.get(this.#namespacedKey(param2)); + }, + { + name: "Key-Value Store Get", + icon: "database-export", + params: { key: param2 }, + properties: this.#sharedProperties(param2), + style: { style: "minimal" }, + } + ); + } + + async has(cacheKey: string | any[], key: string): Promise; + async has(key: string): Promise; + async has(param1: string | any[], param2?: string): Promise { + const runStore = runLocalStorage.getStore(); + + if (!runStore) { + if (typeof param1 !== "string") { + throw new Error( + "Please use the store without a cacheKey when accessing from outside a run." + ); + } + + return await this.apiClient.store.has(this.#namespacedKey(param1)); + } + + const { io } = runStore; + + if (!param2) { + throw new Error("Please provide a non-empty key when accessing the store from inside a run."); + } + + return await io.runTask( + param1, + async (task) => { + return await this.apiClient.store.has(this.#namespacedKey(param2)); + }, + { + name: "Key-Value Store Has", + icon: "database-search", + params: { key: param2 }, + properties: this.#sharedProperties(param2), + style: { style: "minimal" }, + } + ); + } + + async set>(cacheKey: string | any[], key: string, value: T): Promise; + async set>(key: string, value: T): Promise; + async set>(param1: string | any[], param2: string | T, param3?: T): Promise { + const runStore = runLocalStorage.getStore(); + + if (!runStore) { + if (typeof param1 !== "string") { + throw new Error( + "Please use the store without a cacheKey when accessing from outside a run." + ); + } + + return await this.apiClient.store.set(this.#namespacedKey(param1), param2 as T); + } + + const { io } = runStore; + + if (!param2 || typeof param2 !== "string") { + throw new Error("Please provide a non-empty key when accessing the store from inside a run."); + } + + const value = param3 as T; + + return await io.runTask( + param1, + async (task) => { + return await this.apiClient.store.set(this.#namespacedKey(param2), value); + }, + { + name: "Key-Value Store Set", + icon: "database-plus", + params: { key: param2, value }, + properties: [ + ...this.#sharedProperties(param2), + ...(typeof value !== "object" || value === null + ? [ + { + label: "value", + text: String(value) ?? "undefined", + }, + ] + : []), + ], + style: { style: "minimal" }, + } + ); + } +} diff --git a/packages/trigger-sdk/src/store/keyValueStoreClient.ts b/packages/trigger-sdk/src/store/keyValueStoreClient.ts new file mode 100644 index 000000000..e294bbcd4 --- /dev/null +++ b/packages/trigger-sdk/src/store/keyValueStoreClient.ts @@ -0,0 +1,86 @@ +import { AsyncMap } from "@trigger.dev/core"; +import { KeyValueStoreResponseBody } from "@trigger.dev/core"; +import { JSONOutputSerializer, Json } from "../io"; + +type QueryKeyValueStoreFunction = ( + action: "DELETE" | "GET" | "HAS" | "SET", + data: { + key: string; + value?: string; + } +) => Promise; + +export class KeyValueStoreClient implements AsyncMap { + #serializer = new JSONOutputSerializer(); + + constructor( + private queryStore: QueryKeyValueStoreFunction, + private type: string | null = null, + private namespace: string = "" + ) {} + + #namespacedKey(key: string) { + const parts = []; + + if (this.type) { + parts.push(this.type); + } + + if (this.namespace) { + parts.push(this.namespace); + } + + parts.push(key); + + return parts.join(":"); + } + + async delete(key: string): Promise { + const result = await this.queryStore("DELETE", { + key: this.#namespacedKey(key), + }); + + if (result.action !== "DELETE") { + throw new Error(`Unexpected key-value store response: ${result.action}`); + } + + return result.deleted; + } + + async get>(key: string): Promise { + const result = await this.queryStore("GET", { + key: this.#namespacedKey(key), + }); + + if (result.action !== "GET") { + throw new Error(`Unexpected key-value store response: ${result.action}`); + } + + return this.#serializer.deserialize(result.value); + } + + async has(key: string): Promise { + const result = await this.queryStore("HAS", { + key: this.#namespacedKey(key), + }); + + if (result.action !== "HAS") { + throw new Error(`Unexpected key-value store response: ${result.action}`); + } + + return result.has; + } + + async set>(key: string, value: T): Promise { + const result = await this.queryStore("SET", { + key: this.#namespacedKey(key), + value: this.#serializer.serialize(value), + }); + + if (result.action !== "SET") { + throw new Error(`Unexpected key-value store response: ${result.action}`); + } + + return this.#serializer.deserialize(result.value); + } +} diff --git a/packages/trigger-sdk/src/triggerClient.ts b/packages/trigger-sdk/src/triggerClient.ts index aeb14016d..e2d378249 100644 --- a/packages/trigger-sdk/src/triggerClient.ts +++ b/packages/trigger-sdk/src/triggerClient.ts @@ -1,6 +1,7 @@ import { API_VERSIONS, ConnectionAuth, + DELIVER_WEBHOOK_REQUEST, DeserializedJson, EphemeralEventDispatcherRequestBody, ErrorWithStackSchema, @@ -23,9 +24,13 @@ import { PreprocessRunBodySchema, Prettify, REGISTER_SOURCE_EVENT_V2, + REGISTER_WEBHOOK, RegisterSourceEventSchemaV2, RegisterSourceEventV2, RegisterTriggerBodyV2, + RegisterWebhookPayload, + RegisterWebhookPayloadSchema, + RequestWithRawBodySchema, RunJobBody, RunJobBodySchema, RunJobErrorResponse, @@ -37,6 +42,9 @@ import { SourceMetadataV2, StatusUpdate, SuccessfulRunNotification, + WebhookDeliveryResponse, + WebhookMetadata, + WebhookSourceRequestHeadersSchema, } from "@trigger.dev/core"; import { yellow } from "colorette"; import { ApiClient } from "./apiClient"; @@ -67,8 +75,39 @@ import type { Trigger, TriggerContext, TriggerPreprocessContext, + VerifyResult, } from "./types"; +const parseRequestPayload = (rawPayload: any) => { + const result = RequestWithRawBodySchema.safeParse(rawPayload); + + if (!result.success) { + throw new ParsedPayloadSchemaError(formatSchemaErrors(result.error.issues)); + } + + return new Request(new URL(result.data.url), { + method: result.data.method, + headers: result.data.headers, + body: result.data.rawBody, + }); +}; + +const deliverWebhookEvent = (key: string): EventSpecification => ({ + name: `${DELIVER_WEBHOOK_REQUEST}.${key}`, + title: "Deliver Webhook", + source: "internal", + icon: "mail-fast", + parsePayload: parseRequestPayload, +}); + +const registerWebhookEvent = (key: string): EventSpecification => ({ + name: `${REGISTER_WEBHOOK}.${key}`, + title: "Register Webhook", + source: "internal", + icon: "webhook", + parsePayload: RegisterWebhookPayloadSchema.parse, +}); + const registerSourceEvent: EventSpecification = { name: REGISTER_SOURCE_EVENT_V2, title: "Register Source", @@ -79,6 +118,9 @@ const registerSourceEvent: EventSpecification = { import EventEmitter from "node:events"; import * as packageJson from "../package.json"; +import { formatSchemaErrors } from "./utils/formatSchemaErrors"; +import { WebhookDeliveryContext, WebhookSource } from "./triggers/webhook"; +import { KeyValueStore } from "./store/keyValueStore"; export type TriggerClientOptions = { /** The `id` property is used to uniquely identify the client. @@ -111,11 +153,24 @@ export type TriggerAuthResolver = ( integration: TriggerIntegration ) => Promise; +type WebhookVerifyFunction = ( + request: Request, + client: TriggerClient, + ctx: WebhookDeliveryContext +) => Promise; + +type WebhookEventGeneratorFunction = ( + request: Request, + client: TriggerClient, + ctx: WebhookDeliveryContext +) => Promise; + /** A [TriggerClient](https://trigger.dev/docs/documentation/concepts/client-adaptors) is used to connect to a specific [Project](https://trigger.dev/docs/documentation/concepts/projects) by using an [API Key](https://trigger.dev/docs/documentation/concepts/environments-apikeys). */ export class TriggerClient { #options: TriggerClientOptions; #registeredJobs: Record>, any>> = {}; #registeredSources: Record = {}; + #registeredWebhooks: Record = {}; #registeredHttpSourceHandlers: Record< string, ( @@ -127,6 +182,13 @@ export class TriggerClient { metadata?: HttpSourceResponseMetadata; } | void> > = {}; + #registeredWebhookSourceHandlers: Record< + string, + { + verify: WebhookVerifyFunction; + generateEvents: WebhookEventGeneratorFunction; + } + > = {}; #registeredDynamicTriggers: Record< string, DynamicTrigger, ExternalSource> @@ -135,6 +197,7 @@ export class TriggerClient { #registeredSchedules: Record> = {}; #registeredHttpEndpoints: Record>> = {}; #authResolvers: Record = {}; + #envStore: KeyValueStore; #eventEmitter: NotificationsEventEmitter = new EventEmitter() as NotificationsEventEmitter; #client: ApiClient; @@ -149,6 +212,7 @@ export class TriggerClient { "output", "noopTasksSet", ]); + this.#envStore = new KeyValueStore(this.#client); } on = this.#eventEmitter.on.bind(this.#eventEmitter); @@ -261,6 +325,7 @@ export class TriggerClient { const body: IndexEndpointResponse = { jobs: this.#buildJobsIndex(), sources: Object.values(this.#registeredSources), + webhooks: Object.values(this.#registeredWebhooks), dynamicTriggers: Object.values(this.#registeredDynamicTriggers).map((trigger) => ({ id: trigger.id, jobs: this.#jobMetadataByDynamicTriggers[trigger.id] ?? [], @@ -507,6 +572,61 @@ export class TriggerClient { headers: this.#standardResponseHeaders(timeOrigin), }; } + case "DELIVER_WEBHOOK_REQUEST": { + const headers = WebhookSourceRequestHeadersSchema.safeParse( + Object.fromEntries(request.headers.entries()) + ); + + if (!headers.success) { + return { + status: 400, + body: { + message: "Invalid headers", + }, + }; + } + + const sourceRequestNeedsBody = headers.data["x-ts-http-method"] !== "GET"; + + const sourceRequestInit: RequestInit = { + method: headers.data["x-ts-http-method"], + headers: headers.data["x-ts-http-headers"], + body: sourceRequestNeedsBody ? request.body : undefined, + }; + + if (sourceRequestNeedsBody) { + try { + // @ts-ignore + sourceRequestInit.duplex = "half"; + } catch (error) { + // ignore + } + } + + const webhookRequest = new Request(headers.data["x-ts-http-url"], sourceRequestInit); + + const key = headers.data["x-ts-key"]; + const secret = headers.data["x-ts-secret"]; + const params = headers.data["x-ts-params"]; + + const ctx = { + key, + secret, + params, + }; + + const { response, verified, error } = await this.#handleWebhookRequest(webhookRequest, ctx); + + return { + status: 200, + body: { + response, + verified, + error, + }, + headers: this.#standardResponseHeaders(timeOrigin), + }; + } case "VALIDATE": { return { status: 200, @@ -751,6 +871,179 @@ export class TriggerClient { this.#registeredSchedules[key] = jobs; } + attachWebhook< + TIntegration extends TriggerIntegration, + TParams extends any, + TConfig extends Record, + >(options: { + key: string; + source: WebhookSource; + event: EventSpecification; + params: any; + config: TConfig; + }): void { + const { source } = options; + + this.#registeredWebhookSourceHandlers[options.key] = { + verify: source.verify.bind(source), + generateEvents: source.generateEvents.bind(source), + }; + + let registeredWebhook = this.#registeredWebhooks[options.key]; + + if (!registeredWebhook) { + registeredWebhook = { + key: options.key, + params: options.params, + config: options.config, + integration: { + id: source.integration.id, + metadata: source.integration.metadata, + authSource: source.integration.authSource, + }, + httpEndpoint: { + id: options.key, + }, + }; + } else { + registeredWebhook.config = deepMergeOptions(registeredWebhook.config, options.config); + } + + this.#registeredWebhooks[options.key] = registeredWebhook; + + // new Job(this, { + // id: `webhook.deliver.${options.key}`, + // name: `webhook.deliver.${options.key}`, + // version: source.version, + // trigger: new EventTrigger({ + // event: deliverWebhookEvent(options.key), + // // verify: source.verify.bind(source), + // }), + // integrations: { + // integration: source.integration, + // }, + // run: async (request, io, ctx) => { + // this.#internalLogger.debug("[webhook.deliver]"); + + // const webhookContextMetadata = WebhookContextMetadataSchema.parse(ctx.source?.metadata); + + // const webhookContext = { + // ...ctx, + // webhook: webhookContextMetadata, + // }; + + // const verifyResult = await io.runTask( + // "verify", + // async () => { + // return await source.verify(request, io, webhookContext); + // }, + // { + // name: "Verify Signature", + // icon: "certificate", + // } + // ); + + // if (!verifyResult.success) { + // throw new Error(verifyResult.reason); + // } + + // return await io.runTask( + // "generate-events", + // async () => { + // return await source.generateEvents(request, io, webhookContext); + // }, + // { + // name: "Generate Events", + // icon: "building-factory-2", + // } + // ); + // }, + // __internal: true, + // }); + + new Job(this, { + id: `webhook.register.${options.key}`, + name: `webhook.register.${options.key}`, + version: source.version, + trigger: new EventTrigger({ + event: registerWebhookEvent(options.key), + }), + integrations: { + integration: source.integration, + }, + run: async (registerPayload, io, ctx) => { + return await io.try( + async () => { + this.#internalLogger.debug("[webhook.register] Start"); + + const crudOptions = { + io, + // this is just a more strongly typed payload + ctx: registerPayload as Parameters<(typeof source)["crud"]["create"]>[0]["ctx"], + }; + + if (!registerPayload.active) { + this.#internalLogger.debug("[webhook.register] Not active, run create"); + + await io.try( + async () => { + await source.crud.create(crudOptions); + }, + async (error) => { + this.#internalLogger.debug( + "[webhook.register] Error during create, re-trying with delete first", + { error } + ); + + await io.runTask("create-retry", async () => { + await source.crud.delete(crudOptions); + await source.crud.create(crudOptions); + }); + } + ); + + return await io.updateWebhook("update-webhook-success", { + key: options.key, + active: true, + config: registerPayload.config.desired, + }); + } + + this.#internalLogger.debug("[webhook.register] Already active, run update"); + + if (source.crud.update) { + await source.crud.update(crudOptions); + } else { + this.#internalLogger.debug( + "[webhook.register] Run delete and create instead of update" + ); + + await source.crud.delete(crudOptions); + await source.crud.create(crudOptions); + } + + return await io.updateWebhook("update-webhook-success", { + key: options.key, + active: true, + config: registerPayload.config.desired, + }); + }, + async (error) => { + this.#internalLogger.debug("[webhook.register] Error", { error }); + + await io.updateWebhook("update-webhook-error", { + key: options.key, + active: false, + }); + + throw error; + } + ); + }, + __internal: true, + }); + } + async registerTrigger( id: string, key: string, @@ -830,6 +1123,12 @@ export class TriggerClient { return this.#client.createEphemeralEventDispatcher(payload); } + get store() { + return { + env: this.#envStore, + }; + } + authorized( apiKey?: string | null ): "authorized" | "unauthorized" | "missing-client" | "missing-header" { @@ -880,6 +1179,7 @@ export class TriggerClient { const io = new IO({ id: body.run.id, + jobId: job.id, cachedTasks: body.tasks, cachedTasksCursor: body.cachedTaskCursor, yieldedExecutions: body.yieldedExecutions ?? [], @@ -1283,6 +1583,54 @@ export class TriggerClient { }; } + async #handleWebhookRequest( + request: Request, + ctx: WebhookDeliveryContext + ): Promise { + this.#internalLogger.debug("Handling webhook request", { + ctx, + }); + + const okResponse = { + status: 200, + body: { + ok: true, + }, + }; + + const handlers = this.#registeredWebhookSourceHandlers[ctx.key]; + + if (!handlers) { + this.#internalLogger.debug("No handler registered for webhook", { + ctx, + }); + + return { + response: okResponse, + verified: false, + }; + } + + const { verify, generateEvents } = handlers; + + const verifyResult = await verify(request, this, ctx); + + if (!verifyResult.success) { + return { + response: okResponse, + verified: false, + error: verifyResult.reason, + }; + } + + await generateEvents(request, this, ctx); + + return { + response: okResponse, + verified: true, + }; + } + async #resolveConnections( ctx: TriggerContext, integrations?: Record, diff --git a/packages/trigger-sdk/src/triggers/eventTrigger.ts b/packages/trigger-sdk/src/triggers/eventTrigger.ts index 59602bb25..0a7d2f245 100644 --- a/packages/trigger-sdk/src/triggers/eventTrigger.ts +++ b/packages/trigger-sdk/src/triggers/eventTrigger.ts @@ -1,15 +1,23 @@ import { EventFilter, TriggerMetadata, deepMergeFilters } from "@trigger.dev/core"; import { Job } from "../job"; import { TriggerClient } from "../triggerClient"; -import { EventSpecification, EventSpecificationExample, SchemaParser, Trigger } from "../types"; +import { + EventSpecification, + EventSpecificationExample, + EventTypeFromSpecification, + SchemaParser, + Trigger, +} from "../types"; import { formatSchemaErrors } from "../utils/formatSchemaErrors"; import { ParsedPayloadSchemaError } from "../errors"; +import { VerifyCallback } from "../httpEndpoint"; type EventTriggerOptions> = { event: TEventSpecification; name?: string | string[]; source?: string; filter?: EventFilter; + verify?: EventTypeFromSpecification extends Request ? VerifyCallback : never; }; export class EventTrigger> @@ -44,6 +52,13 @@ export class EventTrigger> } async verifyPayload(payload: ReturnType) { + if (this.#options.verify) { + if ((payload as any) instanceof Request) { + const clonedRequest = (payload as Request).clone(); + return this.#options.verify(clonedRequest); + } + } + return { success: true as const }; } } diff --git a/packages/trigger-sdk/src/triggers/webhook.ts b/packages/trigger-sdk/src/triggers/webhook.ts new file mode 100644 index 000000000..4e36cca88 --- /dev/null +++ b/packages/trigger-sdk/src/triggers/webhook.ts @@ -0,0 +1,339 @@ +import { + DisplayProperty, + EventFilter, + HandleTriggerSource, + RegisterWebhookSource, + TriggerMetadata, + deepMergeFilters, +} from "@trigger.dev/core"; +import { IOWithIntegrations, TriggerIntegration } from "../integrations"; +import { IO } from "../io"; +import { Job } from "../job"; +import { TriggerClient } from "../triggerClient"; +import type { + EventSpecification, + SchemaParser, + Trigger, + TriggerContext, + VerifyResult, +} from "../types"; +import { slugifyId } from "../utils"; +import { SerializableJson } from "@trigger.dev/core"; +import { Prettify } from "@trigger.dev/core"; +import { createHash } from "node:crypto"; + +type WebhookCRUDContext> = { + active: boolean; + params: TParams; + config: { + current: Partial; + desired: TConfig; + }; + url: string; + secret: string; +}; + +type WebhookCRUDFunction< + TIntegration extends TriggerIntegration, + TParams extends any, + TConfig extends Record, +> = (options: { + io: IOWithIntegrations<{ integration: TIntegration }>; + ctx: WebhookCRUDContext; +}) => Promise; + +interface WebhookCRUD< + TIntegration extends TriggerIntegration, + TParams extends any, + TConfig extends Record, +> { + create: WebhookCRUDFunction; + read?: WebhookCRUDFunction; // currently unused + update?: WebhookCRUDFunction; + delete: WebhookCRUDFunction; +} + +export type WebhookConfig = { + [K in TConfigKeys]: string[]; +}; + +type RegisterFunctionEvent> = { + source: { + active: boolean; + data?: any; + secret: string; + url: string; + }; + params: TParams; + config: TConfig; +}; + +type WebhookRegisterEvent> = { + id: string; + source: RegisterWebhookSource; + dynamicTriggerId?: string; + config: TConfig; +}; + +type RegisterFunctionOutput> = { + secret?: string; + data?: SerializableJson; + config: TConfig; +}; + +type RegisterFunction< + TIntegration extends TriggerIntegration, + TParams extends any, + TConfig extends Record, +> = ( + event: RegisterFunctionEvent, + io: IOWithIntegrations<{ integration: TIntegration }>, + ctx: TriggerContext +) => Promise | undefined>; + +export type WebhookHandlerEvent = { + rawEvent: Request; + source: Prettify & { params: TParams }>; +}; + +type WebhookHandlerContext> = { + params: TParams; + config: TConfig; + secret: string; +}; + +export type WebhookDeliveryContext = { + key: string; + secret: string; + params: any; +}; + +type EventGenerator< + TParams extends any, + TConfig extends Record, + TIntegration extends TriggerIntegration, +> = (options: { + request: Request; + client: TriggerClient; + ctx: WebhookDeliveryContext; +}) => Promise; + +type KeyFunction = (params: TParams) => string; + +type FilterFunction> = ( + params: TParams, + config?: TConfig +) => EventFilter; + +type WebhookOptions< + TIntegration extends TriggerIntegration, + TParams extends any, + TConfig extends Record, +> = { + id: string; + version: string; + integration: TIntegration; + schemas: { + params: SchemaParser; + config?: SchemaParser; + }; + key: KeyFunction; + crud: WebhookCRUD; + filter?: FilterFunction; + register?: RegisterFunction; + verify?: (options: { + request: Request; + client: TriggerClient; + ctx: WebhookDeliveryContext; + }) => Promise; + generateEvents: EventGenerator; + properties?: (params: TParams) => DisplayProperty[]; +}; + +export class WebhookSource< + TIntegration extends TriggerIntegration, + TParams extends any = any, + TConfig extends Record = Record, +> { + constructor(private options: WebhookOptions) {} + + async generateEvents(request: Request, client: TriggerClient, ctx: WebhookDeliveryContext) { + return this.options.generateEvents({ + request, + client, + ctx, + }); + } + + filter(params: TParams, config?: TConfig): EventFilter { + return this.options.filter?.(params, config) ?? {}; + } + + properties(params: TParams): DisplayProperty[] { + return this.options.properties?.(params) ?? []; + } + + get crud() { + return this.options.crud; + } + + async register( + params: TParams, + registerEvent: WebhookRegisterEvent, + io: IO, + ctx: TriggerContext + ) { + if (!this.options.register) { + return; + } + + const updates = await this.options.register( + { + ...registerEvent, + params, + }, + io as IOWithIntegrations<{ integration: TIntegration }>, + ctx + ); + + return updates; + } + + async verify( + request: Request, + client: TriggerClient, + ctx: WebhookDeliveryContext + ): Promise { + if (this.options.verify) { + const clonedRequest = request.clone(); + return this.options.verify({ request: clonedRequest, client, ctx }); + } + + return { success: true as const }; + } + + #shortHash(str: string) { + const hash = createHash("sha1").update(str).digest("hex"); + return hash.slice(0, 7); + } + + key(params: TParams): string { + const parts = ["webhook"]; + + parts.push(this.options.key(params)); + parts.push(this.integration.id); + + return `${this.options.id}-${this.#shortHash(parts.join(""))}`; + } + + get integration() { + return this.options.integration; + } + + get integrationConfig() { + return { + id: this.integration.id, + metadata: this.integration.metadata, + }; + } + + get id() { + return this.options.id; + } + + get version() { + return this.options.version; + } +} + +export type GetWebhookParams> = + TWebhook extends WebhookSource ? TParams : never; + +export type GetWebhookConfig> = + TWebhook extends WebhookSource ? TConfig : never; + +export type WebhookTriggerOptions< + TEventSpecification extends EventSpecification, + TEventSource extends WebhookSource, + TConfig extends Record = Record, +> = { + event: TEventSpecification; + source: TEventSource; + params: GetWebhookParams; + config: TConfig; +}; + +export class WebhookTrigger< + TEventSpecification extends EventSpecification, + TEventSource extends WebhookSource, +> implements Trigger +{ + constructor(private options: WebhookTriggerOptions) {} + + get event() { + return this.options.event; + } + + get source() { + return this.options.source; + } + + get key() { + return slugifyId(this.options.source.key(this.options.params)); + } + + toJSON(): TriggerMetadata { + return { + type: "static", + title: "Webhook", + rule: { + event: this.event.name, + payload: deepMergeFilters( + this.options.source.filter(this.options.params, this.options.config), + this.event.filter ?? {} + ), + source: this.event.source, + }, + properties: this.options.source.properties(this.options.params), + link: `http-endpoints/${this.key}`, + }; + } + + filter(eventFilter: EventFilter) { + const { event, ...optionsWithoutEvent } = this.options; + const { filter, ...eventWithoutFilter } = event; + + return new WebhookTrigger({ + ...optionsWithoutEvent, + event: { + ...eventWithoutFilter, + filter: deepMergeFilters(filter ?? {}, eventFilter), + }, + }); + } + + attachToJob(triggerClient: TriggerClient, job: Job, any>) { + triggerClient.defineHttpEndpoint({ + id: this.key, + source: "trigger.dev", + icon: this.event.icon, + verify: async () => ({ success: true }), + }); + + triggerClient.attachWebhook({ + key: this.key, + source: this.options.source, + event: this.options.event, + params: this.options.params, + config: this.options.config, + }); + } + + get preprocessRuns() { + return true; + } + + async verifyPayload(payload: ReturnType) { + return { success: true as const }; + } +} diff --git a/packages/trigger-sdk/src/types.ts b/packages/trigger-sdk/src/types.ts index 283ccaa47..63adad147 100644 --- a/packages/trigger-sdk/src/types.ts +++ b/packages/trigger-sdk/src/types.ts @@ -120,6 +120,13 @@ export const EventSpecificationExampleSchema = z.object({ export type EventSpecificationExample = z.infer; +export type TypedEventSpecificationExample = { + id: string; + name: string; + icon?: string; + payload: TEvent +} + export interface EventSpecification { name: string | string[]; title: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6162633b8..740a31c2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,7 @@ importers: morgan: ^1.10.0 nanoid: ^3.3.4 npm-run-all: ^4.1.5 + ohash: ^1.1.3 postcss: ^8.4.21 postcss-import: ^14.1.0 posthog-js: ^1.83.0 @@ -289,6 +290,7 @@ importers: marked: 4.2.5 morgan: 1.10.0 nanoid: 3.3.4 + ohash: 1.1.3 postcss-import: 14.1.0_postcss@8.4.21 posthog-js: 1.83.0 posthog-node: 3.1.1 @@ -595,6 +597,31 @@ importers: tsup: 7.1.0_typescript@4.9.4 typescript: 4.9.4 + integrations/shopify: + specifiers: + '@shopify/shopify-api': ^8.0.2 + '@trigger.dev/integration-kit': workspace:^2.2.6 + '@trigger.dev/sdk': workspace:^2.2.6 + '@trigger.dev/tsconfig': workspace:* + '@trigger.dev/tsup': workspace:* + '@types/node': 16.x + rimraf: ^3.0.2 + tsup: 7.1.x + typescript: 4.9.4 + zod: 3.22.3 + dependencies: + '@shopify/shopify-api': 8.0.2 + '@trigger.dev/integration-kit': link:../../packages/integration-kit + '@trigger.dev/sdk': link:../../packages/trigger-sdk + zod: 3.22.3 + devDependencies: + '@trigger.dev/tsconfig': link:../../config-packages/tsconfig + '@trigger.dev/tsup': link:../../config-packages/tsup + '@types/node': 16.18.11 + rimraf: 3.0.2 + tsup: 7.1.0_typescript@4.9.4 + typescript: 4.9.4 + integrations/slack: specifiers: '@slack/web-api': ^6.8.1 @@ -1250,6 +1277,7 @@ importers: '@trigger.dev/resend': workspace:* '@trigger.dev/sdk': workspace:* '@trigger.dev/sendgrid': workspace:* + '@trigger.dev/shopify': workspace:* '@trigger.dev/slack': workspace:* '@trigger.dev/stripe': workspace:* '@trigger.dev/supabase': workspace:* @@ -1276,6 +1304,7 @@ importers: '@trigger.dev/resend': link:../../integrations/resend '@trigger.dev/sdk': link:../../packages/trigger-sdk '@trigger.dev/sendgrid': link:../../integrations/sendgrid + '@trigger.dev/shopify': link:../../integrations/shopify '@trigger.dev/slack': link:../../integrations/slack '@trigger.dev/stripe': link:../../integrations/stripe '@trigger.dev/supabase': link:../../integrations/supabase @@ -11963,6 +11992,25 @@ packages: - debug dev: false + /@shopify/network/3.2.1: + resolution: {integrity: sha512-Ih/6Oe80dynlUsRfEqptWBfsySCqI0rjQvPAjS8HuWeK9nZ+TvmYScfxjucKXZ2deXwAClnU6SMdh3/B1lMMog==} + engines: {node: ^14.17.0 || >=16.0.0} + dev: false + + /@shopify/shopify-api/8.0.2: + resolution: {integrity: sha512-hvVLoEsYglE4GRqFhr9D6oMr2bV6tEdsD9PxuNZ6bYDptoD+kQFKsaP83jE1qtHhB3ve0DeevaVVYjS/2TU7MA==} + dependencies: + '@shopify/network': 3.2.1 + compare-versions: 5.0.3 + isbot: 3.6.13 + jose: 4.15.4 + node-fetch: 2.6.12 + tslib: 2.6.2 + uuid: 9.0.0 + transitivePeerDependencies: + - encoding + dev: false + /@sideway/address/4.1.4: resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==} dependencies: @@ -18113,6 +18161,10 @@ packages: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} dev: true + /compare-versions/5.0.3: + resolution: {integrity: sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A==} + dev: false + /component-emitter/1.3.0: resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==} @@ -24484,6 +24536,10 @@ packages: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + /jose/4.15.4: + resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} + dev: false + /joycon/3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -27034,6 +27090,10 @@ packages: - encoding dev: false + /ohash/1.1.3: + resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} + dev: false + /on-exit-leak-free/2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} diff --git a/references/job-catalog/package.json b/references/job-catalog/package.json index 166a63326..9479ec2e1 100644 --- a/references/job-catalog/package.json +++ b/references/job-catalog/package.json @@ -33,6 +33,7 @@ "built-ins": "nodemon --watch src/built-ins.ts -r tsconfig-paths/register -r dotenv/config src/built-ins.ts", "edge-cases": "nodemon --watch src/edge-cases.ts -r tsconfig-paths/register -r dotenv/config src/edge-cases.ts", "notifications": "nodemon --watch src/notifications.ts -r tsconfig-paths/register -r dotenv/config src/notifications.ts", + "shopify": "nodemon --watch src/shopify.ts -r tsconfig-paths/register -r dotenv/config src/shopify.ts", "dev:trigger": "trigger-cli dev --port 8080" }, "dependencies": { @@ -48,6 +49,7 @@ "@trigger.dev/resend": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@trigger.dev/sendgrid": "workspace:*", + "@trigger.dev/shopify": "workspace:*", "@trigger.dev/slack": "workspace:*", "@trigger.dev/stripe": "workspace:*", "@trigger.dev/supabase": "workspace:*", diff --git a/references/job-catalog/src/built-ins.ts b/references/job-catalog/src/built-ins.ts index 26badf977..159bc37d4 100644 --- a/references/job-catalog/src/built-ins.ts +++ b/references/job-catalog/src/built-ins.ts @@ -205,7 +205,7 @@ const sendWaitForEventJob = client.defineJob({ }); }, }); - + client.defineJob({ id: "send-event-example", name: "Send Event Example", @@ -299,4 +299,67 @@ client.defineJob({ run: async (payload, io, ctx) => {}, }); +client.defineJob({ + id: "store-example", + name: "Key-Value Store Example", + version: "1.0.0", + trigger: eventTrigger({ + name: "store.example", + }), + run: async (payload, io, ctx) => { + // value tests + await io.store.job.set("set-undefined", "test", undefined); + await io.store.job.get("get-undefined", "test"); + + await io.store.job.set("set-null", "test", null); + await io.store.job.get("get-null", "test"); + + await io.store.job.set("set-false", "test", false); + await io.store.job.get("get-false", "test"); + + await io.store.job.set("set-zero", "test", 0); + await io.store.job.get("get-zero", "test"); + + await io.store.job.set("set-object", "test", { foo: "bar" }); + await io.store.job.get("get-object", "test"); + + await io.store.job.delete("delete-value-test", "test"); + + // job store + await io.store.job.get("job-get-nonexistent", "some-key"); + await io.store.job.has("job-has-nonexistent", "some-key"); + await io.store.job.set("job-set", "some-key", "some-value"); + await io.store.job.has("job-has", "some-key"); + await io.store.job.get("job-get", "some-key"); + await io.store.job.delete("job-delete", "some-key"); + await io.store.job.delete("job-delete-nonexistent", "some-key"); + + // run store + await io.store.run.get("run-get-nonexistent", "some-key"); + await io.store.run.has("run-has-nonexistent", "some-key"); + await io.store.run.set("run-set", "some-key", "some-value"); + await io.store.run.has("run-has", "some-key"); + await io.store.run.get("run-get", "some-key"); + await io.store.run.delete("run-delete", "some-key"); + await io.store.run.delete("run-delete-nonexistent", "some-key"); + + // env store + await io.store.env.get("env-get-nonexistent", "some-key"); + await io.store.env.has("env-has-nonexistent", "some-key"); + await io.store.env.set("env-set", "some-key", "some-value"); + await io.store.env.has("env-has", "some-key"); + await io.store.env.get("env-get", "some-key"); + await io.store.env.delete("env-delete", "some-key"); + await io.store.env.delete("env-delete-nonexistent", "some-key"); + + // fail on large value + const largeValue = Array(256 * 1024) + .fill("F") + .join(""); + + await io.store.job.set("large-value-fail", "large-value", largeValue); + await io.store.job.delete("large-value-delete", "large-value"); + }, +}); + createExpressServer(client); diff --git a/references/job-catalog/src/shopify.ts b/references/job-catalog/src/shopify.ts new file mode 100644 index 000000000..eb9dc9cf1 --- /dev/null +++ b/references/job-catalog/src/shopify.ts @@ -0,0 +1,97 @@ +import { TriggerClient, eventTrigger } from "@trigger.dev/sdk"; +import { createExpressServer } from "@trigger.dev/express"; +import { Shopify } from "@trigger.dev/shopify"; + +export const client = new TriggerClient({ + id: "job-catalog", + apiKey: process.env["TRIGGER_API_KEY"], + apiUrl: process.env["TRIGGER_API_URL"], + verbose: false, + ioLogLocalEnabled: true, +}); + +const shopify = new Shopify({ + id: "shopify", + adminAccessToken: process.env["SHOPIFY_ADMIN_ACCESS_TOKEN"]!, + apiKey: process.env["SHOPIFY_API_KEY"]!, + apiSecretKey: process.env["SHOPIFY_API_SECRET_KEY"]!, + hostName: process.env["SHOPIFY_SHOP_DOMAIN"]!, +}); + +// const shopify = new Shopify({ +// id: "shopify-oauth", +// }); + +client.defineJob({ + id: "shopify-products-create", + name: "Shopify: products/create", + version: "0.1.0", + trigger: shopify.on("products/create"), + run: async (payload, io, ctx) => { + await io.logger.log(`product created: ${payload.id}`); + }, +}); + +client.defineJob({ + id: "shopify-products-delete", + name: "Shopify: products/delete", + version: "0.1.0", + trigger: shopify.on("products/delete"), + run: async (payload, io, ctx) => { + await io.logger.log(`product deleted: ${payload.id}`); + }, +}); + +client.defineJob({ + id: "shopify-task-examples", + name: "Shopify: Task Examples", + version: "0.1.0", + trigger: eventTrigger({ + name: "shopify.task.examples", + }), + integrations: { + shopify, + }, + run: async (payload, io, ctx) => { + await io.shopify.rest.Product.count("count-products"); + + const createdProduct = await io.shopify.rest.Product.save("create-product", { + fromData: { + title: "Some Product", + }, + }); + + await io.logger.info(`Created product ${createdProduct.id}: ${createdProduct.title}`); + + await io.shopify.rest.Product.count("count-products-again"); + + const foundProduct = await io.shopify.rest.Product.find("find-product", { + id: createdProduct.id, + }); + + if (foundProduct) { + await io.shopify.rest.Variant.all("get-all-variants", { + product_id: foundProduct.id, + }); + + await io.shopify.rest.Product.delete("delete-product", { + id: foundProduct.id, + }); + } + + const allProducts = await io.shopify.rest.Product.all("get-all-products", { + limit: 2, + autoPaginate: true, + }); + + if (allProducts.data.length) { + const firstProduct = allProducts.data[0]; + + await io.shopify.rest.Product.delete("delete-first", { + id: firstProduct.id, + }); + } + }, +}); + +createExpressServer(client); diff --git a/references/job-catalog/tsconfig.json b/references/job-catalog/tsconfig.json index 80823d1a8..ac55e8446 100644 --- a/references/job-catalog/tsconfig.json +++ b/references/job-catalog/tsconfig.json @@ -1,9 +1,14 @@ { "extends": "@trigger.dev/tsconfig/node18.json", - "include": ["./src/**/*.ts"], + "include": [ + "./src/**/*.ts" + ], "compilerOptions": { "baseUrl": ".", - "lib": ["DOM", "DOM.Iterable"], + "lib": [ + "DOM", + "DOM.Iterable" + ], "paths": { "@/*": [ "./src/*" @@ -103,7 +108,13 @@ ], "@trigger.dev/replicate/*": [ "../../integrations/replicate/src/*" + ], + "@trigger.dev/shopify": [ + "../../integrations/shopify/src/index" + ], + "@trigger.dev/shopify/*": [ + "../../integrations/shopify/src/*" ] } } -} +} \ No newline at end of file