Feature: New Webhook Triggers, KV Store, and Shopify integration (#745)

* Attach webhook to http endpoint

* Create internal webhook handler job

* Fix delivery

* Add webhook trigger

* Index webhooks

* Fix webhook connect

* Webhook activation

* Rename to TriggerWebhook

* Save webhooks to index

* Extend register payload

* Revert attachSource

* Add update webhook helper

* Use crud interface for registration

* Remove filter from params

* Destructure options

* Missing ohash dep

* Chainable trigger filter

* Optional request verification

* Handler call with context

* Update config on register success

* Rename to webhookData

* Add basic key-value store

* Improve kv store

* Webhook trigger UI

* Webhook delivery UI

* Some fixes

* Airtable experiments

* Fix tabs transitions

* Only run register if config differs

* Use subtasks for delivery

* Missing dep

* Error handing and fixes

* Namespace cursor

* Fix hmac verification

* Gut Airtable

* Lockfile

* Template optional api key

* Template event sources and icons

* Template remove filter from params

* Fix models template

* Template fixme

* Fix webhook trigger header

* Actually send params for registration..

* Add back oauthed client

* Verify signature header encoding param

* Fix delivery context

* Webhook create retry with prior delete

* Webhook source type fixes

* Webhook config merge

* Update webhook icon

* Verify webhook task options

* Rename handler to generateEvents

* Send event icons

* Shopify with webhooks

* Shopify job-catalog entry

* Lockfile

* Add webhook migration

* Better icons

* Type fixes

* Make param type optional

* Scope type

* More examples

* More schemas

* Trigger catalog

* Bump versions

* Just a few more events they said

* Simplify session and config

* Typed serializer

* Fix payload type

* Remove unused getters

* Reduce logging output

* Rest resource tasks

* Lockfile

* Bump version

* Remaining triggers

* Fix event types

* Fix import

* Link from webhook trigger to http endpoint

* Use rest resource tasks for crud

* Fix rest save type

* Improve KV types

* Improve attach webhook logging

* Make header schema more lenient

* Fix save type again

* Final error callback

* Shorten keys

* Remove ohash diff

* Disable Airtable webhooks

* Webhook environments

* Fix shopify error import

* Link to integration and http endpoint

* Integration catalog entry

* Disable oauth

* Require api key, simplify client secret

* Move trigger types

* Update catalog example

* Small KV endpoint refactor

* Fix custom migration

* Webhook environment migration

* Improve crud types

* Remove unused schema

* Fix crud context type

* KV limits and HTTP verbs

* KV improvements

* Jobless webhook delivery

* Airtable delivery fixes

* Make deliveries sexy again

* Some docs

* Fix payload types.. again

* Final payload pass

* Schema cleanup

* Refactor handler service

* Remove more stale schemas

* registerJobNamespace import

* Expose KV on TriggerClient

* Remove dummy click handler

* Remove unused components

* Fix delivery pagination

* Enable registration rerun button

* Comment out registration route action handler

* Rename KV as not tied to IO anymore

* Swap api with trigger client

* Disabled fields param

* KV docs

* Changeset

* Shopify docs fixes
This commit is contained in:
nicktrn
2023-11-24 10:22:12 +00:00
committed by GitHub
parent 2cce68b5f7
commit 067e19fec9
181 changed files with 10352 additions and 241 deletions
+12
View File
@@ -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
@@ -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 (
<div className="flex items-center gap-1">
{icon && typeof icon === "string" ? <NamedIcon name={icon} className="h-4 w-4" /> : icon}
{label && (
<Paragraph variant="extra-small/caps" className="mt-0.5 whitespace-nowrap">
{label}:
{label}
{value && ":"}
</Paragraph>
)}
<Paragraph variant="small">{value}</Paragraph>
{value && <Paragraph variant="small">{value}</Paragraph>}
</div>
);
}
@@ -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 (
<div className={cn(`flex flex-row gap-x-6 border-b border-ui-border`, className)}>
{tabs.map((tab, index) => (
@@ -26,7 +27,7 @@ export function Tabs({ tabs, className }: TabsProps) {
{tab.label}
</span>
{isActive || isPending ? (
<motion.div layoutId="underline" className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-slate-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -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 (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Run</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Last Error</TableHeaderCell>
<TableHeaderCell>Started</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell>Verified</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs found for this Job" />
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs match your filters" />
</TableBlankRow>
) : (
runs.map((run) => {
return (
<TableRow key={run.id}>
<TableCell>#{run.number}</TableCell>
<TableCell>
<EnvironmentLabel environment={run.environment} />
</TableCell>
<TableCell>
<RunStatus
status={
!run.deliveredAt
? "STARTED"
: run.error || !run.verified
? "FAILURE"
: "SUCCESS"
}
/>
</TableCell>
<TableCell>{run.error?.slice(0, 30) ?? ""}</TableCell>
<TableCell>{run.createdAt ? <DateTime date={run.createdAt} /> : ""}</TableCell>
<TableCell>
{formatDuration(run.createdAt, run.deliveredAt, {
style: "short",
})}
</TableCell>
<TableCell>
{run.verified ? (
<CheckIcon className="h-4 w-4 text-slate-400" />
) : (
<StopIcon className="h-4 w-4 text-slate-850" />
)}
</TableCell>
<TableCell>{run.createdAt ? <DateTime date={run.createdAt} /> : ""}</TableCell>
</TableRow>
);
})
)}
{isLoading && (
<TableBlankRow
colSpan={8}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-slate-900/90"
>
<Spinner /> <span className="text-dimmed">Loading</span>
</TableBlankRow>
)}
</TableBody>
</Table>
);
}
function NoRuns({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
<Paragraph className="w-auto">{title}</Paragraph>
</div>
);
}
@@ -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(
@@ -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<ReturnType<WebhookDeliveryListPresenter["call"]>>;
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,
},
};
}
}
@@ -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,
},
};
}
}
@@ -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}`;
@@ -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 };
}
}
@@ -113,7 +113,7 @@ export default function Integrations() {
/>
</PageInfoGroup>
</PageInfoRow>
<PageTabs tabs={tabs} />
<PageTabs layoutId="integrations" tabs={tabs} />
</PageHeader>
<PageBody scrollable={true}>
@@ -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() {
</LinkButton>
</PageButtons>
</PageTitleRow>
{httpEndpoint.webhook && (
<PageInfoRow>
<PageInfoGroup>
<PageInfoProperty
icon="webhook"
label="Webhook Trigger"
to={httpEndpoint.webhookLink}
/>
</PageInfoGroup>
</PageInfoRow>
)}
</PageHeader>
<PageBody>
<Help defaultOpen={true}>
@@ -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 (
<div className={cn("flex items-center gap-1", className)}>
<PreviousButton cursor={list.pagination.previous} />
@@ -154,6 +154,7 @@ export default function Job() {
)}
<PageTabs
layoutId="jobs"
tabs={[
{ label: "Runs", to: jobPath(organization, project, job) },
{ label: "Test", to: jobTestPath(organization, project, job) },
@@ -0,0 +1,145 @@
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { LabelValueStack } from "~/components/primitives/LabelValueStack";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { WebhookTriggersPresenter } from "~/presenters/WebhookTriggersPresenter.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import { ProjectParamSchema, trimTrailingSlash, webhookTriggerPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
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) => (
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Webhook Triggers" />
),
};
export default function Integrations() {
const { webhooks } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
return (
<>
<Paragraph variant="small" spacing>
A Webhook Trigger runs a Job when it receives a matching payload at a registered HTTP Endpoint.
</Paragraph>
<Table containerClassName="mt-4">
<TableHeader>
<TableRow>
<TableHeaderCell>Key</TableHeaderCell>
<TableHeaderCell>Integration</TableHeaderCell>
<TableHeaderCell>Properties</TableHeaderCell>
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell>Active</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.length > 0 ? (
webhooks.map((w) => {
const path = webhookTriggerPath(organization, project, w);
return (
<TableRow key={w.id} className={cn(!w.active && "bg-rose-500/30")}>
<TableCell to={path}>{w.key}</TableCell>
<TableCell to={path}>
<div className="flex items-center gap-1">
<NamedIcon
name={w.integration.definition.icon ?? w.integration.definitionId}
className="h-8 w-8"
/>
<LabelValueStack
label={w.integration.title}
value={w.integration.slug}
variant="primary"
/>
</div>
</TableCell>
<TableCell to={path}>
{w.params && (
<SimpleTooltip
button={
<div className="flex max-w-[200px] items-start justify-start gap-5 truncate">
{Object.entries(w.params).map(([label, value], index) => (
<LabelValueStack
key={index}
label={label}
value={value}
className="last:truncate"
/>
))}
</div>
}
content={
<div className="flex flex-col gap-2">
{Object.entries(w.params).map(([label, value], index) => (
<LabelValueStack key={index} label={label} value={value} />
))}
</div>
}
/>
)}
</TableCell>
<TableCell to={path}>
<div className="flex items-center justify-end gap-1">
{w.webhookEnvironments.map((env) => (
<EnvironmentLabel
key={env.id}
environment={env.environment}
/>
))}
</div>
</TableCell>
<TableCell to={path}>
{w.active ? (
<CheckCircleIcon className="h-6 w-6 text-green-500" />
) : (
<XCircleIcon className="h-6 w-6 text-rose-500" />
)}
</TableCell>
<TableCellChevron to={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={100}>
<Paragraph>No External triggers</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</>
);
}
@@ -17,6 +17,7 @@ import {
docsPath,
projectScheduledTriggersPath,
projectTriggersPath,
projectWebhookTriggersPath,
trimTrailingSlash,
} from "~/utils/pathBuilder";
@@ -45,6 +46,7 @@ export default function Page() {
</PageTitleRow>
<PageDescription>A Trigger is what starts a Job Run.</PageDescription>
<PageTabs
layoutId="triggers"
tabs={[
{
label: "External Triggers",
@@ -54,6 +56,10 @@ export default function Page() {
label: "Scheduled Triggers",
to: projectScheduledTriggersPath(organization, project),
},
{
label: "Webhook Triggers",
to: projectWebhookTriggersPath(organization, project),
},
]}
/>
</PageHeader>
@@ -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<typeof loader>(match);
if (!data) return null;
const org = useOrganization(matches);
const project = useProject(matches);
return (
<Fragment>
<BreadcrumbLink to={projectTriggersPath(org, project)} title="Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink
to={trimTrailingSlash(match.pathname)}
title={data.trigger.key}
/>
</Fragment>
);
},
};
export default function Page() {
const { trigger } = useTypedLoaderData<typeof loader>();
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 (
<>
<Paragraph variant="small" spacing>
Webhook Triggers need to be registered with the external service. You can see the list
of attempted registrations below.
</Paragraph>
{!trigger.active &&
<Form method="post" {...form.props}>
<Callout variant="error" className="justiy-between mb-4 items-center">
<Paragraph variant="small" className={cn(variantClasses.error.textColor, "grow")}>
Registration hasn't succeeded yet, check the runs below.
</Paragraph>
{/* <input
{...conform.input(jobId, { type: "hidden" })}
defaultValue={trigger.registrationJob?.id}
/>
<Button
variant="danger/small"
type="submit"
name={conform.INTENT}
value="retry"
disabled={isLoading}
LeadingIcon={isLoading ? "spinner-white" : undefined}
>
{isLoading ? "Retrying…" : "Retry now"}
</Button> */}
</Callout>
</Form>}
{trigger.runList ? (
<>
<ListPagination list={trigger.runList} className="mb-2 justify-end" />
<RunsTable
runs={trigger.runList.runs}
total={trigger.runList.runs.length}
hasFilters={false}
runsParentPath={webhookTriggerRunsParentPath(organization, project, trigger)}
/>
<ListPagination list={trigger.runList} className="mt-2 justify-end" />
</>
) : (
<Callout variant="warning">No registration runs found</Callout>
)}
</>
);
}
@@ -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<typeof loader>(match);
if (!data) return null;
const org = useOrganization(matches);
const project = useProject(matches);
return (
<Fragment>
<BreadcrumbLink to={projectTriggersPath(org, project)} title="Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink
to={webhookTriggerPath(org, project, { id: data.webhook.id })}
title={data.webhook.key}
/>
<BreadcrumbIcon />
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Deliveries" />
</Fragment>
);
},
};
export default function Page() {
const { webhook } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
return (
<>
<Paragraph variant="small" spacing>
Webhook payloads are delivered to clients for validation and event generation. You can see
the list of attempted deliveries below.
</Paragraph>
{webhook.requestDeliveries ? (
<>
<ListPagination list={webhook.requestDeliveries} className="mb-2 justify-end" />
<WebhookDeliveryRunsTable
runs={webhook.requestDeliveries.runs}
total={webhook.requestDeliveries.runs.length}
hasFilters={false}
runsParentPath={webhookTriggerDeliveryRunsParentPath(organization, project, webhook)}
/>
<ListPagination list={webhook.requestDeliveries} className="mt-2 justify-end" />
</>
) : (
<Callout variant="warning">No registration runs found</Callout>
)}
</>
);
}
@@ -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<typeof loader>();
const organization = useOrganization();
const project = useProject();
return (
<PageContainer>
<PageHeader hideBorder>
<PageTitleRow>
<PageTitle
title={trigger.key}
backButton={{
to: projectWebhookTriggersPath(organization, project),
text: "Webhook Triggers",
}}
/>
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup>
<PageInfoProperty
icon={trigger.integration.definition.icon ?? trigger.integration.definitionId}
label={trigger.integration.title ?? ""}
value={trigger.integration.slug}
to={trigger.integrationLink}
/>
<PageInfoProperty
icon="webhook"
label="HTTP Endpoint"
to={trigger.httpEndpointLink}
/>
</PageInfoGroup>
</PageInfoRow>
<PageTabs
layoutId="webhook-trigger"
tabs={[
{
label: "Registrations",
to: webhookTriggerPath(organization, project, trigger),
},
{
label: "Deliveries",
to: webhookDeliveryPath(organization, project, trigger),
},
]}
/>
</PageHeader>
<PageBody scrollable={false}>
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<Outlet />
</div>
</PageBody>
</PageContainer>
);
}
@@ -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<typeof runLoader>(
"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 <RunCompletedDetail run={run} />;
}
@@ -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 });
}
@@ -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<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
}
@@ -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<typeof loader>();
return (
<TriggerDetail
trigger={trigger}
event={{ icon: "webhook", title: "Register Webhook" }}
properties={[]}
/>
);
}
@@ -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<typeof loader>(match);
if (!data) return null;
const org = useOrganization(matches);
const project = useProject(matches);
return (
<Fragment>
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink
to={webhookTriggerPath(org, project, { id: data.trigger.id })}
title={data.trigger.key}
/>
<BreadcrumbIcon />
<BreadcrumbLink
to={webhookTriggerPath(org, project, { id: data.trigger.id })}
title="Registrations"
/>
<BreadcrumbIcon />
{data && data.run && (
<BreadcrumbLink
to={trimTrailingSlash(match.pathname)}
title={`Run #${data.run.number}`}
/>
)}
</Fragment>
);
},
};
export default function Page() {
const { run, trigger } = useTypedLoaderData<typeof loader>();
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 (
<RunOverview
run={run}
trigger={{ icon: "webhook", title: "Register Webhook" }}
showRerun={true}
paths={{
back: webhookTriggerPath(organization, project, { id: trigger.id }),
run: webhookTriggerRunPath(organization, project, { id: trigger.id }, run),
runsPath: webhookTriggerRunsParentPath(organization, project, {
id: trigger.id,
}),
}}
/>
);
}
@@ -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<typeof runLoader>(
"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 <RunCompletedDetail run={run} />;
}
@@ -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 });
}
@@ -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<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
}
@@ -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<typeof loader>();
return (
<TriggerDetail
trigger={trigger}
event={{ icon: "mail-fast", title: "Deliver Webhook" }}
properties={[]}
/>
);
}
@@ -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<typeof loader>(match);
if (!data) return null;
const org = useOrganization(matches);
const project = useProject(matches);
return (
<Fragment>
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink
to={webhookTriggerPath(org, project, { id: data.trigger.id })}
title={`${data.trigger.integration.title}: ${data.trigger.integration.slug}`}
/>
<BreadcrumbIcon />
<BreadcrumbLink to={webhookDeliveryPath(org, project, { id: data.trigger.id })} title="Deliveries" />
<BreadcrumbIcon />
{data && data.run && (
<BreadcrumbLink
to={trimTrailingSlash(match.pathname)}
title={`Run #${data.run.number}`}
/>
)}
</Fragment>
);
},
};
export default function Page() {
const { run, trigger } = useTypedLoaderData<typeof loader>();
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 (
<RunOverview
run={run}
trigger={{ icon: "mail-fast", title: "Deliver Webhook" }}
showRerun={false}
paths={{
back: webhookDeliveryPath(organization, project, { id: trigger.id }),
run: webhookTriggerDeliveryRunPath(organization, project, { id: trigger.id }, run),
runsPath: webhookTriggerDeliveryRunsParentPath(organization, project, {
id: trigger.id,
}),
}}
/>
);
}
+156
View File
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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;
@@ -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,
@@ -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,
@@ -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({
@@ -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({
@@ -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)],
},
},
},
};
@@ -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<string, string>,
@@ -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 = {
@@ -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);
}
@@ -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<string, string>,
rawBody: requestDelivery.body,
},
});
await this.#prismaClient.webhookRequestDelivery.update({
where: {
id,
},
data: {
deliveredAt: new Date(),
verified,
error,
},
});
return response;
}
}
@@ -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);
}
@@ -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<TriggerSource> {
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,
};
}
}
@@ -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<boolean> {
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<string | undefined> {
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<boolean> {
const keyValueItem = await this.#prismaClient.keyValueItem.findUnique({
select: {
id: true,
},
where: {
environmentId_key: {
key,
environmentId: this.environment.id,
},
},
});
return !!keyValueItem;
}
async set<TValue extends string>(key: string, value: TValue): Promise<TValue> {
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;
}
}
@@ -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 ?? {},
},
},
});
}
}
+12
View File
@@ -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,
+83 -1
View File
@@ -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<Job, "slug">;
export type RunForPath = Pick<Job, "id">;
export type IntegrationForPath = Pick<Integration, "slug">;
export type TriggerForPath = Pick<TriggerSource, "id">;
export type WebhookForPath = Pick<Webhook, "id">;
export type HttpEndpointForPath = Pick<TriggerHttpEndpoint, "key">;
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)}`;
+1
View File
@@ -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",
+229
View File
@@ -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.<Resource>.<method>("cacheKey", params)
```
<ParamField body="cacheKey" type="string" required>
Should be a stable and unique cache key inside the `run()`. See
[resumability](/documentation/concepts/resumability) for more information.
</ParamField>
<ParamField body="params" type="object">
Resource-specific parameters.
</ParamField>
### `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 = "<strong>What a great teapot!</strong>";
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}`,
};
},
});
```
+852
View File
@@ -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")
```
<ParamField body="topic" type="string">
The webhook topic you want to subscribe to. Generally a pattern of `<resource>/<action>`.
</ParamField>
### Helpers
The `filter()` method returns a new trigger with the applied payload filter:
```ts
const trigger = shopify.on("topic").filter(filter)
```
<ResponseField name="filter" type="EventFilter" required>
A filter to apply to the event. See our [EventFilter guide](/documentation/guides/event-filter).
</ResponseField>
## 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: "<my-id>",
name: "<my-job-name>",
version: "0.1.0",
trigger: shopify.on("fulfillments/create"),
run: async (payload, io, ctx) => {
// Add tasks here
},
});
```
<Accordion title="'fulfillments/create' example payload">
```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"
}
```
</Accordion>
### `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: "<my-id>",
name: "<my-job-name>",
version: "0.1.0",
trigger: shopify.on("inventory_items/update"),
run: async (payload, io, ctx) => {
// Add tasks here
},
});
```
<Accordion title="'inventory_items/update' example payload">
```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"
}
```
</Accordion>
### `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: "<my-id>",
name: "<my-job-name>",
version: "0.1.0",
trigger: shopify.on("orders/delete"),
run: async (payload, io, ctx) => {
// Add tasks here
},
});
```
<Accordion title="'orders/delete' example payload">
```json
{
"id": 820982911946154500
}
```
</Accordion>
### `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: "<my-id>",
name: "<my-job-name>",
version: "0.1.0",
trigger: shopify.on("orders/paid"),
run: async (payload, io, ctx) => {
// Add tasks here
},
});
```
<Accordion title="'orders/paid' example payload">
```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": []
}
]
}
```
</Accordion>
### `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: "<my-id>",
name: "<my-job-name>",
version: "0.1.0",
trigger: shopify.on("products/create"),
run: async (payload, io, ctx) => {
// Add tasks here
},
});
```
<Accordion title="'products/create' example payload">
```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
}
```
</Accordion>
### `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: "<my-id>",
name: "<my-job-name>",
version: "0.1.0",
trigger: shopify.on("products/delete"),
run: async (payload, io, ctx) => {
// Add tasks here
},
});
```
<Accordion title="'products/delete' example payload">
```json
{
"id": 1
}
```
</Accordion>
### `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: "<my-id>",
name: "<my-job-name>",
version: "0.1.0",
trigger: shopify.on("subscription_billing_attempts/failure"),
run: async (payload, io, ctx) => {
// Add tasks here
},
});
```
<Accordion title="'subscription_billing_attempts/failure' example payload">
```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
}
```
</Accordion>
+75
View File
@@ -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.
{/* <Card
title="Jobs Showcase - Shopify"
icon="rocket"
href="https://trigger.dev/showcase?tags=&apis=shopify"
>
Check out pre-built Shopify jobs in our showcase.
</Card> */}
## 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`:
<CodeGroup>
```bash npm
npm install @trigger.dev/shopify@latest
```
```bash pnpm
pnpm install @trigger.dev/shopify@latest
```
```bash yarn
yarn add @trigger.dev/shopify@latest
```
</CodeGroup>
## 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.
<CardGroup cols={2}>
<Card title="Triggers" icon="stars" href="/integrations/apis/shopify-triggers">
Trigger Jobs when events happen in Shopify, like a deleted product or a paid order.
</Card>
<Card title="Tasks" icon="sparkles" href="/integrations/apis/shopify-tasks">
Perform Tasks such as creating new variants, or editing orders, and more.
</Card>
</CardGroup>
+1
View File
@@ -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 | ✅ | ✅ |
+10
View File
@@ -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",
+4
View File
@@ -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)
+114
View File
@@ -0,0 +1,114 @@
---
title: "store"
sidebarTitle: "store"
description: "Exposes namespaced **Key-Value Stores** you can access inside of your Jobs."
---
<Warning>
Only use this for small values - there's a **256KB** per-item size limit.
</Warning>
## 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.
<Snippet file="stable-key-param.mdx" />
<ParamField body="key" type="string" required>
The `key` of the item to delete.
</ParamField>
#### 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.
<Snippet file="stable-key-param.mdx" />
<ParamField body="key" type="string" required>
The `key` of the item to check existence of.
</ParamField>
#### 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.
<Snippet file="stable-key-param.mdx" />
<ParamField body="key" type="string" required>
The `key` of the item to retrieve.
</ParamField>
#### 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.
<Snippet file="stable-key-param.mdx" />
<ParamField body="key" type="string" required>
The `key` of the item to store.
</ParamField>
<ParamField body="value" type="any" required>
The serializable `value` to store.
</ParamField>
```ts
const val = await client.store.env.set("cacheKey", "key", "value")
```
#### Returns
A `Promise` that resolves to the stored value.
<RequestExample>
```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")
```
</RequestExample>
+4
View File
@@ -30,6 +30,10 @@ Creates a new TriggerClient object.
Is used to uniquely identify the client.
</ResponseField>
<ResponseField name="store" type="object">
Exposes namespaced [Key-Value Stores](/sdk/triggerclient/instancemethods/store) you can access in and outside of your Jobs.
</ResponseField>
## Instance methods
#### [sendEvent()](/sdk/triggerclient/instancemethods/sendevent)
+105
View File
@@ -0,0 +1,105 @@
---
title: "store"
sidebarTitle: "store"
description: "Exposes namespaced **Key-Value Stores** you can access in and outside of your Jobs."
---
<Warning>
Only use this for small values - there's a **256KB** per-item size limit.
</Warning>
## Namespaces
- `store.env` to access and store data across your **Environment**
## Methods
### `delete()`
Deletes an item from the Key-Value Store.
<ParamField body="key" type="string" required>
The `key` of the item to delete.
</ParamField>
#### 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.
<ParamField body="key" type="string" required>
The `key` of the item to check existence of.
</ParamField>
#### 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.
<ParamField body="key" type="string" required>
The `key` of the item to retrieve.
</ParamField>
#### 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.
<ParamField body="key" type="string" required>
The `key` of the item to store.
</ParamField>
<ParamField body="value" type="any" required>
The serializable `value` to store.
</ParamField>
```ts
const val = await client.store.env.set("key", "value")
```
#### Returns
A `Promise` that resolves to the stored value.
<RequestExample>
```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" })
```
</RequestExample>
+3
View File
@@ -41,6 +41,9 @@ const caldotcom = client.defineHttpEndpoint({
<ResponseField name="headerName" type="string" required>
The name of the header that contains the signature. E.g. `X-Cal-Signature-256`.
</ResponseField>
<ResponseField name="headerEncoding" type="BinaryToTextEncoding">
The header encoding. Defaults to `hex`.
</ResponseField>
<ResponseField name="secret" type="string" required>
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.
+47 -6
View File
@@ -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<RunTaskErrorCallback> {
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,
};
}
}
+162 -167
View File
@@ -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<typeof WebhookFromSourceSchema>;
const WebhookDataTypeSchema = z.union([
z.literal("tableData"),
z.literal("tableFields"),
z.literal("tableMetadata"),
]);
export type WebhookDataType = z.infer<typeof WebhookDataTypeSchema>;
const WebhookChangeTypeSchema = z.union([
z.literal("add"),
z.literal("remove"),
z.literal("update"),
]);
export type WebhookChangeType = z.infer<typeof WebhookChangeTypeSchema>;
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<TEventSpecification extends AirtableEvents> = ExternalSourceTrigger<
type CreateWebhookTriggersResult<TEventSpecification extends AirtableEvents> = WebhookTrigger<
TEventSpecification,
ReturnType<typeof createWebhookEventSource>
ReturnType<typeof createWebhookSource>
>;
export function createTrigger<TEventSpecification extends AirtableEvents>(
source: ReturnType<typeof createWebhookEventSource>,
export function createWebhookTrigger<TEventSpecification extends AirtableEvents>(
source: ReturnType<typeof createWebhookSource>,
event: TEventSpecification,
params: TriggerParams,
options: {
config: {
dataTypes: WebhookDataType[];
changeTypes?: WebhookChangeType[];
fromSources?: WebhookFromSource[];
}
): CreateTriggersResult<TEventSpecification> {
return new ExternalSourceTrigger({
): CreateWebhookTriggersResult<TEventSpecification> {
return new WebhookTrigger({
event,
params,
source,
options,
config,
});
}
@@ -232,21 +246,39 @@ const WebhookListDataSchema = z.object({
type WebhookListData = z.infer<typeof WebhookListDataSchema>;
export function createWebhookEventSource(
const getSpecification = (config: Record<string, string[]>, 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<string>("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<string>(
`${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<string>(
`${registerJobNamespace(ctx.key)}:webhook-id`
);
const cursorKey = `cursor-${webhookId}`;
const cursor = await client.store.env.get<number>(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);
}
+1
View File
@@ -0,0 +1 @@
# @trigger.dev/shopify
+38
View File
@@ -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"
}
}
+18
View File
@@ -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<TResource> = Prettify<
Nullable<TResource> & {
[key: string]: any;
}
>;
export const shopifyEvent = <TTopic extends Parameters<ShopifyExamples>[0]>(topic: TTopic) => {
return eventSpec<ShopifyThis<ShopifyPayloads[TTopic]>>({
topic,
examples: [shopifyExample(topic)],
runProperties: (payload) => basicProperties(payload),
});
};
+238
View File
@@ -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<RestResources>;
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<typeof Shopify>["runTask"];
type EventNamesFromCatalog<TEventCatalog extends WebhookEventCatalog<any, any>> =
TEventCatalog extends WebhookEventCatalog<infer U, any> ? 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<T, TResult extends Json<T> | void>(
key: IntegrationTaskKey,
callback: (
client: ReturnType<Shopify["createClient"]>,
task: IOTask,
io: IO,
session: Session
) => Promise<TResult>,
options?: RunTaskOptions,
errorCallback?: RunTaskErrorCallback
): Promise<TResult> {
if (!this._io) throw new Error("No IO");
if (!this._connectionKey) throw new Error("No connection key");
return this._io.runTask<TResult>(
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<TName extends EventNamesFromCatalog<ReturnType<typeof createWebhookEventCatalog>>>(
name: TName
// additional params have been disabled, see WebhookSource schema
// params?: Omit<GetWebhookParams<ReturnType<typeof createWebhookEventSource>>, "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<RunTaskErrorCallback> {
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,
};
}
}
@@ -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"
}
}
@@ -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
}
@@ -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"
}
@@ -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"
}
@@ -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
}
}
}
@@ -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
}
@@ -0,0 +1,13 @@
{
"collection_listing": {
"collection_id": 408372092144951400,
"updated_at": null,
"body_html": "<b>Some HTML</b>",
"default_product_image": null,
"handle": "mynewcollection",
"image": null,
"title": "My New Collection",
"sort_order": null,
"published_at": "2021-12-31T19:00:00-05:00"
}
}
@@ -0,0 +1,5 @@
{
"collection_listing": {
"collection_id": 408372092144951400
}
}
@@ -0,0 +1,12 @@
{
"id": 408372092144951400,
"handle": "mynewcollection",
"title": "My New Collection",
"updated_at": "2021-12-31T19:00:00-05:00",
"body_html": "<b>Some HTML</b>",
"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"
}
@@ -0,0 +1,5 @@
{
"id": 408372092144951400,
"published_scope": "web",
"admin_graphql_api_id": "gid://shopify/Collection/408372092144951419"
}
@@ -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"
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
@@ -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"
}
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,9 @@
{
"customer_id": 706405506930370000,
"email_address": null,
"email_marketing_consent": {
"state": null,
"opt_in_level": null,
"consent_updated_at": null
}
}
@@ -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"
}
}
@@ -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."
}
]
}
@@ -0,0 +1,3 @@
{
"id": 788032119674292900
}
@@ -0,0 +1,3 @@
{
"id": 1
}
@@ -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"
}
@@ -0,0 +1,10 @@
{
"id": 690933842,
"host": "jsmith.myshopify.com",
"ssl_enabled": true,
"localization": {
"country": null,
"default_locale": "en",
"alternate_locales": []
}
}
@@ -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
}
}
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,7 @@
{
"fulfillment_order": {
"id": "gid://shopify/FulfillmentOrder/1",
"status": "closed"
},
"message": "Order has not been shipped yet."
}
@@ -0,0 +1,8 @@
{
"fulfillment_order": {
"id": "gid://shopify/FulfillmentOrder/1",
"status": "in_progress",
"request_status": "cancellation_rejected"
},
"message": "Order has already been shipped."
}
@@ -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"
}
}
@@ -0,0 +1,10 @@
{
"fulfillment_order": {
"id": "gid://shopify/FulfillmentOrder/1",
"status": "cancelled"
},
"replacement_fulfillment_order": {
"id": "gid://shopify/FulfillmentOrder/2",
"status": "open"
}
}
@@ -0,0 +1,8 @@
{
"fulfillment_order": {
"id": "gid://shopify/FulfillmentOrder/1",
"status": "in_progress",
"request_status": "accepted"
},
"message": "We will ship the item tomorrow."
}
@@ -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."
}
@@ -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"
}
}
@@ -0,0 +1,7 @@
{
"fulfillment_order": {
"id": "gid://shopify/FulfillmentOrder/1",
"status": "closed"
},
"message": "We broke the last item."
}
@@ -0,0 +1,6 @@
{
"fulfillment_order": {
"id": "gid://shopify/FulfillmentOrder/1",
"status": "open"
}
}
@@ -0,0 +1,10 @@
{
"fulfillment_order": {
"id": "gid://shopify/FulfillmentOrder/1",
"status": "open",
"preparable": true,
"delivery_method": {
"method_type": "local"
}
}
}

Some files were not shown because too many files have changed in this diff Show More