Integrations.constants.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. import { getEnableWebhooksSQL } from '@supabase/pg-meta'
  2. import type { Tables } from 'common/marketplace.types'
  3. import { Clock5, Code2, Layers, Timer, Vault, Webhook } from 'lucide-react'
  4. import dynamic from 'next/dynamic'
  5. import Image from 'next/image'
  6. import { ComponentType, ReactNode } from 'react'
  7. import { cn } from 'ui'
  8. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  9. import { UpgradeDatabaseAlert } from '../Queues/UpgradeDatabaseAlert'
  10. import { getStripeSyncSchemaComment } from '../templates/StripeSyncEngine/useStripeSyncStatus'
  11. import { WRAPPERS } from '../Wrappers/Wrappers.constants'
  12. import { WrapperMeta } from '../Wrappers/Wrappers.types'
  13. import { stripeSyncKeys } from '@/data/database-integrations/stripe/keys'
  14. import { installStripeSync } from '@/data/database-integrations/stripe/stripe-sync-install-mutation'
  15. import { enableDatabaseWebhooks } from '@/data/database/hooks-enable-mutation'
  16. import { databaseKeys } from '@/data/database/keys'
  17. import { getSchemas, invalidateSchemasQuery } from '@/data/database/schemas-query'
  18. import { getQueryClient } from '@/data/query-client'
  19. import { BASE_PATH, DOCS_URL } from '@/lib/constants'
  20. import { useTrack } from '@/lib/telemetry/track'
  21. export type NavigationContentLayout = 'constrained' | 'full'
  22. export type Navigation = {
  23. route: string
  24. label: string
  25. hasChild?: boolean
  26. childIcon?: React.ReactNode
  27. children?: Navigation[]
  28. layout?: NavigationContentLayout // applies only to the new marketplace
  29. }
  30. // [Joshen] Basing this on template.json for now
  31. export type IntegrationInputs = {
  32. [key: string]: {
  33. label: string
  34. type: 'text' | 'number' | 'password'
  35. description?: string
  36. required: boolean
  37. actions: {
  38. label: string
  39. href: string
  40. }[]
  41. }
  42. }
  43. type IntegrationStep = {
  44. label: string
  45. description?: string
  46. }
  47. type Listing = Tables<'listings'>
  48. type InstallUrlType = NonNullable<Listing['installation_url_type']>
  49. type InstallIdentificationMethod = NonNullable<Listing['installation_identification_method']>
  50. export type MarketplaceSource = 'Official' | 'Partner' | 'Community'
  51. /**
  52. * [Joshen] For marketplace, we probably need to revisit this definition
  53. * What properties are obsolete, what properties we need from remote source
  54. */
  55. export type IntegrationDefinition = {
  56. id: string
  57. name: string
  58. status?: 'alpha' | 'beta'
  59. categories?: string[]
  60. featured?: boolean
  61. icon: (props?: { className?: string; style?: Record<string, string | number> }) => ReactNode
  62. description: string | null
  63. content?: string | null
  64. files?: string[]
  65. docsUrl: string | null
  66. siteUrl?: string | null
  67. author: {
  68. name: string
  69. websiteUrl: string
  70. }
  71. /** Provenance of the integration — Official (built by Briven), Partner (formal third-party listing), Community (open-source, not officially endorsed). */
  72. source: MarketplaceSource
  73. requiredExtensions: Array<string>
  74. /** Optional component to render if the integration requires extensions that are not available on the current database image */
  75. missingExtensionsAlert?: ReactNode
  76. navigation?: Array<Navigation>
  77. navigate: (props: {
  78. id: string | undefined
  79. pageId: string | undefined
  80. childId: string | undefined
  81. }) => ComponentType<{}> | null
  82. /** For showing the SQL query in the installation sheet */
  83. installationSql?: string
  84. /** Custom command to install the integration (if any - none atm) */
  85. installationCommand?: (props: {
  86. ref: string
  87. track?: ReturnType<typeof useTrack>
  88. [key: string]: unknown
  89. }) => Promise<void>
  90. /**
  91. * Used for long polling to track the progress of the integration installation if async
  92. * The component calling this handles the polling logic, and should terminate the poll depending on the returned value
  93. * Depending on how we want this to work, this method will thereafter also call any RQ invalidation if required
  94. * */
  95. checkInstallationStatus?: (props: {
  96. ref?: string
  97. connectionString?: string | null
  98. [key: string]: unknown
  99. }) => Promise<'installed' | 'installing'>
  100. /** User inputs for template integrations */
  101. inputs?: IntegrationInputs
  102. /** Purely visual, just to show what are the changes on the project from installing the integration */
  103. steps?: IntegrationStep[]
  104. /** These are for OAuth Integrations */
  105. installUrl?: string | null
  106. installUrlType?: InstallUrlType
  107. installIdentificationMethod?: InstallIdentificationMethod
  108. secretKeyPrefix?: string
  109. edgeFunctionSecretName?: string
  110. listingId?: string
  111. } & (
  112. | { type: 'wrapper'; meta: WrapperMeta }
  113. | { type: 'postgres_extension' | 'custom' | 'oauth' | 'template' }
  114. )
  115. const authorBriven = {
  116. name: 'Briven',
  117. websiteUrl: 'https://supabase.com',
  118. }
  119. const BRIVEN_INTEGRATIONS: Array<IntegrationDefinition> = [
  120. {
  121. id: 'queues',
  122. type: 'postgres_extension' as const,
  123. source: 'Community' as const,
  124. requiredExtensions: ['pgmq'],
  125. missingExtensionsAlert: <UpgradeDatabaseAlert minimumVersion="15.6.1.143" />,
  126. name: `Queues`,
  127. icon: ({ className, ...props } = {}) => (
  128. <Layers className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
  129. ),
  130. description: 'Lightweight message queue in Postgres',
  131. docsUrl: 'https://github.com/tembo-io/pgmq',
  132. author: {
  133. name: 'pgmq',
  134. websiteUrl: 'https://github.com/tembo-io/pgmq',
  135. },
  136. navigation: [
  137. {
  138. route: 'overview',
  139. label: 'Overview',
  140. },
  141. {
  142. route: 'queues',
  143. label: 'Queues',
  144. hasChild: true,
  145. childIcon: (
  146. <Layers size={12} strokeWidth={1.5} className={cn('text-foreground w-full h-full')} />
  147. ),
  148. },
  149. {
  150. route: 'settings',
  151. label: 'Settings',
  152. layout: 'constrained',
  153. },
  154. ],
  155. navigate: ({ pageId = 'overview', childId }) => {
  156. if (childId) {
  157. return dynamic(() => import('../Queues/QueuePage').then((mod) => mod.QueuePage), {
  158. loading: Loading,
  159. })
  160. }
  161. switch (pageId) {
  162. case 'overview':
  163. return dynamic(
  164. () =>
  165. import('@/components/interfaces/Integrations/Queues/OverviewTab').then(
  166. (mod) => mod.QueuesOverviewTab
  167. ),
  168. { loading: Loading }
  169. )
  170. case 'queues':
  171. return dynamic(() => import('../Queues/QueuesTab').then((mod) => mod.QueuesTab), {
  172. loading: Loading,
  173. })
  174. case 'settings':
  175. return dynamic(
  176. () => import('../Queues/QueuesSettings').then((mod) => mod.QueuesSettings),
  177. { loading: Loading }
  178. )
  179. }
  180. return null
  181. },
  182. },
  183. {
  184. id: 'cron',
  185. type: 'postgres_extension' as const,
  186. source: 'Community' as const,
  187. requiredExtensions: ['pg_cron'],
  188. name: `Cron`,
  189. icon: ({ className, ...props } = {}) => (
  190. <Clock5 className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
  191. ),
  192. description: 'Schedule recurring Jobs in Postgres',
  193. docsUrl: 'https://github.com/citusdata/pg_cron',
  194. author: {
  195. name: 'Citus Data',
  196. websiteUrl: 'https://github.com/citusdata/pg_cron',
  197. },
  198. navigation: [
  199. {
  200. route: 'overview',
  201. label: 'Overview',
  202. },
  203. {
  204. route: 'jobs',
  205. label: 'Jobs',
  206. hasChild: true,
  207. childIcon: (
  208. <Timer size={12} strokeWidth={1.5} className={cn('text-foreground w-full h-full')} />
  209. ),
  210. },
  211. ],
  212. navigate: ({ pageId = 'overview', childId }) => {
  213. if (childId) {
  214. return dynamic(() => import('../CronJobs/CronJobPage').then((mod) => mod.CronJobPage), {
  215. loading: Loading,
  216. })
  217. }
  218. switch (pageId) {
  219. case 'overview':
  220. return dynamic(
  221. () =>
  222. import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper').then(
  223. (mod) => mod.IntegrationOverviewTabWrapper
  224. ),
  225. {
  226. loading: Loading,
  227. }
  228. )
  229. case 'jobs':
  230. return dynamic(() => import('../CronJobs/CronJobsTab').then((mod) => mod.CronjobsTab), {
  231. loading: Loading,
  232. })
  233. }
  234. return null
  235. },
  236. },
  237. {
  238. id: 'vault',
  239. type: 'postgres_extension' as const,
  240. source: 'Official' as const,
  241. requiredExtensions: ['briven_vault'],
  242. missingExtensionsAlert: <UpgradeDatabaseAlert />,
  243. name: `Vault`,
  244. status: 'beta',
  245. icon: ({ className, ...props } = {}) => (
  246. <Vault className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
  247. ),
  248. description: 'Application level encryption for your project',
  249. docsUrl: `${DOCS_URL}/guides/database/vault`,
  250. author: authorBriven,
  251. navigation: [
  252. {
  253. route: 'overview',
  254. label: 'Overview',
  255. },
  256. {
  257. route: 'secrets',
  258. label: 'Secrets',
  259. },
  260. ],
  261. navigate: ({ pageId = 'overview' }) => {
  262. switch (pageId) {
  263. case 'overview':
  264. return dynamic(
  265. () =>
  266. import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper').then(
  267. (mod) => mod.IntegrationOverviewTabWrapper
  268. ),
  269. {
  270. loading: Loading,
  271. }
  272. )
  273. case 'secrets':
  274. return dynamic(
  275. () => import('../Vault/Secrets/SecretsManagement').then((mod) => mod.SecretsManagement),
  276. {
  277. loading: Loading,
  278. }
  279. )
  280. }
  281. return null
  282. },
  283. },
  284. {
  285. id: 'webhooks',
  286. type: 'postgres_extension' as const,
  287. source: 'Official' as const,
  288. name: `Database Webhooks`,
  289. icon: ({ className, ...props } = {}) => (
  290. <Webhook className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
  291. ),
  292. description:
  293. 'Send real-time data from your database to another system when a table event occurs',
  294. docsUrl: `${DOCS_URL}/guides/database/webhooks`,
  295. author: authorBriven,
  296. requiredExtensions: ['pg_net'],
  297. navigation: [
  298. {
  299. route: 'overview',
  300. label: 'Overview',
  301. },
  302. {
  303. route: 'webhooks',
  304. label: 'Webhooks',
  305. layout: 'constrained',
  306. },
  307. ],
  308. navigate: ({ pageId = 'overview' }) => {
  309. switch (pageId) {
  310. case 'overview':
  311. return dynamic(
  312. () =>
  313. import('@/components/interfaces/Integrations/Webhooks/OverviewTab').then(
  314. (mod) => mod.WebhooksOverviewTab
  315. ),
  316. {
  317. loading: Loading,
  318. }
  319. )
  320. case 'webhooks':
  321. return dynamic(
  322. () =>
  323. import('@/components/interfaces/Integrations/Webhooks/ListTab').then(
  324. (mod) => mod.WebhooksListTab
  325. ),
  326. {
  327. loading: Loading,
  328. }
  329. )
  330. }
  331. return null
  332. },
  333. installationSql: getEnableWebhooksSQL(),
  334. installationCommand: async ({ ref }: { ref: string }) => {
  335. const queryClient = getQueryClient()
  336. await enableDatabaseWebhooks({ ref })
  337. await invalidateSchemasQuery(queryClient, ref)
  338. },
  339. },
  340. {
  341. id: 'data_api',
  342. type: 'custom' as const,
  343. source: 'Official' as const,
  344. requiredExtensions: [],
  345. name: `Data API`,
  346. icon: ({ className, ...props } = {}) => (
  347. <Code2 className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
  348. ),
  349. description: 'Auto-generate an API directly from your database schema',
  350. docsUrl: `${DOCS_URL}/guides/api`,
  351. author: authorBriven,
  352. navigation: [
  353. {
  354. route: 'overview',
  355. label: 'Overview',
  356. },
  357. {
  358. route: 'settings',
  359. label: 'Settings',
  360. layout: 'constrained',
  361. },
  362. {
  363. route: 'docs',
  364. label: 'Docs',
  365. },
  366. ],
  367. navigate: ({ pageId = 'overview' }) => {
  368. switch (pageId) {
  369. case 'overview':
  370. return dynamic(
  371. () =>
  372. import('@/components/interfaces/Integrations/DataApi/OverviewTab').then(
  373. (mod) => mod.DataApiOverviewTab
  374. ),
  375. {
  376. loading: Loading,
  377. }
  378. )
  379. case 'settings':
  380. return dynamic(
  381. () =>
  382. import('@/components/interfaces/Integrations/DataApi/SettingsTab').then(
  383. (mod) => mod.DataApiSettingsTab
  384. ),
  385. {
  386. loading: Loading,
  387. }
  388. )
  389. case 'docs':
  390. return dynamic(
  391. () =>
  392. import('@/components/interfaces/Integrations/DataApi/DocsTab').then(
  393. (mod) => mod.DataApiDocsTab
  394. ),
  395. {
  396. loading: Loading,
  397. }
  398. )
  399. }
  400. return null
  401. },
  402. },
  403. {
  404. id: 'graphiql',
  405. type: 'postgres_extension' as const,
  406. source: 'Official' as const,
  407. requiredExtensions: ['pg_graphql'],
  408. name: `GraphQL`,
  409. icon: ({ className, ...props } = {}) => (
  410. <Image
  411. fill
  412. src={`${BASE_PATH}/img/graphql.svg`}
  413. alt="GraphiQL"
  414. className={cn('p-2', className)}
  415. {...props}
  416. />
  417. ),
  418. description: 'Run GraphQL queries through our interactive in-browser IDE',
  419. docsUrl: `${DOCS_URL}/guides/database/extensions/pg_graphql`,
  420. author: authorBriven,
  421. navigation: [
  422. {
  423. route: 'overview',
  424. label: 'Overview',
  425. },
  426. {
  427. route: 'graphiql',
  428. label: 'GraphiQL',
  429. },
  430. ],
  431. navigate: ({ pageId = 'overview' }) => {
  432. switch (pageId) {
  433. case 'overview':
  434. return dynamic(
  435. () =>
  436. import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper').then(
  437. (mod) => mod.IntegrationOverviewTabWrapper
  438. ),
  439. {
  440. loading: Loading,
  441. }
  442. )
  443. case 'graphiql':
  444. return dynamic(
  445. () =>
  446. import('@/components/interfaces/Integrations/GraphQL/GraphiQLTab').then(
  447. (mod) => mod.GraphiQLTab
  448. ),
  449. {
  450. loading: Loading,
  451. }
  452. )
  453. }
  454. return null
  455. },
  456. },
  457. ] as const
  458. const WRAPPER_INTEGRATIONS: Array<IntegrationDefinition> = WRAPPERS.map((w) => {
  459. return {
  460. id: w.name,
  461. type: 'wrapper' as const,
  462. source: 'Official' as const,
  463. name: `${w.label} Wrapper`,
  464. icon: ({ className, ...props } = {}) => (
  465. <Image fill src={w.icon} alt={w.name} className={cn('p-2', className)} {...props} />
  466. ),
  467. requiredExtensions: ['wrappers', 'briven_vault'],
  468. description: w.description,
  469. docsUrl: w.docsUrl,
  470. meta: w,
  471. author: authorBriven,
  472. navigation: [
  473. {
  474. route: 'overview',
  475. label: 'Overview',
  476. },
  477. {
  478. route: 'wrappers',
  479. label: 'Wrappers',
  480. },
  481. ],
  482. navigate: ({ pageId = 'overview' }) => {
  483. switch (pageId) {
  484. case 'overview':
  485. return dynamic(
  486. () =>
  487. import('@/components/interfaces/Integrations/Wrappers/OverviewTab').then(
  488. (mod) => mod.WrapperOverviewTab
  489. ),
  490. {
  491. loading: Loading,
  492. }
  493. )
  494. case 'wrappers':
  495. return dynamic(
  496. () =>
  497. import('@/components/interfaces/Integrations/Wrappers/WrappersTab').then(
  498. (mod) => mod.WrappersTab
  499. ),
  500. {
  501. loading: Loading,
  502. }
  503. )
  504. }
  505. return null
  506. },
  507. }
  508. })
  509. const TEMPLATE_INTEGRATIONS: Array<IntegrationDefinition> = [
  510. {
  511. id: 'stripe_sync_engine',
  512. type: 'template' as const,
  513. source: 'Partner' as const,
  514. requiredExtensions: ['pgmq', 'briven_vault', 'pg_cron', 'pg_net'],
  515. missingExtensionsAlert: <UpgradeDatabaseAlert minimumVersion="15.6.1.143" />,
  516. name: `Stripe Sync Engine`,
  517. status: 'alpha',
  518. icon: ({ className, ...props } = {}) => (
  519. <Image
  520. fill
  521. src={`${BASE_PATH}/img/icons/stripe-icon.svg`}
  522. alt={'Stripe Logo'}
  523. className={cn('p-2', className)}
  524. {...props}
  525. />
  526. ),
  527. description:
  528. 'Continuously sync your payments, customer, and other data from Stripe to your Postgres database',
  529. docsUrl: 'https://github.com/stripe-experiments/sync-engine/',
  530. author: {
  531. name: 'Stripe',
  532. websiteUrl: 'https://www.stripe.com',
  533. },
  534. navigation: [
  535. {
  536. route: 'overview',
  537. label: 'Overview',
  538. },
  539. {
  540. route: 'settings',
  541. label: 'Settings',
  542. layout: 'constrained',
  543. },
  544. ],
  545. navigate: ({ pageId = 'overview' }) => {
  546. switch (pageId) {
  547. case 'overview':
  548. return dynamic(
  549. () =>
  550. import('@/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab').then(
  551. (mod) => mod.StripeSyncEngineOverviewTab
  552. ),
  553. { loading: Loading }
  554. )
  555. case 'settings':
  556. return dynamic(
  557. () =>
  558. import('@/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage').then(
  559. (mod) => mod.StripeSyncSettingsPage
  560. ),
  561. { loading: Loading }
  562. )
  563. }
  564. return null
  565. },
  566. inputs: {
  567. stripe_api_key: {
  568. type: 'password',
  569. required: true,
  570. label: 'Stripe API secret key',
  571. description:
  572. 'Requires write access to Webhook Endpoints and read-only access to all other categories.',
  573. actions: [
  574. {
  575. label: 'Get API key',
  576. href: 'https://dashboard.stripe.com/apikeys',
  577. },
  578. {
  579. label: 'What are Stripe API keys?',
  580. href: 'https://support.stripe.com/questions/what-are-stripe-api-keys-and-how-to-find-them',
  581. },
  582. ],
  583. },
  584. },
  585. steps: [
  586. { label: 'Creates a new database schema named `stripe`' },
  587. { label: 'Creates tables and views in the `stripe` schema for synced Stripe data' },
  588. { label: 'Deploys Edge Functions to handle incoming webhooks from Stripe' },
  589. { label: 'Schedules automatic Stripe data syncs using Briven Queues' },
  590. ],
  591. installationCommand: async ({ ref: projectRef, track, stripe_api_key }) => {
  592. const startTime = Date.now()
  593. await installStripeSync({ projectRef, startTime, stripeSecretKey: stripe_api_key as string })
  594. if (track)
  595. track('integration_install_submitted', {
  596. integrationName: 'stripe_sync_engine',
  597. method: 'template',
  598. })
  599. const queryClient = getQueryClient()
  600. await queryClient.invalidateQueries({ queryKey: stripeSyncKeys.all })
  601. },
  602. checkInstallationStatus: async (props) => {
  603. const queryClient = getQueryClient()
  604. const { projectRef, connectionString } = props || {}
  605. const schemas = await getSchemas({
  606. projectRef: projectRef as string,
  607. connectionString: connectionString as string,
  608. })
  609. const { status, errorMessage } = getStripeSyncSchemaComment(schemas)
  610. if (status === 'install error') {
  611. throw new Error(errorMessage ?? 'Stripe Sync installation failed')
  612. }
  613. if (status === 'installed') {
  614. await queryClient.invalidateQueries({
  615. queryKey: databaseKeys.schemas(projectRef as string),
  616. })
  617. }
  618. return status === 'installed' ? 'installed' : 'installing'
  619. },
  620. },
  621. ]
  622. export const INTEGRATIONS: Array<IntegrationDefinition> = [
  623. ...WRAPPER_INTEGRATIONS,
  624. ...BRIVEN_INTEGRATIONS,
  625. ...TEMPLATE_INTEGRATIONS,
  626. ]
  627. export const Loading = () => (
  628. <div className="p-10">
  629. <GenericSkeletonLoader />
  630. </div>
  631. )