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