Introduce Twilio Segment App (#881)
* bootstrap segment app from cms app * tracking logic * schema configuratioj * config form * form saving * Connected order_created * add more fields * Order updated webhook * Order cancelled event * order refunded webhook * order fully paid * update deps * error handling * logger * logs * Add app to workflow * add icon * remove breadcrumbs * Change 400 to 200 response if payload is invalid * link to docs * change semgent.io to segment
This commit is contained in:
parent
69fe973121
commit
7d574c70c9
62 changed files with 32611 additions and 509 deletions
5
.changeset/silly-cows-carry.md
Normal file
5
.changeset/silly-cows-carry.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-segment.io": major
|
||||
---
|
||||
|
||||
Introduced a Segment.io app that integrates order events with Segment tracking
|
8
.github/dependabot.yaml
vendored
8
.github/dependabot.yaml
vendored
|
@ -109,3 +109,11 @@ updates:
|
|||
prefix: "[skip ci]"
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
open-pull-requests-limit: 1
|
||||
directory: apps/segment
|
||||
commit-message:
|
||||
prefix: "[skip ci]"
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
|
3
.github/labeler.yml
vendored
3
.github/labeler.yml
vendored
|
@ -6,5 +6,6 @@
|
|||
"App: Slack": "apps/slack/**/*"
|
||||
"App: Taxes": "apps/taxes/**/*"
|
||||
"App: Emails & Messages": "apps/emails-and-messages/**/*"
|
||||
"App: CMS": ["apps/cms-v2/**/*"]
|
||||
"App: CMS": "apps/cms-v2/**/*"
|
||||
"App: CRM": "apps/crm/**/*"
|
||||
"App: Segment": "apps/segment/**/*"
|
||||
|
|
18
apps/segment/.env.example
Normal file
18
apps/segment/.env.example
Normal file
|
@ -0,0 +1,18 @@
|
|||
# The key used for metadata encryption. Required for production builds
|
||||
SECRET_KEY=
|
||||
|
||||
# APL Config
|
||||
# https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
|
||||
APL=file
|
||||
REST_APL_ENDPOINT=
|
||||
REST_APL_TOKEN=
|
||||
|
||||
APP_LOG_LEVEL=info
|
||||
|
||||
# Local development variables. When developped locally with Saleor inside docker, these can be set to:
|
||||
# APP_IFRAME_BASE_URL = http://localhost:3000, so Dashboard on host can access iframe
|
||||
# APP_API_BASE_URL=http://host.docker.internal:3000 - so Saleor can reach App running on host, from the container.
|
||||
# If developped with tunnels, set this empty, it will fallback to default Next's localhost:3000
|
||||
# https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
|
||||
APP_IFRAME_BASE_URL=
|
||||
APP_API_BASE_URL=
|
4
apps/segment/.eslintrc.json
Normal file
4
apps/segment/.eslintrc.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["saleor"]
|
||||
}
|
19
apps/segment/.graphqlrc.yml
Normal file
19
apps/segment/.graphqlrc.yml
Normal file
|
@ -0,0 +1,19 @@
|
|||
schema: graphql/schema.graphql
|
||||
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
|
||||
extensions:
|
||||
codegen:
|
||||
overwrite: true
|
||||
generates:
|
||||
generated/graphql.ts:
|
||||
config:
|
||||
dedupeFragments: true
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
- typescript-urql:
|
||||
documentVariablePrefix: "Untyped"
|
||||
fragmentVariablePrefix: "Untyped"
|
||||
- typed-document-node
|
||||
generated/schema.graphql:
|
||||
plugins:
|
||||
- schema-ast
|
31
apps/segment/graphql/fragments/order-base.graphql
Normal file
31
apps/segment/graphql/fragments/order-base.graphql
Normal file
|
@ -0,0 +1,31 @@
|
|||
fragment OrderBase on Order {
|
||||
id
|
||||
user {
|
||||
id
|
||||
email
|
||||
}
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
name
|
||||
}
|
||||
userEmail
|
||||
shippingMethodName
|
||||
total {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
net {
|
||||
currency
|
||||
amount
|
||||
}
|
||||
}
|
||||
lines {
|
||||
id
|
||||
productVariantId
|
||||
productSku
|
||||
variantName
|
||||
}
|
||||
number
|
||||
}
|
10
apps/segment/graphql/mutations/UpdateAppMetadata.graphql
Normal file
10
apps/segment/graphql/mutations/UpdateAppMetadata.graphql
Normal file
|
@ -0,0 +1,10 @@
|
|||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updatePrivateMetadata(id: $id, input: $input) {
|
||||
item {
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
30308
apps/segment/graphql/schema.graphql
Normal file
30308
apps/segment/graphql/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
0
apps/segment/graphql/subscriptions/.gitkeep
Normal file
0
apps/segment/graphql/subscriptions/.gitkeep
Normal file
11
apps/segment/graphql/subscriptions/order-cancelled.graphql
Normal file
11
apps/segment/graphql/subscriptions/order-cancelled.graphql
Normal file
|
@ -0,0 +1,11 @@
|
|||
fragment OrderCancelledSubscriptionPayload on OrderCancelled {
|
||||
order {
|
||||
...OrderBase
|
||||
}
|
||||
}
|
||||
|
||||
subscription OrderCancelled {
|
||||
event {
|
||||
...OrderCancelledSubscriptionPayload
|
||||
}
|
||||
}
|
11
apps/segment/graphql/subscriptions/order-created.graphql
Normal file
11
apps/segment/graphql/subscriptions/order-created.graphql
Normal file
|
@ -0,0 +1,11 @@
|
|||
fragment OrderCreatedSubscriptionPayload on OrderCreated {
|
||||
order {
|
||||
...OrderBase
|
||||
}
|
||||
}
|
||||
|
||||
subscription OrderCreated {
|
||||
event {
|
||||
...OrderCreatedSubscriptionPayload
|
||||
}
|
||||
}
|
11
apps/segment/graphql/subscriptions/order-fully-paid.graphql
Normal file
11
apps/segment/graphql/subscriptions/order-fully-paid.graphql
Normal file
|
@ -0,0 +1,11 @@
|
|||
fragment OrderFullyPaidSubscriptionPayload on OrderFullyPaid {
|
||||
order {
|
||||
...OrderBase
|
||||
}
|
||||
}
|
||||
|
||||
subscription OrderFullyPaid {
|
||||
event {
|
||||
...OrderFullyPaidSubscriptionPayload
|
||||
}
|
||||
}
|
11
apps/segment/graphql/subscriptions/order-refunded.graphql
Normal file
11
apps/segment/graphql/subscriptions/order-refunded.graphql
Normal file
|
@ -0,0 +1,11 @@
|
|||
fragment OrderRefundedSubscriptionPayload on OrderRefunded {
|
||||
order {
|
||||
...OrderBase
|
||||
}
|
||||
}
|
||||
|
||||
subscription OrderRefunded {
|
||||
event {
|
||||
...OrderRefundedSubscriptionPayload
|
||||
}
|
||||
}
|
11
apps/segment/graphql/subscriptions/order-updated.graphql
Normal file
11
apps/segment/graphql/subscriptions/order-updated.graphql
Normal file
|
@ -0,0 +1,11 @@
|
|||
fragment OrderUpdatedSubscriptionPayload on OrderUpdated {
|
||||
order {
|
||||
...OrderBase
|
||||
}
|
||||
}
|
||||
|
||||
subscription OrderUpdated {
|
||||
event {
|
||||
...OrderUpdatedSubscriptionPayload
|
||||
}
|
||||
}
|
5
apps/segment/next-env.d.ts
vendored
Normal file
5
apps/segment/next-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
43
apps/segment/next.config.js
Normal file
43
apps/segment/next.config.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
const { z } = require("zod");
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
|
||||
const RequiredEnvs = z.object({
|
||||
APL: z.string().min(1),
|
||||
});
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = () => {
|
||||
try {
|
||||
RequiredEnvs.parse(process.env);
|
||||
} catch (e) {
|
||||
console.error("🚫 Missing required env variables, see message below");
|
||||
console.error(e.issues);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
|
||||
};
|
||||
};
|
||||
|
||||
const isSentryPropertiesInEnvironment =
|
||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
|
||||
|
||||
const configWithSentry = withSentryConfig(
|
||||
nextConfig,
|
||||
{
|
||||
silent: true,
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
},
|
||||
{
|
||||
widenClientFileUpload: true,
|
||||
transpileClientSDK: true,
|
||||
tunnelRoute: "/monitoring",
|
||||
hideSourceMaps: true,
|
||||
disableLogger: true,
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;
|
67
apps/segment/package.json
Normal file
67
apps/segment/package.json
Normal file
|
@ -0,0 +1,67 @@
|
|||
{
|
||||
"name": "saleor-app-segment",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"build": "pnpm generate && next build",
|
||||
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
|
||||
"generate": "graphql-codegen",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"start": "next start",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@saleor/app-sdk": "0.41.1",
|
||||
"@saleor/apps-shared": "workspace:*",
|
||||
"@saleor/apps-ui": "workspace:*",
|
||||
"@saleor/macaw-ui": "0.8.0-pre.106",
|
||||
"@saleor/react-hook-form-macaw": "workspace:*",
|
||||
"@segment/analytics-node": "^1.1.0",
|
||||
"@sentry/nextjs": "7.55.2",
|
||||
"@tanstack/react-query": "^4.29.19",
|
||||
"@trpc/client": "10.34.0",
|
||||
"@trpc/next": "10.34.0",
|
||||
"@trpc/react-query": "10.34.0",
|
||||
"@trpc/server": "10.34.0",
|
||||
"@urql/exchange-auth": "^2.1.4",
|
||||
"@vitejs/plugin-react": "4.0.4",
|
||||
"graphql": "16.7.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"jsdom": "^20.0.3",
|
||||
"next": "13.4.8",
|
||||
"pino": "^8.14.1",
|
||||
"pino-pretty": "^10.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "4.0.10",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"urql": "^4.0.4",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "4.4.8",
|
||||
"vitest": "0.34.1",
|
||||
"zod": "3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "4.0.1",
|
||||
"@graphql-codegen/introspection": "4.0.0",
|
||||
"@graphql-codegen/typed-document-node": "5.0.1",
|
||||
"@graphql-codegen/typescript": "4.0.1",
|
||||
"@graphql-codegen/typescript-operations": "4.0.1",
|
||||
"@graphql-codegen/typescript-urql": "3.7.3",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/react": "18.2.5",
|
||||
"@types/react-dom": "18.2.5",
|
||||
"eslint": "8.46.0",
|
||||
"eslint-config-saleor": "workspace:*",
|
||||
"node-mocks-http": "^1.12.2",
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"private": true,
|
||||
"saleor": {
|
||||
"schemaVersion": "3.14"
|
||||
}
|
||||
}
|
BIN
apps/segment/public/logo.png
Normal file
BIN
apps/segment/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
37
apps/segment/sentry.client.config.ts
Normal file
37
apps/segment/sentry.client.config.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* This file configures the initialization of Sentry on the client.
|
||||
* The config you add here will be used whenever a users loads a page in their browser.
|
||||
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import pkg from "./package.json";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 0.5,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
/*
|
||||
* This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
* in development and sample at a lower rate in production
|
||||
*/
|
||||
replaysSessionSampleRate: 0.1,
|
||||
|
||||
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||
integrations: [
|
||||
new Sentry.Replay({
|
||||
// Additional Replay configuration goes in here, for example:
|
||||
maskAllText: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
],
|
||||
environment: process.env.SENTRY_ENVIRONMENT,
|
||||
release: `${pkg.name}@${pkg.version}`,
|
||||
});
|
21
apps/segment/sentry.edge.config.ts
Normal file
21
apps/segment/sentry.edge.config.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
* The config you add here will be used whenever one of the edge features is loaded.
|
||||
* Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import pkg from "./package.json";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 0.5,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
environment: process.env.SENTRY_ENVIRONMENT,
|
||||
release: `${pkg.name}@${pkg.version}`,
|
||||
});
|
20
apps/segment/sentry.server.config.ts
Normal file
20
apps/segment/sentry.server.config.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* This file configures the initialization of Sentry on the server.
|
||||
* The config you add here will be used whenever the server handles a request.
|
||||
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import pkg from "./package.json";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 0.5,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
environment: process.env.SENTRY_ENVIRONMENT,
|
||||
release: `${pkg.name}@${pkg.version}`,
|
||||
});
|
5
apps/segment/src/errors.ts
Normal file
5
apps/segment/src/errors.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class SegmentNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("Segment not configured");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||
import { AppConfig } from "./app-config";
|
||||
import { createSettingsManager } from "./metadata-manager";
|
||||
import { createGraphQLClient } from "@saleor/apps-shared";
|
||||
import { AuthData } from "@saleor/app-sdk/APL";
|
||||
|
||||
export class AppConfigMetadataManager {
|
||||
public readonly metadataKey = "app-config-v1";
|
||||
|
||||
constructor(private mm: SettingsManager) {}
|
||||
|
||||
async get() {
|
||||
const metadata = await this.mm.get(this.metadataKey);
|
||||
|
||||
return metadata ? AppConfig.parse(metadata) : new AppConfig();
|
||||
}
|
||||
|
||||
set(config: AppConfig) {
|
||||
return this.mm.set({
|
||||
key: this.metadataKey,
|
||||
value: config.serialize(),
|
||||
});
|
||||
}
|
||||
|
||||
static createFromAuthData(authData: AuthData): AppConfigMetadataManager {
|
||||
const settingsManager = createSettingsManager(
|
||||
createGraphQLClient({
|
||||
saleorApiUrl: authData.saleorApiUrl,
|
||||
token: authData.token,
|
||||
}),
|
||||
authData.appId,
|
||||
);
|
||||
|
||||
return new AppConfigMetadataManager(settingsManager);
|
||||
}
|
||||
}
|
38
apps/segment/src/modules/configuration/app-config.ts
Normal file
38
apps/segment/src/modules/configuration/app-config.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { z } from "zod";
|
||||
import { RootConfig } from "./schemas/root-config.schema";
|
||||
|
||||
export class AppConfig {
|
||||
private rootData: RootConfig.Shape = null;
|
||||
|
||||
constructor(initialData?: RootConfig.Shape) {
|
||||
if (initialData) {
|
||||
this.rootData = RootConfig.Schema.parse(initialData);
|
||||
}
|
||||
}
|
||||
|
||||
static parse(serializedSchema: string) {
|
||||
return new AppConfig(JSON.parse(serializedSchema));
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return JSON.stringify(this.rootData);
|
||||
}
|
||||
|
||||
setSegmentWriteKey(key: string) {
|
||||
const parsedKey = z.string().min(1).parse(key);
|
||||
|
||||
if (this.rootData) {
|
||||
this.rootData.segmentWriteKey = parsedKey;
|
||||
} else {
|
||||
this.rootData = {
|
||||
segmentWriteKey: parsedKey,
|
||||
};
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
return this.rootData;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { createLogger } from "@saleor/apps-shared";
|
||||
import { z } from "zod";
|
||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||
import { router } from "../trpc/trpc-server";
|
||||
import { AppConfigMetadataManager } from "./app-config-metadata-manager";
|
||||
|
||||
const logger = createLogger({ name: "configuration.router" });
|
||||
|
||||
export const configurationRouter = router({
|
||||
getConfig: protectedClientProcedure.query(async ({ ctx }) => {
|
||||
const manager = AppConfigMetadataManager.createFromAuthData({
|
||||
appId: ctx.appId,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
token: ctx.appToken,
|
||||
});
|
||||
|
||||
const config = await manager.get();
|
||||
|
||||
logger.info("Fetched config");
|
||||
|
||||
return config.getConfig();
|
||||
}),
|
||||
setConfig: protectedClientProcedure
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(z.string().min(1))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
logger.info("Request to set config");
|
||||
|
||||
const manager = AppConfigMetadataManager.createFromAuthData({
|
||||
appId: ctx.appId,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
token: ctx.appToken,
|
||||
});
|
||||
|
||||
const config = await manager.get();
|
||||
|
||||
config.setSegmentWriteKey(input);
|
||||
|
||||
await manager.set(config);
|
||||
|
||||
logger.info("Config set successfully");
|
||||
}),
|
||||
});
|
101
apps/segment/src/modules/configuration/metadata-manager.ts
Normal file
101
apps/segment/src/modules/configuration/metadata-manager.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import {
|
||||
MetadataEntry,
|
||||
EncryptedMetadataManager,
|
||||
SettingsManager,
|
||||
} from "@saleor/app-sdk/settings-manager";
|
||||
import { Client, gql } from "urql";
|
||||
import {
|
||||
FetchAppDetailsDocument,
|
||||
FetchAppDetailsQuery,
|
||||
UpdateAppMetadataDocument,
|
||||
} from "../../../generated/graphql";
|
||||
|
||||
gql`
|
||||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updatePrivateMetadata(id: $id, input: $input) {
|
||||
item {
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
gql`
|
||||
query FetchAppDetails {
|
||||
app {
|
||||
id
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
gql`
|
||||
mutation RemoveMetadata($id: ID!, $keys: [String!]!) {
|
||||
deletePrivateMetadata(id: $id, keys: $keys) {
|
||||
errors {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export type SimpleGraphqlClient = Pick<Client, "mutation" | "query">;
|
||||
|
||||
async function fetchAllMetadata(client: SimpleGraphqlClient): Promise<MetadataEntry[]> {
|
||||
const { error, data } = await client
|
||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
if (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
|
||||
}
|
||||
|
||||
async function mutateMetadata(
|
||||
client: SimpleGraphqlClient,
|
||||
metadata: MetadataEntry[],
|
||||
appId: string,
|
||||
) {
|
||||
const { error: mutationError, data: mutationData } = await client
|
||||
.mutation(UpdateAppMetadataDocument, {
|
||||
id: appId,
|
||||
input: metadata,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (mutationError) {
|
||||
throw new Error(`Mutation error: ${mutationError.message}`);
|
||||
}
|
||||
|
||||
return (
|
||||
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
|
||||
key: md.key,
|
||||
value: md.value,
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
export const createSettingsManager = (
|
||||
client: SimpleGraphqlClient,
|
||||
appId: string,
|
||||
): SettingsManager => {
|
||||
/*
|
||||
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
||||
* We recommend it for production, because all values are encrypted.
|
||||
* If your use case require plain text values, you can use MetadataManager.
|
||||
*/
|
||||
return new EncryptedMetadataManager({
|
||||
// Secret key should be randomly created for production and set as environment variable
|
||||
encryptionKey: process.env.SECRET_KEY!,
|
||||
fetchMetadata: () => fetchAllMetadata(client),
|
||||
mutateMetadata: (metadata) => mutateMetadata(client, metadata, appId),
|
||||
});
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export namespace RootConfig {
|
||||
/**
|
||||
* Store entire app config in single file
|
||||
* - Only one request
|
||||
* - Always transactional
|
||||
*/
|
||||
export const Schema = z
|
||||
.object({
|
||||
segmentWriteKey: z.string(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
export type Shape = z.infer<typeof Schema>;
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import { Box, Button } from "@saleor/macaw-ui/next";
|
||||
import { Input } from "@saleor/react-hook-form-macaw";
|
||||
import { RootConfig } from "../schemas/root-config.schema";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TextLink } from "@saleor/apps-ui";
|
||||
import { Text } from "@saleor/macaw-ui/next";
|
||||
import { ButtonsBox } from "@/modules/ui/buttons-box";
|
||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||
import { Skeleton } from "@/modules/ui/skeleton";
|
||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||
|
||||
const Schema = RootConfig.Schema.unwrap();
|
||||
|
||||
type Shape = z.infer<typeof Schema>;
|
||||
|
||||
const SegmentConfigFormBase = (props: { values: Shape; onSubmit(values: Shape): void }) => {
|
||||
const { control, handleSubmit } = useForm({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: props.values,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit(props.onSubmit)}>
|
||||
<Input
|
||||
control={control}
|
||||
name="segmentWriteKey"
|
||||
type="password"
|
||||
label="Segment write key"
|
||||
helperText={
|
||||
<Text variant="caption" as="p" marginTop={2}>
|
||||
Read about write keys in{" "}
|
||||
<TextLink
|
||||
newTab
|
||||
size="small"
|
||||
href="https://segment.com/docs/connections/find-writekey/"
|
||||
>
|
||||
Segment documentation
|
||||
</TextLink>
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<ButtonsBox marginTop={6}>
|
||||
<Button type="submit">Save</Button>
|
||||
</ButtonsBox>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const SegmentConfigForm = () => {
|
||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||
|
||||
const { data: config, isLoading, refetch } = trpcClient.configuration.getConfig.useQuery();
|
||||
const { mutate } = trpcClient.configuration.setConfig.useMutation({
|
||||
onSuccess() {
|
||||
notifySuccess("Configuration saved");
|
||||
refetch();
|
||||
},
|
||||
onError() {
|
||||
notifyError("Error saving configuration");
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SegmentConfigFormBase
|
||||
values={{ segmentWriteKey: config?.segmentWriteKey ?? "" }}
|
||||
onSubmit={(values) => {
|
||||
mutate(values.segmentWriteKey);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
import { AuthData } from "@saleor/app-sdk/APL";
|
||||
import { AppConfigMetadataManager } from "./configuration/app-config-metadata-manager";
|
||||
import { SegmentEventsTracker } from "./tracking-events/segment-events-tracker";
|
||||
import { SegmentClient } from "./segment/segment.client";
|
||||
import { SegmentNotConfiguredError } from "@/errors";
|
||||
|
||||
export const createSegmentClientForWebhookContext = async (context: { authData: AuthData }) => {
|
||||
const config = await AppConfigMetadataManager.createFromAuthData(context.authData).get();
|
||||
|
||||
const segmentKey = config.getConfig()?.segmentWriteKey;
|
||||
|
||||
if (!segmentKey) {
|
||||
throw new SegmentNotConfiguredError();
|
||||
}
|
||||
|
||||
return new SegmentEventsTracker(
|
||||
new SegmentClient({
|
||||
segmentWriteKey: segmentKey,
|
||||
}),
|
||||
);
|
||||
};
|
21
apps/segment/src/modules/graphql/GraphQLProvider.tsx
Normal file
21
apps/segment/src/modules/graphql/GraphQLProvider.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Provider } from "urql";
|
||||
|
||||
import { createGraphQLClient } from "@saleor/apps-shared";
|
||||
|
||||
export function GraphQLProvider(props: PropsWithChildren<{}>) {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
|
||||
|
||||
if (!appBridgeState?.saleorApiUrl) {
|
||||
return <div {...props}></div>;
|
||||
}
|
||||
|
||||
const client = createGraphQLClient({
|
||||
saleorApiUrl,
|
||||
token: appBridgeState.token,
|
||||
});
|
||||
|
||||
return <Provider value={client} {...props} />;
|
||||
}
|
31
apps/segment/src/modules/segment/segment.client.ts
Normal file
31
apps/segment/src/modules/segment/segment.client.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { Analytics, TrackParams } from "@segment/analytics-node";
|
||||
|
||||
//https://segment.com/docs/connections/sources/catalog/libraries/server/node/#graceful-shutdown
|
||||
export class SegmentClient {
|
||||
private readonly client: Analytics;
|
||||
|
||||
constructor({ segmentWriteKey }: { segmentWriteKey: string }) {
|
||||
this.client = new Analytics({
|
||||
writeKey: segmentWriteKey,
|
||||
/**
|
||||
* Since client is used per-event there will be likely a single event per request.
|
||||
* The libarary is configured for a server architecture, where events are batched.
|
||||
* Here we set the batch size to 1 to avoid any delays and submit event instantly.
|
||||
*/
|
||||
flushInterval: 1,
|
||||
maxEventsInBatch: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// https://segment.com/docs/connections/sources/catalog/libraries/server/node/#track
|
||||
track(event: Pick<TrackParams, "properties" | "event"> & { userId: string }) {
|
||||
this.client.track({
|
||||
...event,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
flush() {
|
||||
return this.client.closeAndFlush({ timeout: 1000 });
|
||||
}
|
||||
}
|
24
apps/segment/src/modules/theme/theme-synchronizer.tsx
Normal file
24
apps/segment/src/modules/theme/theme-synchronizer.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useTheme } from "@saleor/macaw-ui/next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ThemeSynchronizer() {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTheme || !appBridgeState?.theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appBridgeState.theme === "light") {
|
||||
setTheme("defaultLight");
|
||||
}
|
||||
|
||||
if (appBridgeState.theme === "dark") {
|
||||
setTheme("defaultDark");
|
||||
}
|
||||
}, [appBridgeState?.theme, setTheme]);
|
||||
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { SegmentClient } from "../segment/segment.client";
|
||||
import { TrackingBaseEvent } from "./tracking-events";
|
||||
|
||||
export class SegmentEventsTracker {
|
||||
constructor(private segmentClient: SegmentClient) {}
|
||||
|
||||
trackEvent(event: TrackingBaseEvent) {
|
||||
this.segmentClient.track({
|
||||
event: event.type,
|
||||
userId: event.userId,
|
||||
properties: event.payload,
|
||||
});
|
||||
|
||||
return this.segmentClient.flush();
|
||||
}
|
||||
}
|
91
apps/segment/src/modules/tracking-events/tracking-events.ts
Normal file
91
apps/segment/src/modules/tracking-events/tracking-events.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { z } from "zod";
|
||||
import { OrderBaseFragment } from "../../../generated/graphql";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export type TrackingBaseEvent = {
|
||||
type: string;
|
||||
userId: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const getUserId = ({ user, userEmail }: OrderBaseFragment) => {
|
||||
const stringValidator = z.string().min(1);
|
||||
|
||||
const userId = user?.id ?? userEmail;
|
||||
|
||||
try {
|
||||
const parsedUserId = stringValidator.parse(userId);
|
||||
|
||||
return parsedUserId;
|
||||
} catch (e) {
|
||||
Sentry.captureMessage(
|
||||
"User ID resolution failed. Both user.id and userEmail are empty in order",
|
||||
);
|
||||
Sentry.captureException(e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Semantic events from Segment:
|
||||
* https://segment.com/docs/connections/spec/ecommerce/v2/
|
||||
*/
|
||||
export const trackingEventFactory = {
|
||||
createOrderCreatedEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
|
||||
const { user, userEmail, ...order } = orderBase;
|
||||
|
||||
return {
|
||||
type: "Checkout Started",
|
||||
userId: getUserId(orderBase),
|
||||
payload: {
|
||||
...order,
|
||||
},
|
||||
};
|
||||
},
|
||||
createOrderUpdatedEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
|
||||
const { user, userEmail, ...order } = orderBase;
|
||||
|
||||
return {
|
||||
type: "Order Updated",
|
||||
userId: getUserId(orderBase),
|
||||
payload: {
|
||||
...order,
|
||||
},
|
||||
};
|
||||
},
|
||||
createOrderCancelledEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
|
||||
const { user, userEmail, ...order } = orderBase;
|
||||
|
||||
return {
|
||||
type: "Order Cancelled",
|
||||
userId: getUserId(orderBase),
|
||||
payload: {
|
||||
...order,
|
||||
},
|
||||
};
|
||||
},
|
||||
createOrderRefundedEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
|
||||
const { user, userEmail, ...order } = orderBase;
|
||||
|
||||
return {
|
||||
type: "Order Refunded",
|
||||
userId: getUserId(orderBase),
|
||||
payload: {
|
||||
...order,
|
||||
},
|
||||
};
|
||||
},
|
||||
createOrderCompletedEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
|
||||
const { user, userEmail, ...order } = orderBase;
|
||||
|
||||
return {
|
||||
type: "Order Completed",
|
||||
userId: getUserId(orderBase),
|
||||
payload: {
|
||||
...order,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
117
apps/segment/src/modules/trpc/protected-client-procedure.ts
Normal file
117
apps/segment/src/modules/trpc/protected-client-procedure.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { verifyJWT } from "@saleor/app-sdk/verify-jwt";
|
||||
import { middleware, procedure } from "./trpc-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
|
||||
import { saleorApp } from "../../saleor-app";
|
||||
import { createGraphQLClient, logger } from "@saleor/apps-shared";
|
||||
|
||||
const attachAppToken = middleware(async ({ ctx, next }) => {
|
||||
logger.debug("attachAppToken middleware");
|
||||
|
||||
if (!ctx.saleorApiUrl) {
|
||||
logger.debug("ctx.saleorApiUrl not found, throwing");
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Missing saleorApiUrl in request",
|
||||
});
|
||||
}
|
||||
|
||||
const authData = await saleorApp.apl.get(ctx.saleorApiUrl);
|
||||
|
||||
if (!authData) {
|
||||
logger.debug("authData not found, throwing 401");
|
||||
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Missing auth data",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
appToken: authData.token,
|
||||
saleorApiUrl: authData.saleorApiUrl,
|
||||
appId: authData.appId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const validateClientToken = middleware(async ({ ctx, next, meta }) => {
|
||||
logger.debug(
|
||||
{
|
||||
permissions: meta?.requiredClientPermissions,
|
||||
},
|
||||
"Calling validateClientToken middleware with permissions required",
|
||||
);
|
||||
|
||||
if (!ctx.token) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Missing token in request. This middleware can be used only in frontend",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.appId) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Missing appId in request. This middleware can be used after auth is attached",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.saleorApiUrl) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"Missing saleorApiUrl in request. This middleware can be used after auth is attached",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.ssr) {
|
||||
try {
|
||||
logger.debug("trying to verify JWT token from frontend");
|
||||
logger.debug({ token: ctx.token ? `${ctx.token[0]}...` : undefined });
|
||||
|
||||
await verifyJWT({
|
||||
appId: ctx.appId,
|
||||
token: ctx.token,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
requiredPermissions: meta?.requiredClientPermissions ?? [],
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug("JWT verification failed, throwing");
|
||||
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Construct common graphQL client and attach it to the context
|
||||
*
|
||||
* Can be used only if called from the frontend (react-query),
|
||||
* otherwise jwks validation will fail (if createCaller used)
|
||||
*
|
||||
* TODO Rethink middleware composition to enable safe server-side router calls
|
||||
*/
|
||||
export const protectedClientProcedure = procedure
|
||||
.use(attachAppToken)
|
||||
.use(validateClientToken)
|
||||
.use(async ({ ctx, next }) => {
|
||||
const client = createGraphQLClient({ saleorApiUrl: ctx.saleorApiUrl, token: ctx.appToken });
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
apiClient: client,
|
||||
appToken: ctx.appToken,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
appId: ctx.appId!,
|
||||
},
|
||||
});
|
||||
});
|
8
apps/segment/src/modules/trpc/trpc-app-router.ts
Normal file
8
apps/segment/src/modules/trpc/trpc-app-router.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { configurationRouter } from "../configuration/configuration.router";
|
||||
import { router } from "./trpc-server";
|
||||
|
||||
export const appRouter = router({
|
||||
configuration: configurationRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
44
apps/segment/src/modules/trpc/trpc-client.ts
Normal file
44
apps/segment/src/modules/trpc/trpc-client.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCNext } from "@trpc/next";
|
||||
|
||||
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||
import { appBridgeInstance } from "../../pages/_app";
|
||||
import { AppRouter } from "./trpc-app-router";
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return "";
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
export const trpcClient = createTRPCNext<AppRouter>({
|
||||
config({ ctx }) {
|
||||
return {
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers() {
|
||||
const { token, saleorApiUrl } = appBridgeInstance?.getState() || {};
|
||||
|
||||
if (!token || !saleorApiUrl) {
|
||||
console.error(
|
||||
"Can't initialize tRPC client before establishing the App Bridge connection",
|
||||
);
|
||||
throw new Error("Token and Saleor API URL unknown");
|
||||
}
|
||||
return {
|
||||
/**
|
||||
* Attach headers from app to client requests, so tRPC can add them to context
|
||||
*/
|
||||
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
|
||||
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
queryClientConfig: { defaultOptions: { queries: { refetchOnWindowFocus: false } } },
|
||||
};
|
||||
},
|
||||
ssr: false,
|
||||
});
|
30
apps/segment/src/modules/trpc/trpc-context.ts
Normal file
30
apps/segment/src/modules/trpc/trpc-context.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
|
||||
import { inferAsyncReturnType } from "@trpc/server";
|
||||
|
||||
const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
|
||||
const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers;
|
||||
|
||||
const xForwardedProtos = Array.isArray(xForwardedProto)
|
||||
? xForwardedProto.join(",")
|
||||
: xForwardedProto;
|
||||
const protocols = xForwardedProtos.split(",");
|
||||
// prefer https over other protocols
|
||||
const protocol = protocols.find((el) => el === "https") || protocols[0];
|
||||
|
||||
return `${protocol}://${host}`;
|
||||
};
|
||||
|
||||
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
|
||||
const baseUrl = getBaseUrl(req.headers);
|
||||
|
||||
return {
|
||||
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
|
||||
saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined,
|
||||
appId: undefined as undefined | string,
|
||||
ssr: undefined as undefined | boolean,
|
||||
baseUrl,
|
||||
};
|
||||
};
|
||||
|
||||
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;
|
31
apps/segment/src/modules/trpc/trpc-server.ts
Normal file
31
apps/segment/src/modules/trpc/trpc-server.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { initTRPC } from "@trpc/server";
|
||||
import { TrpcContext } from "./trpc-context";
|
||||
import { Permission } from "@saleor/app-sdk/types";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
interface Meta {
|
||||
requiredClientPermissions?: Permission[];
|
||||
updateWebhooks?: boolean;
|
||||
}
|
||||
|
||||
const t = initTRPC
|
||||
.context<TrpcContext>()
|
||||
.meta<Meta>()
|
||||
.create({
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.code === "BAD_REQUEST" && error.cause instanceof ZodError
|
||||
? error.cause.flatten()
|
||||
: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure;
|
||||
export const middleware = t.middleware;
|
30
apps/segment/src/modules/ui/app-header.tsx
Normal file
30
apps/segment/src/modules/ui/app-header.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { TextLink } from "@saleor/apps-ui";
|
||||
import { Breadcrumbs } from "@saleor/apps-ui/src/breadcrumbs";
|
||||
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
|
||||
import { ComponentProps, ReactElement } from "react";
|
||||
|
||||
type Props = PropsWithBox<{}>;
|
||||
|
||||
export const AppHeader = ({ ...props }: Props) => {
|
||||
return (
|
||||
<Box
|
||||
marginBottom={14}
|
||||
borderBottomWidth={1}
|
||||
borderBottomStyle="solid"
|
||||
borderColor={"neutralHighlight"}
|
||||
paddingBottom={6}
|
||||
{...props}
|
||||
>
|
||||
<Text as="h1" marginBottom={4} variant="title">
|
||||
Configuration
|
||||
</Text>
|
||||
<Text>
|
||||
Read the{" "}
|
||||
<TextLink href="https://docs.saleor.io/docs/3.x/developer/app-store/apps/segment" newTab>
|
||||
docs
|
||||
</TextLink>{" "}
|
||||
to learn more
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
43
apps/segment/src/modules/ui/app-section.tsx
Normal file
43
apps/segment/src/modules/ui/app-section.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
// todo move to shared
|
||||
export const AppSection = ({
|
||||
heading,
|
||||
sideContent,
|
||||
mainContent,
|
||||
includePadding = true,
|
||||
...props
|
||||
}: PropsWithBox<{
|
||||
heading: string;
|
||||
sideContent?: ReactNode;
|
||||
mainContent: ReactNode;
|
||||
includePadding?: boolean;
|
||||
}>) => {
|
||||
return (
|
||||
<Box
|
||||
as="section"
|
||||
__gridTemplateColumns={"400px auto"}
|
||||
display={"grid"}
|
||||
gap={10}
|
||||
__maxWidth={"1200px"}
|
||||
{...props}
|
||||
>
|
||||
<Box>
|
||||
<Text as="h2" variant={"heading"} size={"large"} marginBottom={1.5}>
|
||||
{heading}
|
||||
</Text>
|
||||
{sideContent}
|
||||
</Box>
|
||||
<Box
|
||||
borderStyle={"solid"}
|
||||
borderColor={"neutralPlain"}
|
||||
borderWidth={1}
|
||||
padding={includePadding ? 5 : 0}
|
||||
borderRadius={4}
|
||||
>
|
||||
{mainContent}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
5
apps/segment/src/modules/ui/buttons-box.tsx
Normal file
5
apps/segment/src/modules/ui/buttons-box.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { BoxProps, Box } from "@saleor/macaw-ui/next";
|
||||
|
||||
export const ButtonsBox = (props: BoxProps) => {
|
||||
return <Box display={"flex"} justifyContent="flex-end" gap={4} {...props} />;
|
||||
};
|
32
apps/segment/src/modules/ui/skeleton.tsx
Normal file
32
apps/segment/src/modules/ui/skeleton.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Box, BoxProps } from "@saleor/macaw-ui/next";
|
||||
|
||||
/*
|
||||
* TODO: Make it more generic, move to shared or contribute to macaw
|
||||
* todo use macaw skeleton
|
||||
*/
|
||||
const Section = (props: BoxProps) => {
|
||||
return (
|
||||
<Box display="grid" gap={2} {...props}>
|
||||
<Box
|
||||
__height="10px"
|
||||
backgroundColor="surfaceNeutralHighlight"
|
||||
borderRadius={2}
|
||||
__width="50%"
|
||||
/>
|
||||
<Box
|
||||
__height="10px"
|
||||
backgroundColor="surfaceNeutralHighlight"
|
||||
borderRadius={2}
|
||||
__width="70%"
|
||||
/>
|
||||
<Box
|
||||
__height="10px"
|
||||
backgroundColor="surfaceNeutralHighlight"
|
||||
borderRadius={2}
|
||||
__width="60%"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const Skeleton = { Section };
|
46
apps/segment/src/pages/_app.tsx
Normal file
46
apps/segment/src/pages/_app.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { GraphQLProvider } from "@/modules/graphql/GraphQLProvider";
|
||||
import { ThemeSynchronizer } from "@/modules/theme/theme-synchronizer";
|
||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
||||
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
||||
import { NoSSRWrapper } from "@saleor/apps-shared";
|
||||
import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
|
||||
import "@saleor/macaw-ui/next/style";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AppProps } from "next/app";
|
||||
|
||||
/**
|
||||
* Ensure instance is a singleton.
|
||||
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
||||
*/
|
||||
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function NextApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<NoSSRWrapper>
|
||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||
<GraphQLProvider>
|
||||
<ThemeProvider>
|
||||
<ThemeSynchronizer />
|
||||
<RoutePropagator />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Box padding={10}>
|
||||
<Component {...pageProps} />
|
||||
</Box>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</GraphQLProvider>
|
||||
</AppBridgeProvider>
|
||||
</NoSSRWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default trpcClient.withTRPC(NextApp);
|
13
apps/segment/src/pages/_document.tsx
Normal file
13
apps/segment/src/pages/_document.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
54
apps/segment/src/pages/api/manifest.ts
Normal file
54
apps/segment/src/pages/api/manifest.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { AppManifest } from "@saleor/app-sdk/types";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
import { orderCreatedWebhook } from "./webhooks/order-created";
|
||||
import { orderUpdatedWebhook } from "./webhooks/order-updated";
|
||||
import { orderCancelledWebhook } from "./webhooks/order-cancelled";
|
||||
import { orderRefundedWebhook } from "./webhooks/order-refunded";
|
||||
import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid";
|
||||
|
||||
export default createManifestHandler({
|
||||
async manifestFactory({ appBaseUrl }) {
|
||||
const iframeBaseUrl = process.env.APP_IFRAME_BASE_URL ?? appBaseUrl;
|
||||
const apiBaseURL = process.env.APP_API_BASE_URL ?? appBaseUrl;
|
||||
|
||||
const manifest: AppManifest = {
|
||||
about: "Seamlessly feed Segment with Saleor events",
|
||||
appUrl: iframeBaseUrl,
|
||||
author: "Saleor Commerce",
|
||||
brand: {
|
||||
logo: {
|
||||
default: `${apiBaseURL}/logo.png`,
|
||||
},
|
||||
},
|
||||
dataPrivacyUrl: "https://saleor.io/legal/privacy/",
|
||||
extensions: [
|
||||
/**
|
||||
* Optionally, extend Dashboard with custom UIs
|
||||
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
|
||||
*/
|
||||
],
|
||||
homepageUrl: "https://github.com/saleor/apps",
|
||||
id: "saleor.app.segment",
|
||||
name: "Twilio Segment",
|
||||
permissions: ["MANAGE_ORDERS"],
|
||||
requiredSaleorVersion: ">=3.14 <4",
|
||||
supportUrl: "https://github.com/saleor/apps/discussions",
|
||||
tokenTargetUrl: `${apiBaseURL}/api/register`,
|
||||
version: packageJson.version,
|
||||
/*
|
||||
* TODO Add webhooks disabled and enable then when configured
|
||||
*/
|
||||
webhooks: [
|
||||
orderCreatedWebhook.getWebhookManifest(appBaseUrl),
|
||||
orderUpdatedWebhook.getWebhookManifest(appBaseUrl),
|
||||
orderCancelledWebhook.getWebhookManifest(appBaseUrl),
|
||||
orderRefundedWebhook.getWebhookManifest(appBaseUrl),
|
||||
orderFullyPaidWebhook.getWebhookManifest(appBaseUrl),
|
||||
],
|
||||
};
|
||||
|
||||
return manifest;
|
||||
},
|
||||
});
|
23
apps/segment/src/pages/api/register.ts
Normal file
23
apps/segment/src/pages/api/register.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { saleorApp } from "@/saleor-app";
|
||||
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
|
||||
|
||||
const allowedUrlsPattern = process.env.ALLOWED_DOMAIN_PATTERN;
|
||||
|
||||
/**
|
||||
* Required endpoint, called by Saleor to install app.
|
||||
* It will exchange tokens with app, so saleorApp.apl will contain token
|
||||
*/
|
||||
export default createAppRegisterHandler({
|
||||
apl: saleorApp.apl,
|
||||
allowedSaleorUrls: [
|
||||
(url) => {
|
||||
if (allowedUrlsPattern) {
|
||||
const regex = new RegExp(allowedUrlsPattern);
|
||||
|
||||
return regex.test(url);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
],
|
||||
});
|
8
apps/segment/src/pages/api/trpc/[trpc].ts
Normal file
8
apps/segment/src/pages/api/trpc/[trpc].ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
|
||||
import { appRouter } from "../../../modules/trpc/trpc-app-router";
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: createTrpcContext,
|
||||
});
|
66
apps/segment/src/pages/api/webhooks/order-cancelled.ts
Normal file
66
apps/segment/src/pages/api/webhooks/order-cancelled.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
|
||||
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderCancelledDocument,
|
||||
OrderUpdatedSubscriptionPayloadFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { SegmentNotConfiguredError } from "@/errors";
|
||||
import { createLogger } from "@saleor/apps-shared";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const orderCancelledWebhook =
|
||||
new SaleorAsyncWebhook<OrderUpdatedSubscriptionPayloadFragment>({
|
||||
name: "Order Cancelled v1",
|
||||
webhookPath: "api/webhooks/order-cancelled",
|
||||
event: "ORDER_CANCELLED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderCancelledDocument,
|
||||
});
|
||||
|
||||
const logger = createLogger({ name: "orderCancelledWebhook" });
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderUpdatedSubscriptionPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context,
|
||||
) => {
|
||||
const { authData, payload } = context;
|
||||
|
||||
if (!payload.order) {
|
||||
Sentry.captureException(new Error("Order not found in payload. This should not happen."));
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
try {
|
||||
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
|
||||
|
||||
logger.info("Sending order cancelled event to Segment");
|
||||
|
||||
await segmentEventTracker.trackEvent(
|
||||
trackingEventFactory.createOrderCancelledEvent(payload.order),
|
||||
);
|
||||
|
||||
return res.status(200).end();
|
||||
} catch (e) {
|
||||
if (e instanceof SegmentNotConfiguredError) {
|
||||
// todo disable webhooks if not configured
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
Sentry.captureException(e);
|
||||
|
||||
return res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export default orderCancelledWebhook.createHandler(handler);
|
65
apps/segment/src/pages/api/webhooks/order-created.ts
Normal file
65
apps/segment/src/pages/api/webhooks/order-created.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
|
||||
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderCreatedDocument,
|
||||
OrderCreatedSubscriptionPayloadFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
import { SegmentNotConfiguredError } from "@/errors";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { createLogger } from "@saleor/apps-shared";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedSubscriptionPayloadFragment>({
|
||||
name: "Order Created v1",
|
||||
webhookPath: "api/webhooks/order-created",
|
||||
event: "ORDER_CREATED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderCreatedDocument,
|
||||
});
|
||||
|
||||
const logger = createLogger({ name: "orderCreatedWebhook" });
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderCreatedSubscriptionPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context,
|
||||
) => {
|
||||
const { authData, payload } = context;
|
||||
|
||||
if (!payload.order) {
|
||||
Sentry.captureException(new Error("Order not found in payload. This should not happen."));
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
try {
|
||||
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
|
||||
|
||||
logger.info("Sending order created event to Segment");
|
||||
|
||||
await segmentEventTracker.trackEvent(
|
||||
trackingEventFactory.createOrderCreatedEvent(payload.order),
|
||||
);
|
||||
|
||||
return res.status(200).end();
|
||||
} catch (e) {
|
||||
if (e instanceof SegmentNotConfiguredError) {
|
||||
// todo disable webhooks if not configured
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
Sentry.captureException(e);
|
||||
|
||||
return res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export default orderCreatedWebhook.createHandler(handler);
|
67
apps/segment/src/pages/api/webhooks/order-fully-paid.ts
Normal file
67
apps/segment/src/pages/api/webhooks/order-fully-paid.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
|
||||
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderFullyPaidDocument,
|
||||
OrderFullyPaidSubscriptionPayloadFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
|
||||
import { SegmentNotConfiguredError } from "@/errors";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { createLogger } from "@saleor/apps-shared";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const orderFullyPaidWebhook =
|
||||
new SaleorAsyncWebhook<OrderFullyPaidSubscriptionPayloadFragment>({
|
||||
name: "Order Fully Paid v1",
|
||||
webhookPath: "api/webhooks/order-fully-paid",
|
||||
event: "ORDER_FULLY_PAID",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderFullyPaidDocument,
|
||||
});
|
||||
|
||||
const logger = createLogger({ name: "orderFullyPaidWebhook" });
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderFullyPaidSubscriptionPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context,
|
||||
) => {
|
||||
const { authData, payload } = context;
|
||||
|
||||
if (!payload.order) {
|
||||
Sentry.captureException(new Error("Order not found in payload. This should not happen."));
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
try {
|
||||
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
|
||||
|
||||
logger.info("Sending order fully paid event to Segment");
|
||||
|
||||
await segmentEventTracker.trackEvent(
|
||||
trackingEventFactory.createOrderCompletedEvent(payload.order),
|
||||
);
|
||||
|
||||
return res.status(200).end();
|
||||
} catch (e) {
|
||||
if (e instanceof SegmentNotConfiguredError) {
|
||||
// todo disable webhooks if not configured
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
Sentry.captureException(e);
|
||||
|
||||
return res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export default orderFullyPaidWebhook.createHandler(handler);
|
67
apps/segment/src/pages/api/webhooks/order-refunded.ts
Normal file
67
apps/segment/src/pages/api/webhooks/order-refunded.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
|
||||
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderRefundedDocument,
|
||||
OrderRefundedSubscriptionPayloadFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
|
||||
import { SegmentNotConfiguredError } from "@/errors";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { createLogger } from "@saleor/apps-shared";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const orderRefundedWebhook =
|
||||
new SaleorAsyncWebhook<OrderRefundedSubscriptionPayloadFragment>({
|
||||
name: "Order Refunded v1",
|
||||
webhookPath: "api/webhooks/order-refunded",
|
||||
event: "ORDER_REFUNDED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderRefundedDocument,
|
||||
});
|
||||
|
||||
const logger = createLogger({ name: "orderRefundedWebhook" });
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderRefundedSubscriptionPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context,
|
||||
) => {
|
||||
const { authData, payload } = context;
|
||||
|
||||
if (!payload.order) {
|
||||
Sentry.captureException(new Error("Order not found in payload"));
|
||||
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
try {
|
||||
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
|
||||
|
||||
logger.info("Sending order refunded event to Segment");
|
||||
|
||||
await segmentEventTracker.trackEvent(
|
||||
trackingEventFactory.createOrderRefundedEvent(payload.order),
|
||||
);
|
||||
|
||||
return res.status(200).end();
|
||||
} catch (e) {
|
||||
if (e instanceof SegmentNotConfiguredError) {
|
||||
// todo disable webhooks if not configured
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
Sentry.captureException(e);
|
||||
|
||||
return res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export default orderRefundedWebhook.createHandler(handler);
|
65
apps/segment/src/pages/api/webhooks/order-updated.ts
Normal file
65
apps/segment/src/pages/api/webhooks/order-updated.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
|
||||
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderUpdatedDocument,
|
||||
OrderUpdatedSubscriptionPayloadFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
import { SegmentNotConfiguredError } from "@/errors";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { createLogger } from "@saleor/apps-shared";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const orderUpdatedWebhook = new SaleorAsyncWebhook<OrderUpdatedSubscriptionPayloadFragment>({
|
||||
name: "Order Updated v1",
|
||||
webhookPath: "api/webhooks/order-updated",
|
||||
event: "ORDER_UPDATED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderUpdatedDocument,
|
||||
});
|
||||
|
||||
const logger = createLogger({ name: "orderUpdatedWebhook" });
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderUpdatedSubscriptionPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context,
|
||||
) => {
|
||||
const { authData, payload } = context;
|
||||
|
||||
if (!payload.order) {
|
||||
Sentry.captureException(new Error("Order not found in payload. This should not happen."));
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
try {
|
||||
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
|
||||
|
||||
logger.info("Sending order updated event to Segment");
|
||||
|
||||
await segmentEventTracker.trackEvent(
|
||||
trackingEventFactory.createOrderUpdatedEvent(payload.order),
|
||||
);
|
||||
|
||||
return res.status(200).end();
|
||||
} catch (e) {
|
||||
if (e instanceof SegmentNotConfiguredError) {
|
||||
// todo disable webhooks if not configured
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
Sentry.captureException(e);
|
||||
|
||||
return res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export default orderUpdatedWebhook.createHandler(handler);
|
32
apps/segment/src/pages/configuration.tsx
Normal file
32
apps/segment/src/pages/configuration.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { SegmentConfigForm } from "@/modules/configuration/segment-config-form/segment-config-form";
|
||||
import { AppHeader } from "@/modules/ui/app-header";
|
||||
import { AppSection } from "@/modules/ui/app-section";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||
import { NextPage } from "next";
|
||||
|
||||
const ConfigurationPage: NextPage = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
|
||||
if (!appBridgeState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (appBridgeState.user?.permissions.includes("MANAGE_APPS") === false) {
|
||||
return <Text>You do not have permission to access this page.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<AppHeader />
|
||||
<AppSection
|
||||
marginBottom={14}
|
||||
heading="Segment.io configration"
|
||||
sideContent={<Text>Provide Segment credentials to allow sending events.</Text>}
|
||||
mainContent={<SegmentConfigForm />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigurationPage;
|
32
apps/segment/src/pages/index.tsx
Normal file
32
apps/segment/src/pages/index.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { NextPage } from "next";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useEffect } from "react";
|
||||
import { useIsMounted } from "usehooks-ts";
|
||||
import { useRouter } from "next/router";
|
||||
import { isInIframe } from "@saleor/apps-shared";
|
||||
|
||||
const IndexPage: NextPage = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const isMounted = useIsMounted();
|
||||
const { replace } = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (isMounted() && appBridgeState?.ready) {
|
||||
replace("/configuration");
|
||||
}
|
||||
}, [isMounted, appBridgeState?.ready, replace]);
|
||||
|
||||
if (isInIframe()) {
|
||||
return <p>Loading</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Saleor App - Twilio Segment integration</h1>
|
||||
<p>This is Saleor App that synchronized order events to Segment</p>
|
||||
<p>Install app in your Saleor instance and open in with Dashboard</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexPage;
|
35
apps/segment/src/saleor-app.ts
Normal file
35
apps/segment/src/saleor-app.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { APL, FileAPL, SaleorCloudAPL, UpstashAPL } from "@saleor/app-sdk/APL";
|
||||
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
|
||||
|
||||
const aplType = process.env.APL ?? "file";
|
||||
|
||||
export let apl: APL;
|
||||
|
||||
switch (aplType) {
|
||||
case "upstash":
|
||||
apl = new UpstashAPL();
|
||||
|
||||
break;
|
||||
case "file":
|
||||
apl = new FileAPL();
|
||||
|
||||
break;
|
||||
case "saleor-cloud": {
|
||||
if (!process.env.REST_APL_ENDPOINT || !process.env.REST_APL_TOKEN) {
|
||||
throw new Error("Rest APL is not configured - missing env variables. Check saleor-app.ts");
|
||||
}
|
||||
|
||||
apl = new SaleorCloudAPL({
|
||||
resourceUrl: process.env.REST_APL_ENDPOINT,
|
||||
token: process.env.REST_APL_TOKEN,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error("Invalid APL config, ");
|
||||
}
|
||||
}
|
||||
export const saleorApp = new SaleorApp({
|
||||
apl,
|
||||
});
|
1
apps/segment/src/setup-tests.ts
Normal file
1
apps/segment/src/setup-tests.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export {};
|
23
apps/segment/tsconfig.json
Normal file
23
apps/segment/tsconfig.json
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
28
apps/segment/turbo.json
Normal file
28
apps/segment/turbo.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"extends": ["//"],
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"env": [
|
||||
"APL",
|
||||
"APP_DEBUG",
|
||||
"NODE_ENV",
|
||||
"SECRET_KEY",
|
||||
"ALLOWED_DOMAIN_PATTERN",
|
||||
"REST_APL_ENDPOINT",
|
||||
"REST_APL_TOKEN",
|
||||
"NEXT_PUBLIC_VERCEL_ENV",
|
||||
"VERCEL_URL",
|
||||
"PORT",
|
||||
"SENTRY_ORG",
|
||||
"SENTRY_PROJECT",
|
||||
"SENTRY_DSN",
|
||||
"SENTRY_AUTH_TOKEN",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SENTRY_ENVIRONMENT",
|
||||
"APP_IFRAME_BASE_URL",
|
||||
"APP_API_BASE_URL"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
17
apps/segment/vitest.config.ts
Normal file
17
apps/segment/vitest.config.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import react from "@vitejs/plugin-react";
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/setup-tests.ts",
|
||||
css: false,
|
||||
alias: {
|
||||
"@": "./src",
|
||||
},
|
||||
},
|
||||
});
|
964
pnpm-lock.yaml
964
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue