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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -113,7 +113,7 @@ export default function Integrations() {
|
||||
/>
|
||||
</PageInfoGroup>
|
||||
</PageInfoRow>
|
||||
<PageTabs tabs={tabs} />
|
||||
<PageTabs layoutId="integrations" tabs={tabs} />
|
||||
</PageHeader>
|
||||
|
||||
<PageBody scrollable={true}>
|
||||
|
||||
+17
-1
@@ -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}>
|
||||
|
||||
+8
-1
@@ -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} />
|
||||
|
||||
+1
@@ -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) },
|
||||
|
||||
+145
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+181
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+108
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+109
@@ -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>
|
||||
);
|
||||
}
|
||||
+20
@@ -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} />;
|
||||
}
|
||||
+13
@@ -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 });
|
||||
}
|
||||
+35
@@ -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>
|
||||
);
|
||||
}
|
||||
+34
@@ -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={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+135
@@ -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,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+20
@@ -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} />;
|
||||
}
|
||||
+13
@@ -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 });
|
||||
}
|
||||
+35
@@ -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>
|
||||
);
|
||||
}
|
||||
+34
@@ -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={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+132
@@ -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,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 | ✅ | ✅ |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# @trigger.dev/shopify
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"fulfillment_order": {
|
||||
"id": "gid://shopify/FulfillmentOrder/1",
|
||||
"status": "closed"
|
||||
},
|
||||
"message": "Order has not been shipped yet."
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"fulfillment_order": {
|
||||
"id": "gid://shopify/FulfillmentOrder/1",
|
||||
"status": "in_progress",
|
||||
"request_status": "cancellation_rejected"
|
||||
},
|
||||
"message": "Order has already been shipped."
|
||||
}
|
||||
+11
@@ -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"
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"fulfillment_order": {
|
||||
"id": "gid://shopify/FulfillmentOrder/1",
|
||||
"status": "in_progress",
|
||||
"request_status": "accepted"
|
||||
},
|
||||
"message": "We will ship the item tomorrow."
|
||||
}
|
||||
+8
@@ -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."
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
}
|
||||
+7
@@ -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"
|
||||
}
|
||||
}
|
||||
+10
@@ -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
Reference in New Issue
Block a user