diff --git a/.env.example b/.env.example index 9d37aa005..3d87d72d7 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,20 @@ SLACK_SIGNING_SECRET= # OPTIONAL: OAuth redirect URL. Defaults to /slack/oauth/callback. SLACK_OAUTH_REDIRECT_URL= +# [[DISCORD]] +# Discord app credentials. Create a bot at discord.com/developers/applications +# (Application -> Bot -> Reset Token; General Information -> Public Key). The +# OAuth redirect URL must be registered under OAuth2 -> Redirects. +# Leave empty to disable the Discord integration. +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +# Application-wide bot token (the same token serves every guild install). +DISCORD_BOT_TOKEN= +# Interaction public key (64 hex chars) used to verify request signatures. +DISCORD_PUBLIC_KEY= +# OPTIONAL: OAuth redirect URL. Defaults to /discord/oauth/callback. +DISCORD_OAUTH_REDIRECT_URL= + # [[BILLING]] # OPTIONAL: Enables Polar checkout, customer portal, and signed webhooks when # both credentials are configured. Use sandbox while testing. diff --git a/CONTEXT.md b/CONTEXT.md index 5cfe48aa0..24c0042b7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,10 +1,10 @@ # Integration glossary -- **Provider**: an external integration family. V1 implements the `webhook` and `slack` providers. +- **Provider**: an external integration family. V1 implements the `webhook`, `slack`, and `discord` providers. - **Connection**: an organization-owned provider account or endpoint. A V1 webhook endpoint is one connection. - **Capability**: a provider feature, such as outbound event delivery. - **Route**: a configured capability instance below a connection. For webhooks it selects V1 event types. - **Integration Event**: an immutable, versioned fact recorded for external delivery. - **Delivery**: one durable attemptable execution of an event for one route; its ID is stable across retries. - **Inbox Event**: a future durable inbound provider event. It is not implemented for V1 webhooks. -- **Binding**: a future durable local-to-remote entity relationship used by bidirectional sync. It is not implemented for V1; Slack identities currently resolve through the user table (email match or stable synthetic email). +- **Binding**: a future durable local-to-remote entity relationship used by bidirectional sync. It is not implemented for V1; Slack and Discord identities currently resolve through the user table (email match where available, or stable synthetic email). diff --git a/apps/server/package.json b/apps/server/package.json index c8c05389c..eb31785a2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,7 @@ "@feeblo/domain": "workspace:*", "@feeblo/id": "workspace:*", "@feeblo/integration-core": "workspace:*", + "@feeblo/integration-discord": "workspace:*", "@feeblo/integration-webhook": "workspace:*", "@feeblo/transactional": "workspace:*", "@feeblo/utils": "workspace:*", diff --git a/apps/server/src/discord.ts b/apps/server/src/discord.ts new file mode 100644 index 000000000..d85835716 --- /dev/null +++ b/apps/server/src/discord.ts @@ -0,0 +1,194 @@ +import { Database } from "@feeblo/db"; +import { + DiscordInboundService, + DiscordIntegrationConfig, + DiscordManagementService, + parseDiscordOAuthCallbackUrl, +} from "@feeblo/domain/integration/discord"; +import type { IntegrationProviderRegistry } from "@feeblo/integration-core"; +import { DiscordOAuthState } from "@feeblo/integration-discord"; +import type { ParsedDiscordInboundRequest } from "@feeblo/integration-discord/inbound-schema"; +import { discordProviderKey } from "@feeblo/integration-discord/manifest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as HttpHeaders from "effect/unstable/http/Headers"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +/** + * Discord HTTP surface: the OAuth callback and the single interactions + * endpoint. Every interaction (ping, slash command, context menu, modal + * submit) arrives at `/discord/interactions`, is signature-verified by the + * Discord provider inbound handler before any domain work runs, and the + * domain service answers with the interaction callback JSON. + */ + +const headerValue = ( + request: HttpServerRequest.HttpServerRequest, + name: string +): string | undefined => + Option.getOrUndefined(HttpHeaders.get(request.headers, name)); + +/** + * Cheap narrow back to the provider's parsed payload. The inbound handler has + * already signature-verified and fully decoded the body, so this guard only + * discriminates on the `kind` tag instead of re-running the full schema. + */ +const isParsedDiscordInboundRequest = ( + value: unknown +): value is ParsedDiscordInboundRequest => + typeof value === "object" && + value !== null && + "kind" in value && + (value.kind === "ping" || + value.kind === "application_command" || + value.kind === "modal_submit" || + value.kind === "unknown"); + +const handleInteraction = ( + request: HttpServerRequest.HttpServerRequest, + registry: IntegrationProviderRegistry +) => + Effect.gen(function* () { + const inboundHandler = registry.getInboundHandler({ + capabilityKey: "interactions", + provider: discordProviderKey, + }); + if (inboundHandler === undefined) { + return HttpServerResponse.text("not found", { status: 404 }); + } + const rawBody = yield* request.text; + const response = yield* inboundHandler.handle({ + headers: { + "x-signature-ed25519": headerValue(request, "x-signature-ed25519"), + "x-signature-timestamp": headerValue(request, "x-signature-timestamp"), + }, + rawBody, + }); + if (response.status !== 200) { + return HttpServerResponse.text(String(response.body), { + status: response.status, + }); + } + // The inbound handler already signature-verified and decoded the payload; + // its 200 body is always a `ParsedDiscordInboundRequest` produced by this + // package. A tag-only guard restores the erased type without a second + // full decode; a malformed body is still a client error, not a crash. + if (!isParsedDiscordInboundRequest(response.body)) { + return HttpServerResponse.text("invalid inbound payload", { + status: 400, + }); + } + const inbound = yield* DiscordInboundService; + const result = yield* inbound.handleInteraction(response.body.payload); + return result.body === undefined + ? HttpServerResponse.empty({ status: result.status }) + : HttpServerResponse.jsonUnsafe(result.body, { status: result.status }); + }); + +const settingsRedirect = ( + appUrl: string, + status: "connected" | "error", + message: string, + organizationId?: string +) => { + const base = + organizationId === undefined + ? `${appUrl}/settings/integrations` + : `${appUrl}/${organizationId}/settings/integrations`; + return `${base}?discord=${status}&message=${encodeURIComponent(message)}`; +}; + +const organizationIdFromOAuthState = (state: string | null) => + state === null + ? undefined + : Option.getOrUndefined( + Schema.decodeUnknownOption(Schema.fromJsonString(DiscordOAuthState))( + state + ).pipe(Option.map(({ organizationId }) => organizationId)) + ); + +/** OAuth callback router; completes the install and redirects to the dashboard settings page. */ +export const makeDiscordOAuthCallbackRouter = () => + HttpRouter.use((router) => + router.add( + "GET", + "/discord/oauth/callback", + (request: HttpServerRequest.HttpServerRequest) => + Effect.gen(function* () { + const config = yield* DiscordIntegrationConfig; + // request.url is the relative path (e.g. + // /discord/oauth/callback?code=…); parse it without a base URL. + const { code, error, state } = parseDiscordOAuthCallbackUrl( + request.url + ); + if (error !== null) { + return HttpServerResponse.redirect( + settingsRedirect( + config.appUrl, + "error", + "Discord installation was cancelled or denied.", + organizationIdFromOAuthState(state) + ) + ); + } + if (code === null || state === null) { + return HttpServerResponse.redirect( + settingsRedirect( + config.appUrl, + "error", + "Discord installation failed.", + organizationIdFromOAuthState(state) + ) + ); + } + const management = yield* DiscordManagementService; + const completed = yield* Effect.exit( + management.connectComplete({ code, state }) + ); + if (Exit.isFailure(completed)) { + return HttpServerResponse.redirect( + settingsRedirect( + config.appUrl, + "error", + "Discord installation failed." + ) + ); + } + return HttpServerResponse.redirect( + settingsRedirect( + config.appUrl, + "connected", + "Feeblo is now connected to Discord.", + completed.value.organizationId + ) + ); + }).pipe(Effect.orDie) + ) + ).pipe(Layer.provide(Database.DatabaseContextLive), Layer.orDie); + +/** Interactions router for every Discord interaction type. */ +const makeDiscordInteractionsRouter = (registry: IntegrationProviderRegistry) => + HttpRouter.use((router) => + router.add( + "POST", + "/discord/interactions", + (request: HttpServerRequest.HttpServerRequest) => + handleInteraction(request, registry).pipe(Effect.orDie) + ) + ); + +/** + * Complete Discord HTTP surface. The routers provide their own service layers + * (management, inbound, repositories, database) so the composition root only + * has to hand over the provider registry. + */ +export const makeDiscordRouters = (registry: IntegrationProviderRegistry) => + Layer.mergeAll( + makeDiscordOAuthCallbackRouter(), + makeDiscordInteractionsRouter(registry) + ).pipe(Layer.orDie); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c0d7d12f5..ab09eb1a4 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -19,6 +19,13 @@ import { EntitlementPolicy } from "@feeblo/domain/entitlement/policies"; import { Api } from "@feeblo/domain/http/api"; import { HttpRoute } from "@feeblo/domain/http/router"; import { WebhookIntegrationConfig } from "@feeblo/domain/integration/config"; +import { + DiscordFeedbackServiceLive, + DiscordInboundServiceLive, + DiscordIntegrationConfig, + DiscordManagementServiceLive, + DiscordUserServiceLive, +} from "@feeblo/domain/integration/discord"; import { SlackFeedbackServiceLive, SlackInboundServiceLive, @@ -61,6 +68,7 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import * as HttpApiScalar from "effect/unstable/httpapi/HttpApiScalar"; import * as RateLimiter from "effect/unstable/persistence/RateLimiter"; import { ServerConfig } from "./config"; +import { makeDiscordRouters } from "./discord"; import { e2eRoadmapSeedRouter } from "./e2e-roadmap-seed"; import { e2eSetPlanRouter } from "./e2e-set-plan"; import { makeIntegrationLayers } from "./integrations"; @@ -195,6 +203,7 @@ const program = Effect.gen(function* () { Effect.provideService(ServerConfig, config) ); const SlackRouters = makeSlackRouters(integrationRuntime.registry); + const DiscordRouters = makeDiscordRouters(integrationRuntime.registry); const ServiceLayers = Layer.mergeAll( WorkFlowLayer, SiteRepository.layer, @@ -219,6 +228,21 @@ const program = Effect.gen(function* () { Layer.provide(PostSubscriptionRepository.layer), Layer.provide(Database.DatabaseContextLive) ), + DiscordManagementServiceLive.pipe( + Layer.provide(DiscordIntegrationConfig.layer), + Layer.provide(Database.DatabaseContextLive) + ), + DiscordInboundServiceLive.pipe( + Layer.provide(DiscordUserServiceLive), + Layer.provide(DiscordFeedbackServiceLive), + Layer.provide(BoardRepository.layer), + Layer.provide(EmailOutboxConfig.layer), + Layer.provide(IntegrationEventRecorderLive), + Layer.provide(PostRepository.layer), + Layer.provide(PostStatusRepository.layer), + Layer.provide(PostSubscriptionRepository.layer), + Layer.provide(Database.DatabaseContextLive) + ), EntitlementPolicy.layer.pipe(Layer.provide(WorkspaceRepository.layer)) ).pipe(Layer.provideMerge(Database.DatabaseContextLive)); const RootRouterLive: Layer.Layer = @@ -285,7 +309,8 @@ const program = Effect.gen(function* () { HttpRoute, BetterAuthRouterLive, DocsRoute, - SlackRouters + SlackRouters, + DiscordRouters ); const AllRoutes = MergedRoutes.pipe( Layer.provide( @@ -339,7 +364,8 @@ program.pipe( ServerConfig.layer, Database.DatabaseContextLive, WebhookIntegrationConfig.layer, - SlackIntegrationConfig.layer + SlackIntegrationConfig.layer, + DiscordIntegrationConfig.layer ) ), NodeRuntime.runMain diff --git a/apps/server/src/integrations.ts b/apps/server/src/integrations.ts index 371857aed..de4432ac2 100644 --- a/apps/server/src/integrations.ts +++ b/apps/server/src/integrations.ts @@ -1,5 +1,6 @@ import { currentDb, type Database, schema } from "@feeblo/db"; import { WebhookIntegrationConfig } from "@feeblo/domain/integration/config"; +import { DiscordIntegrationConfig } from "@feeblo/domain/integration/discord/config"; import { SlackIntegrationConfig } from "@feeblo/domain/integration/slack/config"; import { WebhookManagementServiceLive } from "@feeblo/domain/integration/webhook-management-live"; import type { WebhookManagementService } from "@feeblo/domain/integration/webhook-management-service"; @@ -16,6 +17,10 @@ import { makeIntegrationProviderRegistry, runIntegrationDeliveryWorker, } from "@feeblo/integration-core"; +import { + makeDiscordCredentialResolver, + makeDiscordProviderRegistration, +} from "@feeblo/integration-discord"; import { makeSlackCredentialResolver, makeSlackProviderRegistration, @@ -59,6 +64,7 @@ export const makeIntegrationLayers: Effect.Effect< | Database.Database | WebhookIntegrationConfig | SlackIntegrationConfig + | DiscordIntegrationConfig > = Effect.gen(function* () { const config = yield* ServerConfig; const db = yield* currentDb; @@ -148,12 +154,24 @@ export const makeIntegrationLayers: Effect.Effect< credentialResolver: slackCredentialResolver, signingSecret, }); - // The Slack provider is only exposed when its OAuth client id, client - // secret, and request signing secret are all configured; otherwise the - // server runs webhook-only. + const { + botToken: discordBotToken, + configured: discordConfigured, + publicKey: discordPublicKey, + } = yield* DiscordIntegrationConfig; + const discordCredentialResolver = makeDiscordCredentialResolver({ + botToken: discordBotToken, + }); + const discordRegistration = makeDiscordProviderRegistration({ + credentialResolver: discordCredentialResolver, + publicKey: discordPublicKey, + }); + // Providers are only exposed when their credentials are configured; + // otherwise the server runs with the remaining providers only. const registry = yield* makeIntegrationProviderRegistry([ registration, ...(slackConfigured ? [slackRegistration] : []), + ...(discordConfigured ? [discordRegistration] : []), ]); // Deliveries are claimed only for capability keys the startup-validated diff --git a/apps/web/src/dashboard/features/discord/atoms.ts b/apps/web/src/dashboard/features/discord/atoms.ts new file mode 100644 index 000000000..82df4c890 --- /dev/null +++ b/apps/web/src/dashboard/features/discord/atoms.ts @@ -0,0 +1,67 @@ +import * as Effect from "effect/Effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; +import { + loadChannels, + loadConnections, + loadDiscordStatus, +} from "./lib/connections"; + +export type DiscordConnection = Awaited< + ReturnType +>[number]; +export type DiscordChannel = Awaited>[number]; + +/** + * Client-side source of truth for the Discord settings page. + * + * Connection and channel lists are Effect atoms whose results stream into + * React through `useAtomValue` from `@effect/atom-react`. Mutations refresh + * the matching atom after they succeed so the visible state stays in sync. + */ +export const discordAtomRegistry = AtomRegistry.make(); + +/** Discord connections of one organization, one cached atom per organization id. */ +export const connectionsAtom = Atom.family((organizationId: string) => + Atom.make(Effect.tryPromise(() => loadConnections(organizationId))).pipe( + Atom.swr({ + staleTime: "30 seconds", + revalidateOnFocus: "always", + focusSignal: Atom.windowFocusSignal, + }), + Atom.setIdleTTL("5 minutes") + ) +); + +export type ChannelListArgs = { + readonly organizationId: string; + readonly connectionId: string; +}; + +/** Channels of one connection, one cached atom per connection id. */ +export const channelsAtom = Atom.family((args: ChannelListArgs) => + Atom.make( + Effect.tryPromise(() => + loadChannels(args.organizationId, args.connectionId) + ) + ).pipe( + Atom.swr({ + staleTime: "30 seconds", + revalidateOnFocus: "always", + focusSignal: Atom.windowFocusSignal, + }), + Atom.setIdleTTL("5 minutes") + ) +); + +/** Whether the Discord integration is configured for this deployment (global, not per-org). */ +export const discordStatusAtom = Atom.make( + Effect.tryPromise(() => loadDiscordStatus()) +).pipe( + Atom.swr({ + staleTime: "30 seconds", + revalidateOnFocus: "always", + focusSignal: Atom.windowFocusSignal, + }), + Atom.setIdleTTL("5 minutes") +); diff --git a/apps/web/src/dashboard/features/discord/components/discord-settings.tsx b/apps/web/src/dashboard/features/discord/components/discord-settings.tsx new file mode 100644 index 000000000..60762b39b --- /dev/null +++ b/apps/web/src/dashboard/features/discord/components/discord-settings.tsx @@ -0,0 +1,434 @@ +import { + RegistryContext, + useAtomRefresh, + useAtomValue, +} from "@effect/atom-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, + AlertDialogTrigger, +} from "@feeblo/ui/alert-dialog"; +import { Button } from "@feeblo/ui/button"; +import { Card, CardPanel } from "@feeblo/ui/card"; +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from "@feeblo/ui/frame"; +import { Switch } from "@feeblo/ui/switch"; +import { toastManager } from "@feeblo/ui/toast"; +import * as Option from "effect/Option"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { useState } from "react"; +import { + channelsAtom, + connectionsAtom, + type DiscordChannel, + type DiscordConnection, + discordAtomRegistry, + discordStatusAtom, +} from "../atoms"; +import { + disconnectDiscordConnection, + startDiscordConnect, + updateChannelNotifications, +} from "../lib/connections"; + +type AsyncListState = { + readonly list: readonly T[]; + readonly isLoading: boolean; + readonly loadFailed: boolean; +}; + +/** Collapses an atom AsyncResult into the loading/loaded/error trio the settings frames render. */ +function useAsyncList( + result: AsyncResult.AsyncResult +): AsyncListState { + return AsyncResult.match(result, { + onInitial: () => ({ list: [], isLoading: true, loadFailed: false }), + onFailure: ({ previousSuccess }) => + Option.match(previousSuccess, { + onNone: () => ({ list: [], isLoading: false, loadFailed: true }), + onSome: ({ value }) => ({ + list: value, + isLoading: false, + loadFailed: false, + }), + }), + onSuccess: ({ value }) => ({ + list: value, + isLoading: false, + loadFailed: false, + }), + }); +} + +export function DiscordSettings({ + organizationId, +}: { + readonly organizationId: string; +}) { + return ( + + + + ); +} + +function DiscordSettingsContent({ + organizationId, +}: { + readonly organizationId: string; +}) { + const [connecting, setConnecting] = useState(false); + const connectionsResult = useAtomValue(connectionsAtom(organizationId)); + const refreshConnections = useAtomRefresh(connectionsAtom(organizationId)); + const { + list: connections, + isLoading, + loadFailed, + } = useAsyncList(connectionsResult); + const statusResult = useAtomValue(discordStatusAtom); + const discordConfigured = AsyncResult.match(statusResult, { + onInitial: () => null as boolean | null, + onFailure: () => false, + onSuccess: ({ value }) => value, + }); + + const handleConnect = async () => { + setConnecting(true); + try { + const { authorizeUrl } = await startDiscordConnect(organizationId); + window.location.assign(authorizeUrl.toString()); + } catch { + setConnecting(false); + toastManager.add({ + title: "Could not start Discord connection", + type: "error", + }); + } + }; + + const handleDisconnected = () => { + refreshConnections(); + toastManager.add({ + title: "Discord server disconnected", + type: "success", + }); + }; + + if (discordConfigured === null) { + return ( + + +

Loading Discord…

+
+
+ ); + } + if (!discordConfigured) { + return ( + + +

+ Discord is not configured for this deployment. +

+
+
+ ); + } + + if (isLoading) { + return ( + + +

+ Loading Discord connection… +

+
+
+ ); + } + + if (loadFailed) { + return ( + + +
+ Discord connection could not be loaded.{" "} + +
+
+
+ ); + } + + if (connections.length === 0) { + return ( + + + Discord + + Collect feedback from Discord and post new requests to your + channels. + + + +

Connect your server

+

+ Let your team send feedback with /feeblo, forward messages with + “Send to Feeblo”, and post new requests to your channels. +

+
+ +
+
+ + ); + } + + return ( +
+ {connections.map((connection) => ( + + ))} +
+ ); +} + +function DiscordConnectionFrame({ + connection, + organizationId, + onDisconnected, +}: { + readonly connection: DiscordConnection; + readonly organizationId: string; + readonly onDisconnected: () => void; +}) { + const [dialogOpen, setDialogOpen] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + + const handleDisconnect = async () => { + setDisconnecting(true); + try { + await disconnectDiscordConnection({ + connectionId: connection.id, + organizationId, + }); + setDisconnecting(false); + setDialogOpen(false); + onDisconnected(); + } catch { + setDisconnecting(false); + setDialogOpen(false); + toastManager.add({ + title: "Could not disconnect Discord", + type: "error", + }); + } + }; + + return ( + + + {connection.guildName} + Discord server connected to Feeblo. + + +
+
+

Server

+

+ {connection.guildId ?? "Connecting…"} +

+
+ +
+
+ { + if (!disconnecting) { + setDialogOpen(open); + } + }} + open={dialogOpen} + > + + Disconnect + + } + /> + + + Disconnect Discord? + + Feeblo will lose access to {connection.guildName}. New post + notifications to your channels will stop, and your team won't + be able to send feedback with /feeblo until you reconnect. + + + + Cancel + + {disconnecting ? "Disconnecting…" : "Disconnect"} + + + + +
+
+ {connection.lifecycle === "active" ? ( + +

New post notifications

+

+ Post new requests to one or more text channels. +

+
+ +
+
+ ) : null} + + ); +} + +function ConnectionBadge({ + lifecycle, +}: { + readonly lifecycle: DiscordConnection["lifecycle"]; +}) { + if (lifecycle === "active") { + return ( + + Connected + + ); + } + if (lifecycle === "connecting") { + return ( + + Awaiting Discord approval + + ); + } + return ( + + {lifecycle} + + ); +} + +function FeedbackChannelSelect({ + connectionId, + organizationId, +}: { + readonly connectionId: string; + readonly organizationId: string; +}) { + const args = { connectionId, organizationId }; + const channelsResult = useAtomValue(channelsAtom(args)); + const refreshChannels = useAtomRefresh(channelsAtom(args)); + const { + list: channels, + isLoading, + loadFailed, + } = useAsyncList(channelsResult); + const [pendingChannelId, setPendingChannelId] = useState(null); + + const handleToggle = async (channel: DiscordChannel, enabled: boolean) => { + setPendingChannelId(channel.id); + try { + await updateChannelNotifications({ + channelId: channel.id, + connectionId, + enabled, + organizationId, + }); + refreshChannels(); + toastManager.add({ + title: enabled + ? "New post notifications enabled" + : "New post notifications disabled", + type: "success", + }); + } catch { + refreshChannels(); + toastManager.add({ + title: "Could not update new post notifications", + type: "error", + }); + } finally { + setPendingChannelId(null); + } + }; + + if (isLoading) { + return

Loading channels…

; + } + if (loadFailed) { + return ( +
+ Channels could not be loaded. + +
+ ); + } + if (channels.length === 0) { + return ( +

+ No text channels are visible to the bot yet. Make sure it has been added + to the server, then refresh. +

+ ); + } + + return ( +
+ {channels.map((channel) => { + const pending = pendingChannelId === channel.id; + return ( +
+
+

#{channel.name}

+
+ handleToggle(channel, checked)} + /> +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/dashboard/features/discord/lib/connections.ts b/apps/web/src/dashboard/features/discord/lib/connections.ts new file mode 100644 index 000000000..8c4adccc1 --- /dev/null +++ b/apps/web/src/dashboard/features/discord/lib/connections.ts @@ -0,0 +1,45 @@ +import { fetchRpc } from "~/lib/runtime"; + +export const loadDiscordStatus = () => + fetchRpc((rpc) => rpc.DiscordIntegrationStatus()).then( + (result) => result.configured + ); + +export const loadConnections = (organizationId: string) => + fetchRpc((rpc) => rpc.DiscordConnectionList({ organizationId })).then( + (result) => [...result] + ); + +export const loadChannels = (organizationId: string, connectionId: string) => + fetchRpc((rpc) => + rpc.DiscordChannelList({ connectionId, organizationId }) + ).then((result) => [...result]); + +export const startDiscordConnect = (organizationId: string) => + fetchRpc((rpc) => rpc.DiscordConnectStart({ organizationId })); + +export const updateChannelNotifications = (input: { + readonly channelId: string; + readonly connectionId: string; + readonly enabled: boolean; + readonly organizationId: string; +}) => + fetchRpc((rpc) => + rpc.DiscordChannelNotificationsUpdate({ + channelId: input.channelId, + connectionId: input.connectionId, + enabled: input.enabled, + organizationId: input.organizationId, + }) + ); + +export const disconnectDiscordConnection = (input: { + readonly connectionId: string; + readonly organizationId: string; +}) => + fetchRpc((rpc) => + rpc.DiscordConnectionDisconnect({ + connectionId: input.connectionId, + organizationId: input.organizationId, + }) + ); diff --git a/apps/web/src/dashboard/routeTree.gen.ts b/apps/web/src/dashboard/routeTree.gen.ts index d0398387a..ab4030881 100644 --- a/apps/web/src/dashboard/routeTree.gen.ts +++ b/apps/web/src/dashboard/routeTree.gen.ts @@ -51,6 +51,7 @@ import { Route as OrganizationIdDashboardLayoutBoardBoardSlugActiveRouteImport } import { Route as OrganizationIdDashboardLayoutBoardBoardSlugBacklogRouteImport } from "./routes/$organizationId/_dashboard-layout/board/$boardSlug/backlog" import { Route as OrganizationIdDashboardLayoutChangelogEditChangelogSlugRouteImport } from "./routes/$organizationId/_dashboard-layout/changelog/edit/$changelogSlug" import { Route as OrganizationIdDashboardLayoutPostBoardSlugPostSlugRouteImport } from "./routes/$organizationId/_dashboard-layout/post/$boardSlug/$postSlug" +import { Route as OrganizationIdSettingsIntegrationsDiscordIndexRouteImport } from "./routes/$organizationId/settings/integrations/discord/index" import { Route as OrganizationIdSettingsIntegrationsSlackIndexRouteImport } from "./routes/$organizationId/settings/integrations/slack/index" const OrganizationIdRoute = OrganizationIdRouteImport.update({ @@ -296,6 +297,12 @@ const OrganizationIdDashboardLayoutPostBoardSlugPostSlugRoute = path: "/post/$boardSlug/$postSlug", getParentRoute: () => OrganizationIdDashboardLayoutRoute, } as any) +const OrganizationIdSettingsIntegrationsDiscordIndexRoute = + OrganizationIdSettingsIntegrationsDiscordIndexRouteImport.update({ + id: "/integrations/discord/", + path: "/integrations/discord/", + getParentRoute: () => OrganizationIdSettingsRoute, + } as any) const OrganizationIdSettingsIntegrationsSlackIndexRoute = OrganizationIdSettingsIntegrationsSlackIndexRouteImport.update({ id: "/integrations/slack/", @@ -345,6 +352,7 @@ export interface FileRoutesByFullPath { "/$organizationId/changelog/edit/$changelogSlug": typeof OrganizationIdDashboardLayoutChangelogEditChangelogSlugRoute "/$organizationId/post/$boardSlug/$postSlug": typeof OrganizationIdDashboardLayoutPostBoardSlugPostSlugRoute "/$organizationId/board/$boardSlug/": typeof OrganizationIdDashboardLayoutBoardBoardSlugIndexRoute + "/$organizationId/settings/integrations/discord/": typeof OrganizationIdSettingsIntegrationsDiscordIndexRoute "/$organizationId/settings/integrations/slack/": typeof OrganizationIdSettingsIntegrationsSlackIndexRoute } export interface FileRoutesByTo { @@ -387,6 +395,7 @@ export interface FileRoutesByTo { "/$organizationId/changelog/edit/$changelogSlug": typeof OrganizationIdDashboardLayoutChangelogEditChangelogSlugRoute "/$organizationId/post/$boardSlug/$postSlug": typeof OrganizationIdDashboardLayoutPostBoardSlugPostSlugRoute "/$organizationId/board/$boardSlug": typeof OrganizationIdDashboardLayoutBoardBoardSlugIndexRoute + "/$organizationId/settings/integrations/discord": typeof OrganizationIdSettingsIntegrationsDiscordIndexRoute "/$organizationId/settings/integrations/slack": typeof OrganizationIdSettingsIntegrationsSlackIndexRoute } export interface FileRoutesById { @@ -433,6 +442,7 @@ export interface FileRoutesById { "/$organizationId/_dashboard-layout/changelog/edit/$changelogSlug": typeof OrganizationIdDashboardLayoutChangelogEditChangelogSlugRoute "/$organizationId/_dashboard-layout/post/$boardSlug/$postSlug": typeof OrganizationIdDashboardLayoutPostBoardSlugPostSlugRoute "/$organizationId/_dashboard-layout/board/$boardSlug/": typeof OrganizationIdDashboardLayoutBoardBoardSlugIndexRoute + "/$organizationId/settings/integrations/discord/": typeof OrganizationIdSettingsIntegrationsDiscordIndexRoute "/$organizationId/settings/integrations/slack/": typeof OrganizationIdSettingsIntegrationsSlackIndexRoute } export interface FileRouteTypes { @@ -479,6 +489,7 @@ export interface FileRouteTypes { | "/$organizationId/changelog/edit/$changelogSlug" | "/$organizationId/post/$boardSlug/$postSlug" | "/$organizationId/board/$boardSlug/" + | "/$organizationId/settings/integrations/discord/" | "/$organizationId/settings/integrations/slack/" fileRoutesByTo: FileRoutesByTo to: @@ -521,6 +532,7 @@ export interface FileRouteTypes { | "/$organizationId/changelog/edit/$changelogSlug" | "/$organizationId/post/$boardSlug/$postSlug" | "/$organizationId/board/$boardSlug" + | "/$organizationId/settings/integrations/discord" | "/$organizationId/settings/integrations/slack" id: | "__root__" @@ -566,6 +578,7 @@ export interface FileRouteTypes { | "/$organizationId/_dashboard-layout/changelog/edit/$changelogSlug" | "/$organizationId/_dashboard-layout/post/$boardSlug/$postSlug" | "/$organizationId/_dashboard-layout/board/$boardSlug/" + | "/$organizationId/settings/integrations/discord/" | "/$organizationId/settings/integrations/slack/" fileRoutesById: FileRoutesById } @@ -875,6 +888,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof OrganizationIdDashboardLayoutPostBoardSlugPostSlugRouteImport parentRoute: typeof OrganizationIdDashboardLayoutRoute } + "/$organizationId/settings/integrations/discord/": { + id: "/$organizationId/settings/integrations/discord/" + path: "/integrations/discord" + fullPath: "/$organizationId/settings/integrations/discord/" + preLoaderRoute: typeof OrganizationIdSettingsIntegrationsDiscordIndexRouteImport + parentRoute: typeof OrganizationIdSettingsRoute + } "/$organizationId/settings/integrations/slack/": { id: "/$organizationId/settings/integrations/slack/" path: "/integrations/slack" @@ -963,6 +983,7 @@ interface OrganizationIdSettingsRouteChildren { OrganizationIdSettingsWebhooksConnectionIdRoute: typeof OrganizationIdSettingsWebhooksConnectionIdRoute OrganizationIdSettingsIntegrationsIndexRoute: typeof OrganizationIdSettingsIntegrationsIndexRoute OrganizationIdSettingsWebhooksIndexRoute: typeof OrganizationIdSettingsWebhooksIndexRoute + OrganizationIdSettingsIntegrationsDiscordIndexRoute: typeof OrganizationIdSettingsIntegrationsDiscordIndexRoute OrganizationIdSettingsIntegrationsSlackIndexRoute: typeof OrganizationIdSettingsIntegrationsSlackIndexRoute } @@ -994,6 +1015,8 @@ const OrganizationIdSettingsRouteChildren: OrganizationIdSettingsRouteChildren = OrganizationIdSettingsIntegrationsIndexRoute, OrganizationIdSettingsWebhooksIndexRoute: OrganizationIdSettingsWebhooksIndexRoute, + OrganizationIdSettingsIntegrationsDiscordIndexRoute: + OrganizationIdSettingsIntegrationsDiscordIndexRoute, OrganizationIdSettingsIntegrationsSlackIndexRoute: OrganizationIdSettingsIntegrationsSlackIndexRoute, } diff --git a/apps/web/src/dashboard/routes/$organizationId/settings/integrations/discord/index.tsx b/apps/web/src/dashboard/routes/$organizationId/settings/integrations/discord/index.tsx new file mode 100644 index 000000000..19cf31bb5 --- /dev/null +++ b/apps/web/src/dashboard/routes/$organizationId/settings/integrations/discord/index.tsx @@ -0,0 +1,40 @@ +import { hasPermission, usePolicy } from "@feeblo/web-shared/use-policy"; +import { createFileRoute } from "@tanstack/react-router"; +import { DiscordSettings } from "~/features/discord/components/discord-settings"; +import { SettingsAccessDenied } from "~/features/settings/components/settings-access-denied"; +import { SettingsLayout } from "~/features/settings/components/settings-layout"; +import { useOrganizationId } from "~/hooks/use-organization-id"; + +export const Route = createFileRoute( + "/$organizationId/settings/integrations/discord/" +)({ + component: DiscordIntegrationSettingsRoute, +}); + +function DiscordIntegrationSettingsRoute() { + const organizationId = useOrganizationId(); + const { allowed, isPending } = usePolicy( + hasPermission(organizationId, "integrations.manage") + ); + + if (isPending) { + return null; + } + if (!allowed) { + return ; + } + + return ( + + + Discord + + Collect feedback from Discord and post new requests to your channels. + + + + + + + ); +} diff --git a/apps/web/src/dashboard/routes/$organizationId/settings/integrations/index.tsx b/apps/web/src/dashboard/routes/$organizationId/settings/integrations/index.tsx index 394675bf9..ba2319a5c 100644 --- a/apps/web/src/dashboard/routes/$organizationId/settings/integrations/index.tsx +++ b/apps/web/src/dashboard/routes/$organizationId/settings/integrations/index.tsx @@ -3,13 +3,22 @@ import { Button } from "@feeblo/ui/button"; import { Card, CardPanel } from "@feeblo/ui/card"; import { toastManager } from "@feeblo/ui/toast"; import { hasPermission, usePolicy } from "@feeblo/web-shared/use-policy"; -import { Chat01Icon } from "@hugeicons/core-free-icons"; +import { Chat01Icon, ChatBotIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { createFileRoute, useRouter } from "@tanstack/react-router"; import * as Option from "effect/Option"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { useEffect, useState } from "react"; import { z } from "zod"; +import { + discordAtomRegistry, + connectionsAtom as discordConnectionsAtom, + discordStatusAtom, +} from "~/features/discord/atoms"; +import { + type loadConnections as loadDiscordConnections, + startDiscordConnect, +} from "~/features/discord/lib/connections"; import { SettingsAccessDenied } from "~/features/settings/components/settings-access-denied"; import { SettingsLayout } from "~/features/settings/components/settings-layout"; import { @@ -28,6 +37,7 @@ export const Route = createFileRoute("/$organizationId/settings/integrations/")( validateSearch: (search) => z .object({ + discord: z.enum(["connected", "error"]).optional(), slack: z.enum(["connected", "error"]).optional(), message: z.string().min(1).optional(), }) @@ -44,19 +54,30 @@ function IntegrationsSettingsRoute() { const search = Route.useSearch(); const router = useRouter(); - // Surface the result of the Slack OAuth install flow the server redirected - // back with, then strip the query params so the notice shows only once. + // Surface the result of the Slack and Discord OAuth install flows the + // server redirected back with, then strip the query params so the notice + // shows only once. useEffect(() => { - if (search.slack === undefined) { - return; - } - if (search.slack === "connected") { - toastManager.add({ title: "Slack connected", type: "success" }); + if (search.slack !== undefined) { + if (search.slack === "connected") { + toastManager.add({ title: "Slack connected", type: "success" }); + } else { + toastManager.add({ + title: search.message ?? "Could not connect Slack", + type: "error", + }); + } + } else if (search.discord !== undefined) { + if (search.discord === "connected") { + toastManager.add({ title: "Discord connected", type: "success" }); + } else { + toastManager.add({ + title: search.message ?? "Could not connect Discord", + type: "error", + }); + } } else { - toastManager.add({ - title: search.message ?? "Could not connect Slack", - type: "error", - }); + return; } router.navigate({ to: "/$organizationId/settings/integrations", @@ -64,7 +85,7 @@ function IntegrationsSettingsRoute() { search: {}, replace: true, }); - }, [organizationId, router, search.message, search.slack]); + }, [organizationId, router, search.discord, search.message, search.slack]); if (isPending) { return null; @@ -82,11 +103,14 @@ function IntegrationsSettingsRoute() { - -
+
+ -
- + + + + +
); @@ -248,3 +272,136 @@ function SlackIntegrationCard({ ); } + +function DiscordIntegrationCard({ + organizationId, +}: { + readonly organizationId: string; +}) { + const router = useRouter(); + const [connecting, setConnecting] = useState(false); + const connectionsResult = useAtomValue( + discordConnectionsAtom(organizationId) + ); + const statusResult = useAtomValue(discordStatusAtom); + const discordConfigured = AsyncResult.match(statusResult, { + onInitial: () => null as boolean | null, + onFailure: () => false, + onSuccess: ({ value }) => value, + }); + + const { connections, isLoading, loadFailed } = AsyncResult.match( + connectionsResult, + { + onInitial: () => ({ + connections: [] as Awaited>, + isLoading: true, + loadFailed: false, + }), + onFailure: ({ previousSuccess }) => + Option.match(previousSuccess, { + onNone: () => ({ + connections: [], + isLoading: false, + loadFailed: true, + }), + onSome: ({ value }) => ({ + connections: value, + isLoading: false, + loadFailed: false, + }), + }), + onSuccess: ({ value }) => ({ + connections: value, + isLoading: false, + loadFailed: false, + }), + } + ); + + const activeConnections = connections.filter( + (connection) => + connection.lifecycle === "active" || connection.lifecycle === "connecting" + ); + const connected = activeConnections.length > 0; + + const handleConnect = async () => { + setConnecting(true); + try { + const { authorizeUrl } = await startDiscordConnect(organizationId); + window.location.assign(authorizeUrl.toString()); + } catch { + setConnecting(false); + toastManager.add({ + title: "Could not start Discord connection", + type: "error", + }); + } + }; + + if (discordConfigured === null) { + return ( + + +

Loading Discord…

+
+
+ ); + } + if (!discordConfigured) { + return null; + } + + return ( + + +
+
+
+ +
+
+
+

Discord

+ {connectionStatusBadge({ isLoading, loadFailed, connected })} +
+

+ Let your team send feedback with /feeblo, forward messages with + “Send to Feeblo”, and get new requests posted to your channels. +

+ {connected ? ( +

+ {activeConnections + .map((connection) => connection.guildName) + .join(", ")} +

+ ) : null} +
+
+
+ {connected ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/docs/integrations.md b/docs/integrations.md index 51c3bf4b4..51f0adf50 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -13,7 +13,18 @@ Inbound requests are signature-verified (HMAC-SHA256, 5-minute freshness window) by the provider before any domain work. The server mounts `/slack/oauth/callback`, `/slack/commands/feeblo`, and `/slack/interactive`. The dashboard settings page (`/$organizationId/settings/integrations/slack`, `integrations.manage`) handles workspace connect/disconnect and per-channel notification toggles. -Discord, Linear, HubSpot, inbox processing, bindings, bidirectional synchronization, and in-modal similar-request upvoting are future phases, not packages or capabilities supplied by V1. +## Discord provider + +`integrations/discord` implements the Discord provider (`discord`, OAuth 2.0): + +- **`channel.notifications`** (outbound) — one route per channel; the durable delivery worker posts a `ChannelUpdateMessage` rendered into a Discord embed for every `feedback.post.created` event. Unlike Slack there is no join step: the bot's channel access is granted at install time through the OAuth permissions bitfield (View Channels, Send Messages, Embed Links, Read Message History) and may be overridden per channel by the server. +- **`interactions`** (inbound) — every interaction type arrives at the single `/discord/interactions` endpoint: the `/feeblo` slash command and the “Send to Feeblo” message context menu open the feedback modal (title, details, board select), and `MODAL_SUBMIT` creates a post (source `DISCORD`) and answers with an ephemeral confirmation. Modal metadata travels in the `custom_id` (`feeblo:::[:]`) because Discord caps custom ids at 100 characters. Discord identities resolve against the user table through the stable synthetic email `discord--@discord.invalid` (`emailVerified: false`); Discord never exposes the invoking user's email to the integration, so the Slack email-linking step does not apply. + +Application-wide credentials differ from Slack: the bot token and interaction public key are shared by every guild install, so they live in configuration (`DISCORD_BOT_TOKEN`, `DISCORD_PUBLIC_KEY`) like the Slack signing secret. Only the per-install OAuth artifacts (installer user token, OAuth state nonce) are encrypted at rest with `INTEGRATION_ENCRYPTION_KEY` (falling back to `AUTH_ENCRYPTION_KEY`). Connect registers the `/feeblo` and “Send to Feeblo” commands in the guild (`PUT /applications/{app}/guilds/{guild}/commands`), so they appear instantly and are scoped to the connected server. + +Inbound requests are signature-verified (Ed25519, `X-Signature-Ed25519` over `X-Signature-Timestamp + body`, 5-minute freshness window) by the provider before any domain work. The server mounts `/discord/oauth/callback` and `/discord/interactions`. The dashboard settings page (`/$organizationId/settings/integrations/discord`, `integrations.manage`) handles server connect/disconnect and per-channel notification toggles. + +Linear, HubSpot, inbox processing, bindings, bidirectional synchronization, and in-modal similar-request upvoting are future phases, not packages or capabilities supplied by V1. ## Operations diff --git a/integrations/core/package.json b/integrations/core/package.json index c1f3ae0e3..892239825 100644 --- a/integrations/core/package.json +++ b/integrations/core/package.json @@ -18,6 +18,7 @@ "dependencies": { "@feeblo/db": "workspace:*", "@feeblo/id": "workspace:*", + "@feeblo/utils": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:" }, diff --git a/integrations/core/src/channel-update-message.ts b/integrations/core/src/channel-update-message.ts new file mode 100644 index 000000000..c547fc25a --- /dev/null +++ b/integrations/core/src/channel-update-message.ts @@ -0,0 +1,21 @@ +/** + * Channel-update message model shared by channel-notification providers + * (Slack, Discord). Providers render this pure model into their own wire + * format (Block Kit blocks, embeds); no provider-specific types leak into + * the model. + */ +export interface ChannelUpdateMessage { + /** Absolute URL to open the post. */ + readonly actionUrl: string; + /** Display name of the actor, when known. */ + readonly actorName?: string; + /** Event type that produced the update. */ + readonly eventType: string; + /** Facts rendered as key/value rows, e.g. board and status. */ + readonly facts: ReadonlyArray<{ + readonly label: string; + readonly value: string; + }>; + /** Post title, truncated for channel display by the renderer. */ + readonly title: string; +} diff --git a/integrations/core/src/credential-encryption.test.ts b/integrations/core/src/credential-encryption.test.ts new file mode 100644 index 000000000..e1da460ad --- /dev/null +++ b/integrations/core/src/credential-encryption.test.ts @@ -0,0 +1,100 @@ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vitest"; +import { + decryptIntegrationCredentialMaterial, + encryptIntegrationCredentialMaterial, +} from "./credential-encryption"; + +const encryptionKey = Redacted.make("0123456789abcdef0123456789abcdef"); + +/** Minimal provider material shape exercising the generic helpers. */ +const TestCredentialMaterial = Schema.Struct({ + secret: Schema.optionalKey(Schema.String), + oauthState: Schema.optionalKey(Schema.String), +}); + +describe("integration credential material", () => { + it("round-trips material through encrypt and decrypt", async () => { + const ciphertext = await Effect.runPromise( + encryptIntegrationCredentialMaterial( + encryptionKey, + TestCredentialMaterial, + { + oauthState: "state-nonce", + secret: "provider-secret", + } + ) + ); + expect(ciphertext).not.toContain("state-nonce"); + expect(ciphertext).not.toContain("provider-secret"); + + const decrypted = await Effect.runPromise( + decryptIntegrationCredentialMaterial( + encryptionKey, + TestCredentialMaterial, + ciphertext, + (material) => ({ + ...(material.secret === undefined + ? {} + : { secret: Redacted.make(material.secret) }), + ...(material.oauthState === undefined + ? {} + : { oauthState: material.oauthState }), + }) + ) + ); + expect(decrypted.oauthState).toBe("state-nonce"); + if (decrypted.secret === undefined) { + throw new Error("expected secret"); + } + expect(Redacted.value(decrypted.secret)).toBe("provider-secret"); + }); + + it("rejects a short encryption key", async () => { + const result = await Effect.runPromiseExit( + encryptIntegrationCredentialMaterial( + Redacted.make("short"), + TestCredentialMaterial, + {} + ) + ); + expect(Exit.isFailure(result)).toBe(true); + }); + + it("fails to decrypt malformed ciphertext", async () => { + const result = await Effect.runPromiseExit( + decryptIntegrationCredentialMaterial( + encryptionKey, + TestCredentialMaterial, + "not-ciphertext", + (material) => material + ) + ); + expect(Exit.isFailure(result)).toBe(true); + }); + + it("fails to decrypt material that decodes against the wrong schema", async () => { + const ciphertext = await Effect.runPromise( + encryptIntegrationCredentialMaterial( + encryptionKey, + TestCredentialMaterial, + { + secret: "provider-secret", + } + ) + ); + const WrongMaterial = Schema.Struct({ required: Schema.String }); + const result = await Effect.runPromiseExit( + decryptIntegrationCredentialMaterial( + encryptionKey, + WrongMaterial, + ciphertext, + (material) => material + ) + ); + expect(Exit.isFailure(result)).toBe(true); + }); +}); diff --git a/integrations/core/src/credential-encryption.ts b/integrations/core/src/credential-encryption.ts new file mode 100644 index 000000000..77e8a3b2d --- /dev/null +++ b/integrations/core/src/credential-encryption.ts @@ -0,0 +1,137 @@ +import { symmetricDecrypt, symmetricEncrypt } from "@feeblo/utils/crypto"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +/** Failure encrypting or decrypting provider credentials at rest. */ +export class IntegrationCredentialEncryptionError extends Schema.TaggedErrorClass()( + "IntegrationCredentialEncryptionError", + { operation: Schema.Literals(["encrypt", "decrypt"]), reason: Schema.String } +) {} + +/** Encrypted at-rest value for provider credentials and OAuth state. */ +export type EncryptedIntegrationCredential = string; + +/** Validates the redacted integration encryption key without ever returning its plaintext. */ +export const validateIntegrationEncryptionKey = ( + encryptionKey: Redacted.Redacted, + operation: "decrypt" | "encrypt" = "encrypt" +): Effect.Effect< + Redacted.Redacted, + IntegrationCredentialEncryptionError +> => + new TextEncoder().encode(Redacted.value(encryptionKey)).byteLength >= 32 + ? Effect.succeed(encryptionKey) + : Effect.fail( + new IntegrationCredentialEncryptionError({ + operation, + reason: "Integration encryption key must contain at least 32 bytes", + }) + ); + +/** Encrypts a redacted provider credential with the redacted integration encryption key. */ +export const encryptIntegrationCredential = ( + encryptionKey: Redacted.Redacted, + credential: Redacted.Redacted +): Effect.Effect< + EncryptedIntegrationCredential, + IntegrationCredentialEncryptionError +> => + Effect.tryPromise({ + try: () => + symmetricEncrypt({ + key: Redacted.value(encryptionKey), + data: Redacted.value(credential), + }), + catch: () => + new IntegrationCredentialEncryptionError({ + operation: "encrypt", + reason: "Provider credential encryption failed", + }), + }); + +/** Decrypts a credential only at the provider boundary and immediately returns it redacted. */ +export const decryptIntegrationCredential = ( + encryptionKey: Redacted.Redacted, + encryptedCredential: EncryptedIntegrationCredential +): Effect.Effect< + Redacted.Redacted, + IntegrationCredentialEncryptionError +> => + Effect.tryPromise({ + try: () => + symmetricDecrypt({ + key: Redacted.value(encryptionKey), + data: encryptedCredential, + }), + catch: () => + new IntegrationCredentialEncryptionError({ + operation: "decrypt", + reason: "Provider credential decryption failed", + }), + }).pipe(Effect.map(Redacted.make)); + +/** + * Encrypts a provider-defined credential material schema (JSON-encoded, then + * encrypted) together. The material shape is owned by each provider; this + * helper owns the encoding and ciphertext steps. + */ +export const encryptIntegrationCredentialMaterial = ( + encryptionKey: Redacted.Redacted, + schema: Schema.Codec, + material: Material +): Effect.Effect< + EncryptedIntegrationCredential, + IntegrationCredentialEncryptionError +> => + Effect.gen(function* () { + const key = yield* validateIntegrationEncryptionKey(encryptionKey); + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(schema))( + material + ).pipe( + Effect.mapError( + () => + new IntegrationCredentialEncryptionError({ + operation: "encrypt", + reason: "Provider credentials could not be encoded", + }) + ) + ); + return yield* encryptIntegrationCredential(key, Redacted.make(encoded)); + }); + +/** + * Decrypts and decodes a provider-defined credential material schema, then + * applies the provider's redaction policy so secret-bearing fields never + * leave the provider boundary as plaintext. + */ +export const decryptIntegrationCredentialMaterial = < + Material, + Encoded, + Decoded, +>( + encryptionKey: Redacted.Redacted, + schema: Schema.Codec, + encryptedCredential: EncryptedIntegrationCredential, + redact: (material: Material) => Decoded +): Effect.Effect => + Effect.flatMap( + validateIntegrationEncryptionKey(encryptionKey, "decrypt"), + (key) => + Effect.flatMap( + decryptIntegrationCredential(key, encryptedCredential), + (value) => + Schema.decodeUnknownEffect(Schema.fromJsonString(schema))( + Redacted.value(value) + ).pipe( + Effect.map(redact), + Effect.mapError( + () => + new IntegrationCredentialEncryptionError({ + operation: "decrypt", + reason: "Encrypted provider credentials are malformed", + }) + ) + ) + ) + ); diff --git a/integrations/core/src/index.ts b/integrations/core/src/index.ts index 061c1d709..642676638 100644 --- a/integrations/core/src/index.ts +++ b/integrations/core/src/index.ts @@ -1,5 +1,7 @@ /** biome-ignore-all lint/performance/noBarrelFile: package public entry point */ /** Provider-neutral integration kernel contracts and deterministic delivery policy. */ +export * from "./channel-update-message"; +export * from "./credential-encryption"; export * from "./delivery-policy"; export * from "./delivery-worker-defaults"; export * from "./integration-contracts"; @@ -9,5 +11,7 @@ export * from "./integration-event-recorder"; export * from "./integration-lifecycle"; export * from "./integration-management-repository"; export * from "./integration-telemetry"; +export * from "./oauth-state"; export * from "./provider-registry"; +export * from "./request-signature"; export * from "./route-event-selection"; diff --git a/integrations/core/src/integration-contracts.ts b/integrations/core/src/integration-contracts.ts index 23af537b9..890126c92 100644 --- a/integrations/core/src/integration-contracts.ts +++ b/integrations/core/src/integration-contracts.ts @@ -131,6 +131,7 @@ export const IntegrationPostEventData = Schema.Struct({ }), post: Schema.Struct({ id: PostId.schema, + metadata: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), status: Schema.Struct({ id: PostStatusId.schema, type: PostStatusType, diff --git a/integrations/core/src/oauth-state.ts b/integrations/core/src/oauth-state.ts new file mode 100644 index 000000000..0b077f6b2 --- /dev/null +++ b/integrations/core/src/oauth-state.ts @@ -0,0 +1,15 @@ +import * as Schema from "effect/Schema"; + +/** + * OAuth state token passed through every provider install flow (Slack, + * Discord). It is not a secret; the connection lookup is additionally guarded + * by the encrypted nonce stored on the pending connection row. + */ +export const IntegrationOAuthState = Schema.Struct({ + connectionId: Schema.String, + organizationId: Schema.String, + nonce: Schema.String, +}); +export type IntegrationOAuthState = Schema.Schema.Type< + typeof IntegrationOAuthState +>; diff --git a/integrations/core/src/request-signature.test.ts b/integrations/core/src/request-signature.test.ts new file mode 100644 index 000000000..5478d2f90 --- /dev/null +++ b/integrations/core/src/request-signature.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { + IntegrationRequestSignatureError, + verifyRequestSignature, +} from "./request-signature"; + +const okVerify = () => + verifyRequestSignature({ + maxAgeMs: 60_000, + signatureHeader: "sig", + timestampHeader: String(Math.floor(Date.now() / 1000)), + verify: () => Effect.void, + }); + +describe("verifyRequestSignature", () => { + it.effect("accepts a fresh request and delegates to the provider check", () => + okVerify().pipe( + Effect.tap((result) => Effect.sync(() => expect(result).toBeUndefined())) + ) + ); + + it.effect("rejects a missing header", () => + Effect.gen(function* () { + let verified = false; + const failure = yield* Effect.flip( + verifyRequestSignature({ + maxAgeMs: 60_000, + signatureHeader: undefined, + timestampHeader: undefined, + verify: () => + Effect.sync(() => { + verified = true; + }), + }) + ); + expect(failure.reason).toBe("Request signature headers are missing"); + expect(verified).toBe(false); + }) + ); + + it.effect("rejects a stale timestamp outside the freshness window", () => + Effect.gen(function* () { + const stale = String(Math.floor((Date.now() - 120_000) / 1000)); + const failure = yield* Effect.flip( + verifyRequestSignature({ + maxAgeMs: 60_000, + signatureHeader: "sig", + timestampHeader: stale, + verify: () => Effect.void, + }) + ); + expect(failure.reason).toBe("Request timestamp is stale"); + }) + ); + + it.effect("rejects a non-numeric timestamp", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + verifyRequestSignature({ + maxAgeMs: 60_000, + signatureHeader: "sig", + timestampHeader: "not-a-timestamp", + verify: () => Effect.void, + }) + ); + expect(failure.reason).toBe("Request timestamp is invalid"); + }) + ); + + it.effect("surfaces the provider check failure", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + verifyRequestSignature({ + maxAgeMs: 60_000, + signatureHeader: "sig", + timestampHeader: String(Math.floor(Date.now() / 1000)), + verify: () => + Effect.fail( + new IntegrationRequestSignatureError({ + reason: "cryptographic check failed", + }) + ), + }) + ); + expect(failure.reason).toBe("cryptographic check failed"); + }) + ); +}); diff --git a/integrations/core/src/request-signature.ts b/integrations/core/src/request-signature.ts new file mode 100644 index 000000000..e3d8dd08a --- /dev/null +++ b/integrations/core/src/request-signature.ts @@ -0,0 +1,56 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +/** Failure verifying a provider request signature (freshness window, scheme, or cryptographic match). */ +export class IntegrationRequestSignatureError extends Schema.TaggedErrorClass()( + "IntegrationRequestSignatureError", + { reason: Schema.String } +) {} + +/** + * Shared request-signature scaffolding used by the Slack (HMAC-SHA256) and + * Discord (Ed25519) providers: parses the request timestamp header, enforces + * the freshness window, then delegates the provider-specific cryptographic + * check. Providers own their wire format and crypto primitive; this owns the + * common "was this request forged or replayed" window. + */ +export const verifyRequestSignature = ({ + maxAgeMs, + now = Date.now(), + signatureHeader, + timestampHeader, + verify, +}: { + readonly maxAgeMs: number; + readonly now?: number; + readonly signatureHeader: string | undefined; + readonly timestampHeader: string | undefined; + readonly verify: (input: { + readonly signatureHeader: string; + readonly timestampHeader: string; + }) => Effect.Effect; +}): Effect.Effect => { + if (timestampHeader === undefined || signatureHeader === undefined) { + return Effect.fail( + new IntegrationRequestSignatureError({ + reason: "Request signature headers are missing", + }) + ); + } + const timestamp = Number(timestampHeader); + if (!Number.isFinite(timestamp)) { + return Effect.fail( + new IntegrationRequestSignatureError({ + reason: "Request timestamp is invalid", + }) + ); + } + if (Math.abs(now - timestamp * 1000) > maxAgeMs) { + return Effect.fail( + new IntegrationRequestSignatureError({ + reason: "Request timestamp is stale", + }) + ); + } + return verify({ signatureHeader, timestampHeader }); +}; diff --git a/integrations/discord/package.json b/integrations/discord/package.json new file mode 100644 index 000000000..da88fa35f --- /dev/null +++ b/integrations/discord/package.json @@ -0,0 +1,32 @@ +{ + "name": "@feeblo/integration-discord", + "type": "module", + "version": "0.0.1", + "private": true, + "exports": { + ".": "./src/index.ts", + "./manifest": "./src/discord-manifest.ts", + "./signature": "./src/discord-signature.ts", + "./credentials": "./src/discord-credentials.ts", + "./embeds": "./src/discord-embeds.ts", + "./inbound-schema": "./src/discord-inbound-schema.ts", + "./provider-registration": "./src/discord-provider-registration.ts" + }, + "scripts": { + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@feeblo/integration-core": "workspace:*", + "@feeblo/utils": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "4.0.0-beta.94", + "@feeblo/config": "workspace:*", + "@feeblo/id": "workspace:*", + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/integrations/discord/src/discord-api.ts b/integrations/discord/src/discord-api.ts new file mode 100644 index 000000000..0528bdf3e --- /dev/null +++ b/integrations/discord/src/discord-api.ts @@ -0,0 +1,479 @@ +import { + IntegrationProviderAuthenticationError, + IntegrationProviderInvalidConfigurationError, + IntegrationProviderPermanentRejection, + IntegrationProviderRateLimitedError, + IntegrationProviderTemporaryFailure, +} from "@feeblo/integration-core"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type { DiscordApiFailure } from "./discord-errors"; +import { discordProviderKey } from "./discord-manifest"; + +/** Discord API base URL. */ +export const DISCORD_API_BASE_URL = "https://discord.com/api/v10"; + +/** Discord OAuth endpoints. */ +export const DISCORD_OAUTH_AUTHORIZE_URL = + "https://discord.com/oauth2/authorize"; +export const DISCORD_OAUTH_TOKEN_URL = `${DISCORD_API_BASE_URL}/oauth2/token`; +export const DISCORD_OAUTH_TOKEN_REVOKE_URL = `${DISCORD_API_BASE_URL}/oauth2/token/revoke`; + +/** Maximum Discord API request duration. */ +export const DISCORD_API_REQUEST_TIMEOUT_MS = 10_000; + +/** Discord API error body (`{"code": , "message": }`). */ +export const DiscordApiErrorBody = Schema.Struct({ + code: Schema.optionalKey(Schema.Int), + message: Schema.optionalKey(Schema.String), + retry_after: Schema.optionalKey(Schema.Number), +}); +export type DiscordApiErrorBody = Schema.Schema.Type< + typeof DiscordApiErrorBody +>; + +/** Discord error codes that mean the bot lacks access rather than a bad request. */ +const DISCORD_MISSING_ACCESS_CODES = new Set([50_001, 50_013]); + +/** Discord error codes that mean the referenced guild/channel no longer exists. */ +const DISCORD_NOT_FOUND_CODES = new Set([10_003, 10_004, 10_008]); + +/** `oauth2/token` response carrying the installer token and the guild the bot joined. */ +export const DiscordOAuthTokenResponse = Schema.Struct({ + access_token: Schema.String, + token_type: Schema.String, + expires_in: Schema.Int, + scope: Schema.String, + guild: Schema.optionalKey( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + icon: Schema.optionalKey(Schema.NullOr(Schema.String)), + }) + ), + user: Schema.optionalKey( + Schema.Struct({ + id: Schema.String, + username: Schema.String, + global_name: Schema.optionalKey(Schema.NullOr(Schema.String)), + }) + ), +}); +export type DiscordOAuthTokenResponse = Schema.Schema.Type< + typeof DiscordOAuthTokenResponse +>; + +/** `applications/@me` response used to identify the bot application. */ +export const DiscordApplicationResponse = Schema.Struct({ + id: Schema.String, + name: Schema.String, + description: Schema.optionalKey(Schema.String), +}); +export type DiscordApplicationResponse = Schema.Schema.Type< + typeof DiscordApplicationResponse +>; + +/** One channel returned by `guilds/{guildId}/channels`. */ +export const DiscordChannel = Schema.Struct({ + id: Schema.String, + name: Schema.String, + type: Schema.Int, + parent_id: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +export type DiscordChannel = Schema.Schema.Type; + +/** `channels/{id}/messages` response. */ +export const DiscordMessageResponse = Schema.Struct({ + id: Schema.String, + channel_id: Schema.String, + content: Schema.String, +}); +export type DiscordMessageResponse = Schema.Schema.Type< + typeof DiscordMessageResponse +>; + +/** One registered guild command returned by the commands API. */ +export const DiscordGuildCommand = Schema.Struct({ + id: Schema.String, + name: Schema.String, + type: Schema.Int, +}); +export type DiscordGuildCommand = Schema.Schema.Type< + typeof DiscordGuildCommand +>; + +/** + * Classifies a failed Discord API response into the typed provider failure + * algebra. Discord answers with real HTTP status codes and a JSON error body + * carrying an application error code; decode enough of it so transient errors + * can be retried with the correct backoff. + */ +export const classifyDiscordApiError = ( + response: { + readonly body?: unknown; + readonly status?: number; + }, + context: string +): DiscordApiFailure => { + const status = response.status; + const decoded = Schema.decodeUnknownOption(DiscordApiErrorBody)( + response.body ?? {} + ); + const errorCode = decoded._tag === "Some" ? decoded.value.code : undefined; + if (status === 401) { + return new IntegrationProviderAuthenticationError({ + message: `Discord rejected authentication during ${context}`, + provider: discordProviderKey, + httpStatus: status, + }); + } + if (status === 403) { + // Missing Access / Missing Permissions are configuration problems (the + // bot was not granted the right channel or guild permissions), not + // authentication failures. + if ( + errorCode !== undefined && + DISCORD_MISSING_ACCESS_CODES.has(errorCode) + ) { + return new IntegrationProviderInvalidConfigurationError({ + message: `Discord bot is missing access during ${context}`, + provider: discordProviderKey, + httpStatus: status, + }); + } + return new IntegrationProviderAuthenticationError({ + message: `Discord rejected authentication during ${context}`, + provider: discordProviderKey, + httpStatus: status, + }); + } + if (status === 404) { + if (errorCode !== undefined && DISCORD_NOT_FOUND_CODES.has(errorCode)) { + return new IntegrationProviderInvalidConfigurationError({ + message: `Discord channel or guild is invalid during ${context}`, + provider: discordProviderKey, + httpStatus: status, + }); + } + return new IntegrationProviderPermanentRejection({ + message: `Discord rejected ${context}: not found`, + provider: discordProviderKey, + httpStatus: status, + }); + } + if (status === 429) { + const retryAfterMs = + decoded._tag === "Some" && decoded.value.retry_after !== undefined + ? decoded.value.retry_after * 1000 + : undefined; + return new IntegrationProviderRateLimitedError({ + message: `Discord rate limited ${context}`, + provider: discordProviderKey, + ...(retryAfterMs === undefined ? {} : { retryAfterMs }), + httpStatus: status, + }); + } + if (status !== undefined && status >= 500) { + return new IntegrationProviderTemporaryFailure({ + message: `Discord temporarily failed during ${context}`, + provider: discordProviderKey, + httpStatus: status, + }); + } + return new IntegrationProviderPermanentRejection({ + message: `Discord rejected ${context}${errorCode === undefined ? "" : `: error ${errorCode}`}`, + provider: discordProviderKey, + ...(status === undefined ? {} : { httpStatus: status }), + }); +}; + +/** + * Discord API client. Every method takes the bot token and classifies failures + * into the typed provider failure algebra; response bodies and tokens never + * appear in failure messages. + */ +export interface DiscordApiClient { + readonly applicationsMe: (input: { + readonly botToken: Redacted.Redacted; + }) => Effect.Effect; + readonly channelsMessagesCreate: (input: { + readonly botToken: Redacted.Redacted; + readonly channelId: string; + readonly content?: string; + readonly embeds: readonly unknown[]; + }) => Effect.Effect; + readonly guildsChannels: (input: { + readonly botToken: Redacted.Redacted; + readonly guildId: string; + }) => Effect.Effect; + readonly guildsCommandsBulkOverwrite: (input: { + readonly applicationId: string; + readonly botToken: Redacted.Redacted; + readonly commands: readonly unknown[]; + readonly guildId: string; + }) => Effect.Effect; + /** Removes the application bot from one Discord guild. */ + readonly guildsLeave: (input: { + readonly botToken: Redacted.Redacted; + readonly guildId: string; + }) => Effect.Effect; + readonly oauth2TokenExchange: (input: { + readonly clientId: string; + readonly clientSecret: Redacted.Redacted; + readonly code: string; + readonly redirectUri: string; + }) => Effect.Effect; + readonly oauth2TokenRevoke: (input: { + readonly clientId: string; + readonly clientSecret: Redacted.Redacted; + readonly userToken: Redacted.Redacted; + }) => Effect.Effect; +} + +/** Creates a Discord API client, using the supplied HTTP client or fetch by default. */ +export const makeDiscordApiClient = ( + httpClient?: HttpClient.HttpClient +): DiscordApiClient => { + const request = Effect.fn("DiscordApi.request")( + (input: { + readonly httpRequest: HttpClientRequest.HttpClientRequest; + readonly context: string; + readonly responseBody?: "empty" | "json"; + }) => + Effect.gen(function* () { + const execute = HttpClient.execute(input.httpRequest); + const response = yield* ( + httpClient === undefined + ? execute.pipe(Effect.provide(FetchHttpClient.layer)) + : execute.pipe( + Effect.provideService(HttpClient.HttpClient, httpClient) + ) + ).pipe( + Effect.mapError( + () => + new IntegrationProviderTemporaryFailure({ + message: `Discord request failed during ${input.context}`, + provider: discordProviderKey, + }) + ) + ); + const status = response.status; + if (status < 200 || status >= 300) { + // Read every error response best-effort so status and Discord's error + // code participate in the same classification path. + const bodyResult = yield* Effect.result(response.json); + const body = Result.isSuccess(bodyResult) + ? bodyResult.success + : undefined; + return yield* classifyDiscordApiError( + { body, status }, + input.context + ); + } + if (input.responseBody === "empty") { + return undefined; + } + return yield* response.json.pipe( + Effect.mapError( + () => + new IntegrationProviderPermanentRejection({ + message: `Discord returned an unexpected response during ${input.context}`, + provider: discordProviderKey, + httpStatus: status, + }) + ) + ); + }).pipe( + Effect.timeout(DISCORD_API_REQUEST_TIMEOUT_MS), + Effect.catchTag( + "TimeoutError", + () => + new IntegrationProviderTemporaryFailure({ + message: `Discord request failed during ${input.context}`, + provider: discordProviderKey, + }) + ) + ) + ); + + const jsonRequest = ( + method: "DELETE" | "GET" | "POST" | "PUT", + path: string, + { + body, + botToken, + }: { + readonly body?: unknown; + readonly botToken?: Redacted.Redacted; + }, + context: string + ): Effect.Effect => + Effect.gen(function* () { + const url = `${DISCORD_API_BASE_URL}${path}`; + let httpRequest = HttpClientRequest.make(method)(url); + if (botToken !== undefined) { + httpRequest = HttpClientRequest.setHeader( + httpRequest, + "authorization", + `Bot ${Redacted.value(botToken)}` + ); + } + if (body !== undefined) { + httpRequest = yield* HttpClientRequest.bodyJson(httpRequest, body).pipe( + Effect.mapError( + () => + new IntegrationProviderPermanentRejection({ + message: `Discord ${context} request could not be encoded`, + provider: discordProviderKey, + }) + ) + ); + } + return yield* request({ httpRequest, context }); + }); + + const decodeResponse = + (schema: S, context: string) => + ( + effect: Effect.Effect + ): Effect.Effect => + effect.pipe( + Effect.flatMap((body) => + Schema.decodeUnknownEffect(schema)(body).pipe( + Effect.mapError( + () => + new IntegrationProviderPermanentRejection({ + message: `Discord ${context} response was invalid`, + provider: discordProviderKey, + }) + ) + ) + ) + ); + + const formRequest = ( + url: string, + body: Record, + context: string + ): Effect.Effect => + request({ + httpRequest: HttpClientRequest.post(url, { + headers: { "content-type": "application/x-www-form-urlencoded" }, + }).pipe(HttpClientRequest.bodyUrlParams(body)), + context, + }); + + return { + applicationsMe: ({ botToken }) => + decodeResponse( + DiscordApplicationResponse, + "applications.@me" + )( + jsonRequest( + "GET", + "/applications/@me", + { botToken }, + "applications.@me" + ) + ), + channelsMessagesCreate: ({ botToken, channelId, content, embeds }) => + decodeResponse( + DiscordMessageResponse, + "channels.messages.create" + )( + jsonRequest( + "POST", + `/channels/${channelId}/messages`, + { + body: { + ...(content === undefined ? {} : { content }), + embeds, + }, + botToken, + }, + "channels.messages.create" + ) + ), + guildsChannels: ({ botToken, guildId }) => + decodeResponse( + Schema.Array(DiscordChannel), + "guilds.channels" + )( + jsonRequest( + "GET", + `/guilds/${guildId}/channels`, + { botToken }, + "guilds.channels" + ) + ), + guildsCommandsBulkOverwrite: ({ + applicationId, + botToken, + commands, + guildId, + }) => + decodeResponse( + Schema.Array(DiscordGuildCommand), + "guilds.commands.bulkOverwrite" + )( + jsonRequest( + "PUT", + `/applications/${applicationId}/guilds/${guildId}/commands`, + { body: commands, botToken }, + "guilds.commands.bulkOverwrite" + ) + ), + guildsLeave: ({ botToken, guildId }) => + request({ + httpRequest: HttpClientRequest.make("DELETE")( + `${DISCORD_API_BASE_URL}/users/@me/guilds/${guildId}` + ).pipe( + HttpClientRequest.setHeader( + "authorization", + `Bot ${Redacted.value(botToken)}` + ) + ), + context: "guilds.leave", + responseBody: "empty", + }).pipe(Effect.as(undefined)), + oauth2TokenExchange: ({ clientId, clientSecret, code, redirectUri }) => + decodeResponse( + DiscordOAuthTokenResponse, + "oauth2.token" + )( + formRequest( + DISCORD_OAUTH_TOKEN_URL, + { + client_id: clientId, + client_secret: Redacted.value(clientSecret), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }, + "oauth2.token" + ) + ), + oauth2TokenRevoke: ({ clientId, clientSecret, userToken }) => + request({ + httpRequest: HttpClientRequest.post(DISCORD_OAUTH_TOKEN_REVOKE_URL, { + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${Buffer.from( + `${clientId}:${Redacted.value(clientSecret)}` + ).toString("base64")}`, + }, + }).pipe( + HttpClientRequest.bodyUrlParams({ + token: Redacted.value(userToken), + }) + ), + context: "oauth2.token.revoke", + responseBody: "empty", + }).pipe(Effect.as(undefined)), + }; +}; diff --git a/integrations/discord/src/discord-credentials.test.ts b/integrations/discord/src/discord-credentials.test.ts new file mode 100644 index 000000000..8ca767b3f --- /dev/null +++ b/integrations/discord/src/discord-credentials.test.ts @@ -0,0 +1,48 @@ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Redacted from "effect/Redacted"; +import { describe, expect, it } from "vitest"; +import { + decryptDiscordCredentialMaterial, + encryptDiscordCredentialMaterial, +} from "./discord-credentials"; + +const encryptionKey = Redacted.make("0123456789abcdef0123456789abcdef"); + +describe("discord credential material", () => { + it("round-trips the oauth state and user token", async () => { + const ciphertext = await Effect.runPromise( + encryptDiscordCredentialMaterial(encryptionKey, { + oauthState: "state-nonce", + userToken: "discord-user-token", + }) + ); + expect(ciphertext).not.toContain("state-nonce"); + expect(ciphertext).not.toContain("discord-user-token"); + + const decrypted = await Effect.runPromise( + decryptDiscordCredentialMaterial(encryptionKey, ciphertext) + ); + expect(decrypted.oauthState).toBe("state-nonce"); + if (decrypted.userToken === undefined) { + throw new Error("expected user token"); + } + expect(Redacted.value(decrypted.userToken)).toBe("discord-user-token"); + }); + + it("rejects a short encryption key", async () => { + const result = await Effect.runPromiseExit( + encryptDiscordCredentialMaterial(Redacted.make("short"), { + oauthState: "state-nonce", + }) + ); + expect(Exit.isFailure(result)).toBe(true); + }); + + it("fails to decrypt malformed ciphertext", async () => { + const result = await Effect.runPromiseExit( + decryptDiscordCredentialMaterial(encryptionKey, "not-ciphertext") + ); + expect(Exit.isFailure(result)).toBe(true); + }); +}); diff --git a/integrations/discord/src/discord-credentials.ts b/integrations/discord/src/discord-credentials.ts new file mode 100644 index 000000000..eac5eeee5 --- /dev/null +++ b/integrations/discord/src/discord-credentials.ts @@ -0,0 +1,59 @@ +import { + decryptIntegrationCredentialMaterial, + encryptIntegrationCredentialMaterial, +} from "@feeblo/integration-core"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +/** Encrypted at-rest value for Discord credentials and OAuth state. */ +export type EncryptedDiscordCredential = string; + +/** + * Stored encrypted credential structure; this schema crosses persistence only + * as one encrypted JSON value. + * + * Unlike Slack, the Discord bot token is application-wide and therefore comes + * from `DISCORD_BOT_TOKEN` configuration (exactly like the Slack signing + * secret): every guild install of the same bot shares one token, so encrypting + * it per connection would duplicate a single secret with no isolation benefit. + * The at-rest material therefore carries only the per-install OAuth artifacts. + */ +export const DiscordEncryptedCredentialMaterial = Schema.Struct({ + /** OAuth state nonce kept while a connection is `connecting`. */ + oauthState: Schema.optionalKey(Schema.String), + /** Discord user token from the OAuth handshake; unused by V1 and stored for future account linking. */ + userToken: Schema.optionalKey(Schema.String), +}); +export type DiscordEncryptedCredentialMaterial = Schema.Schema.Type< + typeof DiscordEncryptedCredentialMaterial +>; + +/** Encrypts the OAuth handshake material together. */ +export const encryptDiscordCredentialMaterial = ( + encryptionKey: Redacted.Redacted, + credentials: DiscordEncryptedCredentialMaterial +) => + encryptIntegrationCredentialMaterial( + encryptionKey, + DiscordEncryptedCredentialMaterial, + credentials + ); + +/** Decodes encrypted stored credentials at the provider boundary and immediately redacts secret-bearing fields. */ +export const decryptDiscordCredentialMaterial = ( + encryptionKey: Redacted.Redacted, + encryptedCredential: EncryptedDiscordCredential +) => + decryptIntegrationCredentialMaterial( + encryptionKey, + DiscordEncryptedCredentialMaterial, + encryptedCredential, + (decoded) => ({ + ...(decoded.oauthState === undefined + ? {} + : { oauthState: decoded.oauthState }), + ...(decoded.userToken === undefined + ? {} + : { userToken: Redacted.make(decoded.userToken) }), + }) + ); diff --git a/integrations/discord/src/discord-embeds.test.ts b/integrations/discord/src/discord-embeds.test.ts new file mode 100644 index 000000000..b994c40fa --- /dev/null +++ b/integrations/discord/src/discord-embeds.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + DISCORD_EMBED_TEXT_MAX, + renderChannelUpdateMessageEmbed, + renderDiscordFeedbackConfirmationEmbed, + TRUNCATED_FOOTER_MAX, + TRUNCATED_TITLE_MAX, +} from "./discord-embeds"; + +describe("renderChannelUpdateMessageEmbed", () => { + const message = { + actionUrl: "https://feeblo.example/org/post/board/slug", + actorName: "Ada Lovelace", + eventType: "feedback.post.created", + facts: [ + { label: "Board", value: "Product ideas" }, + { label: "Status", value: "PENDING" }, + { label: "customer_tier", value: "Enterprise" }, + ], + title: "Support dark mode", + }; + + it("renders a rich embed with title, url, facts, and actor footer", () => { + const embed = renderChannelUpdateMessageEmbed(message); + expect(embed.type).toBe("rich"); + expect(embed.title).toBe("Support dark mode"); + expect(embed.url).toBe(message.actionUrl); + expect(embed.description).toBe( + "**Board:** Product ideas\n**Status:** PENDING\n**Customer Tier:** Enterprise" + ); + expect(embed.footer?.text).toBe("Posted by Ada Lovelace"); + expect(embed.color).toBe(0x11_18_27); + }); + + it("truncates long titles", () => { + const embed = renderChannelUpdateMessageEmbed({ + ...message, + title: "x".repeat(300), + }); + expect(embed.title.length).toBeLessThanOrEqual(256); + expect(embed.title.endsWith("…")).toBe(true); + }); + + it("truncates the complete description and footer to Discord limits", () => { + const embed = renderChannelUpdateMessageEmbed({ + ...message, + actorName: "a".repeat(3000), + facts: Array.from({ length: 10 }, (_, index) => ({ + label: `Fact ${index}`, + value: "v".repeat(1024), + })), + }); + expect(embed.description?.length).toBeLessThanOrEqual(4096); + expect(embed.footer?.text.length).toBeLessThanOrEqual(2048); + expect(embed.footer?.text.startsWith("Posted by ")).toBe(true); + }); + + it("keeps aggregate embed text at Discord's exact limit", () => { + const embed = renderChannelUpdateMessageEmbed({ + ...message, + title: "t".repeat(TRUNCATED_TITLE_MAX), + actorName: "a".repeat(TRUNCATED_FOOTER_MAX), + facts: Array.from({ length: 4 }, () => ({ + label: "Fact", + value: "v".repeat(1024), + })), + }); + + expect( + embed.title.length + + (embed.description?.length ?? 0) + + (embed.footer?.text.length ?? 0) + ).toBe(DISCORD_EMBED_TEXT_MAX); + }); + + it("omits the actor footer when no actor name is known", () => { + const { actorName: _actorName, ...withoutActor } = message; + const embed = renderChannelUpdateMessageEmbed(withoutActor); + expect(embed).not.toHaveProperty("footer"); + }); + + it("omits the description when there are no facts", () => { + const embed = renderChannelUpdateMessageEmbed({ + ...message, + facts: [], + }); + expect(embed).not.toHaveProperty("description"); + }); +}); + +describe("renderDiscordFeedbackConfirmationEmbed", () => { + it("renders the submitted feedback and its metadata", () => { + const embed = renderDiscordFeedbackConfirmationEmbed({ + actionUrl: "https://feeblo.example/org/post/ideas/dark-mode", + boardName: "Product ideas", + metadata: { + customer_tier: "Enterprise", + empty_value: " ", + region: "Asia Pacific", + }, + postId: "post_123", + status: "IN_PROGRESS", + submitterName: "Ada Lovelace", + title: "Support dark mode", + }); + + expect(embed).toMatchObject({ + title: "Support dark mode", + url: "https://feeblo.example/org/post/ideas/dark-mode", + description: "Your feedback was successfully added to Feeblo.", + fields: [ + { inline: true, name: "Board", value: "Product ideas" }, + { inline: true, name: "Status", value: "In Progress" }, + { inline: true, name: "Source", value: "Discord" }, + { inline: true, name: "Submitted by", value: "Ada Lovelace" }, + { inline: false, name: "Post ID", value: "`post_123`" }, + { inline: true, name: "Customer Tier", value: "Enterprise" }, + { inline: true, name: "Region", value: "Asia Pacific" }, + ], + footer: { text: "Only you can see this confirmation." }, + }); + }); + + it("uses a safe submitter fallback and respects Discord field limits", () => { + const embed = renderDiscordFeedbackConfirmationEmbed({ + actionUrl: "https://feeblo.example/post", + boardName: "b".repeat(2000), + metadata: Object.fromEntries( + Array.from({ length: 30 }, (_, index) => [ + `metadata_${index}`, + "v".repeat(1024), + ]) + ), + postId: "p".repeat(2000), + status: "PENDING", + submitterName: "", + title: "Feedback", + }); + + expect(embed.fields?.[0]?.value.length).toBeLessThanOrEqual(1024); + expect(embed.fields?.[3]?.value).toBe("Discord user"); + expect(embed.fields?.[4]?.value.length).toBeLessThanOrEqual(1024); + expect(embed.fields?.length).toBeLessThanOrEqual(25); + expect( + (embed.title.length ?? 0) + + (embed.description?.length ?? 0) + + (embed.footer?.text.length ?? 0) + + (embed.fields ?? []).reduce( + (length, field) => length + field.name.length + field.value.length, + 0 + ) + ).toBeLessThanOrEqual(DISCORD_EMBED_TEXT_MAX); + }); +}); diff --git a/integrations/discord/src/discord-embeds.ts b/integrations/discord/src/discord-embeds.ts new file mode 100644 index 000000000..862a7bb76 --- /dev/null +++ b/integrations/discord/src/discord-embeds.ts @@ -0,0 +1,195 @@ +import type { ChannelUpdateMessage } from "@feeblo/integration-core"; + +export type { ChannelUpdateMessage } from "@feeblo/integration-core"; + +import { truncate } from "@feeblo/utils/text"; + +/** Discord's maximum aggregate text length across an embed's title, description, and footer. */ +export const DISCORD_EMBED_TEXT_MAX = 6000; + +/** Discord's maximum embed title length. */ +export const TRUNCATED_TITLE_MAX = 256; +/** Discord's maximum embed description length. */ +export const TRUNCATED_DESCRIPTION_MAX = 4096; +/** Discord's maximum embed footer length. */ +export const TRUNCATED_FOOTER_MAX = 2048; +const TRUNCATED_FACT_LABEL_MAX = 256; +const TRUNCATED_FACT_VALUE_MAX = 1024; +const DISCORD_EMBED_FIELD_MAX = 25; + +/** Feeblo brand color rendered as the embed accent. */ +const EMBED_COLOR = 0x11_18_27; + +/** Rich Discord embed fields emitted for channel notifications. */ +export interface DiscordEmbed { + readonly color: number; + readonly description?: string; + readonly fields?: readonly DiscordEmbedField[]; + readonly footer?: { readonly text: string }; + readonly title: string; + readonly type: "rich"; + readonly url: string; +} + +/** One labelled metadata value displayed inside a Discord embed. */ +export interface DiscordEmbedField { + readonly inline: boolean; + readonly name: string; + readonly value: string; +} + +/** Metadata shown after Discord feedback is successfully submitted. */ +export interface DiscordFeedbackConfirmation { + readonly actionUrl: string; + readonly boardName: string; + readonly metadata: Readonly>; + readonly postId: string; + readonly status: string; + readonly submitterName: string; + readonly title: string; +} + +const formatDiscordMetadataValue = (value: string): string => + value + .toLowerCase() + .split("_") + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(" "); + +/** Renders the private success confirmation for feedback submitted from Discord. */ +export const renderDiscordFeedbackConfirmationEmbed = ( + confirmation: DiscordFeedbackConfirmation +): DiscordEmbed => { + const title = truncate(confirmation.title, TRUNCATED_TITLE_MAX); + const description = "Your feedback was successfully added to Feeblo."; + const footer = { text: "Only you can see this confirmation." }; + const postFields: readonly DiscordEmbedField[] = [ + { + inline: true, + name: "Board", + value: truncate(confirmation.boardName, TRUNCATED_FACT_VALUE_MAX), + }, + { + inline: true, + name: "Status", + value: truncate( + formatDiscordMetadataValue(confirmation.status), + TRUNCATED_FACT_VALUE_MAX + ), + }, + { + inline: true, + name: "Source", + value: "Discord", + }, + { + inline: true, + name: "Submitted by", + value: truncate( + confirmation.submitterName || "Discord user", + TRUNCATED_FACT_VALUE_MAX + ), + }, + { + inline: false, + name: "Post ID", + value: `\`${truncate(confirmation.postId, TRUNCATED_FACT_VALUE_MAX - 2)}\``, + }, + ]; + let remainingTextLength = + DISCORD_EMBED_TEXT_MAX - + title.length - + description.length - + footer.text.length - + postFields.reduce( + (length, field) => length + field.name.length + field.value.length, + 0 + ); + const metadataFields: DiscordEmbedField[] = []; + for (const [metadataName, metadataValue] of Object.entries( + confirmation.metadata + )) { + if ( + metadataFields.length >= DISCORD_EMBED_FIELD_MAX - postFields.length || + remainingTextLength <= 0 + ) { + break; + } + const name = truncate( + formatDiscordMetadataValue(metadataName.trim()), + TRUNCATED_FACT_LABEL_MAX + ); + const normalizedValue = metadataValue.trim(); + if (name.length === 0 || normalizedValue.length === 0) { + continue; + } + const availableValueLength = Math.min( + TRUNCATED_FACT_VALUE_MAX, + remainingTextLength - name.length + ); + if (availableValueLength <= 0) { + break; + } + const value = truncate(normalizedValue, availableValueLength); + metadataFields.push({ inline: true, name, value }); + remainingTextLength -= name.length + value.length; + } + + return { + type: "rich", + title, + url: confirmation.actionUrl, + description, + color: EMBED_COLOR, + fields: [...postFields, ...metadataFields], + footer, + }; +}; + +/** + * Renders a channel-update message into a Discord message embed for + * `channels/{id}/messages`. Embeds are plain JSON values so this module stays + * importable from browser-safe code. + */ +export const renderChannelUpdateMessageEmbed = ( + message: ChannelUpdateMessage +): DiscordEmbed => { + const title = truncate(message.title, TRUNCATED_TITLE_MAX); + const footer = + message.actorName !== undefined && message.actorName.length > 0 + ? { + text: truncate( + `Posted by ${message.actorName}`, + TRUNCATED_FOOTER_MAX + ), + } + : undefined; + const descriptionMax = Math.min( + TRUNCATED_DESCRIPTION_MAX, + DISCORD_EMBED_TEXT_MAX - title.length - (footer?.text.length ?? 0) + ); + + return { + type: "rich", + title, + url: message.actionUrl, + ...(message.facts.length > 0 + ? { + description: truncate( + message.facts + .map( + ({ label, value }) => + `**${truncate( + formatDiscordMetadataValue(label), + TRUNCATED_FACT_LABEL_MAX + )}:** ${truncate(value, TRUNCATED_FACT_VALUE_MAX)}` + ) + .join("\n"), + descriptionMax + ), + } + : {}), + color: EMBED_COLOR, + ...(footer === undefined ? {} : { footer }), + }; +}; diff --git a/integrations/discord/src/discord-errors.ts b/integrations/discord/src/discord-errors.ts new file mode 100644 index 000000000..05ac776e5 --- /dev/null +++ b/integrations/discord/src/discord-errors.ts @@ -0,0 +1,34 @@ +/** biome-ignore-all lint/performance/noBarrelFile: provider error re-exports are the public entry surface */ +import * as Schema from "effect/Schema"; + +export { + IntegrationCredentialEncryptionError as DiscordCredentialEncryptionError, + IntegrationProviderAuthenticationError, + IntegrationProviderInvalidConfigurationError, + IntegrationProviderPermanentRejection, + IntegrationProviderRateLimitedError, + IntegrationProviderTemporaryFailure, + IntegrationRequestSignatureError as DiscordSignatureVerificationError, +} from "@feeblo/integration-core"; + +import type { + IntegrationProviderAuthenticationError, + IntegrationProviderInvalidConfigurationError, + IntegrationProviderPermanentRejection, + IntegrationProviderRateLimitedError, + IntegrationProviderTemporaryFailure, +} from "@feeblo/integration-core"; + +/** Failure parsing a verified Discord interaction request body into its typed payload. */ +export class DiscordInboundPayloadError extends Schema.TaggedErrorClass()( + "DiscordInboundPayloadError", + { reason: Schema.String } +) {} + +/** Typed Discord API failure algebra re-exported at the provider boundary. */ +export type DiscordApiFailure = + | IntegrationProviderAuthenticationError + | IntegrationProviderRateLimitedError + | IntegrationProviderInvalidConfigurationError + | IntegrationProviderTemporaryFailure + | IntegrationProviderPermanentRejection; diff --git a/integrations/discord/src/discord-inbound-schema.ts b/integrations/discord/src/discord-inbound-schema.ts new file mode 100644 index 000000000..a9d167d06 --- /dev/null +++ b/integrations/discord/src/discord-inbound-schema.ts @@ -0,0 +1,179 @@ +import { IntegrationOAuthState } from "@feeblo/integration-core"; +import * as Schema from "effect/Schema"; + +/** Discord `PING` interaction; the endpoint must answer `PONG`. */ +export const DiscordPingPayload = Schema.Struct({ + id: Schema.String, + type: Schema.Literal(1), +}); +export type DiscordPingPayload = Schema.Schema.Type; + +/** Discord user object carried by interactions (the invoking user, never a secret). */ +export const DiscordInteractionUser = Schema.Struct({ + id: Schema.String, + username: Schema.String, + global_name: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +export type DiscordInteractionUser = Schema.Schema.Type< + typeof DiscordInteractionUser +>; + +/** Discord guild member object carried by guild interactions. */ +export const DiscordInteractionMember = Schema.Struct({ + user: DiscordInteractionUser, + nick: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +export type DiscordInteractionMember = Schema.Schema.Type< + typeof DiscordInteractionMember +>; + +/** One option of an application command invocation. */ +export const DiscordApplicationCommandOption = Schema.Struct({ + name: Schema.String, + type: Schema.Int, + value: Schema.optionalKey(Schema.Unknown), +}); +export type DiscordApplicationCommandOption = Schema.Schema.Type< + typeof DiscordApplicationCommandOption +>; + +/** Message resolved by a message context menu invocation (`data.resolved.messages`). */ +export const DiscordResolvedMessage = Schema.Struct({ + id: Schema.String, + content: Schema.String, +}); +export type DiscordResolvedMessage = Schema.Schema.Type< + typeof DiscordResolvedMessage +>; + +/** Application command data; `type` discriminates slash (1) from context menu (2 = user, 3 = message). */ +export const DiscordApplicationCommandData = Schema.Struct({ + id: Schema.String, + name: Schema.String, + type: Schema.Int, + options: Schema.optionalKey(Schema.Array(DiscordApplicationCommandOption)), + target_id: Schema.optionalKey(Schema.String), + resolved: Schema.optionalKey( + Schema.Struct({ + messages: Schema.optionalKey( + Schema.Record(Schema.String, DiscordResolvedMessage) + ), + }) + ), +}); +export type DiscordApplicationCommandData = Schema.Schema.Type< + typeof DiscordApplicationCommandData +>; + +/** Discord `APPLICATION_COMMAND` interaction (`/feeblo` and the "Send to Feeblo" context menu). */ +export const DiscordApplicationCommandPayload = Schema.Struct({ + id: Schema.String, + application_id: Schema.String, + type: Schema.Literal(2), + data: DiscordApplicationCommandData, + guild_id: Schema.String, + channel_id: Schema.String, + member: Schema.optionalKey(DiscordInteractionMember), + user: Schema.optionalKey(DiscordInteractionUser), + token: Schema.String, +}); +export type DiscordApplicationCommandPayload = Schema.Schema.Type< + typeof DiscordApplicationCommandPayload +>; + +/** One text input or string-select value submitted inside a modal label. */ +export const DiscordModalComponentValue = Schema.Union([ + Schema.Struct({ + type: Schema.Literal(4), + custom_id: Schema.String, + value: Schema.String, + }), + Schema.Struct({ + type: Schema.Literal(3), + custom_id: Schema.String, + values: Schema.Array(Schema.String), + }), +]); +export type DiscordModalComponentValue = Schema.Schema.Type< + typeof DiscordModalComponentValue +>; + +/** Modal submit data; each type-18 label wraps one submitted component. */ +export const DiscordModalSubmitData = Schema.Struct({ + custom_id: Schema.String, + components: Schema.Array( + Schema.Struct({ + type: Schema.Literal(18), + component: DiscordModalComponentValue, + }) + ), +}); +export type DiscordModalSubmitData = Schema.Schema.Type< + typeof DiscordModalSubmitData +>; + +/** Discord `MODAL_SUBMIT` interaction (the feedback form was sent). */ +export const DiscordModalSubmitPayload = Schema.Struct({ + id: Schema.String, + application_id: Schema.String, + type: Schema.Literal(5), + data: DiscordModalSubmitData, + guild_id: Schema.String, + channel_id: Schema.String, + member: Schema.optionalKey(DiscordInteractionMember), + user: Schema.optionalKey(DiscordInteractionUser), + token: Schema.String, +}); +export type DiscordModalSubmitPayload = Schema.Schema.Type< + typeof DiscordModalSubmitPayload +>; + +/** + * Catch-all for interaction types Feeblo does not handle: `3` (message + * component) and `4` (application command autocomplete). Feeblo messages + * carry embeds only and its commands have no autocomplete options, so these + * never fire in practice; the domain service answers them with a deferred + * acknowledgment. Kept as a closed literal union so the parsed payload stays + * a fully discriminated union over `type`. + */ +export const DiscordUnknownInteractionPayload = Schema.Struct({ + type: Schema.Literals([3, 4]), +}); +export type DiscordUnknownInteractionPayload = Schema.Schema.Type< + typeof DiscordUnknownInteractionPayload +>; + +/** Every Discord interaction payload Feeblo accepts; order matters, specific types first. */ +export const DiscordInteraction = Schema.Union([ + DiscordPingPayload, + DiscordApplicationCommandPayload, + DiscordModalSubmitPayload, + DiscordUnknownInteractionPayload, +]); +export type DiscordInteraction = Schema.Schema.Type; + +/** Provider-inbound request parsed into a discriminated payload for the domain inbound service. */ +export const ParsedDiscordInboundRequest = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("ping"), + payload: DiscordPingPayload, + }), + Schema.Struct({ + kind: Schema.Literal("application_command"), + payload: DiscordApplicationCommandPayload, + }), + Schema.Struct({ + kind: Schema.Literal("modal_submit"), + payload: DiscordModalSubmitPayload, + }), + Schema.Struct({ + kind: Schema.Literal("unknown"), + payload: DiscordUnknownInteractionPayload, + }), +]); +export type ParsedDiscordInboundRequest = Schema.Schema.Type< + typeof ParsedDiscordInboundRequest +>; + +export const DiscordOAuthState = IntegrationOAuthState; +export type DiscordOAuthState = Schema.Schema.Type; diff --git a/integrations/discord/src/discord-manifest.ts b/integrations/discord/src/discord-manifest.ts new file mode 100644 index 000000000..d6c92cd2b --- /dev/null +++ b/integrations/discord/src/discord-manifest.ts @@ -0,0 +1,112 @@ +/** Browser-safe Discord capability metadata; this module imports no Node APIs. */ +import { + IntegrationCapabilityKey, + IntegrationProviderKey, + IntegrationProviderManifest, +} from "@feeblo/integration-core"; +import * as Schema from "effect/Schema"; + +/** Provider key owned by the Discord adapter, outside the provider-neutral kernel. */ +export const discordProviderKey = IntegrationProviderKey.make("discord"); + +/** Discord OAuth scopes requested during server installation. */ +export const DISCORD_OAUTH_SCOPES = [ + // Identify the installer so the connection records who added the bot. + "identify", + // Registers `/feeblo` and the "Send to Feeblo" context menu in the guild. + "applications.commands", + // Adds the bot to the guild the user selects during install. + "bot", +] as const; + +/** + * Base bot permissions requested during install. Discord servers can still + * override these per channel; the dashboard lists channels regardless and the + * delivery worker surfaces permission failures through the typed failure + * algebra. + * + * View Channels (1 << 10) | Send Messages (1 << 11) | + * Embed Links (1 << 14) | Read Message History (1 << 16) + */ +export const DISCORD_OAUTH_PERMISSIONS = 84_992; + +/** + * Guild commands registered on connect (`PUT /applications/{app}/guilds/{guild}/commands`). + * Guild-scoped commands appear instantly, unlike global commands which can + * take up to an hour to propagate — the right fit for a per-organization + * install model. + */ +export const DISCORD_GUILD_COMMANDS = [ + { + name: "feeblo", + type: 1, + description: "Send feedback to Feeblo", + options: [ + { + type: 3, + name: "text", + description: "Your feedback (optional)", + required: false, + }, + ], + }, + { + name: "Send to Feeblo", + // Message context menu (Discord type 3); context menus have no + // description or options, and their names may contain spaces. + type: 3, + }, +] as const; + +/** Discord text channel types the bot can post notifications into. */ +export const DISCORD_NOTIFICATION_CHANNEL_TYPES = [0, 5] as const; + +/** + * Browser-safe connection form data. Discord connections are created through + * OAuth (never a form), so the connection configuration is an empty record; + * it exists to satisfy the provider contract. + */ +export const DiscordConnectionConfiguration = Schema.Struct({}); + +/** + * Browser-safe route configuration for the `channel.notifications` + * capability: the Discord text channel the bot posts new-post updates into. + */ +export const DiscordChannelNotificationRouteConfiguration = Schema.Struct({ + version: Schema.Literal(1), + channelId: Schema.NonEmptyString, +}); +export type DiscordChannelNotificationRouteConfiguration = Schema.Schema.Type< + typeof DiscordChannelNotificationRouteConfiguration +>; + +/** Browser-safe route configuration for the inbound Discord capability; nothing to configure. */ +export const DiscordInboundRouteConfiguration = Schema.Struct({ + version: Schema.Literal(1), +}); + +/** Browser-safe Discord provider manifest consumed by management UI and registry composition. */ +export const discordProviderManifest = IntegrationProviderManifest.make({ + provider: discordProviderKey, + displayName: "Discord", + connectionMode: "oauth2", + capabilities: [ + { + key: IntegrationCapabilityKey.make("channel.notifications"), + direction: "outbound", + configVersion: 1, + }, + { + key: IntegrationCapabilityKey.make("interactions"), + direction: "inbound", + configVersion: 1, + }, + ], +}); + +export type DiscordConnectionConfiguration = Schema.Schema.Type< + typeof DiscordConnectionConfiguration +>; +export type DiscordInboundRouteConfiguration = Schema.Schema.Type< + typeof DiscordInboundRouteConfiguration +>; diff --git a/integrations/discord/src/discord-provider-registration.test.ts b/integrations/discord/src/discord-provider-registration.test.ts new file mode 100644 index 000000000..0b85aacdf --- /dev/null +++ b/integrations/discord/src/discord-provider-registration.test.ts @@ -0,0 +1,378 @@ +import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, it } from "@effect/vitest"; +import { + asLegid, + IntegrationConnectionId, + IntegrationDeliveryId, + IntegrationEventId, + IntegrationRouteId, + WorkspaceId, +} from "@feeblo/id"; +import { + type IntegrationProviderDeliveryInput, + IntegrationProviderInvalidConfigurationError, +} from "@feeblo/integration-core"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Redacted from "effect/Redacted"; +import type { DiscordApiClient } from "./discord-api"; +import { discordProviderKey } from "./discord-manifest"; +import { makeDiscordProviderRegistration } from "./discord-provider-registration"; + +// Discord interaction signatures are Ed25519; the test keypair is generated +// inline so no fixture secret is needed. +const { publicKeyHex, privateKeyHex } = (() => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + return { + publicKeyHex: publicKey + .export({ format: "der", type: "spki" }) + .subarray(-32) + .toString("hex"), + privateKeyHex: privateKey + .export({ format: "der", type: "pkcs8" }) + .toString("hex"), + }; +})(); + +const signBody = (timestamp: string, rawBody: string) => + sign( + null, + Buffer.from(`${timestamp}${rawBody}`), + createPrivateKey({ + key: Buffer.from(privateKeyHex, "hex"), + format: "der", + type: "pkcs8", + }) + ).toString("hex"); + +const signedHeaders = (rawBody: string) => { + const timestamp = String(Math.floor(Date.now() / 1000)); + return { + headers: { + "x-signature-ed25519": signBody(timestamp, rawBody), + "x-signature-timestamp": timestamp, + }, + rawBody, + }; +}; + +const deliveryInput = ( + overrides: Partial = {} +): IntegrationProviderDeliveryInput => ({ + connection: { + credentialGeneration: 1, + id: asLegid(IntegrationConnectionId)("conn_1"), + lifecycleStatus: "active", + name: "Acme", + organizationId: asLegid(WorkspaceId)("org_1"), + provider: discordProviderKey, + safeMetadata: {}, + }, + delivery: { + actionKey: "channel.notifications:route_1", + attemptCount: 0, + eventId: asLegid(IntegrationEventId)("event_1"), + id: asLegid(IntegrationDeliveryId)("delivery_1"), + leaseExpiresAt: null, + leaseOwner: null, + nextAttemptAt: DateTime.makeUnsafe(new Date()), + orderingKey: null, + routeId: asLegid(IntegrationRouteId)("route_1"), + state: "pending", + }, + event: { + causalHopCount: 0, + correlationId: "corr_1", + data: { + actor: { kind: "end_user" }, + board: { id: "brd_1", name: "Ideas", slug: "ideas" }, + post: { + id: "pst_1", + metadata: { customer_tier: "Enterprise" }, + status: { id: "pss_1", type: "PENDING" }, + title: "Dark mode please", + url: "https://feeblo.example/org/post/ideas/dark-mode", + }, + }, + id: asLegid(IntegrationEventId)("event_1"), + occurredAt: DateTime.makeUnsafe(new Date("2026-08-12T00:00:00.000Z")), + organizationId: asLegid(WorkspaceId)("org_1"), + origin: { kind: "feeblo" }, + type: "feedback.post.created", + version: 1, + }, + route: { + capabilityKey: "channel.notifications", + configVersion: 1, + connectionId: asLegid(IntegrationConnectionId)("conn_1"), + enabled: true, + eventTypes: ["feedback.post.created"], + id: asLegid(IntegrationRouteId)("route_1"), + provider: discordProviderKey, + providerConfig: { channelId: "123456789012345678", version: 1 }, + safeMetadata: { channelName: "feedback" }, + }, + ...overrides, +}); + +const credentialResolver = { + loadDiscordCredentials: () => + Effect.succeed({ botToken: Redacted.make("discord-bot-token") }), +}; + +const makePostMessageSpy = () => { + let lastCall: + | { + readonly channelId: string; + readonly embeds: readonly unknown[]; + } + | undefined; + const apiClient: DiscordApiClient = { + applicationsMe: () => Effect.succeed({ id: "app_1", name: "Feeblo" }), + channelsMessagesCreate: ({ channelId, embeds }) => { + lastCall = { channelId, embeds }; + return Effect.succeed({ + channel_id: channelId, + content: "", + id: "message_1", + }); + }, + guildsChannels: () => Effect.succeed([]), + guildsCommandsBulkOverwrite: () => Effect.succeed([]), + guildsLeave: () => Effect.void, + oauth2TokenExchange: () => + Effect.succeed({ + access_token: "user-token", + expires_in: 604_800, + guild: { id: "guild_1", name: "Acme" }, + scope: "identify applications.commands bot", + token_type: "Bearer", + }), + oauth2TokenRevoke: () => Effect.void, + }; + return { apiClient, getLastCall: () => lastCall }; +}; + +describe("discord provider registration", () => { + it.effect( + "posts a channel-update embed for feedback.post.created deliveries", + () => + Effect.gen(function* () { + const spy = makePostMessageSpy(); + const registration = makeDiscordProviderRegistration({ + apiClient: spy.apiClient, + credentialResolver, + publicKey: publicKeyHex, + }); + const handler = registration.handlers.find( + (candidate) => candidate.capabilityKey === "channel.notifications" + ); + expect(handler).toBeDefined(); + if (handler === undefined) { + return; + } + + const result = yield* Effect.exit(handler.deliver(deliveryInput())); + expect(Exit.isSuccess(result)).toBe(true); + + const call = spy.getLastCall(); + expect(call?.channelId).toBe("123456789012345678"); + const embed = call?.embeds[0] as { + description: string; + title: string; + url: string; + }; + expect(embed.title).toBe("Dark mode please"); + expect(embed.description).toContain("**Customer Tier:** Enterprise"); + expect(embed.url).toBe( + "https://feeblo.example/org/post/ideas/dark-mode" + ); + }) + ); + + it.effect("rejects unsupported event types as invalid configuration", () => + Effect.gen(function* () { + const spy = makePostMessageSpy(); + const registration = makeDiscordProviderRegistration({ + apiClient: spy.apiClient, + credentialResolver, + publicKey: publicKeyHex, + }); + const handler = registration.handlers[0]; + expect(handler).toBeDefined(); + if (handler === undefined) { + return; + } + const failure = yield* Effect.flip( + handler.deliver( + deliveryInput({ + event: { + ...deliveryInput().event, + type: "feedback.post.status_changed", + }, + }) + ) + ); + expect(failure._tag).toBe("IntegrationProviderInvalidConfigurationError"); + }) + ); + + it.effect("classifies a missing bot token as invalid configuration", () => + Effect.gen(function* () { + const spy = makePostMessageSpy(); + const registration = makeDiscordProviderRegistration({ + apiClient: spy.apiClient, + credentialResolver: { + loadDiscordCredentials: () => + Effect.fail( + new IntegrationProviderInvalidConfigurationError({ + message: "no credentials", + provider: discordProviderKey, + }) + ), + }, + publicKey: publicKeyHex, + }); + const handler = registration.handlers[0]; + expect(handler).toBeDefined(); + if (handler === undefined) { + return; + } + const failure = yield* Effect.flip(handler.deliver(deliveryInput())); + expect(failure._tag).toBe("IntegrationProviderInvalidConfigurationError"); + }) + ); + + it.effect("verifies inbound signatures and parses application commands", () => + Effect.gen(function* () { + const registration = makeDiscordProviderRegistration({ + credentialResolver, + publicKey: publicKeyHex, + }); + const interactions = registration.inboundHandlers.find( + (candidate) => candidate.capabilityKey === "interactions" + ); + expect(interactions).toBeDefined(); + + const rawBody = JSON.stringify({ + id: "interaction_1", + application_id: "app_1", + type: 2, + data: { + id: "command_1", + name: "feeblo", + type: 1, + options: [{ name: "text", type: 3, value: "Dark mode please" }], + }, + guild_id: "guild_1", + channel_id: "channel_1", + member: { user: { id: "user_1", username: "alice" } }, + token: "token_1", + }); + + const response = yield* interactions?.handle(signedHeaders(rawBody)) ?? + Effect.never; + expect(response.status).toBe(200); + const parsed = response.body as { + kind: string; + payload: { data: { name: string } }; + }; + expect(parsed.kind).toBe("application_command"); + expect(parsed.payload.data.name).toBe("feeblo"); + }) + ); + + it.effect("parses ping interactions", () => + Effect.gen(function* () { + const registration = makeDiscordProviderRegistration({ + credentialResolver, + publicKey: publicKeyHex, + }); + const interactions = registration.inboundHandlers.find( + (candidate) => candidate.capabilityKey === "interactions" + ); + const rawBody = JSON.stringify({ id: "ping_1", type: 1 }); + const response = yield* interactions?.handle(signedHeaders(rawBody)) ?? + Effect.never; + expect(response.status).toBe(200); + const parsed = response.body as { kind: string }; + expect(parsed.kind).toBe("ping"); + }) + ); + + it.effect("parses modal submissions", () => + Effect.gen(function* () { + const registration = makeDiscordProviderRegistration({ + credentialResolver, + publicKey: publicKeyHex, + }); + const interactions = registration.inboundHandlers.find( + (candidate) => candidate.capabilityKey === "interactions" + ); + const rawBody = JSON.stringify({ + id: "interaction_2", + application_id: "app_1", + type: 5, + data: { + custom_id: "feeblo:org_1:guild_1:channel_1", + components: [ + { + type: 18, + component: { type: 4, custom_id: "title", value: "Dark mode" }, + }, + { + type: 18, + component: { type: 4, custom_id: "details", value: "Please" }, + }, + { + type: 18, + component: { type: 3, custom_id: "board", values: ["brd_1"] }, + }, + ], + }, + guild_id: "guild_1", + channel_id: "channel_1", + member: { user: { id: "user_1", username: "alice" } }, + token: "token_2", + }); + const response = yield* interactions?.handle(signedHeaders(rawBody)) ?? + Effect.never; + expect(response.status).toBe(200); + const parsed = response.body as { kind: string }; + expect(parsed.kind).toBe("modal_submit"); + }) + ); + + it.effect("rejects unsigned inbound requests", () => + Effect.gen(function* () { + const registration = makeDiscordProviderRegistration({ + credentialResolver, + publicKey: publicKeyHex, + }); + const interactions = registration.inboundHandlers.find( + (candidate) => candidate.capabilityKey === "interactions" + ); + const response = yield* interactions?.handle({ + headers: {}, + rawBody: JSON.stringify({ type: 1 }), + }) ?? Effect.never; + expect(response.status).toBe(401); + }) + ); + + it.effect("rejects malformed JSON inbound requests", () => + Effect.gen(function* () { + const registration = makeDiscordProviderRegistration({ + credentialResolver, + publicKey: publicKeyHex, + }); + const interactions = registration.inboundHandlers.find( + (candidate) => candidate.capabilityKey === "interactions" + ); + const response = yield* interactions?.handle(signedHeaders("not-json")) ?? + Effect.never; + expect(response.status).toBe(400); + }) + ); +}); diff --git a/integrations/discord/src/discord-provider-registration.ts b/integrations/discord/src/discord-provider-registration.ts new file mode 100644 index 000000000..f7272be8c --- /dev/null +++ b/integrations/discord/src/discord-provider-registration.ts @@ -0,0 +1,229 @@ +import { + type IntegrationInboundCapabilityHandler, + type IntegrationInboundRequest, + type IntegrationInboundResponse, + IntegrationPostEventData, + type IntegrationProviderDeliveryInput, + IntegrationProviderInvalidConfigurationError, + type IntegrationProviderRegistration, + type IntegrationProviderTemporaryFailure, +} from "@feeblo/integration-core"; +import * as Effect from "effect/Effect"; +import type * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { type DiscordApiClient, makeDiscordApiClient } from "./discord-api"; +import { renderChannelUpdateMessageEmbed } from "./discord-embeds"; +import { DiscordInboundPayloadError } from "./discord-errors"; +import { + DiscordInteraction, + type ParsedDiscordInboundRequest, +} from "./discord-inbound-schema"; +import { + DiscordChannelNotificationRouteConfiguration, + DiscordConnectionConfiguration, + DiscordInboundRouteConfiguration, + discordProviderKey, + discordProviderManifest, +} from "./discord-manifest"; +import { verifyDiscordRequestSignature } from "./discord-signature"; + +/** + * Decrypted Discord provider credentials are supplied by the composition + * root, never stored in core-safe records. The Discord bot token is + * application-wide configuration (like the Slack signing secret), so the + * resolver simply hands it over for every delivery. + */ +export interface DiscordProviderCredentialResolver { + readonly loadDiscordCredentials: ( + input: IntegrationProviderDeliveryInput + ) => Effect.Effect< + { readonly botToken: Redacted.Redacted }, + | IntegrationProviderInvalidConfigurationError + | IntegrationProviderTemporaryFailure + >; +} + +/** Builds a credential resolver from the configured application bot token. */ +export const makeDiscordCredentialResolver = ({ + botToken, +}: { + readonly botToken: Redacted.Redacted; +}): DiscordProviderCredentialResolver => ({ + loadDiscordCredentials: () => Effect.succeed({ botToken }), +}); + +/** + * Parses a verified Discord interaction request body into the typed payload + * the domain inbound service consumes. Discord posts JSON (unlike Slack's + * form-encoded payloads), so this is a JSON decode plus a kind tag. + */ +const parseInteraction = ( + rawBody: string +): Effect.Effect => { + let json: unknown; + try { + json = JSON.parse(rawBody); + } catch { + return Effect.fail( + new DiscordInboundPayloadError({ + reason: "Discord interaction body is not JSON", + }) + ); + } + return Schema.decodeUnknownEffect(DiscordInteraction)(json).pipe( + Effect.map((interaction): ParsedDiscordInboundRequest => { + switch (interaction.type) { + case 1: + return { kind: "ping", payload: interaction }; + case 2: + return { kind: "application_command", payload: interaction }; + case 5: + return { kind: "modal_submit", payload: interaction }; + default: + return { kind: "unknown", payload: interaction }; + } + }), + Effect.mapError( + () => + new DiscordInboundPayloadError({ + reason: "Discord interaction payload is invalid", + }) + ) + ); +}; + +/** + * Creates the single inbound capability handler for Discord. Every + * interaction (ping, slash command, context menu, modal submit) arrives at + * one endpoint; the handler signature-verifies the request and parses the raw + * body, leaving response shaping to the domain inbound service. + */ +const makeDiscordInteractionsHandler = ({ + publicKey, +}: { + readonly publicKey: string; +}): IntegrationInboundCapabilityHandler => ({ + capabilityKey: "interactions", + handle: (input: IntegrationInboundRequest) => + Effect.gen(function* () { + const verified = yield* Effect.result( + verifyDiscordRequestSignature({ + publicKey, + rawBody: input.rawBody, + signatureHeader: input.headers["x-signature-ed25519"] ?? "", + timestampHeader: input.headers["x-signature-timestamp"] ?? "", + }) + ); + if (Result.isFailure(verified)) { + yield* Effect.logWarning( + "Discord interaction signature validation failed" + ).pipe(Effect.annotateLogs("reason", verified.failure.reason)); + return { + body: "invalid request signature", + status: 401, + } satisfies IntegrationInboundResponse; + } + const parsed = yield* Effect.result(parseInteraction(input.rawBody)); + if (Result.isFailure(parsed)) { + yield* Effect.logWarning( + "Discord interaction payload validation failed" + ).pipe(Effect.annotateLogs("reason", parsed.failure.reason)); + return { + body: "invalid request payload", + status: 400, + } satisfies IntegrationInboundResponse; + } + return { + body: parsed.success, + status: 200, + } satisfies IntegrationInboundResponse; + }), +}); + +/** Creates the static Discord provider registration. */ +export const makeDiscordProviderRegistration = ({ + apiClient = makeDiscordApiClient(), + credentialResolver, + publicKey, +}: { + readonly apiClient?: DiscordApiClient; + readonly credentialResolver: DiscordProviderCredentialResolver; + readonly publicKey: string; +}): IntegrationProviderRegistration => { + const channelNotificationsHandler = { + capabilityKey: "channel.notifications" as const, + deliver: (input: IntegrationProviderDeliveryInput) => + Effect.gen(function* () { + if (input.event.type !== "feedback.post.created") { + return yield* new IntegrationProviderInvalidConfigurationError({ + message: "Discord channel notifications only support new posts", + provider: discordProviderKey, + }); + } + const credentials = + yield* credentialResolver.loadDiscordCredentials(input); + const routeConfig = yield* Schema.decodeUnknownEffect( + DiscordChannelNotificationRouteConfiguration + )(input.route.providerConfig).pipe( + Effect.mapError( + () => + new IntegrationProviderInvalidConfigurationError({ + message: "Discord route configuration is invalid", + provider: discordProviderKey, + }) + ) + ); + const eventData = yield* Schema.decodeUnknownEffect( + IntegrationPostEventData + )(input.event.data).pipe( + Effect.mapError( + () => + new IntegrationProviderInvalidConfigurationError({ + message: "Discord event payload is invalid", + provider: discordProviderKey, + }) + ) + ); + const embed = renderChannelUpdateMessageEmbed({ + actionUrl: eventData.post.url.toString(), + ...(eventData.actor.kind === "member" && + eventData.actor.displayName !== undefined + ? { actorName: eventData.actor.displayName } + : {}), + eventType: input.event.type, + facts: [ + { label: "Board", value: eventData.board.name }, + { label: "Status", value: eventData.post.status.type }, + ...Object.entries(eventData.post.metadata ?? {}).map( + ([label, value]) => ({ label, value }) + ), + ], + title: eventData.post.title, + }); + // Unlike Slack, Discord has no join step: the bot's channel access is + // granted at install time through the OAuth permissions bitfield and + // may be overridden per channel by the server. Post the embed and let + // the typed failure algebra surface missing permissions. + return yield* apiClient + .channelsMessagesCreate({ + botToken: credentials.botToken, + channelId: routeConfig.channelId, + embeds: [embed], + }) + .pipe(Effect.as({})); + }), + }; + + return { + connectionConfigurationSchema: DiscordConnectionConfiguration, + handlers: [channelNotificationsHandler], + inboundHandlers: [makeDiscordInteractionsHandler({ publicKey })], + manifest: discordProviderManifest, + routeConfigurationSchemas: new Map([ + ["channel.notifications", DiscordChannelNotificationRouteConfiguration], + ["interactions", DiscordInboundRouteConfiguration], + ]), + }; +}; diff --git a/integrations/discord/src/discord-signature.test.ts b/integrations/discord/src/discord-signature.test.ts new file mode 100644 index 000000000..afd4ea43b --- /dev/null +++ b/integrations/discord/src/discord-signature.test.ts @@ -0,0 +1,142 @@ +import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { + DISCORD_SIGNATURE_MAX_AGE_MS, + verifyDiscordRequestSignature, +} from "./discord-signature"; + +// Discord public keys are 32-byte Ed25519 keys as 64 hex chars. The test +// keypair is generated inline so the tests never depend on a fixture secret. +const { publicKeyHex, privateKeyHex } = (() => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const publicKeyHex = publicKey + .export({ format: "der", type: "spki" }) + .subarray(-32) + .toString("hex"); + const privateKeyHex = privateKey + .export({ format: "der", type: "pkcs8" }) + .toString("hex"); + return { publicKeyHex, privateKeyHex }; +})(); + +const signBody = (timestamp: string, rawBody: string) => + sign( + null, + Buffer.from(`${timestamp}${rawBody}`), + createPrivateKey({ + key: Buffer.from(privateKeyHex, "hex"), + format: "der", + type: "pkcs8", + }) + ).toString("hex"); + +const expectFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.exit, + Effect.tap((result) => + Effect.sync(() => expect(Exit.isFailure(result)).toBe(true)) + ) + ); + +describe("verifyDiscordRequestSignature", () => { + it.effect("accepts a valid signature", () => + Effect.gen(function* () { + const rawBody = JSON.stringify({ type: 1 }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const result = yield* verifyDiscordRequestSignature({ + now: Date.now(), + publicKey: publicKeyHex, + rawBody, + signatureHeader: signBody(timestamp, rawBody), + timestampHeader: timestamp, + }); + expect(result).toBeUndefined(); + }) + ); + + it.effect("rejects a missing header", () => + expectFailure( + verifyDiscordRequestSignature({ + now: Date.now(), + publicKey: publicKeyHex, + rawBody: "{}", + signatureHeader: undefined, + timestampHeader: undefined, + }) + ) + ); + + it.effect("rejects a stale timestamp outside the freshness window", () => { + const rawBody = "{}"; + const stale = String( + Math.floor((Date.now() - DISCORD_SIGNATURE_MAX_AGE_MS - 60_000) / 1000) + ); + return expectFailure( + verifyDiscordRequestSignature({ + now: Date.now(), + publicKey: publicKeyHex, + rawBody, + signatureHeader: signBody(stale, rawBody), + timestampHeader: stale, + }) + ); + }); + + it.effect("rejects a future timestamp outside the freshness window", () => { + const rawBody = "{}"; + const now = Date.now(); + const future = String( + Math.floor((now + DISCORD_SIGNATURE_MAX_AGE_MS + 60_000) / 1000) + ); + return expectFailure( + verifyDiscordRequestSignature({ + now, + publicKey: publicKeyHex, + rawBody, + signatureHeader: signBody(future, rawBody), + timestampHeader: future, + }) + ); + }); + + it.effect("rejects a tampered body", () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + return expectFailure( + verifyDiscordRequestSignature({ + now: Date.now(), + publicKey: publicKeyHex, + rawBody: "tampered", + signatureHeader: signBody(timestamp, "original"), + timestampHeader: timestamp, + }) + ); + }); + + it.effect("rejects a non-hex signature", () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + return expectFailure( + verifyDiscordRequestSignature({ + now: Date.now(), + publicKey: publicKeyHex, + rawBody: "{}", + signatureHeader: "not-a-signature", + timestampHeader: timestamp, + }) + ); + }); + + it.effect("rejects an invalid public key", () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + return expectFailure( + verifyDiscordRequestSignature({ + now: Date.now(), + publicKey: "zz", + rawBody: "{}", + signatureHeader: signBody(timestamp, "{}"), + timestampHeader: timestamp, + }) + ); + }); +}); diff --git a/integrations/discord/src/discord-signature.ts b/integrations/discord/src/discord-signature.ts new file mode 100644 index 000000000..ef2f4d702 --- /dev/null +++ b/integrations/discord/src/discord-signature.ts @@ -0,0 +1,82 @@ +import { createPublicKey, verify } from "node:crypto"; +import { + IntegrationRequestSignatureError, + verifyRequestSignature, +} from "@feeblo/integration-core"; +import * as Effect from "effect/Effect"; +import type { DiscordSignatureVerificationError } from "./discord-errors"; + +/** Maximum age of a Discord request before its signature is rejected (Discord recommends a small replay window). */ +export const DISCORD_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000; + +/** DER SubjectPublicKeyInfo prefix for a raw 32-byte Ed25519 public key (Discord ships the key as 64 hex chars). */ +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +const DISCORD_SIGNATURE_PATTERN = /^[0-9a-f]{128}$/; + +/** + * Verifies a Discord interaction request signature (`x-signature-ed25519` + * over `x-signature-timestamp + rawBody`) against the application public key + * and rejects requests older than the freshness window. Successful verification + * completes without a value. + */ +export const verifyDiscordRequestSignature = ({ + rawBody, + publicKey, + timestampHeader, + signatureHeader, + now = Date.now(), +}: { + readonly rawBody: string; + readonly publicKey: string; + readonly timestampHeader: string | undefined; + readonly signatureHeader: string | undefined; + readonly now?: number; +}): Effect.Effect => + verifyRequestSignature({ + maxAgeMs: DISCORD_SIGNATURE_MAX_AGE_MS, + now, + signatureHeader, + timestampHeader, + verify: ({ signatureHeader, timestampHeader }) => { + // Discord signs with Ed25519; the header is always 64 bytes of hex. + if (!DISCORD_SIGNATURE_PATTERN.test(signatureHeader)) { + return Effect.fail( + new IntegrationRequestSignatureError({ + reason: "Discord signature is invalid", + }) + ); + } + let key: ReturnType; + try { + key = createPublicKey({ + key: Buffer.concat([ + ED25519_SPKI_PREFIX, + Buffer.from(publicKey, "hex"), + ]), + format: "der", + type: "spki", + }); + } catch { + return Effect.fail( + new IntegrationRequestSignatureError({ + reason: "Discord public key is invalid", + }) + ); + } + const valid = verify( + null, + Buffer.from(`${timestampHeader}${rawBody}`), + key, + Buffer.from(signatureHeader, "hex") + ); + if (!valid) { + return Effect.fail( + new IntegrationRequestSignatureError({ + reason: "Discord signature does not match", + }) + ); + } + return Effect.void; + }, + }); diff --git a/integrations/discord/src/index.ts b/integrations/discord/src/index.ts new file mode 100644 index 000000000..08794df27 --- /dev/null +++ b/integrations/discord/src/index.ts @@ -0,0 +1,10 @@ +/** biome-ignore-all lint/performance/noBarrelFile: package public entry point */ +/** Public Discord provider package entry point. */ +export * from "./discord-api"; +export * from "./discord-credentials"; +export * from "./discord-embeds"; +export * from "./discord-errors"; +export * from "./discord-inbound-schema"; +export * from "./discord-manifest"; +export * from "./discord-provider-registration"; +export * from "./discord-signature"; diff --git a/integrations/discord/tsconfig.json b/integrations/discord/tsconfig.json new file mode 100644 index 000000000..e47adcd44 --- /dev/null +++ b/integrations/discord/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@feeblo/config/tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*"] +} diff --git a/integrations/discord/vitest.config.ts b/integrations/discord/vitest.config.ts new file mode 100644 index 000000000..7f9a503c9 --- /dev/null +++ b/integrations/discord/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + pool: "threads", + }, +}); diff --git a/integrations/slack/src/slack-blocks.test.ts b/integrations/slack/src/slack-blocks.test.ts index 210647e16..18a9435c1 100644 --- a/integrations/slack/src/slack-blocks.test.ts +++ b/integrations/slack/src/slack-blocks.test.ts @@ -9,6 +9,7 @@ describe("renderChannelUpdateMessageBlocks", () => { facts: [ { label: "Board", value: "Product ideas" }, { label: "Status", value: "PENDING" }, + { label: "customer_tier", value: "Enterprise" }, ], title: "Support dark mode", }; @@ -25,7 +26,7 @@ describe("renderChannelUpdateMessageBlocks", () => { elements: [ { type: "mrkdwn", - text: "*Board:* Product ideas · *Status:* PENDING", + text: "*Board:* Product ideas · *Status:* PENDING · *Customer Tier:* Enterprise", }, ], }); diff --git a/integrations/slack/src/slack-blocks.ts b/integrations/slack/src/slack-blocks.ts index 1b845f2fe..1af180238 100644 --- a/integrations/slack/src/slack-blocks.ts +++ b/integrations/slack/src/slack-blocks.ts @@ -1,27 +1,18 @@ -import { truncate } from "@feeblo/utils/text"; +import type { ChannelUpdateMessage } from "@feeblo/integration-core"; -/** - * Channel-update message model shared by channel-notification providers - * (Slack now, Discord later). Providers render this pure model into their own - * wire format; no provider-specific types leak into the model. - */ -export interface ChannelUpdateMessage { - /** Absolute URL to open the post. */ - readonly actionUrl: string; - /** Display name of the actor, when known. */ - readonly actorName?: string; - /** Event type that produced the update. */ - readonly eventType: string; - /** Facts rendered as key/value rows, e.g. board and status. */ - readonly facts: ReadonlyArray<{ - readonly label: string; - readonly value: string; - }>; - /** Post title, truncated for channel display by the renderer. */ - readonly title: string; -} +export type { ChannelUpdateMessage } from "@feeblo/integration-core"; + +import { truncate } from "@feeblo/utils/text"; const TRUNCATED_TITLE_MAX = 150; +const TRUNCATED_CONTEXT_MAX = 3000; + +const formatSlackFactLabel = (value: string): string => + value + .toLowerCase() + .split("_") + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(" "); /** * Renders a channel-update message into Slack Block Kit blocks for @@ -42,7 +33,13 @@ export const renderChannelUpdateMessageBlocks = ( }, ]; const factText = message.facts - .map(({ label, value }) => `*${label}:* ${value}`) + .filter( + ({ label, value }) => label.trim().length > 0 && value.trim().length > 0 + ) + .map( + ({ label, value }) => + `*${formatSlackFactLabel(label.trim())}:* ${value.trim()}` + ) .join(" · "); if (factText.length > 0) { blocks.push({ @@ -50,7 +47,7 @@ export const renderChannelUpdateMessageBlocks = ( elements: [ { type: "mrkdwn", - text: factText, + text: truncate(factText, TRUNCATED_CONTEXT_MAX), }, ], }); diff --git a/integrations/slack/src/slack-credentials.ts b/integrations/slack/src/slack-credentials.ts index a4ff8311c..3f53fccf7 100644 --- a/integrations/slack/src/slack-credentials.ts +++ b/integrations/slack/src/slack-credentials.ts @@ -1,10 +1,10 @@ -import { symmetricDecrypt, symmetricEncrypt } from "@feeblo/utils/crypto"; -import * as Effect from "effect/Effect"; +import { + decryptIntegrationCredentialMaterial, + encryptIntegrationCredentialMaterial, +} from "@feeblo/integration-core"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import { SlackCredentialEncryptionError } from "./slack-errors"; - /** Encrypted at-rest value for Slack credentials and OAuth state. */ export type EncryptedSlackCredential = string; @@ -23,118 +23,38 @@ export type SlackEncryptedCredentialMaterial = Schema.Schema.Type< typeof SlackEncryptedCredentialMaterial >; -/** Validates the redacted integration encryption key without ever returning its plaintext. */ -export const validateSlackEncryptionKey = ( - encryptionKey: Redacted.Redacted, - operation: "decrypt" | "encrypt" = "encrypt" -): Effect.Effect, SlackCredentialEncryptionError> => - new TextEncoder().encode(Redacted.value(encryptionKey)).byteLength >= 32 - ? Effect.succeed(encryptionKey) - : Effect.fail( - new SlackCredentialEncryptionError({ - operation, - reason: "Integration encryption key must contain at least 32 bytes", - }) - ); - /** Encrypts the bot token and optional OAuth handshake material together. */ export const encryptSlackCredentialMaterial = ( encryptionKey: Redacted.Redacted, credentials: SlackEncryptedCredentialMaterial -): Effect.Effect => - Effect.gen(function* () { - const key = yield* validateSlackEncryptionKey(encryptionKey); - const encoded = yield* Schema.encodeEffect( - Schema.fromJsonString(SlackEncryptedCredentialMaterial) - )(credentials).pipe( - Effect.mapError( - () => - new SlackCredentialEncryptionError({ - operation: "encrypt", - reason: "Slack credentials could not be encoded", - }) - ) - ); - return yield* encryptSlackCredential(key, Redacted.make(encoded)); - }); +) => + encryptIntegrationCredentialMaterial( + encryptionKey, + SlackEncryptedCredentialMaterial, + credentials + ); /** Decodes encrypted stored credentials at the provider boundary and immediately redacts secret-bearing fields. */ export const decryptSlackCredentialMaterial = ( encryptionKey: Redacted.Redacted, encryptedCredential: EncryptedSlackCredential -): Effect.Effect< - { - readonly botToken?: Redacted.Redacted; - readonly oauthState?: string; - readonly userToken?: Redacted.Redacted; - readonly incomingWebhookUrl?: Redacted.Redacted; - }, - SlackCredentialEncryptionError -> => - Effect.flatMap(validateSlackEncryptionKey(encryptionKey, "decrypt"), (key) => - Effect.flatMap(decryptSlackCredential(key, encryptedCredential), (value) => - Schema.decodeUnknownEffect( - Schema.fromJsonString(SlackEncryptedCredentialMaterial) - )(Redacted.value(value)).pipe( - Effect.map((decoded) => ({ - ...(decoded.botToken === undefined - ? {} - : { botToken: Redacted.make(decoded.botToken) }), - ...(decoded.oauthState === undefined - ? {} - : { oauthState: decoded.oauthState }), - ...(decoded.userToken === undefined - ? {} - : { userToken: Redacted.make(decoded.userToken) }), - ...(decoded.incomingWebhookUrl === undefined - ? {} - : { - incomingWebhookUrl: Redacted.make(decoded.incomingWebhookUrl), - }), - })), - Effect.mapError( - () => - new SlackCredentialEncryptionError({ - operation: "decrypt", - reason: "Encrypted Slack credentials are malformed", - }) - ) - ) - ) +) => + decryptIntegrationCredentialMaterial( + encryptionKey, + SlackEncryptedCredentialMaterial, + encryptedCredential, + (decoded) => ({ + ...(decoded.botToken === undefined + ? {} + : { botToken: Redacted.make(decoded.botToken) }), + ...(decoded.oauthState === undefined + ? {} + : { oauthState: decoded.oauthState }), + ...(decoded.userToken === undefined + ? {} + : { userToken: Redacted.make(decoded.userToken) }), + ...(decoded.incomingWebhookUrl === undefined + ? {} + : { incomingWebhookUrl: Redacted.make(decoded.incomingWebhookUrl) }), + }) ); - -/** Encrypts a redacted Slack credential with the redacted integration encryption key. */ -export const encryptSlackCredential = ( - encryptionKey: Redacted.Redacted, - credential: Redacted.Redacted -): Effect.Effect => - Effect.tryPromise({ - try: () => - symmetricEncrypt({ - key: Redacted.value(encryptionKey), - data: Redacted.value(credential), - }), - catch: () => - new SlackCredentialEncryptionError({ - operation: "encrypt", - reason: "Slack credential encryption failed", - }), - }); - -/** Decrypts a credential only at the provider boundary and immediately returns it redacted. */ -export const decryptSlackCredential = ( - encryptionKey: Redacted.Redacted, - encryptedCredential: EncryptedSlackCredential -): Effect.Effect, SlackCredentialEncryptionError> => - Effect.tryPromise({ - try: () => - symmetricDecrypt({ - key: Redacted.value(encryptionKey), - data: encryptedCredential, - }), - catch: () => - new SlackCredentialEncryptionError({ - operation: "decrypt", - reason: "Slack credential decryption failed", - }), - }).pipe(Effect.map(Redacted.make)); diff --git a/integrations/slack/src/slack-errors.ts b/integrations/slack/src/slack-errors.ts index fcbf470e3..9bbaf3072 100644 --- a/integrations/slack/src/slack-errors.ts +++ b/integrations/slack/src/slack-errors.ts @@ -2,11 +2,13 @@ import * as Schema from "effect/Schema"; export { + IntegrationCredentialEncryptionError as SlackCredentialEncryptionError, IntegrationProviderAuthenticationError, IntegrationProviderInvalidConfigurationError, IntegrationProviderPermanentRejection, IntegrationProviderRateLimitedError, IntegrationProviderTemporaryFailure, + IntegrationRequestSignatureError as SlackSignatureVerificationError, } from "@feeblo/integration-core"; import type { @@ -17,18 +19,6 @@ import type { IntegrationProviderTemporaryFailure, } from "@feeblo/integration-core"; -/** Failure encrypting or decrypting Slack credentials at rest. */ -export class SlackCredentialEncryptionError extends Schema.TaggedErrorClass()( - "SlackCredentialEncryptionError", - { operation: Schema.Literals(["encrypt", "decrypt"]), reason: Schema.String } -) {} - -/** Failure verifying a Slack request signature. */ -export class SlackSignatureVerificationError extends Schema.TaggedErrorClass()( - "SlackSignatureVerificationError", - { reason: Schema.String } -) {} - /** Failure parsing a verified Slack inbound request body into its typed payload. */ export class SlackInboundPayloadError extends Schema.TaggedErrorClass()( "SlackInboundPayloadError", diff --git a/integrations/slack/src/slack-inbound-schema.ts b/integrations/slack/src/slack-inbound-schema.ts index 9b94ee3d8..14821673c 100644 --- a/integrations/slack/src/slack-inbound-schema.ts +++ b/integrations/slack/src/slack-inbound-schema.ts @@ -1,3 +1,4 @@ +import { IntegrationOAuthState } from "@feeblo/integration-core"; import * as Schema from "effect/Schema"; /** Slack slash-command payload (`application/x-www-form-urlencoded` decoded). */ @@ -145,16 +146,7 @@ export type ParsedSlackInboundRequest = Schema.Schema.Type< typeof ParsedSlackInboundRequest >; -/** - * OAuth state token passed through the Slack install flow. It is not a - * secret; the connection lookup is additionally guarded by the encrypted - * nonce stored on the pending connection row. - */ -export const SlackOAuthState = Schema.Struct({ - connectionId: Schema.String, - organizationId: Schema.String, - nonce: Schema.String, -}); +export const SlackOAuthState = IntegrationOAuthState; export type SlackOAuthState = Schema.Schema.Type; /** Canonical Slack interactive callback ids owned by Feeblo. */ diff --git a/integrations/slack/src/slack-provider-registration.test.ts b/integrations/slack/src/slack-provider-registration.test.ts index f86786bd3..04dc6be28 100644 --- a/integrations/slack/src/slack-provider-registration.test.ts +++ b/integrations/slack/src/slack-provider-registration.test.ts @@ -54,6 +54,7 @@ const deliveryInput = ( board: { id: "brd_1", name: "Ideas", slug: "ideas" }, post: { id: "pst_1", + metadata: { customer_tier: "Enterprise" }, status: { id: "pss_1", type: "PENDING" }, title: "Dark mode please", url: "https://feeblo.example/org/post/ideas/dark-mode", @@ -154,6 +155,15 @@ describe("slack provider registration", () => { expect(call?.text).toBe("Dark mode please"); const header = call?.blocks[0] as { text: { text: string } }; expect(header.text.text).toBe("Dark mode please"); + expect(call?.blocks[1]).toMatchObject({ + elements: [ + { + text: expect.stringContaining("*Customer Tier:* Enterprise"), + type: "mrkdwn", + }, + ], + type: "context", + }); }); it("rejects unsupported event types as invalid configuration", async () => { diff --git a/integrations/slack/src/slack-provider-registration.ts b/integrations/slack/src/slack-provider-registration.ts index 1dad561b9..1fc275ce2 100644 --- a/integrations/slack/src/slack-provider-registration.ts +++ b/integrations/slack/src/slack-provider-registration.ts @@ -254,6 +254,9 @@ export const makeSlackProviderRegistration = ({ facts: [ { label: "Board", value: eventData.board.name }, { label: "Status", value: eventData.post.status.type }, + ...Object.entries(eventData.post.metadata ?? {}).map( + ([label, value]) => ({ label, value }) + ), ], title: eventData.post.title, }); diff --git a/integrations/slack/src/slack-signature.ts b/integrations/slack/src/slack-signature.ts index 490ccb4a0..bc2758930 100644 --- a/integrations/slack/src/slack-signature.ts +++ b/integrations/slack/src/slack-signature.ts @@ -1,7 +1,11 @@ import { createHmac, timingSafeEqual } from "node:crypto"; +import { + IntegrationRequestSignatureError, + verifyRequestSignature, +} from "@feeblo/integration-core"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; -import { SlackSignatureVerificationError } from "./slack-errors"; +import type { SlackSignatureVerificationError } from "./slack-errors"; /** Maximum age of a Slack request before its signature is rejected (Slack recommends 5 minutes). */ export const SLACK_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000; @@ -23,51 +27,36 @@ export const verifySlackRequestSignature = ({ readonly timestampHeader: string | undefined; readonly signatureHeader: string | undefined; readonly now?: number; -}): Effect.Effect => { - if (timestampHeader === undefined || signatureHeader === undefined) { - return Effect.fail( - new SlackSignatureVerificationError({ - reason: "Slack signature headers are missing", - }) - ); - } - const timestamp = Number(timestampHeader); - if (!Number.isFinite(timestamp)) { - return Effect.fail( - new SlackSignatureVerificationError({ - reason: "Slack request timestamp is invalid", - }) - ); - } - if (Math.abs(now - timestamp * 1000) > SLACK_SIGNATURE_MAX_AGE_MS) { - return Effect.fail( - new SlackSignatureVerificationError({ - reason: "Slack request timestamp is stale", - }) - ); - } - if (!signatureHeader.startsWith("v0=")) { - return Effect.fail( - new SlackSignatureVerificationError({ - reason: "Slack signature scheme is invalid", - }) - ); - } - const expected = createHmac("sha256", Redacted.value(signingSecret)) - .update(`v0:${timestampHeader}:${rawBody}`) - .digest("hex"); - const received = signatureHeader.slice("v0=".length); - const expectedBuffer = Buffer.from(expected, "hex"); - const receivedBuffer = Buffer.from(received, "hex"); - if ( - expectedBuffer.length !== receivedBuffer.length || - !timingSafeEqual(expectedBuffer, receivedBuffer) - ) { - return Effect.fail( - new SlackSignatureVerificationError({ - reason: "Slack signature does not match", - }) - ); - } - return Effect.void; -}; +}): Effect.Effect => + verifyRequestSignature({ + maxAgeMs: SLACK_SIGNATURE_MAX_AGE_MS, + now, + signatureHeader, + timestampHeader, + verify: ({ signatureHeader, timestampHeader }) => { + if (!signatureHeader.startsWith("v0=")) { + return Effect.fail( + new IntegrationRequestSignatureError({ + reason: "Slack signature scheme is invalid", + }) + ); + } + const expected = createHmac("sha256", Redacted.value(signingSecret)) + .update(`v0:${timestampHeader}:${rawBody}`) + .digest("hex"); + const received = signatureHeader.slice("v0=".length); + const expectedBuffer = Buffer.from(expected, "hex"); + const receivedBuffer = Buffer.from(received, "hex"); + if ( + expectedBuffer.length !== receivedBuffer.length || + !timingSafeEqual(expectedBuffer, receivedBuffer) + ) { + return Effect.fail( + new IntegrationRequestSignatureError({ + reason: "Slack signature does not match", + }) + ); + } + return Effect.void; + }, + }); diff --git a/packages/db/src/migrations/20260813052319_slimy_rumiko_fujikawa/migration.sql b/packages/db/src/migrations/20260813052319_slimy_rumiko_fujikawa/migration.sql new file mode 100644 index 000000000..72bc1a94f --- /dev/null +++ b/packages/db/src/migrations/20260813052319_slimy_rumiko_fujikawa/migration.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX "integration_connection_provider_remote_account_active_uidx" ON "integration_connection" ("provider","remote_account_id") WHERE "provider" = 'discord' and "remote_account_id" is not null and "lifecycle" = 'active'; diff --git a/packages/db/src/migrations/20260813052319_slimy_rumiko_fujikawa/snapshot.json b/packages/db/src/migrations/20260813052319_slimy_rumiko_fujikawa/snapshot.json new file mode 100644 index 000000000..e8554626e --- /dev/null +++ b/packages/db/src/migrations/20260813052319_slimy_rumiko_fujikawa/snapshot.json @@ -0,0 +1,12619 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "36ae8855-0542-4b1e-b9ad-1112e7da0a63", + "prevIds": [ + "c9e125be-2de0-4515-ace8-748f6bef141e" + ], + "ddl": [ + { + "values": [ + "PUBLIC", + "PRIVATE" + ], + "name": "board_visibility", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "draft", + "scheduled", + "published" + ], + "name": "changelog_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "PUBLIC", + "HIDDEN" + ], + "name": "changelog_visibility", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "PUBLIC", + "INTERNAL" + ], + "name": "post_comment_visibility", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "PUBLIC", + "HIDDEN" + ], + "name": "roadmap_visibility", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "public", + "private" + ], + "name": "saved_roadmap_visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "account", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "invitation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jwt_secret", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "organization", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "product", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "session", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "subscription", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "two_factor", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "user", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "verification", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "board", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "changelog_category_link", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "changelog_category", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "changelog_post", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "changelog", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "changelog_tag", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "comment_reaction", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "comment", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "company_attribute_definition", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "company_attribute_value", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "company", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "contact_attribute_definition", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "contact_attribute_value", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "contact", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "email_contact", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "email_delivery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "email_outbox", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "email_provider_event", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "email_subscription", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "email_suppression", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notification", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_activity", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_reaction", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_status", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_subscription", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_tag", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "roadmap_column", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "roadmap", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "site", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "submission_notification_batch", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "submission_notification_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tag", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "upvote", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "integration_connection", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "integration_delivery_attempt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "integration_delivery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "integration_event", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "integration_route", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "asset", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "changelog_asset", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_asset", + "entityType": "tables", + "schema": "public" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issuer", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_expires_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "invitation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "invitation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "invitation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "invitation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "invitation" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "invitation" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "invitation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inviter_id", + "entityType": "columns", + "schema": "public", + "table": "invitation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jwt_secret" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "jwt_secret" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret", + "entityType": "columns", + "schema": "public", + "table": "jwt_secret" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "jwt_secret" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "jwt_secret" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "member" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "member" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "member" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'manager'", + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "member" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "member" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "organization" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "organization" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "organization" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logo", + "entityType": "columns", + "schema": "public", + "table": "organization" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "organization" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "organization" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trial_interval", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trial_interval_count", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recurring_interval", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recurring_interval_count", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "is_recurring", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "is_archived", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_organization_id", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prices", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "product" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_address", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_agent", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "impersonated_by", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "active_organization_id", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_id", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "real", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cancel_at_period_end", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "currency", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recurring_interval", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recurring_interval_count", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "current_period_start", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "current_period_end", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trial_start", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trial_end", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "canceled_at", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_at", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customer_id", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "product_id", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "discount_id", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checkout_id", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seats", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "two_factor" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret", + "entityType": "columns", + "schema": "public", + "table": "two_factor" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "backup_codes", + "entityType": "columns", + "schema": "public", + "table": "two_factor" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "two_factor" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "email_verified", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "banned", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ban_reason", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ban_expires", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "two_factor_enabled", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_login_method", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jwt_auto_login_at", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email_hash", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restricted_to_organization_id", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "identifier", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "board" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "board" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "board" + }, + { + "type": "board_visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "board" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "board" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creator_id", + "entityType": "columns", + "schema": "public", + "table": "board" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creator_member_id", + "entityType": "columns", + "schema": "public", + "table": "board" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "board" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "board" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "changelog_category_link" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changelog_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_category_link" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_category_link" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_category_link" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "changelog_category_link" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "changelog_category_link" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "changelog_category" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "changelog_category" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'color'", + "generated": null, + "identity": null, + "name": "icon_type", + "entityType": "columns", + "schema": "public", + "table": "changelog_category" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "changelog_category" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_category" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "changelog_category" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "changelog_category" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changelog_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_post" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "changelog_post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cover_image", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "changelog_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creator_id", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creator_member_id", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "changelog" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "changelog_tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changelog_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_tag" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "changelog_tag" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "changelog_tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "comment_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "comment_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_id", + "entityType": "columns", + "schema": "public", + "table": "comment_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comment_id", + "entityType": "columns", + "schema": "public", + "table": "comment_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emoji", + "entityType": "columns", + "schema": "public", + "table": "comment_reaction" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "comment_reaction" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "comment_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_id", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "post_comment_visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'PUBLIC'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent_comment_id", + "entityType": "columns", + "schema": "public", + "table": "comment" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_required", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "company_id", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attribute_id", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_text", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_integer", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "real", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_decimal", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_boolean", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_date", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "company_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "company" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "company" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_id", + "entityType": "columns", + "schema": "public", + "table": "company" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "company" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "company" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_created_at", + "entityType": "columns", + "schema": "public", + "table": "company" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'DASHBOARD'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "company" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "company" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "company" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_required", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contact_id", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attribute_id", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_text", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_integer", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "real", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_decimal", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_boolean", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_date", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_id", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "company_id", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'DASHBOARD'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "email_contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "email_contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "email_contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "email_contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_state", + "entityType": "columns", + "schema": "public", + "table": "email_contact" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verified_at", + "entityType": "columns", + "schema": "public", + "table": "email_contact" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "email_contact" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "email_contact" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outbox_id", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contact_id", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recipient_email", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "template", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "template_version", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "template_payload", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message_id", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempt_count", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "next_attempt_at", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted_at", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivered_at", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_metadata", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "email_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aggregate_type", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aggregate_id", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deduplication_key", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "email_outbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_event_id", + "entityType": "columns", + "schema": "public", + "table": "email_provider_event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivery_id", + "entityType": "columns", + "schema": "public", + "table": "email_provider_event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "email_provider_event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "email_provider_event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "received_at", + "entityType": "columns", + "schema": "public", + "table": "email_provider_event" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "email_provider_event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contact_id", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic_type", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic_id", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_token_hash", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_expires_at", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unsubscribe_token_hash", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verified_at", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unsubscribed_at", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "email_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "email_suppression" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "email_suppression" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_event_id", + "entityType": "columns", + "schema": "public", + "table": "email_suppression" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "email_suppression" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recipient_member_id", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_member_id", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource_type", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource_id", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deduplication_key", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "read_at", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "notification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_member_id", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previous_value", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "next_value", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comment_id", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "post_activity" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "post_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "post_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_id", + "entityType": "columns", + "schema": "public", + "table": "post_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "post_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emoji", + "entityType": "columns", + "schema": "public", + "table": "post_reaction" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "post_reaction" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "post_reaction" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "post_status" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "post_status" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "order_index", + "entityType": "columns", + "schema": "public", + "table": "post_status" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "post_status" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "post_status" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "post_status" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "post_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "post_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_id", + "entityType": "columns", + "schema": "public", + "table": "post_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "post_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "post_subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "post_subscription" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "post_subscription" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "board_id", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status_schema_id", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eta_quarter", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creator_id", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creator_member_id", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contact_id", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'DASHBOARD'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locked_at", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archived_at", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merged_into_post_id", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merged_at", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "vector(1536)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "embedding", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "embedding_model", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "embedded_at", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "post" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "post_tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "post_tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag_id", + "entityType": "columns", + "schema": "public", + "table": "post_tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "post_tag" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "post_tag" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "post_tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "roadmap_column" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roadmap_id", + "entityType": "columns", + "schema": "public", + "table": "roadmap_column" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "roadmap_column" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "roadmap_column" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "roadmap_column" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "roadmap_column" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "roadmap_column" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_primary", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "saved_roadmap_visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "filter", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "roadmap" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subdomain", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "custom_domain", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "changelog_visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'PUBLIC'", + "generated": null, + "identity": null, + "name": "changelog_visibility", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "roadmap_visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'PUBLIC'", + "generated": null, + "identity": null, + "name": "roadmap_visibility", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "no_index", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hide_powered_by", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "site" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "submission_notification_batch" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "submission_notification_batch" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "submission_notification_batch" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "submission_notification_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "submission_notification_queue" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "submission_notification_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creator_id", + "entityType": "columns", + "schema": "public", + "table": "tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creator_member_id", + "entityType": "columns", + "schema": "public", + "table": "tag" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tag" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "tag" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "upvote" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "upvote" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_id", + "entityType": "columns", + "schema": "public", + "table": "upvote" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "upvote" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "upvote" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "upvote" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "upvote" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_account_id", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lifecycle", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "credential_generation", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentials_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "safe_display_metadata", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_exhausted_deliveries", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_succeeded_at", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_failed_at", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archived_at", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retention_expires_at", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "integration_connection" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivery_id", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attempt_number", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "duration_ms", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "http_status", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_tag", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retry_decision", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "diagnostics", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retention_expires_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connection_id", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "route_id", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action_key", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ordering_key", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempt_count", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "next_attempt_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_owner", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_expires_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "succeeded_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exhausted_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "canceled_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retention_expires_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "integration_delivery" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "causation_id", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "correlation_id", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "causal_hop_count", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retention_expires_at", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "integration_event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connection_id", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "capability_key", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_types", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "route_key", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config_version", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_config", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "safe_display_metadata", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "integration_route" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bucket", + "entityType": "columns", + "schema": "public", + "table": "asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "asset" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "asset" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changelog_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "asset_id", + "entityType": "columns", + "schema": "public", + "table": "changelog_asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "post_asset" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "asset_id", + "entityType": "columns", + "schema": "public", + "table": "post_asset" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "issuer", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_issuer_account_id_uidx", + "entityType": "indexes", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "invitation_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "invitation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "invitation_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "invitation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "jwt_secret_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "jwt_secret" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"revoked_at\" is null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "jwt_secret_organizationId_active_uidx", + "entityType": "indexes", + "schema": "public", + "table": "jwt_secret" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "member" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "member" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_organizationId_userId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "member" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "organization_slug_uidx", + "entityType": "indexes", + "schema": "public", + "table": "organization" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "session_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "session" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "secret", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "twoFactor_secret_idx", + "entityType": "indexes", + "schema": "public", + "table": "two_factor" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "twoFactor_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "two_factor" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "user_emailHash_idx", + "entityType": "indexes", + "schema": "public", + "table": "user" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "restricted_to_organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "user_restricted_to_organization_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "user" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "identifier", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "verification_identifier_idx", + "entityType": "indexes", + "schema": "public", + "table": "verification" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "board_organizationId_slug_uidx", + "entityType": "indexes", + "schema": "public", + "table": "board" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "changelog_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_category_link_changelogId_idx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_category_link" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_category_link_categoryId_idx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_category_link" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "changelog_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "category_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_category_link_changelogId_categoryId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_category_link" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_category_organizationId_name_uidx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_category" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_post_postId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_post" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_post_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_post" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_organizationId_slug_uidx", + "entityType": "indexes", + "schema": "public", + "table": "changelog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "changelog_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_tag_changelogId_idx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_tag" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_tag_tagId_idx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_tag" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "changelog_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_tag_changelogId_tagId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_tag" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "comment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "emoji", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "commentReaction_userId_commentId_emoji_uidx", + "entityType": "indexes", + "schema": "public", + "table": "comment_reaction" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "company_attribute_definition_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "company_attribute_definition_organizationId_key_uidx", + "entityType": "indexes", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "company_attribute_value_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "company_attribute_value" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "company_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "company_attribute_value_companyId_idx", + "entityType": "indexes", + "schema": "public", + "table": "company_attribute_value" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "attribute_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "company_attribute_value_attributeId_idx", + "entityType": "indexes", + "schema": "public", + "table": "company_attribute_value" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "company_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "attribute_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "company_attribute_value_companyId_attributeId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "company_attribute_value" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "company_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "company" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "company_organizationId_externalId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "company" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "company_organizationId_name_uidx", + "entityType": "indexes", + "schema": "public", + "table": "company" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_attribute_definition_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_attribute_definition_organizationId_key_uidx", + "entityType": "indexes", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_attribute_value_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contact_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_attribute_value_contactId_idx", + "entityType": "indexes", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "attribute_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_attribute_value_attributeId_idx", + "entityType": "indexes", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contact_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "attribute_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_attribute_value_contactId_attributeId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "contact" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "company_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_companyId_idx", + "entityType": "indexes", + "schema": "public", + "table": "contact" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "contact" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_organizationId_externalId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "contact" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "contact_organizationId_email_uidx", + "entityType": "indexes", + "schema": "public", + "table": "contact" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_contact_organizationId_email_uidx", + "entityType": "indexes", + "schema": "public", + "table": "email_contact" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_contact_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "email_contact" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "outbox_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "recipient_email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_delivery_outboxId_recipientEmail_uidx", + "entityType": "indexes", + "schema": "public", + "table": "email_delivery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "message_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_delivery_messageId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "email_delivery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "next_attempt_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_delivery_state_nextAttemptAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "email_delivery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contact_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_delivery_contactId_idx", + "entityType": "indexes", + "schema": "public", + "table": "email_delivery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "deduplication_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_outbox_organizationId_deduplicationKey_uidx", + "entityType": "indexes", + "schema": "public", + "table": "email_outbox" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduled_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_outbox_state_scheduledAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "email_outbox" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_outbox_organizationId_state_idx", + "entityType": "indexes", + "schema": "public", + "table": "email_outbox" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "aggregate_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"state\" = 'pending' AND \"kind\" = 'post.status_changed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_outbox_pendingStatusAggregate_uidx", + "entityType": "indexes", + "schema": "public", + "table": "email_outbox" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "delivery_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_provider_event_deliveryId_occurredAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "email_provider_event" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_subscription_organizationId_state_idx", + "entityType": "indexes", + "schema": "public", + "table": "email_subscription" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "topic_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "topic_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "contact_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_subscription_recipientLookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "email_subscription" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_subscription_state_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "email_subscription" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "provider_event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "email_suppression_providerEventId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "email_suppression" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "recipient_member_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "read_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notification_recipient_read_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "notification" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notification_organization_idx", + "entityType": "indexes", + "schema": "public", + "table": "notification" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "recipient_member_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "deduplication_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notification_recipient_deduplication_uidx", + "entityType": "indexes", + "schema": "public", + "table": "notification" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_activity_postId_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "post_activity" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_activity_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "post_activity" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "emoji", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "postReaction_userId_postId_emoji_uidx", + "entityType": "indexes", + "schema": "public", + "table": "post_reaction" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_status_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "post_status" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_status_organizationId_type_uidx", + "entityType": "indexes", + "schema": "public", + "table": "post_status" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "order_index", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_status_organizationId_orderIndex_uidx", + "entityType": "indexes", + "schema": "public", + "table": "post_status" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_subscription_postId_idx", + "entityType": "indexes", + "schema": "public", + "table": "post_subscription" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_subscription_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "post_subscription" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_subscription_postId_userId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "post_subscription" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status_schema_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_statusId_idx", + "entityType": "indexes", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "archived_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_archivedAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "merged_into_post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_mergedIntoPostId_idx", + "entityType": "indexes", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "embedding", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": { + "name": "vector_cosine_ops", + "default": false + } + } + ], + "isUnique": false, + "where": "\"embedding\" is not null", + "with": "", + "method": "hnsw", + "concurrently": false, + "name": "post_embedding_hnsw_idx", + "entityType": "indexes", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_id_organizationId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_organizationId_slug_uidx", + "entityType": "indexes", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_tag_postId_idx", + "entityType": "indexes", + "schema": "public", + "table": "post_tag" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_tag_tagId_idx", + "entityType": "indexes", + "schema": "public", + "table": "post_tag" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_tag_postId_tagId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "post_tag" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roadmap_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "roadmap_column_roadmapId_idx", + "entityType": "indexes", + "schema": "public", + "table": "roadmap_column" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roadmap_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "roadmap_column_roadmapId_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "roadmap_column" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "roadmap_organizationId_slug_uidx", + "entityType": "indexes", + "schema": "public", + "table": "roadmap" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "roadmap_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "roadmap" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"is_primary\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "roadmap_primary_organizationId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "roadmap" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "site_organizationId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "site" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subdomain", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "site_subdomain_uidx", + "entityType": "indexes", + "schema": "public", + "table": "site" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "submission_notification_queue_organization_idx", + "entityType": "indexes", + "schema": "public", + "table": "submission_notification_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tag_organizationId_type_name_uidx", + "entityType": "indexes", + "schema": "public", + "table": "tag" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tag_organizationId_type_slug_uidx", + "entityType": "indexes", + "schema": "public", + "table": "tag" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "upvote_postId_idx", + "entityType": "indexes", + "schema": "public", + "table": "upvote" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "upvote_userId_postId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "upvote" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_connection_organization_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_connection" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lifecycle", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_connection_organization_lifecycle_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_connection" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_connection_organization_id_uidx", + "entityType": "indexes", + "schema": "public", + "table": "integration_connection" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "remote_account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"provider\" = 'discord' and \"remote_account_id\" is not null and \"lifecycle\" = 'active'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_connection_provider_remote_account_active_uidx", + "entityType": "indexes", + "schema": "public", + "table": "integration_connection" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "delivery_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "attempt_number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_delivery_attempt_delivery_number_uidx", + "entityType": "indexes", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "delivery_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "started_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_delivery_attempt_delivery_started_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "retention_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_delivery_attempt_retention_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "route_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_delivery_route_event_action_uidx", + "entityType": "indexes", + "schema": "public", + "table": "integration_delivery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "next_attempt_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lease_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_delivery_lease_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_delivery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "connection_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_delivery_connection_state_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_delivery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_delivery_organization_state_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_delivery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "retention_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_delivery_retention_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_delivery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_event_organization_occurred_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_event" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "retention_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_event_retention_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_event" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_event_organization_id_uidx", + "entityType": "indexes", + "schema": "public", + "table": "integration_event" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "connection_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_route_connection_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_route" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_route_organization_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "integration_route" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_route_organization_id_uidx", + "entityType": "indexes", + "schema": "public", + "table": "integration_route" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "connection_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "capability_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "route_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "integration_route_connection_capability_key_uidx", + "entityType": "indexes", + "schema": "public", + "table": "integration_route" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "bucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "asset_key_uidx", + "entityType": "indexes", + "schema": "public", + "table": "asset" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "COALESCE(\"user_id\", \"organization_id\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"kind\" IN ('profile_image', 'organization_logo')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "asset_owner_kind_singleton_uidx", + "entityType": "indexes", + "schema": "public", + "table": "asset" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "asset_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "asset" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "asset_organizationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "asset" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "url", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "asset_url_idx", + "entityType": "indexes", + "schema": "public", + "table": "asset" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "asset_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "changelog_asset_assetId_idx", + "entityType": "indexes", + "schema": "public", + "table": "changelog_asset" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "asset_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "post_asset_assetId_idx", + "entityType": "indexes", + "schema": "public", + "table": "post_asset" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "account_user_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "invitation_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "invitation" + }, + { + "nameExplicit": false, + "columns": [ + "inviter_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "invitation_inviter_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "invitation" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "jwt_secret_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "jwt_secret" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "member_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "member" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "member_user_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "member" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "session_user_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "session" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "subscription_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "subscription" + }, + { + "nameExplicit": false, + "columns": [ + "product_id" + ], + "schemaTo": "public", + "tableTo": "product", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "subscription_product_id_product_id_fk", + "entityType": "fks", + "schema": "public", + "table": "subscription" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "two_factor_user_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "two_factor" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "board_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "board" + }, + { + "nameExplicit": false, + "columns": [ + "creator_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "board_creator_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "board" + }, + { + "nameExplicit": false, + "columns": [ + "creator_member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "board_creator_member_id_member_id_fk", + "entityType": "fks", + "schema": "public", + "table": "board" + }, + { + "nameExplicit": false, + "columns": [ + "changelog_id" + ], + "schemaTo": "public", + "tableTo": "changelog", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_category_link_changelog_id_changelog_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "changelog_category_link" + }, + { + "nameExplicit": false, + "columns": [ + "category_id" + ], + "schemaTo": "public", + "tableTo": "changelog_category", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_category_link_category_id_changelog_category_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "changelog_category_link" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_category_link_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "changelog_category_link" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_category_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "changelog_category" + }, + { + "nameExplicit": false, + "columns": [ + "changelog_id" + ], + "schemaTo": "public", + "tableTo": "changelog", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_post_changelog_id_changelog_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "changelog_post" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_post_post_id_post_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "changelog_post" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_post_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "changelog_post" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "changelog" + }, + { + "nameExplicit": false, + "columns": [ + "creator_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "changelog_creator_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "changelog" + }, + { + "nameExplicit": false, + "columns": [ + "creator_member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "changelog_creator_member_id_member_id_fk", + "entityType": "fks", + "schema": "public", + "table": "changelog" + }, + { + "nameExplicit": false, + "columns": [ + "changelog_id" + ], + "schemaTo": "public", + "tableTo": "changelog", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_tag_changelog_id_changelog_id_fk", + "entityType": "fks", + "schema": "public", + "table": "changelog_tag" + }, + { + "nameExplicit": false, + "columns": [ + "tag_id" + ], + "schemaTo": "public", + "tableTo": "tag", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_tag_tag_id_tag_id_fk", + "entityType": "fks", + "schema": "public", + "table": "changelog_tag" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_tag_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "changelog_tag" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comment_reaction_user_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "comment_reaction" + }, + { + "nameExplicit": false, + "columns": [ + "member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "comment_reaction_member_id_member_id_fk", + "entityType": "fks", + "schema": "public", + "table": "comment_reaction" + }, + { + "nameExplicit": false, + "columns": [ + "comment_id" + ], + "schemaTo": "public", + "tableTo": "comment", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comment_reaction_comment_id_comment_id_fk", + "entityType": "fks", + "schema": "public", + "table": "comment_reaction" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comment_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "comment" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comment_post_id_post_id_fk", + "entityType": "fks", + "schema": "public", + "table": "comment" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comment_user_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "comment" + }, + { + "nameExplicit": false, + "columns": [ + "member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "comment_member_id_member_id_fk", + "entityType": "fks", + "schema": "public", + "table": "comment" + }, + { + "nameExplicit": false, + "columns": [ + "parent_comment_id" + ], + "schemaTo": "public", + "tableTo": "comment", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comment_parent_comment_id_comment_id_fk", + "entityType": "fks", + "schema": "public", + "table": "comment" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "company_attribute_definition_kTIdzOEhY84w_fkey", + "entityType": "fks", + "schema": "public", + "table": "company_attribute_definition" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "company_attribute_value_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "company_attribute_value" + }, + { + "nameExplicit": false, + "columns": [ + "company_id" + ], + "schemaTo": "public", + "tableTo": "company", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "company_attribute_value_company_id_company_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "company_attribute_value" + }, + { + "nameExplicit": false, + "columns": [ + "attribute_id" + ], + "schemaTo": "public", + "tableTo": "company_attribute_definition", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "company_attribute_value_llKdT8uQbpgY_fkey", + "entityType": "fks", + "schema": "public", + "table": "company_attribute_value" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "company_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "company" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "contact_attribute_definition_kTIdzMCiJSi5_fkey", + "entityType": "fks", + "schema": "public", + "table": "contact_attribute_definition" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "contact_attribute_value_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "nameExplicit": false, + "columns": [ + "contact_id" + ], + "schemaTo": "public", + "tableTo": "contact", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "contact_attribute_value_contact_id_contact_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "nameExplicit": false, + "columns": [ + "attribute_id" + ], + "schemaTo": "public", + "tableTo": "contact_attribute_definition", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "contact_attribute_value_4kkzVjc1dj7o_fkey", + "entityType": "fks", + "schema": "public", + "table": "contact_attribute_value" + }, + { + "nameExplicit": false, + "columns": [ + "company_id" + ], + "schemaTo": "public", + "tableTo": "company", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "contact_company_id_company_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "contact" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "contact_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "contact" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "contact_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "contact" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "email_contact_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "email_contact" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "email_contact_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "email_contact" + }, + { + "nameExplicit": false, + "columns": [ + "outbox_id" + ], + "schemaTo": "public", + "tableTo": "email_outbox", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "email_delivery_outbox_id_email_outbox_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "email_delivery" + }, + { + "nameExplicit": false, + "columns": [ + "contact_id" + ], + "schemaTo": "public", + "tableTo": "email_contact", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "email_delivery_contact_id_email_contact_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "email_delivery" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "email_outbox_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "email_outbox" + }, + { + "nameExplicit": false, + "columns": [ + "delivery_id" + ], + "schemaTo": "public", + "tableTo": "email_delivery", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "email_provider_event_delivery_id_email_delivery_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "email_provider_event" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "email_subscription_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "email_subscription" + }, + { + "nameExplicit": false, + "columns": [ + "contact_id" + ], + "schemaTo": "public", + "tableTo": "email_contact", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "email_subscription_contact_id_email_contact_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "email_subscription" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notification_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notification" + }, + { + "nameExplicit": false, + "columns": [ + "recipient_member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notification_recipient_member_id_member_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notification" + }, + { + "nameExplicit": false, + "columns": [ + "actor_member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notification_actor_member_id_member_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notification" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_activity_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_activity" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_activity_post_id_post_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_activity" + }, + { + "nameExplicit": false, + "columns": [ + "actor_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "post_activity_actor_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_activity" + }, + { + "nameExplicit": false, + "columns": [ + "actor_member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "post_activity_actor_member_id_member_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_activity" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_reaction_user_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_reaction" + }, + { + "nameExplicit": false, + "columns": [ + "member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "post_reaction_member_id_member_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_reaction" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_reaction_post_id_post_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_reaction" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_status_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_status" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_subscription_user_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_subscription" + }, + { + "nameExplicit": false, + "columns": [ + "member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "post_subscription_member_id_member_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_subscription" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_subscription_post_id_post_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_subscription" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_subscription_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_subscription" + }, + { + "nameExplicit": false, + "columns": [ + "board_id" + ], + "schemaTo": "public", + "tableTo": "board", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_board_id_board_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": false, + "columns": [ + "status_schema_id" + ], + "schemaTo": "public", + "tableTo": "post_status", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "post_status_schema_id_post_status_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": false, + "columns": [ + "creator_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "post_creator_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": false, + "columns": [ + "creator_member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "post_creator_member_id_member_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": false, + "columns": [ + "contact_id" + ], + "schemaTo": "public", + "tableTo": "contact", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "post_contact_id_contact_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": true, + "columns": [ + "merged_into_post_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id", + "organization_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "post_merged_into_same_organization_fk", + "entityType": "fks", + "schema": "public", + "table": "post" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_tag_post_id_post_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_tag" + }, + { + "nameExplicit": false, + "columns": [ + "tag_id" + ], + "schemaTo": "public", + "tableTo": "tag", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_tag_tag_id_tag_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_tag" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_tag_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "post_tag" + }, + { + "nameExplicit": false, + "columns": [ + "roadmap_id" + ], + "schemaTo": "public", + "tableTo": "roadmap", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "roadmap_column_roadmap_id_roadmap_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "roadmap_column" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "roadmap_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "roadmap" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "site_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "site" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "submission_notification_batch_kCxPdJxLS9cJ_fkey", + "entityType": "fks", + "schema": "public", + "table": "submission_notification_batch" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "submission_notification_queue_post_id_post_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "submission_notification_queue" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "submission_notification_queue_54LYiNshYitK_fkey", + "entityType": "fks", + "schema": "public", + "table": "submission_notification_queue" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tag_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "tag" + }, + { + "nameExplicit": false, + "columns": [ + "creator_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tag_creator_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "tag" + }, + { + "nameExplicit": false, + "columns": [ + "creator_member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tag_creator_member_id_member_id_fk", + "entityType": "fks", + "schema": "public", + "table": "tag" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "upvote_user_id_user_id_fk", + "entityType": "fks", + "schema": "public", + "table": "upvote" + }, + { + "nameExplicit": false, + "columns": [ + "member_id" + ], + "schemaTo": "public", + "tableTo": "member", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "upvote_member_id_member_id_fk", + "entityType": "fks", + "schema": "public", + "table": "upvote" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "upvote_post_id_post_id_fk", + "entityType": "fks", + "schema": "public", + "table": "upvote" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "upvote_organization_id_organization_id_fk", + "entityType": "fks", + "schema": "public", + "table": "upvote" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "integration_connection_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "integration_connection" + }, + { + "nameExplicit": false, + "columns": [ + "delivery_id" + ], + "schemaTo": "public", + "tableTo": "integration_delivery", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "integration_delivery_attempt_xOqDtFxhvWcc_fkey", + "entityType": "fks", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "integration_delivery_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "integration_delivery" + }, + { + "nameExplicit": true, + "columns": [ + "organization_id", + "connection_id" + ], + "schemaTo": "public", + "tableTo": "integration_connection", + "columnsTo": [ + "organization_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "integration_delivery_organization_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "integration_delivery" + }, + { + "nameExplicit": true, + "columns": [ + "organization_id", + "route_id" + ], + "schemaTo": "public", + "tableTo": "integration_route", + "columnsTo": [ + "organization_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "integration_delivery_organization_route_fkey", + "entityType": "fks", + "schema": "public", + "table": "integration_delivery" + }, + { + "nameExplicit": true, + "columns": [ + "organization_id", + "event_id" + ], + "schemaTo": "public", + "tableTo": "integration_event", + "columnsTo": [ + "organization_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "integration_delivery_organization_event_fkey", + "entityType": "fks", + "schema": "public", + "table": "integration_delivery" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "integration_event_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "integration_event" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "integration_route_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "integration_route" + }, + { + "nameExplicit": true, + "columns": [ + "organization_id", + "connection_id" + ], + "schemaTo": "public", + "tableTo": "integration_connection", + "columnsTo": [ + "organization_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "integration_route_organization_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "integration_route" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "asset_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "asset" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "asset_organization_id_organization_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "asset" + }, + { + "nameExplicit": false, + "columns": [ + "changelog_id" + ], + "schemaTo": "public", + "tableTo": "changelog", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_asset_changelog_id_changelog_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "changelog_asset" + }, + { + "nameExplicit": false, + "columns": [ + "asset_id" + ], + "schemaTo": "public", + "tableTo": "asset", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "changelog_asset_asset_id_asset_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "changelog_asset" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "post", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_asset_post_id_post_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_asset" + }, + { + "nameExplicit": false, + "columns": [ + "asset_id" + ], + "schemaTo": "public", + "tableTo": "asset", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_asset_asset_id_asset_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "post_asset" + }, + { + "columns": [ + "changelog_id", + "post_id" + ], + "nameExplicit": false, + "name": "changelog_post_pkey", + "entityType": "pks", + "schema": "public", + "table": "changelog_post" + }, + { + "columns": [ + "changelog_id", + "asset_id" + ], + "nameExplicit": false, + "name": "changelog_asset_pkey", + "entityType": "pks", + "schema": "public", + "table": "changelog_asset" + }, + { + "columns": [ + "post_id", + "asset_id" + ], + "nameExplicit": false, + "name": "post_asset_pkey", + "entityType": "pks", + "schema": "public", + "table": "post_asset" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pkey", + "schema": "public", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "invitation_pkey", + "schema": "public", + "table": "invitation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jwt_secret_pkey", + "schema": "public", + "table": "jwt_secret", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "member_pkey", + "schema": "public", + "table": "member", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "organization_pkey", + "schema": "public", + "table": "organization", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "product_pkey", + "schema": "public", + "table": "product", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pkey", + "schema": "public", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "subscription_pkey", + "schema": "public", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "two_factor_pkey", + "schema": "public", + "table": "two_factor", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pkey", + "schema": "public", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pkey", + "schema": "public", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "board_pkey", + "schema": "public", + "table": "board", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "changelog_category_link_pkey", + "schema": "public", + "table": "changelog_category_link", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "changelog_category_pkey", + "schema": "public", + "table": "changelog_category", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "changelog_pkey", + "schema": "public", + "table": "changelog", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "changelog_tag_pkey", + "schema": "public", + "table": "changelog_tag", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "comment_reaction_pkey", + "schema": "public", + "table": "comment_reaction", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "comment_pkey", + "schema": "public", + "table": "comment", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "company_attribute_definition_pkey", + "schema": "public", + "table": "company_attribute_definition", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "company_attribute_value_pkey", + "schema": "public", + "table": "company_attribute_value", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "company_pkey", + "schema": "public", + "table": "company", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "contact_attribute_definition_pkey", + "schema": "public", + "table": "contact_attribute_definition", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "contact_attribute_value_pkey", + "schema": "public", + "table": "contact_attribute_value", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "contact_pkey", + "schema": "public", + "table": "contact", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "email_contact_pkey", + "schema": "public", + "table": "email_contact", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "email_delivery_pkey", + "schema": "public", + "table": "email_delivery", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "email_outbox_pkey", + "schema": "public", + "table": "email_outbox", + "entityType": "pks" + }, + { + "columns": [ + "provider_event_id" + ], + "nameExplicit": false, + "name": "email_provider_event_pkey", + "schema": "public", + "table": "email_provider_event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "email_subscription_pkey", + "schema": "public", + "table": "email_subscription", + "entityType": "pks" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "email_suppression_pkey", + "schema": "public", + "table": "email_suppression", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notification_pkey", + "schema": "public", + "table": "notification", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "post_activity_pkey", + "schema": "public", + "table": "post_activity", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "post_reaction_pkey", + "schema": "public", + "table": "post_reaction", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "post_status_pkey", + "schema": "public", + "table": "post_status", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "post_subscription_pkey", + "schema": "public", + "table": "post_subscription", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "post_pkey", + "schema": "public", + "table": "post", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "post_tag_pkey", + "schema": "public", + "table": "post_tag", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "roadmap_column_pkey", + "schema": "public", + "table": "roadmap_column", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "roadmap_pkey", + "schema": "public", + "table": "roadmap", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "site_pkey", + "schema": "public", + "table": "site", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "submission_notification_batch_pkey", + "schema": "public", + "table": "submission_notification_batch", + "entityType": "pks" + }, + { + "columns": [ + "post_id" + ], + "nameExplicit": false, + "name": "submission_notification_queue_pkey", + "schema": "public", + "table": "submission_notification_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tag_pkey", + "schema": "public", + "table": "tag", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "upvote_pkey", + "schema": "public", + "table": "upvote", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integration_connection_pkey", + "schema": "public", + "table": "integration_connection", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integration_delivery_attempt_pkey", + "schema": "public", + "table": "integration_delivery_attempt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integration_delivery_pkey", + "schema": "public", + "table": "integration_delivery", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integration_event_pkey", + "schema": "public", + "table": "integration_event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integration_route_pkey", + "schema": "public", + "table": "integration_route", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "asset_pkey", + "schema": "public", + "table": "asset", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "organization_id", + "secret", + "revoked_at" + ], + "nullsNotDistinct": true, + "name": "jwt_secret_organizationId_secret_revokedAt_uidx", + "entityType": "uniques", + "schema": "public", + "table": "jwt_secret" + }, + { + "nameExplicit": true, + "columns": [ + "contact_id", + "topic_type", + "topic_id" + ], + "nullsNotDistinct": true, + "name": "email_subscription_contactId_topicType_topicId_uidx", + "entityType": "uniques", + "schema": "public", + "table": "email_subscription" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "organization_slug_unique", + "schema": "public", + "table": "organization", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "session_token_unique", + "schema": "public", + "table": "session", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "external_id" + ], + "nullsNotDistinct": false, + "name": "subscription_external_id_unique", + "schema": "public", + "table": "subscription", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "user_email_unique", + "schema": "public", + "table": "user", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "organization_id" + ], + "nullsNotDistinct": false, + "name": "submission_notification_batch_organization_id_key", + "schema": "public", + "table": "submission_notification_batch", + "entityType": "uniques" + }, + { + "value": "(\"merged_into_post_id\" is null and \"merged_at\" is null) or (\"merged_into_post_id\" is not null and \"merged_at\" is not null)", + "name": "post_merge_requires_target_and_timestamp_chk", + "entityType": "checks", + "schema": "public", + "table": "post" + }, + { + "value": "\"merged_into_post_id\" is null or \"archived_at\" is not null", + "name": "post_merged_rows_must_be_archived_chk", + "entityType": "checks", + "schema": "public", + "table": "post" + }, + { + "value": "\"merged_into_post_id\" is null or \"merged_into_post_id\" <> \"id\"", + "name": "post_no_self_merge_chk", + "entityType": "checks", + "schema": "public", + "table": "post" + }, + { + "value": "(\"embedding\" is null and \"embedding_model\" is null and \"embedded_at\" is null) or (\"embedding\" is not null and \"embedding_model\" is not null and \"embedded_at\" is not null)", + "name": "post_embedding_metadata_chk", + "entityType": "checks", + "schema": "public", + "table": "post" + }, + { + "value": "\"eta_quarter\" is null or \"eta_quarter\" ~ '^[0-9]{4}-Q[1-4]$'", + "name": "post_eta_quarter_format_chk", + "entityType": "checks", + "schema": "public", + "table": "post" + }, + { + "value": "\"credential_generation\" > 0", + "name": "integration_connection_credential_generation_check", + "entityType": "checks", + "schema": "public", + "table": "integration_connection" + }, + { + "value": "\"consecutive_exhausted_deliveries\" >= 0", + "name": "integration_connection_exhausted_count_check", + "entityType": "checks", + "schema": "public", + "table": "integration_connection" + }, + { + "value": "\"attempt_number\" > 0", + "name": "integration_delivery_attempt_number_check", + "entityType": "checks", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "value": "\"duration_ms\" IS NULL OR \"duration_ms\" >= 0", + "name": "integration_delivery_attempt_duration_check", + "entityType": "checks", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "value": "\"http_status\" IS NULL OR \"http_status\" BETWEEN 100 AND 599", + "name": "integration_delivery_attempt_http_status_check", + "entityType": "checks", + "schema": "public", + "table": "integration_delivery_attempt" + }, + { + "value": "\"attempt_count\" >= 0", + "name": "integration_delivery_attempt_count_check", + "entityType": "checks", + "schema": "public", + "table": "integration_delivery" + }, + { + "value": "(\"state\" = 'leased') = (\"lease_owner\" IS NOT NULL AND \"lease_expires_at\" IS NOT NULL)", + "name": "integration_delivery_lease_state_check", + "entityType": "checks", + "schema": "public", + "table": "integration_delivery" + }, + { + "value": "(\"state\" = 'succeeded') = (\"succeeded_at\" IS NOT NULL) AND (\"state\" = 'exhausted') = (\"exhausted_at\" IS NOT NULL) AND (\"state\" = 'canceled') = (\"canceled_at\" IS NOT NULL)", + "name": "integration_delivery_terminal_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "integration_delivery" + }, + { + "value": "\"version\" > 0", + "name": "integration_event_version_check", + "entityType": "checks", + "schema": "public", + "table": "integration_event" + }, + { + "value": "\"causal_hop_count\" >= 0", + "name": "integration_event_causal_hop_count_check", + "entityType": "checks", + "schema": "public", + "table": "integration_event" + }, + { + "value": "\"config_version\" > 0", + "name": "integration_route_config_version_check", + "entityType": "checks", + "schema": "public", + "table": "integration_route" + }, + { + "value": "(\"user_id\" IS NOT NULL) <> (\"organization_id\" IS NOT NULL)", + "name": "asset_owner_check", + "entityType": "checks", + "schema": "public", + "table": "asset" + } + ], + "renames": [] +} diff --git a/packages/db/src/schema/integration.ts b/packages/db/src/schema/integration.ts index 6998f349b..14b10be41 100644 --- a/packages/db/src/schema/integration.ts +++ b/packages/db/src/schema/integration.ts @@ -86,6 +86,13 @@ export const integrationConnectionTable = pgTable( table.organizationId, table.id ), + // A Discord guild has one global interaction endpoint, so one active + // guild installation must resolve to exactly one Feeblo organization. + uniqueIndex("integration_connection_provider_remote_account_active_uidx") + .on(table.provider, table.remoteAccountId) + .where( + sql`${table.provider} = 'discord' and ${table.remoteAccountId} is not null and ${table.lifecycle} = 'active'` + ), ] ); diff --git a/packages/db/src/validation-schema/integration.ts b/packages/db/src/validation-schema/integration.ts index f9c3f0010..ef21e4b49 100644 --- a/packages/db/src/validation-schema/integration.ts +++ b/packages/db/src/validation-schema/integration.ts @@ -28,12 +28,13 @@ export type TIntegrationConnectionMode = S.Schema.Type< typeof IntegrationConnectionMode >; -/** Capabilities persisted by integration routes. `events.post` is the V1 custom-webhook capability; Slack owns the remaining keys. */ +/** Capabilities persisted by integration routes. `events.post` is the V1 custom-webhook capability; Slack and Discord own the remaining keys. */ export const IntegrationCapabilityKey = S.Literals([ "events.post", "channel.notifications", "commands", "message.action", + "interactions", ]); export type TIntegrationCapabilityKey = S.Schema.Type< typeof IntegrationCapabilityKey diff --git a/packages/db/src/validation-schema/post-source.ts b/packages/db/src/validation-schema/post-source.ts index ecad75a21..72b368516 100644 --- a/packages/db/src/validation-schema/post-source.ts +++ b/packages/db/src/validation-schema/post-source.ts @@ -13,6 +13,7 @@ export const PostSource = S.Literals([ "IMPORT", "PUBLIC_BOARD", "SLACK", + "DISCORD", ]); export type TPostSource = S.Schema.Type; diff --git a/packages/domain/package.json b/packages/domain/package.json index 83126433d..bb266d8c1 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -325,6 +325,36 @@ "./integration/slack/handlers": { "default": "./src/integration/slack/handlers.ts" }, + "./integration/discord": { + "default": "./src/integration/discord/index.ts" + }, + "./integration/discord/config": { + "default": "./src/integration/discord/config.ts" + }, + "./integration/discord/schema": { + "default": "./src/integration/discord/schema.ts" + }, + "./integration/discord/errors": { + "default": "./src/integration/discord/errors.ts" + }, + "./integration/discord/management-service": { + "default": "./src/integration/discord/management-service.ts" + }, + "./integration/discord/management-live": { + "default": "./src/integration/discord/management-live.ts" + }, + "./integration/discord/inbound-service": { + "default": "./src/integration/discord/inbound-service.ts" + }, + "./integration/discord/inbound-live": { + "default": "./src/integration/discord/inbound-live.ts" + }, + "./integration/discord/rpcs": { + "default": "./src/integration/discord/rpcs.ts" + }, + "./integration/discord/handlers": { + "default": "./src/integration/discord/handlers.ts" + }, "./board/repository": { "default": "./src/board/repository.ts" }, @@ -345,6 +375,7 @@ "@feeblo/db": "workspace:*", "@feeblo/id": "workspace:*", "@feeblo/integration-core": "workspace:*", + "@feeblo/integration-discord": "workspace:*", "@feeblo/integration-slack": "workspace:*", "@feeblo/integration-webhook": "workspace:*", "@feeblo/permissions": "workspace:*", diff --git a/packages/domain/src/integration/discord/config.ts b/packages/domain/src/integration/discord/config.ts new file mode 100644 index 000000000..d68576546 --- /dev/null +++ b/packages/domain/src/integration/discord/config.ts @@ -0,0 +1,132 @@ +import { + DISCORD_OAUTH_PERMISSIONS, + DISCORD_OAUTH_SCOPES, +} from "@feeblo/integration-discord/manifest"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +const trailingSlashPattern = /\/$/; + +/** + * Runtime Discord integration configuration: the application credentials, + * the application-wide bot token, the interaction public key, the OAuth + * redirect URL, and the at-rest encryption key shared with the webhook + * provider. + * + * The bot token and public key are application-wide (one bot serves every + * guild install), so they live in configuration — exactly like the Slack + * signing secret — instead of in per-connection encrypted credentials. + */ +export class DiscordIntegrationConfig extends Context.Service()( + "DiscordIntegrationConfig", + { + make: Effect.gen(function* () { + const appUrl = yield* Config.schema(Schema.URLFromString, "APP_URL"); + const apiUrl = yield* Config.schema(Schema.URLFromString, "API_URL"); + const clientId = yield* Config.string("DISCORD_CLIENT_ID").pipe( + Config.option + ); + const clientSecret = yield* Config.redacted("DISCORD_CLIENT_SECRET").pipe( + Config.option + ); + const botToken = yield* Config.redacted("DISCORD_BOT_TOKEN").pipe( + Config.option + ); + const publicKey = yield* Config.string("DISCORD_PUBLIC_KEY").pipe( + Config.option + ); + const oauthRedirectUrl = yield* Config.string( + "DISCORD_OAUTH_REDIRECT_URL" + ).pipe(Config.option); + // INTEGRATION_ENCRYPTION_KEY defaults to AUTH_ENCRYPTION_KEY when unset, + // so deployments can share a single required secret for both. + const encryptionKey = yield* Config.redacted( + "INTEGRATION_ENCRYPTION_KEY" + ).pipe( + Config.option, + Effect.flatMap( + Option.match({ + onNone: () => Config.redacted("AUTH_ENCRYPTION_KEY"), + onSome: (key) => Effect.succeed(key), + }) + ) + ); + const apiUrlValue = apiUrl.href.replace(trailingSlashPattern, ""); + const appUrlValue = appUrl.href.replace(trailingSlashPattern, ""); + const configuredRedirectUrl = Option.getOrElse( + oauthRedirectUrl, + () => `${apiUrlValue}/discord/oauth/callback` + ); + const configuredClientId = Option.getOrElse(clientId, () => ""); + const configuredClientSecret = Option.getOrElse(clientSecret, () => + Redacted.make("") + ); + const configuredBotToken = Option.getOrElse(botToken, () => + Redacted.make("") + ); + const configuredPublicKey = Option.getOrElse(publicKey, () => ""); + return { + appUrl: appUrlValue, + authorizeScopes: DISCORD_OAUTH_SCOPES, + botToken: configuredBotToken, + clientId: configuredClientId, + clientSecret: configuredClientSecret, + // The provider is only exposed when the OAuth client id, client + // secret, bot token, and interaction public key are all configured; + // otherwise the server runs without the Discord integration. + configured: + configuredClientId !== "" && + Redacted.value(configuredClientSecret) !== "" && + Redacted.value(configuredBotToken) !== "" && + configuredPublicKey !== "", + encryptionKey, + oauthRedirectUrl: configuredRedirectUrl, + permissions: DISCORD_OAUTH_PERMISSIONS, + publicKey: configuredPublicKey, + } as const; + }), + } +) { + static readonly layer = Layer.effect(this, this.make); + + /** Supplies a fixed configuration to tests. */ + static readonly layerTest = ({ + appUrl = "http://localhost:3001", + botToken = Redacted.make("discord-bot-token"), + clientId = "discord-client-id", + clientSecret = Redacted.make("discord-client-secret"), + configured = true, + encryptionKey = Redacted.make("0123456789abcdef0123456789abcdef"), + oauthRedirectUrl = "http://localhost:3000/discord/oauth/callback", + publicKey = "0".repeat(64), + }: { + readonly appUrl?: string; + readonly botToken?: Redacted.Redacted; + readonly clientId?: string; + readonly clientSecret?: Redacted.Redacted; + readonly configured?: boolean; + readonly encryptionKey?: Redacted.Redacted; + readonly oauthRedirectUrl?: string; + readonly publicKey?: string; + } = {}) => + Layer.succeed( + this, + this.of({ + appUrl, + authorizeScopes: DISCORD_OAUTH_SCOPES, + botToken, + clientId, + clientSecret, + configured, + encryptionKey, + oauthRedirectUrl, + permissions: DISCORD_OAUTH_PERMISSIONS, + publicKey, + }) + ); +} diff --git a/packages/domain/src/integration/discord/discord-channel-service.ts b/packages/domain/src/integration/discord/discord-channel-service.ts new file mode 100644 index 000000000..b916c6066 --- /dev/null +++ b/packages/domain/src/integration/discord/discord-channel-service.ts @@ -0,0 +1,280 @@ +import { currentDb, type Database, schema } from "@feeblo/db"; +import { IntegrationRouteId } from "@feeblo/id"; +import { + DISCORD_NOTIFICATION_CHANNEL_TYPES, + type DiscordApiClient, + makeDiscordApiClient, +} from "@feeblo/integration-discord"; +import { DiscordChannelNotificationRouteConfiguration } from "@feeblo/integration-discord/manifest"; +import { and, eq } from "drizzle-orm"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { InternalServerError, NotFoundError } from "../../rpc-errors"; +import { DiscordIntegrationConfig } from "./config"; +import { + findDiscordConnection, + lockDiscordConnection, + mapDiscordApiError, + mapManagementError, +} from "./discord-management-shared"; +import type { DiscordIntegrationError } from "./errors"; +import type * as S from "./schema"; + +const decodeProviderConfig = (value: unknown) => + Schema.decodeUnknownEffect(DiscordChannelNotificationRouteConfiguration)( + value ?? {} + ).pipe( + Effect.mapError( + () => + new InternalServerError({ + message: "Stored Discord route configuration is invalid", + }) + ) + ); + +/** + * Channel listing and channel-notification routing for an active Discord + * connection: lists the guild's text channels and toggles per-channel + * notification routes. + */ +export interface DiscordChannelServiceShape { + readonly listChannels: ( + input: S.TDiscordChannelList + ) => Effect.Effect; + readonly setChannelNotifications: ( + input: S.TDiscordChannelNotificationsUpdate + ) => Effect.Effect; +} + +export class DiscordChannelService extends Context.Service< + DiscordChannelService, + DiscordChannelServiceShape +>()("@feeblo/DiscordChannelService") {} + +/** Creates the Discord channel service with an injectable API client. */ +export const makeDiscordChannelServiceLive = ( + apiClient: DiscordApiClient = makeDiscordApiClient() +): Layer.Layer< + DiscordChannelService, + never, + Database.Database | DiscordIntegrationConfig +> => + Layer.effect( + DiscordChannelService, + Effect.gen(function* () { + const db = yield* currentDb; + const config = yield* DiscordIntegrationConfig; + + const listChannels = Effect.fn("DiscordChannel.listChannels")( + function* (input: S.TDiscordChannelList) { + const [connection] = yield* findDiscordConnection( + db, + input.connectionId, + input.organizationId + ); + if (connection === undefined || connection.lifecycle !== "active") { + return yield* new NotFoundError({ + message: "Discord connection was not found", + }); + } + if (connection.remoteAccountId === null) { + return yield* new NotFoundError({ + message: "Discord connection was not found", + }); + } + const routeRows = yield* db + .select() + .from(schema.integrationRouteTable) + .where( + and( + eq( + schema.integrationRouteTable.connectionId, + input.connectionId + ), + eq( + schema.integrationRouteTable.capabilityKey, + "channel.notifications" + ), + eq(schema.integrationRouteTable.enabled, true) + ) + ); + const notificationChannels = yield* Effect.forEach( + routeRows, + (route) => + decodeProviderConfig(route.providerConfig).pipe( + Effect.map((routeConfig) => routeConfig.channelId) + ) + ); + const enabledChannels = new Set(notificationChannels); + // `guilds/{guildId}/channels` returns every channel in one request; + // only text and announcement channels can carry notifications. + const discordChannels = yield* apiClient + .guildsChannels({ + botToken: config.botToken, + guildId: connection.remoteAccountId, + }) + .pipe(mapDiscordApiError("channel listing")); + return discordChannels + .filter((channel) => + DISCORD_NOTIFICATION_CHANNEL_TYPES.includes( + channel.type as (typeof DISCORD_NOTIFICATION_CHANNEL_TYPES)[number] + ) + ) + .map((channel) => ({ + id: channel.id, + name: channel.name, + notificationsEnabled: enabledChannels.has(channel.id), + })); + }, + (effect) => effect.pipe(mapManagementError("channel list")) + ); + + const setChannelNotifications = Effect.fn( + "DiscordChannel.setChannelNotifications" + )( + function* (input: S.TDiscordChannelNotificationsUpdate) { + const [connection] = yield* findDiscordConnection( + db, + input.connectionId, + input.organizationId + ); + if ( + connection === undefined || + connection.lifecycle !== "active" || + connection.remoteAccountId === null + ) { + return yield* new NotFoundError({ + message: "Discord connection was not found", + }); + } + // Discord owns channel membership and names. Resolve the requested + // channel against the connection's guild before persisting a route so + // a caller cannot route deliveries into another installed guild. + const discordChannels = yield* apiClient + .guildsChannels({ + botToken: config.botToken, + guildId: connection.remoteAccountId, + }) + .pipe(mapDiscordApiError("channel validation")); + const selectedChannel = discordChannels.find( + (channel) => + channel.id === input.channelId && + DISCORD_NOTIFICATION_CHANNEL_TYPES.includes( + channel.type as (typeof DISCORD_NOTIFICATION_CHANNEL_TYPES)[number] + ) + ); + if (selectedChannel === undefined) { + return yield* new NotFoundError({ + message: "Discord channel was not found in the connected server", + }); + } + return yield* db + .transaction(() => + Effect.gen(function* () { + const [connection] = yield* lockDiscordConnection( + db, + input.connectionId, + input.organizationId + ); + if ( + connection === undefined || + connection.lifecycle !== "active" + ) { + return yield* new NotFoundError({ + message: "Discord connection was not found", + }); + } + // One route per channel: look the target channel's route up by + // its routeKey (the channel id) instead of decoding every + // channel.notifications route's providerConfig. + const [route] = yield* db + .select() + .from(schema.integrationRouteTable) + .where( + and( + eq( + schema.integrationRouteTable.connectionId, + input.connectionId + ), + eq( + schema.integrationRouteTable.capabilityKey, + "channel.notifications" + ), + eq( + schema.integrationRouteTable.organizationId, + input.organizationId + ), + eq(schema.integrationRouteTable.routeKey, input.channelId) + ) + ) + .limit(1); + const channelName = selectedChannel.name; + const now = new Date(); + if (input.enabled) { + if (route === undefined) { + yield* db.insert(schema.integrationRouteTable).values({ + capabilityKey: "channel.notifications", + configVersion: 1, + connectionId: input.connectionId, + enabled: true, + eventTypes: ["feedback.post.created"], + id: yield* IntegrationRouteId.generate, + organizationId: input.organizationId, + providerConfig: { + channelId: input.channelId, + version: 1, + }, + routeKey: input.channelId, + safeDisplayMetadata: { channelName }, + createdAt: now, + updatedAt: now, + }); + } else if (route.enabled !== true) { + yield* db + .update(schema.integrationRouteTable) + .set({ + enabled: true, + eventTypes: ["feedback.post.created"], + providerConfig: { + channelId: input.channelId, + version: 1, + }, + safeDisplayMetadata: { channelName }, + updatedAt: now, + }) + .where(eq(schema.integrationRouteTable.id, route.id)); + } + } else if (route?.enabled === true) { + yield* db + .update(schema.integrationRouteTable) + .set({ enabled: false, updatedAt: now }) + .where(eq(schema.integrationRouteTable.id, route.id)); + yield* db + .update(schema.integrationDeliveryTable) + .set({ canceledAt: now, state: "canceled", updatedAt: now }) + .where( + and( + eq(schema.integrationDeliveryTable.routeId, route.id), + eq(schema.integrationDeliveryTable.state, "pending") + ) + ); + } + }) + ) + .pipe(mapManagementError("channel notifications update")); + }, + (effect) => + effect.pipe(mapManagementError("channel notifications update")) + ); + + return DiscordChannelService.of({ + listChannels, + setChannelNotifications, + }); + }) + ); + +/** Live layer with the default fetch-backed Discord API client. */ +export const DiscordChannelServiceLive = makeDiscordChannelServiceLive(); diff --git a/packages/domain/src/integration/discord/discord-connection-service.ts b/packages/domain/src/integration/discord/discord-connection-service.ts new file mode 100644 index 000000000..39e312b71 --- /dev/null +++ b/packages/domain/src/integration/discord/discord-connection-service.ts @@ -0,0 +1,595 @@ +import { currentDb, type Database, schema } from "@feeblo/db"; +import { + asLegid, + IntegrationConnectionId, + IntegrationRouteId, + type LegidFrom, + WorkspaceId, +} from "@feeblo/id"; +import { + DISCORD_GUILD_COMMANDS, + DISCORD_OAUTH_AUTHORIZE_URL, + type DiscordApiClient, + DiscordOAuthState, + makeDiscordApiClient, +} from "@feeblo/integration-discord"; +import { encryptDiscordCredentialMaterial } from "@feeblo/integration-discord/credentials"; +import { discordProviderKey } from "@feeblo/integration-discord/manifest"; +import { and, desc, eq, inArray, ne } from "drizzle-orm"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { + BadRequestError, + InternalServerError, + NotFoundError, +} from "../../rpc-errors"; +import { DiscordIntegrationConfig } from "./config"; +import { + decryptConnectionCredentials, + findDiscordConnection, + lockDiscordConnection, + mapDiscordApiError, + mapManagementError, +} from "./discord-management-shared"; +import type { DiscordIntegrationError } from "./errors"; +import type * as S from "./schema"; + +const retentionMs = 30 * 24 * 60 * 60 * 1000; + +const DiscordSafeDisplayMetadata = Schema.Struct({ + guildId: Schema.optionalKey(Schema.String), + guildName: Schema.optionalKey(Schema.String), + installerUserId: Schema.optionalKey(Schema.String), +}); + +const decodeSafeDisplayMetadata = (value: unknown) => + Schema.decodeUnknownEffect(DiscordSafeDisplayMetadata)(value ?? {}).pipe( + Effect.mapError( + () => + new InternalServerError({ + message: "Stored Discord display metadata is invalid", + }) + ) + ); + +/** + * OAuth connection lifecycle: installing a Discord guild (connect start and + * completion with guild-scoped command registration), listing connections, + * and disconnecting with credential erasure. + */ +export interface DiscordConnectionServiceShape { + readonly connectComplete: (input: { + readonly code: string; + readonly state: string; + }) => Effect.Effect< + { readonly organizationId: LegidFrom }, + DiscordIntegrationError + >; + readonly connectStart: ( + input: S.TDiscordConnectStart + ) => Effect.Effect; + readonly disconnect: ( + input: S.TDiscordConnectionDisconnect + ) => Effect.Effect; + readonly listConnections: ( + input: S.TDiscordConnectionList + ) => Effect.Effect; +} + +export class DiscordConnectionService extends Context.Service< + DiscordConnectionService, + DiscordConnectionServiceShape +>()("@feeblo/DiscordConnectionService") {} + +/** Creates the Discord connection lifecycle service with an injectable API client. */ +export const makeDiscordConnectionServiceLive = ( + apiClient: DiscordApiClient = makeDiscordApiClient() +): Layer.Layer< + DiscordConnectionService, + never, + Database.Database | DiscordIntegrationConfig +> => + Layer.effect( + DiscordConnectionService, + Effect.gen(function* () { + const db = yield* currentDb; + const config = yield* DiscordIntegrationConfig; + + const connectStart = Effect.fn("DiscordConnection.connectStart")( + function* ({ organizationId }: { readonly organizationId: string }) { + if (!config.configured) { + return yield* new InternalServerError({ + message: "Discord integration is not configured", + }); + } + const nonce = crypto.randomUUID(); + const connectionId = yield* IntegrationConnectionId.generate; + const ciphertext = yield* encryptDiscordCredentialMaterial( + config.encryptionKey, + { oauthState: nonce } + ).pipe( + Effect.mapError( + () => + new InternalServerError({ + message: "Discord credentials could not be encrypted", + }) + ) + ); + yield* db.transaction(() => + Effect.gen(function* () { + // A new install attempt replaces any abandoned connecting row so + // listConnections never accumulates stale in-progress entries. + yield* db + .delete(schema.integrationConnectionTable) + .where( + and( + eq( + schema.integrationConnectionTable.organizationId, + organizationId + ), + eq( + schema.integrationConnectionTable.provider, + discordProviderKey + ), + eq( + schema.integrationConnectionTable.lifecycle, + "connecting" + ) + ) + ); + yield* db.insert(schema.integrationConnectionTable).values({ + credentialGeneration: 1, + credentialsCiphertext: ciphertext, + id: connectionId, + lifecycle: "connecting", + name: "Discord", + organizationId, + provider: discordProviderKey, + safeDisplayMetadata: {}, + }); + }) + ); + const state = yield* Schema.encodeEffect( + Schema.fromJsonString(DiscordOAuthState) + )({ + connectionId, + nonce, + organizationId, + }).pipe( + Effect.mapError( + () => + new InternalServerError({ + message: "Discord OAuth state could not be encoded", + }) + ) + ); + const authorizeUrl = new URL(DISCORD_OAUTH_AUTHORIZE_URL); + authorizeUrl.searchParams.set("client_id", config.clientId); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set( + "redirect_uri", + config.oauthRedirectUrl + ); + authorizeUrl.searchParams.set( + "scope", + config.authorizeScopes.join(" ") + ); + authorizeUrl.searchParams.set( + "permissions", + String(config.permissions) + ); + authorizeUrl.searchParams.set("state", state); + return { authorizeUrl }; + }, + (effect) => effect.pipe(mapManagementError("connect start")) + ); + + const connectComplete = Effect.fn("DiscordConnection.connectComplete")( + function* ({ + code, + state, + }: { + readonly code: string; + readonly state: string; + }) { + const decoded = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(DiscordOAuthState) + )(state).pipe( + Effect.mapError( + () => + new BadRequestError({ + message: "Discord OAuth state is invalid", + }) + ) + ); + const decodedOrganizationId = yield* Schema.decodeUnknownEffect( + WorkspaceId.schema + )(decoded.organizationId).pipe( + Effect.mapError( + () => + new BadRequestError({ + message: "Discord OAuth state is invalid", + }) + ) + ); + const organizationId = asLegid(WorkspaceId)(decodedOrganizationId); + const [pendingConnection] = yield* findDiscordConnection( + db, + decoded.connectionId, + organizationId + ); + if ( + pendingConnection === undefined || + pendingConnection.lifecycle !== "connecting" || + pendingConnection.credentialsCiphertext === null + ) { + return yield* new NotFoundError({ + message: "Discord connection was not found", + }); + } + const pendingCredentials = yield* decryptConnectionCredentials( + config, + pendingConnection.credentialsCiphertext + ); + if (pendingCredentials.oauthState !== decoded.nonce) { + return yield* new BadRequestError({ + message: "Discord OAuth state does not match", + }); + } + // OAuth exchange runs before any transaction: the code is single-use, + // so Discord itself serializes replays. The guarded connection + // transition below is the idempotency boundary for concurrent + // callback replays. + const oauth = yield* apiClient + .oauth2TokenExchange({ + clientId: config.clientId, + clientSecret: config.clientSecret, + code, + redirectUri: config.oauthRedirectUrl, + }) + .pipe(mapDiscordApiError("OAuth handshake")); + // The `guild` field is present only when the user actually added the + // bot to a server; without it there is nothing to connect. + if (oauth.guild === undefined) { + return yield* new BadRequestError({ + message: "Discord install did not include a server", + }); + } + const guild = oauth.guild; + const [connectionForAnotherOrganization] = yield* db + .select({ id: schema.integrationConnectionTable.id }) + .from(schema.integrationConnectionTable) + .where( + and( + eq( + schema.integrationConnectionTable.provider, + discordProviderKey + ), + eq(schema.integrationConnectionTable.remoteAccountId, guild.id), + eq(schema.integrationConnectionTable.lifecycle, "active"), + ne( + schema.integrationConnectionTable.organizationId, + organizationId + ) + ) + ) + .limit(1); + if (connectionForAnotherOrganization !== undefined) { + return yield* new BadRequestError({ + message: + "Discord server is already connected to another organization", + }); + } + // Register the guild-scoped commands up front; a failure here aborts + // the install so the connection never activates without its surface. + const application = yield* apiClient + .applicationsMe({ botToken: config.botToken }) + .pipe(mapDiscordApiError("application lookup")); + yield* apiClient + .guildsCommandsBulkOverwrite({ + applicationId: application.id, + botToken: config.botToken, + commands: DISCORD_GUILD_COMMANDS, + guildId: guild.id, + }) + .pipe(mapDiscordApiError("command registration")); + const ciphertext = yield* encryptDiscordCredentialMaterial( + config.encryptionKey, + { userToken: oauth.access_token } + ).pipe( + Effect.mapError( + () => + new InternalServerError({ + message: "Discord credentials could not be encrypted", + }) + ) + ); + return yield* db.transaction(() => + Effect.gen(function* () { + const [connection] = yield* lockDiscordConnection( + db, + decoded.connectionId, + organizationId + ); + if ( + connection === undefined || + connection.lifecycle !== "connecting" + ) { + return yield* new NotFoundError({ + message: "Discord connection was not found", + }); + } + if (connection.credentialsCiphertext === null) { + return yield* new NotFoundError({ + message: "Discord connection was not found", + }); + } + const credentials = yield* decryptConnectionCredentials( + config, + connection.credentialsCiphertext + ); + if (credentials.oauthState !== decoded.nonce) { + return yield* new BadRequestError({ + message: "Discord OAuth state does not match", + }); + } + const now = new Date(); + // A Discord guild has at most one active connection per + // organization: archive any pre-existing active connection for + // the same guild before activating the current row, so + // inbound-live resolves a single credential source. + yield* db + .update(schema.integrationConnectionTable) + .set({ + archivedAt: now, + credentialsCiphertext: null, + lifecycle: "archived", + retentionExpiresAt: new Date(now.getTime() + retentionMs), + updatedAt: now, + }) + .where( + and( + eq( + schema.integrationConnectionTable.organizationId, + organizationId + ), + eq( + schema.integrationConnectionTable.provider, + discordProviderKey + ), + eq( + schema.integrationConnectionTable.remoteAccountId, + guild.id + ), + eq(schema.integrationConnectionTable.lifecycle, "active") + ) + ); + yield* db + .update(schema.integrationConnectionTable) + .set({ + credentialGeneration: 1, + credentialsCiphertext: ciphertext, + lifecycle: "active", + name: guild.name, + remoteAccountId: guild.id, + safeDisplayMetadata: { + guildId: guild.id, + guildName: guild.name, + ...(oauth.user === undefined + ? {} + : { installerUserId: oauth.user.id }), + }, + updatedAt: now, + }) + .where(eq(schema.integrationConnectionTable.id, connection.id)); + // One inbound route per connection: Discord routes all + // interactions (slash commands, context menus, modal submits) to + // the single `/discord/interactions` endpoint, keyed by guild id. + // Duplicate replays are ignored via the connection/capability + // unique index. + yield* db + .insert(schema.integrationRouteTable) + .values({ + capabilityKey: "interactions", + configVersion: 1, + connectionId: connection.id, + enabled: true, + eventTypes: [], + id: yield* IntegrationRouteId.generate, + organizationId, + providerConfig: { version: 1 }, + routeKey: "", + safeDisplayMetadata: {}, + }) + .onConflictDoNothing(); + return { organizationId }; + }) + ); + }, + (effect) => effect.pipe(mapManagementError("connect complete")) + ); + + const listConnections = Effect.fn("DiscordConnection.listConnections")( + function* ({ organizationId }: { readonly organizationId: string }) { + const rows = yield* db + .select() + .from(schema.integrationConnectionTable) + .where( + and( + eq( + schema.integrationConnectionTable.organizationId, + organizationId + ), + eq( + schema.integrationConnectionTable.provider, + discordProviderKey + ), + inArray(schema.integrationConnectionTable.lifecycle, [ + "connecting", + "active", + "paused", + "reauth_required", + "disconnecting", + "disconnected", + "revocation_unconfirmed", + ]) + ) + ) + .orderBy(desc(schema.integrationConnectionTable.createdAt)); + return yield* Effect.forEach(rows, (connection) => + decodeSafeDisplayMetadata(connection.safeDisplayMetadata).pipe( + Effect.map((metadata) => ({ + createdAt: connection.createdAt, + id: asLegid(IntegrationConnectionId)(connection.id), + guildId: metadata.guildId ?? null, + guildName: metadata.guildName ?? connection.name, + lifecycle: connection.lifecycle, + })) + ) + ); + }, + (effect) => effect.pipe(mapManagementError("list")) + ); + + const disconnect = Effect.fn("DiscordConnection.disconnect")(function* ( + input: S.TDiscordConnectionDisconnect + ) { + const { connectionId, organizationId } = input; + const disconnectingConnection = yield* db + .transaction(() => + Effect.gen(function* () { + const [connection] = yield* lockDiscordConnection( + db, + connectionId, + organizationId + ); + if (connection === undefined) { + return yield* new NotFoundError({ + message: "Discord connection was not found", + }); + } + if (connection.lifecycle === "archived") { + return undefined; + } + const now = new Date(); + yield* db + .update(schema.integrationConnectionTable) + .set({ lifecycle: "disconnecting", updatedAt: now }) + .where(eq(schema.integrationConnectionTable.id, connectionId)); + yield* db + .update(schema.integrationRouteTable) + .set({ enabled: false, updatedAt: now }) + .where( + and( + eq(schema.integrationRouteTable.connectionId, connectionId), + eq( + schema.integrationRouteTable.organizationId, + organizationId + ) + ) + ); + yield* db + .update(schema.integrationDeliveryTable) + .set({ canceledAt: now, state: "canceled", updatedAt: now }) + .where( + and( + eq( + schema.integrationDeliveryTable.connectionId, + connectionId + ), + eq( + schema.integrationDeliveryTable.organizationId, + organizationId + ), + eq(schema.integrationDeliveryTable.state, "pending") + ) + ); + return { + credentialsCiphertext: + connection.credentialsCiphertext ?? undefined, + guildId: connection.remoteAccountId ?? undefined, + }; + }) + ) + .pipe(mapManagementError("disconnect")); + if (disconnectingConnection === undefined) { + return; + } + if (disconnectingConnection.guildId !== undefined) { + const leftGuild = yield* Effect.exit( + apiClient.guildsLeave({ + botToken: config.botToken, + guildId: disconnectingConnection.guildId, + }) + ); + if (Exit.isFailure(leftGuild)) { + yield* db + .update(schema.integrationConnectionTable) + .set({ + lifecycle: "revocation_unconfirmed", + updatedAt: new Date(), + }) + .where(eq(schema.integrationConnectionTable.id, connectionId)) + .pipe(mapManagementError("disconnect state update")); + return yield* Effect.failCause(leftGuild.cause).pipe( + mapDiscordApiError("guild disconnect") + ); + } + } + const credentialsCiphertext = + disconnectingConnection.credentialsCiphertext; + if (credentialsCiphertext === undefined) { + yield* Effect.logWarning( + "Discord disconnect had no installer credential to revoke" + ); + } else { + const credentials = yield* Effect.exit( + decryptConnectionCredentials(config, credentialsCiphertext) + ); + if (Exit.isFailure(credentials)) { + yield* Effect.logWarning( + "Could not decrypt Discord credentials for token revocation" + ); + } else if (credentials.value.userToken !== undefined) { + const revoked = yield* Effect.exit( + apiClient.oauth2TokenRevoke({ + clientId: config.clientId, + clientSecret: config.clientSecret, + userToken: credentials.value.userToken, + }) + ); + if (Exit.isFailure(revoked)) { + yield* Effect.logWarning( + "Discord token revocation failed after guild disconnect" + ); + } + } + } + const now = new Date(); + yield* db + .update(schema.integrationConnectionTable) + .set({ + archivedAt: now, + credentialsCiphertext: null, + lifecycle: "archived", + retentionExpiresAt: new Date(now.getTime() + retentionMs), + updatedAt: now, + }) + .where(eq(schema.integrationConnectionTable.id, connectionId)) + .pipe(mapManagementError("disconnect archive")); + }); + + return DiscordConnectionService.of({ + connectComplete, + connectStart, + disconnect, + listConnections, + }); + }) + ); + +/** Live layer with the default fetch-backed Discord API client. */ +export const DiscordConnectionServiceLive = makeDiscordConnectionServiceLive(); diff --git a/packages/domain/src/integration/discord/discord-feedback-service.ts b/packages/domain/src/integration/discord/discord-feedback-service.ts new file mode 100644 index 000000000..8ffb219fa --- /dev/null +++ b/packages/domain/src/integration/discord/discord-feedback-service.ts @@ -0,0 +1,206 @@ +import { currentDb, Database, schema } from "@feeblo/db"; +import { + asLegid, + BoardId, + PostId, + PostStatusId, + WorkspaceId, +} from "@feeblo/id"; +import { IntegrationEventRecorder } from "@feeblo/integration-core"; +import { htmlToExcerpt } from "@feeblo/utils/html"; +import { sanitizeMarkdown } from "@feeblo/utils/markdown-sanitizer"; +import { and, eq } from "drizzle-orm"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { EmailOutboxConfig } from "../../email-outbox/config"; +import { recordPostIntegrationEvent } from "../../integration/post-event-recording"; +import { + PostEmbeddingService, + schedulePostEmbeddingBestEffort, +} from "../../post/embedding-service"; +import { PostRepository } from "../../post/repository"; +import { PostStatusRepository } from "../../post-status/repository"; +import { PostSubscriptionRepository } from "../../post-subscription/repository"; +import { DiscordInboundFailure } from "./errors"; + +/** A feedback post created from an inbound Discord submission. */ +export interface DiscordPost { + readonly boardId: string; + readonly boardName: string; + readonly boardSlug: string; + readonly id: string; + readonly metadata: Readonly>; + readonly slug: string; + readonly status: string; + readonly title: string; +} + +export interface DiscordPostInput { + readonly boardId: string; + readonly content: string; + readonly metadata?: Readonly>; + readonly organizationId: string; + readonly title: string; + readonly userId: string; +} + +/** + * Creates a feedback post from an inbound Discord submission: sanitizes the + * content, records the integration event, subscribes the author, and schedules + * best-effort embedding. + */ +export interface DiscordFeedbackServiceShape { + readonly createPost: ( + input: DiscordPostInput + ) => Effect.Effect; +} + +export class DiscordFeedbackService extends Context.Service< + DiscordFeedbackService, + DiscordFeedbackServiceShape +>()("@feeblo/DiscordFeedbackService") {} + +export const makeDiscordFeedbackServiceLive = (): Layer.Layer< + DiscordFeedbackService, + never, + | Database.Database + | EmailOutboxConfig + | IntegrationEventRecorder + | PostRepository + | PostStatusRepository + | PostSubscriptionRepository +> => + Layer.effect( + DiscordFeedbackService, + Effect.gen(function* () { + const db = yield* currentDb; + const postRepository = yield* PostRepository; + const postStatusRepository = yield* PostStatusRepository; + const postSubscriptionRepository = yield* PostSubscriptionRepository; + const emailOutboxConfig = yield* EmailOutboxConfig; + const eventRecorder = yield* IntegrationEventRecorder; + const embeddingService = + yield* Effect.serviceOption(PostEmbeddingService); + + const createPost = ({ + boardId, + content, + metadata = {}, + organizationId, + title, + userId, + }: DiscordPostInput) => + Effect.gen(function* () { + const statuses = yield* postStatusRepository.findMany({ + organizationId, + }); + const defaultStatus = statuses[0]; + if (defaultStatus === undefined) { + return yield* new DiscordInboundFailure({ + message: "Organization has no default post status", + }); + } + const { sanitizedMarkdown, sanitizedHtml } = + sanitizeMarkdown(content); + const id = yield* PostId.generate; + const excerpt = htmlToExcerpt(sanitizedHtml); + const [board] = yield* db + .select({ + name: schema.boardTable.name, + slug: schema.boardTable.slug, + }) + .from(schema.boardTable) + .where( + and( + eq(schema.boardTable.id, boardId), + eq(schema.boardTable.organizationId, organizationId) + ) + ) + .limit(1); + if (board === undefined) { + return yield* new DiscordInboundFailure({ + message: "Discord post board was not found", + }); + } + const boardSlug = board.slug; + const slug = yield* db.transaction(() => + Effect.gen(function* () { + const createdSlug = yield* postRepository.create({ + boardId, + content: sanitizedMarkdown, + creatorId: userId, + creatorMemberId: null, + excerpt, + id, + metadata: { ...metadata }, + organizationId, + source: "DISCORD", + statusId: defaultStatus.id, + title, + }); + yield* recordPostIntegrationEvent({ + actor: { kind: "end_user" }, + boardId: asLegid(BoardId)(boardId), + eventType: "feedback.post.created", + organizationId: asLegid(WorkspaceId)(organizationId), + postId: id, + ...(Object.keys(metadata).length === 0 ? {} : { metadata }), + postSlug: createdSlug, + statusId: asLegid(PostStatusId)(defaultStatus.id), + title, + }).pipe( + Effect.provideService(Database.Database, db), + Effect.provideService(IntegrationEventRecorder, eventRecorder), + Effect.provideService(EmailOutboxConfig, emailOutboxConfig), + Effect.provideService(PostRepository, postRepository), + Effect.mapError( + () => + new DiscordInboundFailure({ + message: "Could not record post integration event", + }) + ) + ); + yield* postSubscriptionRepository.subscribe({ + organizationId, + postId: id, + userId, + }); + return createdSlug; + }) + ); + yield* schedulePostEmbeddingBestEffort({ + content: sanitizedMarkdown, + postId: id, + organizationId, + title, + ...(embeddingService._tag === "Some" + ? { embeddingService: embeddingService.value } + : {}), + }).pipe(Effect.provideService(Database.Database, db)); + return { + boardId, + boardName: board.name, + boardSlug, + id, + metadata, + slug, + status: defaultStatus.type, + title, + }; + }).pipe( + Effect.mapError((error) => + error instanceof DiscordInboundFailure + ? error + : new DiscordInboundFailure({ + message: "Could not create the Discord feedback post", + }) + ) + ); + + return DiscordFeedbackService.of({ createPost }); + }) + ); + +/** Live layer with real repositories and the default database context. */ +export const DiscordFeedbackServiceLive = makeDiscordFeedbackServiceLive(); diff --git a/packages/domain/src/integration/discord/discord-management-shared.ts b/packages/domain/src/integration/discord/discord-management-shared.ts new file mode 100644 index 000000000..5ae744a89 --- /dev/null +++ b/packages/domain/src/integration/discord/discord-management-shared.ts @@ -0,0 +1,112 @@ +import { schema } from "@feeblo/db"; +import type { DiscordApiFailure } from "@feeblo/integration-discord"; +import { decryptDiscordCredentialMaterial } from "@feeblo/integration-discord/credentials"; +import { discordProviderKey } from "@feeblo/integration-discord/manifest"; +import { and, eq } from "drizzle-orm"; +import type * as PgDrizzle from "drizzle-orm/effect-postgres"; +import * as Effect from "effect/Effect"; +import type * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import { InternalServerError } from "../../rpc-errors"; +import { DiscordIntegrationErrors } from "./errors"; + +/** + * Shared helpers for the Discord management services: connection row lookups + * and management/API error mapping. Owned here because both the connection + * lifecycle service and the channel service read (and lock) connection rows + * and translate failures at their boundaries. + */ + +/** Maps any non-Discord failure to `InternalServerError`, preserving Discord errors. */ +export const mapManagementError = + (operation: string) => + (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((error: unknown) => + Schema.is(DiscordIntegrationErrors)(error) + ? error + : new InternalServerError({ + message: `Discord ${operation} failed`, + }) + ) + ); + +/** Decrypts a connection's stored credentials, mapping decryption failures to an `InternalServerError`. */ +export const decryptConnectionCredentials = ( + config: { readonly encryptionKey: Redacted.Redacted }, + ciphertext: string +): Effect.Effect< + { + readonly oauthState?: string; + readonly userToken?: Redacted.Redacted; + }, + InternalServerError +> => + decryptDiscordCredentialMaterial(config.encryptionKey, ciphertext).pipe( + Effect.mapError( + () => + new InternalServerError({ + message: "Discord credentials could not be decrypted", + }) + ) + ); + +/** Maps a Discord API failure to an `InternalServerError` for one operation. */ +export const mapDiscordApiError = (operation: string) => + Effect.mapError((error: DiscordApiFailure) => { + switch (error._tag) { + case "IntegrationProviderAuthenticationError": + return new InternalServerError({ + message: `Discord rejected authentication during ${operation}`, + }); + case "IntegrationProviderRateLimitedError": + return new InternalServerError({ + message: `Discord rate limited ${operation}`, + }); + case "IntegrationProviderTemporaryFailure": + return new InternalServerError({ + message: `Discord temporarily failed during ${operation}`, + }); + case "IntegrationProviderInvalidConfigurationError": + return new InternalServerError({ + message: `Discord configuration is invalid during ${operation}`, + }); + case "IntegrationProviderPermanentRejection": + return new InternalServerError({ + message: `Discord rejected ${operation}`, + }); + default: + // Defensive arm for a future provider failure tag; the union is closed. + return new InternalServerError({ + message: `Discord ${operation} failed`, + }); + } + }); + +/** Finds a Discord connection by id and organization without locking. */ +export const findDiscordConnection = ( + db: PgDrizzle.EffectPgDatabase, + connectionId: string, + organizationId: string +) => + db + .select() + .from(schema.integrationConnectionTable) + .where( + and( + eq(schema.integrationConnectionTable.id, connectionId), + eq(schema.integrationConnectionTable.organizationId, organizationId), + eq(schema.integrationConnectionTable.provider, discordProviderKey) + ) + ) + .limit(1); + +/** + * Row lock for connection updates inside transactions; plain reads use + * `findDiscordConnection` instead. + */ +export const lockDiscordConnection = ( + db: PgDrizzle.EffectPgDatabase, + connectionId: string, + organizationId: string +) => findDiscordConnection(db, connectionId, organizationId).for("update"); diff --git a/packages/domain/src/integration/discord/discord-modals.ts b/packages/domain/src/integration/discord/discord-modals.ts new file mode 100644 index 000000000..1cb1ce3c5 --- /dev/null +++ b/packages/domain/src/integration/discord/discord-modals.ts @@ -0,0 +1,196 @@ +import type { DiscordEmbed } from "@feeblo/integration-discord/embeds"; +import { truncate } from "@feeblo/utils/text"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { DiscordInboundFailure } from "./errors"; + +const TITLE_MAX_LENGTH = 200; +const DETAILS_MAX_LENGTH = 3000; +const BOARD_SELECT_MAX_OPTIONS = 25; +const FEEDBACK_MODAL_TITLE = "Send feedback to Feeblo"; + +/** + * Metadata embedded in the feedback modal `custom_id`. Discord limits custom + * ids to 100 characters, so this is a colon-delimited string rather than the + * JSON Slack stores in `private_metadata`: every Feeblo id is URL-safe and + * Discord snowflakes are digits, so the format is unambiguous. + * + * Budget: "feeblo:" (7) + organization id (~16) + guild/channel/message + * snowflakes (20 each) + separators fits comfortably under the 100-character + * limit. + */ +export const DiscordFeedbackModalMetadata = Schema.Struct({ + organizationId: Schema.String, + guildId: Schema.String, + channelId: Schema.String, + messageId: Schema.optionalKey(Schema.String), +}); +export type DiscordFeedbackModalMetadata = Schema.Schema.Type< + typeof DiscordFeedbackModalMetadata +>; + +export const DISCORD_FEEDBACK_MODAL_CUSTOM_ID_PREFIX = "feeblo"; + +/** Encodes modal metadata into the interaction `custom_id`. */ +export const encodeModalMetadata = (metadata: DiscordFeedbackModalMetadata) => + [ + DISCORD_FEEDBACK_MODAL_CUSTOM_ID_PREFIX, + metadata.organizationId, + metadata.guildId, + metadata.channelId, + ...(metadata.messageId === undefined ? [] : [metadata.messageId]), + ].join(":"); + +/** Decodes the interaction `custom_id` back into modal metadata. */ +export const decodeModalMetadata = ( + customId: string +): Effect.Effect => { + const parts = customId.split(":"); + if ( + parts[0] !== DISCORD_FEEDBACK_MODAL_CUSTOM_ID_PREFIX || + parts.length < 4 || + parts.length > 5 + ) { + return Effect.fail( + new DiscordInboundFailure({ + message: "Discord feedback modal custom id is malformed", + }) + ); + } + const [organizationId, guildId, channelId, messageId] = [ + parts[1], + parts[2], + parts[3], + parts[4], + ]; + if ( + organizationId === undefined || + guildId === undefined || + channelId === undefined + ) { + return Effect.fail( + new DiscordInboundFailure({ + message: "Discord feedback modal custom id is malformed", + }) + ); + } + return Schema.decodeUnknownEffect(DiscordFeedbackModalMetadata)({ + ...(messageId === undefined ? {} : { messageId }), + channelId, + guildId, + organizationId, + }).pipe( + Effect.mapError( + () => + new DiscordInboundFailure({ + message: "Discord feedback modal custom id is malformed", + }) + ) + ); +}; + +/** Reads a submitted value from the modal submit payload by `custom_id`. */ +export const readModalValue = ( + components: readonly { + readonly type: 18; + readonly component: + | { readonly type: 4; readonly custom_id: string; readonly value: string } + | { + readonly type: 3; + readonly custom_id: string; + readonly values: readonly string[]; + }; + }[], + customId: string +): string | undefined => { + const value = components + .map(({ component }) => component) + .find((component) => component.custom_id === customId); + return value?.type === 4 ? value.value : value?.values[0]; +}; + +/** + * Discord `MODAL` interaction response (callback type 9) that collects a + * feedback post. The modal's `custom_id` carries the metadata the submit + * interaction returns. + */ +export const buildFeedbackModal = ({ + boards, + customId, + initialTitle, +}: { + readonly boards: readonly { + readonly id: string; + readonly name: string; + }[]; + readonly customId: string; + readonly initialTitle: string; +}): unknown => ({ + type: 9, + data: { + custom_id: customId, + title: FEEDBACK_MODAL_TITLE, + components: [ + { + type: 18, + label: "Title", + component: { + type: 4, + custom_id: "title", + style: 1, + required: true, + max_length: TITLE_MAX_LENGTH, + ...(initialTitle.length > 0 + ? { value: truncate(initialTitle, TITLE_MAX_LENGTH) } + : {}), + }, + }, + { + type: 18, + label: "Details", + component: { + type: 4, + custom_id: "details", + style: 2, + required: false, + max_length: DETAILS_MAX_LENGTH, + }, + }, + { + type: 18, + label: "Board", + component: { + type: 3, + custom_id: "board", + placeholder: "Choose a board", + required: true, + // Discord's string select accepts at most 25 options; cap the + // board list so the modal stays valid for large organizations. + options: boards.slice(0, BOARD_SELECT_MAX_OPTIONS).map((board) => ({ + label: truncate(board.name, 75), + value: board.id, + })), + }, + }, + ], + }, +}); + +/** Discord `CHANNEL_MESSAGE_WITH_SOURCE` response with the ephemeral flag (callback type 4, flags 64). */ +export const buildEphemeralMessage = ( + content: string, + embeds: readonly DiscordEmbed[] = [] +): unknown => ({ + type: 4, + data: { + flags: 64, + content, + ...(embeds.length === 0 ? {} : { embeds }), + }, +}); + +/** Discord `PONG` response to a `PING` interaction (callback type 1). */ +export const buildPong = (): unknown => ({ type: 1 }); + +/** Discord deferred-update acknowledgment for unhandled interaction types (callback type 6). */ +export const buildDeferredUpdate = (): unknown => ({ type: 6 }); diff --git a/packages/domain/src/integration/discord/discord-user-service.ts b/packages/domain/src/integration/discord/discord-user-service.ts new file mode 100644 index 000000000..82392ea02 --- /dev/null +++ b/packages/domain/src/integration/discord/discord-user-service.ts @@ -0,0 +1,109 @@ +import { currentDb, type Database, schema } from "@feeblo/db"; +import { UserId } from "@feeblo/id"; +import { truncate } from "@feeblo/utils/text"; +import { eq } from "drizzle-orm"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { isUniqueViolation } from "../../rpc-errors"; +import { DiscordInboundFailure } from "./errors"; + +const SYNTHETIC_DISCORD_EMAIL_SUFFIX = "@discord.invalid"; + +/** + * Resolves a Discord identity to a Feeblo user, creating an anonymous user + * when no existing account matches. + * + * Unlike Slack, Discord never exposes the invoking user's email to the + * integration, so the SSO-style email-linking step does not apply: the + * resolution is the stable synthetic-email reuse and anonymous-user creation + * pair from the Slack policy. + */ +export interface DiscordUserServiceShape { + readonly resolveUser: (input: { + readonly displayName: string; + readonly guildId: string; + readonly userId: string; + }) => Effect.Effect; +} + +export class DiscordUserService extends Context.Service< + DiscordUserService, + DiscordUserServiceShape +>()("@feeblo/DiscordUserService") {} + +/** Creates the Discord user resolution service. */ +export const makeDiscordUserServiceLive = (): Layer.Layer< + DiscordUserService, + never, + Database.Database +> => + Layer.effect( + DiscordUserService, + Effect.gen(function* () { + const db = yield* currentDb; + + const resolveUser = ({ + displayName, + guildId, + userId, + }: { + readonly displayName: string; + readonly guildId: string; + readonly userId: string; + }) => + Effect.gen(function* () { + // Stable anonymous identity: the synthetic email derived from the + // guild + discord user id maps every future submission back to the + // same user, without a dedicated link table. Guild-scoped so the + // same discord id in two servers can never collide. + const syntheticEmail = `discord-${guildId.toLowerCase()}-${userId.toLowerCase()}${SYNTHETIC_DISCORD_EMAIL_SUFFIX}`; + const [existing] = yield* db + .select({ id: schema.userTable.id }) + .from(schema.userTable) + .where(eq(schema.userTable.email, syntheticEmail)) + .limit(1); + if (existing !== undefined) { + return existing.id; + } + // Create the anonymous user. These users never receive transactional + // email (synthetic address, emailVerified false). + const createdUserId = yield* UserId.generate; + yield* db + .insert(schema.userTable) + .values({ + email: syntheticEmail, + emailVerified: false, + id: createdUserId, + name: truncate(displayName, 100), + }) + .pipe(Effect.catchIf(isUniqueViolation, () => Effect.void)); + // Concurrent submissions may create the same user; the insert + // conflict is ignored and the winner is reused. + const [winner] = yield* db + .select({ id: schema.userTable.id }) + .from(schema.userTable) + .where(eq(schema.userTable.email, syntheticEmail)) + .limit(1); + if (winner === undefined) { + return yield* new DiscordInboundFailure({ + message: "Could not resolve the created Discord user", + }); + } + return winner.id; + }).pipe( + Effect.mapError((error) => + error instanceof DiscordInboundFailure + ? error + : new DiscordInboundFailure({ + message: "Could not resolve Discord user", + }) + ) + ); + + return DiscordUserService.of({ resolveUser }); + }) + ); + +/** Live layer with real repositories and the default database context. */ +export const DiscordUserServiceLive = makeDiscordUserServiceLive(); diff --git a/packages/domain/src/integration/discord/errors.ts b/packages/domain/src/integration/discord/errors.ts new file mode 100644 index 000000000..2348acd3f --- /dev/null +++ b/packages/domain/src/integration/discord/errors.ts @@ -0,0 +1,26 @@ +import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; +import { PolicyDeniedError } from "../../policy"; +import { + BadRequestError, + InternalServerError, + NotFoundError, + UnauthorizedError, +} from "../../rpc-errors"; + +/** Internal inbound failure; surfaced to Discord as an ephemeral message. */ +export class DiscordInboundFailure extends Data.TaggedError( + "DiscordInboundFailure" +)<{ + readonly message: string; +}> {} + +/** RPC error union for every Discord integration management operation. */ +export const DiscordIntegrationErrors = Schema.Union([ + BadRequestError, + InternalServerError, + NotFoundError, + PolicyDeniedError, + UnauthorizedError, +]); +export type DiscordIntegrationError = typeof DiscordIntegrationErrors.Type; diff --git a/packages/domain/src/integration/discord/handlers.ts b/packages/domain/src/integration/discord/handlers.ts new file mode 100644 index 000000000..a511ff4bd --- /dev/null +++ b/packages/domain/src/integration/discord/handlers.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as Policy from "../../policy"; +import { DiscordManagementService } from "./management-service"; +import { DiscordManagementRpcs } from "./rpcs"; + +/** Authenticated RPC handlers which authorize `integrations.manage` before every service call. */ +export const DiscordManagementRpcHandlersEffect = Effect.gen(function* () { + const service = yield* DiscordManagementService; + const authorize = (organizationId: string) => + Policy.withPolicy( + Policy.canPermission(organizationId, "integrations.manage") + ); + return { + DiscordConnectionList: ( + input: Parameters[0] + ) => service.listConnections(input).pipe(authorize(input.organizationId)), + DiscordConnectStart: (input: Parameters[0]) => + service.connectStart(input).pipe(authorize(input.organizationId)), + DiscordChannelList: (input: Parameters[0]) => + service.listChannels(input).pipe(authorize(input.organizationId)), + DiscordChannelNotificationsUpdate: ( + input: Parameters[0] + ) => + service + .setChannelNotifications(input) + .pipe(authorize(input.organizationId)), + DiscordConnectionDisconnect: ( + input: Parameters[0] + ) => service.disconnect(input).pipe(authorize(input.organizationId)), + DiscordIntegrationStatus: () => service.status(), + }; +}); + +/** RPC layer that leaves the concrete management service to server composition. */ +export const DiscordManagementRpcHandlers = DiscordManagementRpcs.toLayer( + DiscordManagementRpcHandlersEffect +); diff --git a/packages/domain/src/integration/discord/inbound-live.ts b/packages/domain/src/integration/discord/inbound-live.ts new file mode 100644 index 000000000..04949bf60 --- /dev/null +++ b/packages/domain/src/integration/discord/inbound-live.ts @@ -0,0 +1,291 @@ +import { currentDb, type Database, schema } from "@feeblo/db"; +import { + type DiscordEmbed, + renderDiscordFeedbackConfirmationEmbed, +} from "@feeblo/integration-discord/embeds"; +import type { + DiscordApplicationCommandPayload, + DiscordInteraction, + DiscordModalSubmitPayload, +} from "@feeblo/integration-discord/inbound-schema"; +import { discordProviderKey } from "@feeblo/integration-discord/manifest"; +import { and, eq } from "drizzle-orm"; +import * as EffectArray from "effect/Array"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { BoardRepository } from "../../board/repository"; +import { EmailOutboxConfig } from "../../email-outbox/config"; +import { DiscordFeedbackService } from "./discord-feedback-service"; +import { + buildDeferredUpdate, + buildEphemeralMessage, + buildFeedbackModal, + buildPong, + decodeModalMetadata, + encodeModalMetadata, + readModalValue, +} from "./discord-modals"; +import { DiscordUserService } from "./discord-user-service"; +import { DiscordInboundFailure } from "./errors"; +import { + type DiscordInboundHttpResponse, + DiscordInboundService, +} from "./inbound-service"; + +const TITLE_INPUT_MAX = 200; + +/** Slack-style ephemeral response; the only content Discord users see for errors. */ +const ephemeralMessageResponse = ( + text: string, + embeds: readonly DiscordEmbed[] = [] +): DiscordInboundHttpResponse => ({ + body: buildEphemeralMessage(text, embeds), + status: 200, +}); + +const pongResponse = (): DiscordInboundHttpResponse => ({ + body: buildPong(), + status: 200, +}); + +const deferredUpdateResponse = (): DiscordInboundHttpResponse => ({ + body: buildDeferredUpdate(), + status: 200, +}); + +/** Reads the invoking user's display name from the interaction payload. */ +const interactionDisplayName = ( + payload: DiscordApplicationCommandPayload | DiscordModalSubmitPayload +): string => { + const user = payload.member?.user ?? payload.user; + if (user === undefined) { + return ""; + } + return user.global_name ?? user.username; +}; + +/** Reads the `/feeblo` `text` option value from a slash command invocation. */ +const slashCommandText = ( + payload: DiscordApplicationCommandPayload +): string | undefined => { + const value = payload.data.options?.find( + (option) => option.name === "text" + )?.value; + return typeof value === "string" ? value : undefined; +}; + +/** Reads the target message content from a message context menu invocation. */ +const messageContextMenuText = ( + payload: DiscordApplicationCommandPayload +): string | undefined => { + if (payload.data.target_id === undefined) { + return undefined; + } + return payload.data.resolved?.messages?.[payload.data.target_id]?.content; +}; + +/** + * Creates the Discord inbound service. Discord interaction responses are the + * HTTP response itself (modals and ephemeral messages need no API call), so + * unlike Slack this service needs no bot token: the invoking user and target + * message arrive inside the verified payload. + */ +export const makeDiscordInboundServiceLive = (): Layer.Layer< + DiscordInboundService, + never, + | Database.Database + | BoardRepository + | EmailOutboxConfig + | DiscordUserService + | DiscordFeedbackService +> => + Layer.effect( + DiscordInboundService, + Effect.gen(function* () { + const db = yield* currentDb; + const boardRepository = yield* BoardRepository; + const emailOutboxConfig = yield* EmailOutboxConfig; + const discordUserService = yield* DiscordUserService; + const discordFeedbackService = yield* DiscordFeedbackService; + + const findActiveConnection = (guildId: string) => + db + .select() + .from(schema.integrationConnectionTable) + .where( + and( + eq( + schema.integrationConnectionTable.provider, + discordProviderKey + ), + eq(schema.integrationConnectionTable.remoteAccountId, guildId), + eq(schema.integrationConnectionTable.lifecycle, "active") + ) + ) + .limit(1) + .pipe(Effect.map(EffectArray.get(0))); + + const openFeedbackModal = ({ + channelId, + guildId, + initialTitle, + messageId, + organizationId, + }: { + readonly channelId: string; + readonly guildId: string; + readonly initialTitle: string; + readonly messageId?: string; + readonly organizationId: string; + }) => + Effect.gen(function* () { + const boards = yield* boardRepository.findMany({ organizationId }); + if (boards.length === 0) { + return ephemeralMessageResponse( + "Feeblo has no boards configured yet. Ask an admin to create one." + ); + } + const customId = encodeModalMetadata({ + ...(messageId === undefined ? {} : { messageId }), + channelId, + guildId, + organizationId, + }); + return { + body: buildFeedbackModal({ boards, customId, initialTitle }), + status: 200, + } satisfies DiscordInboundHttpResponse; + }); + + const handleApplicationCommand = ( + payload: DiscordApplicationCommandPayload + ) => + Effect.gen(function* () { + const connection = yield* findActiveConnection(payload.guild_id); + if (connection._tag === "None") { + return ephemeralMessageResponse( + "Feeblo is not connected to this Discord server yet. Ask an admin to connect it in the Feeblo dashboard." + ); + } + const messageText = + payload.data.type === 3 + ? messageContextMenuText(payload) + : slashCommandText(payload); + return yield* openFeedbackModal({ + channelId: payload.channel_id, + guildId: payload.guild_id, + initialTitle: (messageText ?? "").trim().slice(0, TITLE_INPUT_MAX), + ...(payload.data.type === 3 && payload.data.target_id !== undefined + ? { messageId: payload.data.target_id } + : {}), + organizationId: connection.value.organizationId, + }); + }); + + const handleModalSubmit = (payload: DiscordModalSubmitPayload) => + Effect.gen(function* () { + const metadata = yield* decodeModalMetadata(payload.data.custom_id); + const connection = yield* findActiveConnection(metadata.guildId); + if (connection._tag === "None") { + return ephemeralMessageResponse( + "Feeblo is not connected to this Discord server anymore." + ); + } + const values = payload.data.components; + const title = readModalValue(values, "title")?.trim() ?? ""; + if (title.length === 0) { + return ephemeralMessageResponse( + "Please enter a title, then run /feeblo again." + ); + } + const details = readModalValue(values, "details")?.trim() ?? ""; + const boardId = readModalValue(values, "board")?.trim() ?? ""; + if (boardId.length === 0) { + return ephemeralMessageResponse( + "Please choose a board, then run /feeblo again." + ); + } + const invokingUserId = payload.member?.user.id ?? payload.user?.id; + if (invokingUserId === undefined) { + return yield* new DiscordInboundFailure({ + message: "Discord interaction user is missing", + }); + } + const userId = yield* discordUserService.resolveUser({ + displayName: interactionDisplayName(payload), + guildId: metadata.guildId, + userId: invokingUserId, + }); + const created = yield* discordFeedbackService.createPost({ + boardId, + content: details, + organizationId: metadata.organizationId, + title, + userId, + }); + const postUrl = + created.boardSlug.length > 0 + ? `${emailOutboxConfig.appUrl}/${encodeURIComponent(metadata.organizationId)}/post/${encodeURIComponent(created.boardSlug)}/${encodeURIComponent(created.slug)}` + : undefined; + if (postUrl === undefined) { + return ephemeralMessageResponse( + `✅ Feedback sent: ${created.title}` + ); + } + const confirmationEmbed = renderDiscordFeedbackConfirmationEmbed({ + actionUrl: postUrl, + boardName: created.boardName, + metadata: created.metadata, + postId: created.id, + status: created.status, + submitterName: interactionDisplayName(payload), + title: created.title, + }); + return ephemeralMessageResponse( + "✅ Feedback sent to Feeblo. Select the title below to view it.", + [confirmationEmbed] + ); + }); + + const handleInteraction = (payload: DiscordInteraction) => + Effect.gen(function* () { + switch (payload.type) { + case 1: + // Discord requires a PONG within 3 seconds of a ping. + return pongResponse(); + case 2: + return yield* handleApplicationCommand(payload); + case 5: + return yield* handleModalSubmit(payload); + default: + // Message components and autocomplete never fire (embeds carry no + // components and no option is autocomplete-backed); answer with a + // deferred acknowledgment so Discord sees a definitive response. + return deferredUpdateResponse(); + } + }); + + const withFallback = ( + effect: Effect.Effect + ): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Discord inbound request failed", { cause }).pipe( + Effect.as( + ephemeralMessageResponse( + "Something went wrong while sending your feedback to Feeblo. Please try again." + ) + ) + ) + ) + ); + + return { + handleInteraction: (payload) => + withFallback(handleInteraction(payload)), + }; + }) + ); + +/** Live layer with real repositories and the default database context. */ +export const DiscordInboundServiceLive = makeDiscordInboundServiceLive(); diff --git a/packages/domain/src/integration/discord/inbound-service.ts b/packages/domain/src/integration/discord/inbound-service.ts new file mode 100644 index 000000000..f30792e36 --- /dev/null +++ b/packages/domain/src/integration/discord/inbound-service.ts @@ -0,0 +1,23 @@ +import type { DiscordInteraction } from "@feeblo/integration-discord/inbound-schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +/** HTTP response the server route returns to Discord. */ +export interface DiscordInboundHttpResponse { + /** Absent means an empty 200 body. */ + readonly body?: unknown; + readonly status: number; +} + +/** Inbound Discord surface: every interaction type arriving at `/discord/interactions`. */ +export interface DiscordInboundServiceShape { + readonly handleInteraction: ( + payload: DiscordInteraction + ) => Effect.Effect; +} + +/** Service key implemented by the server composition root. */ +export class DiscordInboundService extends Context.Service< + DiscordInboundService, + DiscordInboundServiceShape +>()("@feeblo/DiscordInboundService") {} diff --git a/packages/domain/src/integration/discord/index.ts b/packages/domain/src/integration/discord/index.ts new file mode 100644 index 000000000..16c1256ab --- /dev/null +++ b/packages/domain/src/integration/discord/index.ts @@ -0,0 +1,17 @@ +/** biome-ignore-all lint/performance/noBarrelFile: discord module public entry point */ +export * from "./config"; +export * from "./discord-channel-service"; +export * from "./discord-connection-service"; +export * from "./discord-feedback-service"; +export * from "./discord-management-shared"; +export * from "./discord-modals"; +export * from "./discord-user-service"; +export * from "./errors"; +export * from "./handlers"; +export * from "./inbound-live"; +export * from "./inbound-service"; +export * from "./management-live"; +export * from "./management-service"; +export * from "./oauth-callback"; +export * from "./rpcs"; +export * from "./schema"; diff --git a/packages/domain/src/integration/discord/management-live.test.ts b/packages/domain/src/integration/discord/management-live.test.ts new file mode 100644 index 000000000..5ea9c673e --- /dev/null +++ b/packages/domain/src/integration/discord/management-live.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, layer } from "@effect/vitest"; +import { currentDb, Database, schema } from "@feeblo/db"; +import { asLegid, IntegrationConnectionId, WorkspaceId } from "@feeblo/id"; +import { + type DiscordApiClient, + type DiscordChannel, + DiscordOAuthState, +} from "@feeblo/integration-discord"; +import { discordProviderKey } from "@feeblo/integration-discord/manifest"; +import { and, eq } from "drizzle-orm"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import { DiscordIntegrationConfig } from "./config"; +import { makeDiscordManagementServiceLive } from "./management-live"; +import { DiscordManagementService } from "./management-service"; + +const testChannels: readonly DiscordChannel[] = [ + { id: "C1", name: "general", type: 0 }, + { id: "C2", name: "feedback", type: 0 }, + { id: "C3", name: "announcements", type: 5 }, + { id: "V1", name: "voice", type: 2 }, + { id: "CAT1", name: "category", type: 4 }, +]; + +/** Fake Discord API client; captures calls and answers with canned data. */ +const makeFakeDiscordApiClient = ( + channels: readonly DiscordChannel[] = testChannels, + guildId: string | ((exchangeNumber: number) => string) = "G123" +): DiscordApiClient & { + readonly calls: { readonly method: string }[]; + readonly registeredCommands: readonly { name: string; type: number }[]; +} => { + const calls: { readonly method: string }[] = []; + const registeredCommands: { name: string; type: number }[] = []; + let exchangeNumber = 0; + return { + calls, + registeredCommands, + applicationsMe: () => { + calls.push({ method: "applications.@me" }); + return Effect.succeed({ id: "A123", name: "Feeblo" }); + }, + channelsMessagesCreate: () => { + calls.push({ method: "channels.messages.create" }); + return Effect.succeed({ channel_id: "C1", content: "", id: "M1" }); + }, + guildsChannels: () => { + calls.push({ method: "guilds.channels" }); + return Effect.succeed([...channels]); + }, + guildsCommandsBulkOverwrite: ({ commands }) => { + calls.push({ method: "guilds.commands.bulkOverwrite" }); + registeredCommands.length = 0; + registeredCommands.push( + ...(commands as readonly { name: string; type: number }[]) + ); + return Effect.succeed( + commands.map((command, index) => ({ + id: `command_${index}`, + name: (command as { name: string }).name, + type: (command as { type: number }).type, + })) + ); + }, + guildsLeave: () => { + calls.push({ method: "guilds.leave" }); + return Effect.void; + }, + oauth2TokenExchange: () => { + calls.push({ method: "oauth2.token" }); + exchangeNumber += 1; + const exchangedGuildId = + typeof guildId === "string" ? guildId : guildId(exchangeNumber); + return Effect.succeed({ + access_token: "discord-user-token", + expires_in: 604_800, + guild: { id: exchangedGuildId, name: "Acme" }, + scope: "identify applications.commands bot", + token_type: "Bearer", + user: { id: "U123", username: "alice" }, + }); + }, + oauth2TokenRevoke: () => { + calls.push({ method: "oauth2.token.revoke" }); + return Effect.void; + }, + }; +}; + +const testConfig = (configured = true) => + DiscordIntegrationConfig.layerTest({ + botToken: Redacted.make("discord-bot-token"), + clientId: "client-id", + clientSecret: Redacted.make("client-secret"), + configured, + oauthRedirectUrl: "http://localhost:3000/discord/oauth/callback", + publicKey: "0".repeat(64), + }); + +const makeTestLayer = ( + channels?: readonly DiscordChannel[], + configured = true, + guildId: string | ((exchangeNumber: number) => string) = "G123" +) => { + const apiClient = makeFakeDiscordApiClient(channels, guildId); + const serviceLayer = makeDiscordManagementServiceLive(apiClient).pipe( + Layer.provide(testConfig(configured)), + Layer.provide(Database.PgliteDatabaseLive) + ); + return { + apiClient, + layer: Layer.mergeAll(serviceLayer, Database.PgliteDatabaseLive), + }; +}; + +const seedOrganization = Effect.gen(function* () { + const db = yield* currentDb; + const organizationId = yield* WorkspaceId.generate; + yield* db.insert(schema.organizationTable).values({ + createdAt: new Date(), + id: organizationId, + name: "Discord management test", + slug: organizationId, + }); + return organizationId; +}); + +const decodeState = (authorizeUrl: URL) => { + const url = new URL(authorizeUrl); + return Schema.decodeUnknownSync(Schema.fromJsonString(DiscordOAuthState))( + url.searchParams.get("state") ?? "" + ); +}; + +const urlState = (authorizeUrl: URL) => + authorizeUrl.searchParams.get("state") ?? ""; + +describe("discord management service", () => { + const connectFlow = makeTestLayer(); + layer(connectFlow.layer)("connect flow", (it) => { + it.effect( + "connectStart creates a pending connection and authorize URL", + () => + Effect.gen(function* () { + const db = yield* currentDb; + const organizationId = yield* seedOrganization; + const service = yield* DiscordManagementService; + const { authorizeUrl } = yield* service.connectStart({ + organizationId, + }); + + const url = new URL(authorizeUrl); + expect(url.hostname).toBe("discord.com"); + expect(url.searchParams.get("client_id")).toBe("client-id"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("redirect_uri")).toBe( + "http://localhost:3000/discord/oauth/callback" + ); + expect(url.searchParams.get("scope")).toContain("bot"); + expect(url.searchParams.get("scope")).toContain( + "applications.commands" + ); + expect(url.searchParams.get("permissions")).toBe("84992"); + + const state = decodeState(authorizeUrl); + expect(state.organizationId).toBe(organizationId); + + const [connection] = yield* db + .select() + .from(schema.integrationConnectionTable) + .where( + eq(schema.integrationConnectionTable.id, state.connectionId) + ); + expect(connection?.lifecycle).toBe("connecting"); + expect(connection?.credentialsCiphertext).not.toBeNull(); + }) + ); + + it.effect( + "connectComplete exchanges the code, registers commands, and activates the connection", + () => + Effect.gen(function* () { + const db = yield* currentDb; + const organizationId = yield* seedOrganization; + const service = yield* DiscordManagementService; + const { authorizeUrl } = yield* service.connectStart({ + organizationId, + }); + const state = decodeState(new URL(authorizeUrl)); + + const result = yield* service.connectComplete({ + code: "oauth-code", + state: urlState(new URL(authorizeUrl)), + }); + expect(result.organizationId).toBe(organizationId); + + // Guild-scoped commands are registered as part of the install. + expect( + connectFlow.apiClient.registeredCommands.map( + (command) => command.name + ) + ).toEqual(["feeblo", "Send to Feeblo"]); + + const [connection] = yield* db + .select() + .from(schema.integrationConnectionTable) + .where( + eq(schema.integrationConnectionTable.id, state.connectionId) + ); + expect(connection?.lifecycle).toBe("active"); + expect(connection?.remoteAccountId).toBe("G123"); + expect(connection?.name).toBe("Acme"); + expect(connection?.safeDisplayMetadata).toMatchObject({ + guildId: "G123", + guildName: "Acme", + installerUserId: "U123", + }); + + const routes = yield* db + .select() + .from(schema.integrationRouteTable) + .where( + eq(schema.integrationRouteTable.connectionId, state.connectionId) + ); + expect(routes.map((route) => route.capabilityKey)).toEqual([ + "interactions", + ]); + }) + ); + + it.effect("connectComplete rejects a mismatched state nonce", () => + Effect.gen(function* () { + const organizationId = yield* seedOrganization; + const service = yield* DiscordManagementService; + const started = yield* service.connectStart({ organizationId }); + const startedState = decodeState(started.authorizeUrl); + const bogus = yield* Schema.encodeEffect( + Schema.fromJsonString(DiscordOAuthState) + )({ + connectionId: startedState.connectionId, + nonce: "wrong", + organizationId, + }); + const failure = yield* Effect.flip( + service.connectComplete({ code: "code", state: bogus }) + ); + expect(failure).toMatchObject({ + _tag: "BadRequestError", + message: "Discord OAuth state does not match", + }); + }) + ); + }); + + layer(makeTestLayer(undefined, true, "GUILD_OWNERSHIP").layer)( + "guild ownership", + (it) => { + it.effect("rejects connecting one guild to two organizations", () => + Effect.gen(function* () { + const service = yield* DiscordManagementService; + const firstOrganizationId = yield* seedOrganization; + const first = yield* service.connectStart({ + organizationId: firstOrganizationId, + }); + yield* service.connectComplete({ + code: "first-code", + state: urlState(first.authorizeUrl), + }); + + const secondOrganizationId = yield* seedOrganization; + const second = yield* service.connectStart({ + organizationId: secondOrganizationId, + }); + const failure = yield* Effect.flip( + service.connectComplete({ + code: "second-code", + state: urlState(second.authorizeUrl), + }) + ); + + expect(failure).toMatchObject({ + _tag: "BadRequestError", + message: + "Discord server is already connected to another organization", + }); + }) + ); + } + ); + + const channelNotificationsFlow = makeTestLayer( + undefined, + true, + (exchangeNumber) => `GUILD_CHANNELS_${exchangeNumber}` + ); + layer(channelNotificationsFlow.layer)("channel notifications", (it) => { + it.effect("lists text channels and toggles notifications", () => + Effect.gen(function* () { + const db = yield* currentDb; + const organizationId = yield* seedOrganization; + const service = yield* DiscordManagementService; + const { authorizeUrl } = yield* service.connectStart({ + organizationId, + }); + yield* service.connectComplete({ + code: "code", + state: urlState(new URL(authorizeUrl)), + }); + const [connection] = yield* db + .select() + .from(schema.integrationConnectionTable) + .where( + and( + eq( + schema.integrationConnectionTable.organizationId, + organizationId + ), + eq(schema.integrationConnectionTable.provider, discordProviderKey) + ) + ) + .limit(1); + if (connection === undefined) { + throw new Error("expected connection"); + } + const connectionId = asLegid(IntegrationConnectionId)(connection.id); + + const channels = yield* service.listChannels({ + connectionId, + organizationId, + }); + // Voice channels and categories are filtered out; only text and + // announcement channels are selectable. + expect(channels.map((channel) => channel.id).sort()).toEqual([ + "C1", + "C2", + "C3", + ]); + expect( + channels.find((channel) => channel.id === "C1")?.notificationsEnabled + ).toBe(false); + + yield* service.setChannelNotifications({ + channelId: "C2", + connectionId, + enabled: true, + organizationId, + }); + const updated = yield* service.listChannels({ + connectionId, + organizationId, + }); + expect( + updated.find((channel) => channel.id === "C2")?.notificationsEnabled + ).toBe(true); + expect( + updated.find((channel) => channel.id === "C1")?.notificationsEnabled + ).toBe(false); + + yield* service.setChannelNotifications({ + channelId: "C2", + connectionId, + enabled: false, + organizationId, + }); + const disabled = yield* service.listChannels({ + connectionId, + organizationId, + }); + expect( + disabled.find((channel) => channel.id === "C2")?.notificationsEnabled + ).toBe(false); + + const unknownChannelFailure = yield* Effect.flip( + service.setChannelNotifications({ + channelId: "CHANNEL_FROM_ANOTHER_GUILD", + connectionId, + enabled: true, + organizationId, + }) + ); + expect(unknownChannelFailure).toMatchObject({ + _tag: "NotFoundError", + message: "Discord channel was not found in the connected server", + }); + }) + ); + + it.effect("disconnect archives the connection and revokes the token", () => + Effect.gen(function* () { + const db = yield* currentDb; + const organizationId = yield* seedOrganization; + const service = yield* DiscordManagementService; + const { authorizeUrl } = yield* service.connectStart({ + organizationId, + }); + yield* service.connectComplete({ + code: "code", + state: urlState(new URL(authorizeUrl)), + }); + const [connection] = yield* db + .select() + .from(schema.integrationConnectionTable) + .where( + and( + eq( + schema.integrationConnectionTable.organizationId, + organizationId + ), + eq(schema.integrationConnectionTable.provider, discordProviderKey) + ) + ) + .limit(1); + if (connection === undefined) { + throw new Error("expected connection"); + } + + yield* service.disconnect({ + connectionId: asLegid(IntegrationConnectionId)(connection.id), + organizationId, + }); + + const [archived] = yield* db + .select() + .from(schema.integrationConnectionTable) + .where(eq(schema.integrationConnectionTable.id, connection.id)); + expect(archived?.lifecycle).toBe("archived"); + expect(archived?.credentialsCiphertext).toBeNull(); + + const listed = yield* service.listConnections({ organizationId }); + expect(listed).toHaveLength(0); + expect( + channelNotificationsFlow.apiClient.calls.some( + (call) => call.method === "guilds.leave" + ) + ).toBe(true); + }) + ); + }); + + layer(makeTestLayer(undefined, false).layer)( + "unconfigured deployment", + (it) => { + it.effect("connectStart fails when Discord is not configured", () => + Effect.gen(function* () { + const organizationId = yield* seedOrganization; + const service = yield* DiscordManagementService; + const result = yield* Effect.exit( + service.connectStart({ organizationId }) + ); + expect(Exit.isFailure(result)).toBe(true); + }) + ); + } + ); +}); diff --git a/packages/domain/src/integration/discord/management-live.ts b/packages/domain/src/integration/discord/management-live.ts new file mode 100644 index 000000000..88f6c51d5 --- /dev/null +++ b/packages/domain/src/integration/discord/management-live.ts @@ -0,0 +1,53 @@ +import type { Database } from "@feeblo/db"; +import { + type DiscordApiClient, + makeDiscordApiClient, +} from "@feeblo/integration-discord"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { DiscordIntegrationConfig } from "./config"; +import { + DiscordChannelService, + makeDiscordChannelServiceLive, +} from "./discord-channel-service"; +import { + DiscordConnectionService, + makeDiscordConnectionServiceLive, +} from "./discord-connection-service"; +import { DiscordManagementService } from "./management-service"; + +/** + * Composes the connection lifecycle and channel services behind the single + * organization-scoped management boundary. The composition shares one API + * client and owns no operation logic of its own. + */ +export const makeDiscordManagementServiceLive = ( + apiClient: DiscordApiClient = makeDiscordApiClient() +): Layer.Layer< + DiscordManagementService, + never, + Database.Database | DiscordIntegrationConfig +> => + Layer.effect( + DiscordManagementService, + Effect.gen(function* () { + const config = yield* DiscordIntegrationConfig; + const connectionService = yield* DiscordConnectionService; + const channelService = yield* DiscordChannelService; + return DiscordManagementService.of({ + connectComplete: connectionService.connectComplete, + connectStart: connectionService.connectStart, + disconnect: connectionService.disconnect, + listChannels: channelService.listChannels, + listConnections: connectionService.listConnections, + setChannelNotifications: channelService.setChannelNotifications, + status: () => Effect.succeed({ configured: config.configured }), + }); + }) + ).pipe( + Layer.provide(makeDiscordConnectionServiceLive(apiClient)), + Layer.provide(makeDiscordChannelServiceLive(apiClient)) + ); + +/** Live layer with the default fetch-backed Discord API client. */ +export const DiscordManagementServiceLive = makeDiscordManagementServiceLive(); diff --git a/packages/domain/src/integration/discord/management-service.ts b/packages/domain/src/integration/discord/management-service.ts new file mode 100644 index 000000000..bfc6626a1 --- /dev/null +++ b/packages/domain/src/integration/discord/management-service.ts @@ -0,0 +1,40 @@ +import type { LegidFrom, WorkspaceId } from "@feeblo/id"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { DiscordIntegrationError } from "./errors"; +import type * as S from "./schema"; + +/** Organization-scoped Discord management boundary; read methods never return credentials. */ +export interface DiscordManagementServiceShape { + /** Completes the OAuth handshake; called by the server callback route. */ + readonly connectComplete: (input: { + readonly code: string; + readonly state: string; + }) => Effect.Effect< + { readonly organizationId: LegidFrom }, + DiscordIntegrationError + >; + readonly connectStart: ( + input: S.TDiscordConnectStart + ) => Effect.Effect; + readonly disconnect: ( + input: S.TDiscordConnectionDisconnect + ) => Effect.Effect; + readonly listChannels: ( + input: S.TDiscordChannelList + ) => Effect.Effect; + readonly listConnections: ( + input: S.TDiscordConnectionList + ) => Effect.Effect; + readonly setChannelNotifications: ( + input: S.TDiscordChannelNotificationsUpdate + ) => Effect.Effect; + /** Reports whether the Discord integration is configured for this deployment. */ + readonly status: () => Effect.Effect; +} + +/** Service key implemented by the server composition root for Discord commands. */ +export class DiscordManagementService extends Context.Service< + DiscordManagementService, + DiscordManagementServiceShape +>()("@feeblo/DiscordManagementService") {} diff --git a/packages/domain/src/integration/discord/oauth-callback.test.ts b/packages/domain/src/integration/discord/oauth-callback.test.ts new file mode 100644 index 000000000..74865aaa2 --- /dev/null +++ b/packages/domain/src/integration/discord/oauth-callback.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { parseDiscordOAuthCallbackUrl } from "./oauth-callback"; + +describe("parseDiscordOAuthCallbackUrl", () => { + it("parses code and state from the relative callback path", () => { + const parsed = parseDiscordOAuthCallbackUrl( + "/discord/oauth/callback?code=oauth-code-123&state=%7B%22connectionId%22%3A%22icn_1%22%7D" + ); + expect(parsed.code).toBe("oauth-code-123"); + expect(parsed.state).toBe('{"connectionId":"icn_1"}'); + expect(parsed.error).toBeNull(); + }); + + it("surfaces the Discord error parameter on denial", () => { + const parsed = parseDiscordOAuthCallbackUrl( + "/discord/oauth/callback?error=access_denied" + ); + expect(parsed.error).toBe("access_denied"); + expect(parsed.code).toBeNull(); + expect(parsed.state).toBeNull(); + }); + + it("handles a bare path without query parameters", () => { + const parsed = parseDiscordOAuthCallbackUrl("/discord/oauth/callback"); + expect(parsed).toEqual({ code: null, error: null, state: null }); + }); +}); diff --git a/packages/domain/src/integration/discord/oauth-callback.ts b/packages/domain/src/integration/discord/oauth-callback.ts new file mode 100644 index 000000000..2120a4434 --- /dev/null +++ b/packages/domain/src/integration/discord/oauth-callback.ts @@ -0,0 +1,21 @@ +/** + * Parses the Discord OAuth callback request URL (a relative path such as + * `/discord/oauth/callback?code=…&state=…`) into its query parameters. + * + * The server request framework exposes `request.url` as the relative path + * only, so `new URL` needs a dummy base to parse the query string. + */ +export const parseDiscordOAuthCallbackUrl = ( + url: string +): { + readonly code: string | null; + readonly error: string | null; + readonly state: string | null; +} => { + const parsed = new URL(url, "http://localhost"); + return { + code: parsed.searchParams.get("code"), + error: parsed.searchParams.get("error"), + state: parsed.searchParams.get("state"), + }; +}; diff --git a/packages/domain/src/integration/discord/rpcs.ts b/packages/domain/src/integration/discord/rpcs.ts new file mode 100644 index 000000000..8da39d2ab --- /dev/null +++ b/packages/domain/src/integration/discord/rpcs.ts @@ -0,0 +1,39 @@ +import * as Schema from "effect/Schema"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { AuthMiddleware } from "../../session-middleware"; +import { DiscordIntegrationErrors } from "./errors"; +import * as S from "./schema"; + +/** Authenticated RPC surface for organization-scoped Discord integration management. */ +export class DiscordManagementRpcs extends RpcGroup.make( + Rpc.make("DiscordConnectionList", { + success: Schema.Array(S.DiscordConnection), + payload: S.DiscordConnectionList, + error: DiscordIntegrationErrors, + }).middleware(AuthMiddleware), + Rpc.make("DiscordConnectStart", { + success: S.DiscordConnectStarted, + payload: S.DiscordConnectStart, + error: DiscordIntegrationErrors, + }).middleware(AuthMiddleware), + Rpc.make("DiscordChannelList", { + success: Schema.Array(S.DiscordChannel), + payload: S.DiscordChannelList, + error: DiscordIntegrationErrors, + }).middleware(AuthMiddleware), + Rpc.make("DiscordChannelNotificationsUpdate", { + success: Schema.Void, + payload: S.DiscordChannelNotificationsUpdate, + error: DiscordIntegrationErrors, + }).middleware(AuthMiddleware), + Rpc.make("DiscordConnectionDisconnect", { + success: Schema.Void, + payload: S.DiscordConnectionDisconnect, + error: DiscordIntegrationErrors, + }).middleware(AuthMiddleware), + Rpc.make("DiscordIntegrationStatus", { + success: S.DiscordIntegrationStatus, + error: DiscordIntegrationErrors, + }).middleware(AuthMiddleware) +) {} diff --git a/packages/domain/src/integration/discord/schema.ts b/packages/domain/src/integration/discord/schema.ts new file mode 100644 index 000000000..9eeec66ec --- /dev/null +++ b/packages/domain/src/integration/discord/schema.ts @@ -0,0 +1,70 @@ +import { IntegrationConnectionLifecycleStatus } from "@feeblo/db/validation-schema/integration"; +import { IntegrationConnectionId, WorkspaceId } from "@feeblo/id"; +import * as Schema from "effect/Schema"; + +/** Safe Discord connection summary; credentials never cross this boundary. */ +export const DiscordConnection = Schema.Struct({ + id: IntegrationConnectionId.schema, + guildId: Schema.NullOr(Schema.String), + guildName: Schema.String, + lifecycle: IntegrationConnectionLifecycleStatus, + createdAt: Schema.DateFromString, +}); +export type TDiscordConnection = typeof DiscordConnection.Type; + +/** List connections scoped to one organization. */ +export const DiscordConnectionList = Schema.Struct({ + organizationId: WorkspaceId.schema, +}); +export type TDiscordConnectionList = typeof DiscordConnectionList.Type; + +/** Starts the OAuth install flow and returns the Discord authorize URL. */ +export const DiscordConnectStart = Schema.Struct({ + organizationId: WorkspaceId.schema, +}); +export type TDiscordConnectStart = typeof DiscordConnectStart.Type; + +export const DiscordConnectStarted = Schema.Struct({ + authorizeUrl: Schema.URLFromString, +}); +export type TDiscordConnectStarted = typeof DiscordConnectStarted.Type; + +/** Whether the Discord integration is configured for this deployment. */ +export const DiscordIntegrationStatus = Schema.Struct({ + configured: Schema.Boolean, +}); +export type TDiscordIntegrationStatus = typeof DiscordIntegrationStatus.Type; + +/** One Discord text channel the bot can post into, with its notification state. */ +export const DiscordChannel = Schema.Struct({ + id: Schema.String, + name: Schema.String, + /** True when a `channel.notifications` route already exists for this channel. */ + notificationsEnabled: Schema.Boolean, +}); +export type TDiscordChannel = typeof DiscordChannel.Type; + +/** Lists channels of one guild for one connection. */ +export const DiscordChannelList = Schema.Struct({ + connectionId: IntegrationConnectionId.schema, + organizationId: WorkspaceId.schema, +}); +export type TDiscordChannelList = typeof DiscordChannelList.Type; + +/** Toggles new-post notifications for one channel of one connection. */ +export const DiscordChannelNotificationsUpdate = Schema.Struct({ + channelId: Schema.String, + connectionId: IntegrationConnectionId.schema, + enabled: Schema.Boolean, + organizationId: WorkspaceId.schema, +}); +export type TDiscordChannelNotificationsUpdate = + typeof DiscordChannelNotificationsUpdate.Type; + +/** Disconnects a Discord guild connection and erases its credentials. */ +export const DiscordConnectionDisconnect = Schema.Struct({ + connectionId: IntegrationConnectionId.schema, + organizationId: WorkspaceId.schema, +}); +export type TDiscordConnectionDisconnect = + typeof DiscordConnectionDisconnect.Type; diff --git a/packages/domain/src/integration/post-event-recording.ts b/packages/domain/src/integration/post-event-recording.ts index a7facc9a3..40ab8f7e8 100644 --- a/packages/domain/src/integration/post-event-recording.ts +++ b/packages/domain/src/integration/post-event-recording.ts @@ -31,6 +31,7 @@ export interface PostIntegrationEventInput { readonly actor: PostIntegrationEventActor; readonly boardId: LegidOf<"BoardId">; readonly eventType: "feedback.post.created" | "feedback.post.status_changed"; + readonly metadata?: Readonly>; readonly organizationId: LegidOf<"WorkspaceId">; readonly postId: LegidOf<"PostId">; readonly postSlug: string; @@ -113,6 +114,10 @@ export const recordPostIntegrationEvent = Effect.fn( board, post: { id: input.postId, + ...(input.metadata !== undefined && + Object.keys(input.metadata).length > 0 + ? { metadata: { ...input.metadata } } + : {}), status: { id: input.statusId, type: statusType }, title: input.title, url, diff --git a/packages/domain/src/integration/slack/inbound-live.ts b/packages/domain/src/integration/slack/inbound-live.ts index 74f4fa785..427c2263d 100644 --- a/packages/domain/src/integration/slack/inbound-live.ts +++ b/packages/domain/src/integration/slack/inbound-live.ts @@ -270,8 +270,13 @@ export const makeSlackInboundServiceLive = ( body: { response_action: "update", view: buildSuccessModal({ + boardName: created.boardName, + metadata: created.metadata, + postId: created.id, postTitle: created.title, ...(postUrl === undefined ? {} : { postUrl }), + status: created.status, + submitterName: payload.user.name, }), }, status: 200, diff --git a/packages/domain/src/integration/slack/slack-feedback-service.ts b/packages/domain/src/integration/slack/slack-feedback-service.ts index e758ed321..b0cc11885 100644 --- a/packages/domain/src/integration/slack/slack-feedback-service.ts +++ b/packages/domain/src/integration/slack/slack-feedback-service.ts @@ -27,15 +27,19 @@ import { SlackInboundFailure } from "./errors"; /** A feedback post created from an inbound Slack submission. */ export interface SlackPost { readonly boardId: string; + readonly boardName: string; readonly boardSlug: string; readonly id: string; + readonly metadata: Readonly>; readonly slug: string; + readonly status: string; readonly title: string; } export interface SlackPostInput { readonly boardId: string; readonly content: string; + readonly metadata?: Readonly>; readonly organizationId: string; readonly title: string; readonly userId: string; @@ -82,6 +86,7 @@ export const makeSlackFeedbackServiceLive = (): Layer.Layer< const createPost = ({ boardId, content, + metadata = {}, organizationId, title, userId, @@ -101,7 +106,10 @@ export const makeSlackFeedbackServiceLive = (): Layer.Layer< const id = yield* PostId.generate; const excerpt = htmlToExcerpt(sanitizedHtml); const [board] = yield* db - .select({ slug: schema.boardTable.slug }) + .select({ + name: schema.boardTable.name, + slug: schema.boardTable.slug, + }) .from(schema.boardTable) .where( and( @@ -125,6 +133,7 @@ export const makeSlackFeedbackServiceLive = (): Layer.Layer< creatorMemberId: null, excerpt, id, + metadata: { ...metadata }, organizationId, source: "SLACK", statusId: defaultStatus.id, @@ -136,6 +145,7 @@ export const makeSlackFeedbackServiceLive = (): Layer.Layer< eventType: "feedback.post.created", organizationId: asLegid(WorkspaceId)(organizationId), postId: id, + ...(Object.keys(metadata).length === 0 ? {} : { metadata }), postSlug: createdSlug, statusId: asLegid(PostStatusId)(defaultStatus.id), title, @@ -168,7 +178,16 @@ export const makeSlackFeedbackServiceLive = (): Layer.Layer< ? { embeddingService: embeddingService.value } : {}), }).pipe(Effect.provideService(Database.Database, db)); - return { boardId, boardSlug, id, slug, title }; + return { + boardId, + boardName: board.name, + boardSlug, + id, + metadata, + slug, + status: defaultStatus.type, + title, + }; }).pipe( Effect.mapError((error) => error instanceof SlackInboundFailure diff --git a/packages/domain/src/integration/slack/slack-modals.test.ts b/packages/domain/src/integration/slack/slack-modals.test.ts new file mode 100644 index 000000000..cb0f71ac3 --- /dev/null +++ b/packages/domain/src/integration/slack/slack-modals.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { buildSuccessModal } from "./slack-modals"; + +describe("buildSuccessModal", () => { + it("renders post details and non-empty metadata in the Slack confirmation", () => { + expect( + buildSuccessModal({ + boardName: "Product ideas", + metadata: { + customer_tier: "Enterprise", + empty_value: " ", + region: "Asia Pacific", + }, + postId: "post_123", + postTitle: "Support dark mode", + postUrl: "https://feeblo.example/post/dark-mode", + status: "IN_PROGRESS", + submitterName: "Ada Lovelace", + }) + ).toMatchObject({ + blocks: [ + { type: "section" }, + { type: "section" }, + { + fields: [ + { text: "*Board:*\nProduct ideas", type: "mrkdwn" }, + { text: "*Status:*\nIn Progress", type: "mrkdwn" }, + { text: "*Source:*\nSlack", type: "mrkdwn" }, + { text: "*Submitted by:*\nAda Lovelace", type: "mrkdwn" }, + { text: "*Post ID:*\npost_123", type: "mrkdwn" }, + { text: "*Customer Tier:*\nEnterprise", type: "mrkdwn" }, + { text: "*Region:*\nAsia Pacific", type: "mrkdwn" }, + ], + type: "section", + }, + { type: "actions" }, + ], + }); + }); +}); diff --git a/packages/domain/src/integration/slack/slack-modals.ts b/packages/domain/src/integration/slack/slack-modals.ts index 5432ece30..dca451e73 100644 --- a/packages/domain/src/integration/slack/slack-modals.ts +++ b/packages/domain/src/integration/slack/slack-modals.ts @@ -4,6 +4,63 @@ import * as Schema from "effect/Schema"; const TITLE_MAX_LENGTH = 200; const DETAILS_MAX_LENGTH = 3000; +const SUCCESS_FIELD_TEXT_MAX_LENGTH = 2000; +const SUCCESS_FIELDS_PER_SECTION = 10; + +const formatSlackMetadataLabel = (value: string): string => + value + .toLowerCase() + .split("_") + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(" "); + +const buildSuccessMetadataBlocks = ({ + boardName, + metadata, + postId, + status, + submitterName, +}: { + readonly boardName: string; + readonly metadata: Readonly>; + readonly postId: string; + readonly status: string; + readonly submitterName: string; +}): readonly unknown[] => { + const postFields: readonly { + readonly name: string; + readonly value: string; + }[] = [ + { name: "Board", value: boardName }, + { name: "Status", value: formatSlackMetadataLabel(status) }, + { name: "Source", value: "Slack" }, + { name: "Submitted by", value: submitterName || "Slack user" }, + { name: "Post ID", value: postId }, + ...Object.entries(metadata) + .map(([name, value]) => ({ + name: formatSlackMetadataLabel(name.trim()), + value: value.trim(), + })) + .filter(({ name, value }) => name.length > 0 && value.length > 0), + ]; + const blocks: unknown[] = []; + for ( + let startIndex = 0; + startIndex < postFields.length; + startIndex += SUCCESS_FIELDS_PER_SECTION + ) { + blocks.push({ + fields: postFields + .slice(startIndex, startIndex + SUCCESS_FIELDS_PER_SECTION) + .map(({ name, value }) => ({ + text: truncate(`*${name}:*\n${value}`, SUCCESS_FIELD_TEXT_MAX_LENGTH), + type: "mrkdwn", + })), + type: "section", + }); + } + return blocks; +}; /** Private metadata embedded in the feedback modal; safe to send to Slack. */ export const FeedbackModalMetadata = Schema.Struct({ @@ -91,11 +148,21 @@ export const buildFeedbackModal = ({ /** Slack Block Kit `modal` view shown after a feedback post is created. */ export const buildSuccessModal = ({ + boardName, + metadata, + postId, postTitle, postUrl, + status, + submitterName, }: { + readonly boardName: string; + readonly metadata: Readonly>; + readonly postId: string; readonly postTitle: string; readonly postUrl?: string; + readonly status: string; + readonly submitterName: string; }): unknown => ({ callback_id: "feeblo_feedback_success", clear_on_close: true, @@ -117,6 +184,13 @@ export const buildSuccessModal = ({ }, type: "section", }, + ...buildSuccessMetadataBlocks({ + boardName, + metadata, + postId, + status, + submitterName, + }), ...(postUrl === undefined ? [] : [ diff --git a/packages/domain/src/post/repository.ts b/packages/domain/src/post/repository.ts index 6af4ca1db..bed64a785 100644 --- a/packages/domain/src/post/repository.ts +++ b/packages/domain/src/post/repository.ts @@ -63,7 +63,14 @@ interface TPostCreate { id: string; metadata?: Record; organizationId: string; - source?: "DASHBOARD" | "WIDGET" | "API" | "IMPORT" | "PUBLIC_BOARD" | "SLACK"; + source?: + | "DASHBOARD" + | "WIDGET" + | "API" + | "IMPORT" + | "PUBLIC_BOARD" + | "SLACK" + | "DISCORD"; statusId: string; title: string; } diff --git a/packages/domain/src/rpc-group.ts b/packages/domain/src/rpc-group.ts index 431acc62b..b5e4cd75a 100644 --- a/packages/domain/src/rpc-group.ts +++ b/packages/domain/src/rpc-group.ts @@ -9,6 +9,7 @@ import { CommentRpcs } from "./comments/rpcs"; import { CompanyRpcs } from "./company/rpcs"; import { ContactRpcs } from "./contact/rpcs"; import { EmailSubscriptionRpcs } from "./email-subscription/rpcs"; +import { DiscordManagementRpcs } from "./integration/discord/rpcs"; import { WebhookManagementRpcs } from "./integration/rpcs"; import { SlackManagementRpcs } from "./integration/slack/rpcs"; import { JwtSecretRpcs } from "./jwt-secret/rpcs"; @@ -51,5 +52,6 @@ export const AllRpcs = PostRpcs.merge(PostActivityRpcs).merge( ContactRpcs, EmailSubscriptionRpcs, WebhookManagementRpcs, - SlackManagementRpcs + SlackManagementRpcs, + DiscordManagementRpcs ); diff --git a/packages/domain/src/rpc-router.ts b/packages/domain/src/rpc-router.ts index 1ba6aa7e4..df7aa6f7f 100644 --- a/packages/domain/src/rpc-router.ts +++ b/packages/domain/src/rpc-router.ts @@ -14,6 +14,7 @@ import { CommentRpcHandlers } from "./comments/handlers"; import { CompanyRpcHandlers } from "./company/handlers"; import { ContactRpcHandlers } from "./contact/handlers"; import { EmailSubscriptionRpcHandlers } from "./email-subscription/handlers"; +import { DiscordManagementRpcHandlers } from "./integration/discord/handlers"; import { WebhookManagementRpcHandlers } from "./integration/handlers"; import { SlackManagementRpcHandlers } from "./integration/slack/handlers"; import { JwtSecretRpcHandlers } from "./jwt-secret/handlers"; @@ -49,7 +50,8 @@ export const RpcRoute = RpcServer.layerHttp({ PostRpcHandlers, PostActivityRpcHandlers, WebhookManagementRpcHandlers, - SlackManagementRpcHandlers + SlackManagementRpcHandlers, + DiscordManagementRpcHandlers ) ), Layer.provide(BillingRpcHandlers), diff --git a/packages/domain/src/widget/api-live.ts b/packages/domain/src/widget/api-live.ts index ff917813f..791e87c7a 100644 --- a/packages/domain/src/widget/api-live.ts +++ b/packages/domain/src/widget/api-live.ts @@ -225,6 +225,7 @@ export const WidgetApiLive = HttpApiBuilder.group( eventType: "feedback.post.created", organizationId, postId: id, + ...(metadata === undefined ? {} : { metadata }), postSlug, statusId, title, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 998160bf3..4cdfb0297 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,6 +282,9 @@ importers: '@feeblo/integration-core': specifier: workspace:* version: link:../../integrations/core + '@feeblo/integration-discord': + specifier: workspace:* + version: link:../../integrations/discord '@feeblo/integration-slack': specifier: workspace:* version: link:../../integrations/slack @@ -563,6 +566,9 @@ importers: '@feeblo/id': specifier: workspace:* version: link:../../packages/id + '@feeblo/utils': + specifier: workspace:* + version: link:../../packages/utils drizzle-orm: specifier: 'catalog:' version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260702.1)(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/sql-pglite@4.0.0-beta.103(effect@4.0.0-beta.103))(@electric-sql/pglite@0.5.4)(@opentelemetry/api@1.9.1)(@types/pg@8.20.4)(effect@4.0.0-beta.103)(pg@8.22.0)(postgres@3.4.9)(zod@4.4.3) @@ -583,6 +589,37 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(happy-dom@20.11.1)(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)(yaml@2.9.0)) + integrations/discord: + dependencies: + '@feeblo/integration-core': + specifier: workspace:* + version: link:../core + '@feeblo/utils': + specifier: workspace:* + version: link:../../packages/utils + effect: + specifier: 'catalog:' + version: 4.0.0-beta.103 + devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.94 + version: 4.0.0-beta.94(effect@4.0.0-beta.103)(vitest@4.1.10) + '@feeblo/config': + specifier: workspace:* + version: link:../../packages/config + '@feeblo/id': + specifier: workspace:* + version: link:../../packages/id + '@types/node': + specifier: 'catalog:' + version: 24.13.3 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(happy-dom@20.11.1)(msw@2.15.0(@types/node@24.13.3)(typescript@6.0.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)(yaml@2.9.0)) + integrations/slack: dependencies: '@feeblo/integration-core': @@ -822,6 +859,9 @@ importers: '@feeblo/integration-core': specifier: workspace:* version: link:../../integrations/core + '@feeblo/integration-discord': + specifier: workspace:* + version: link:../../integrations/discord '@feeblo/integration-slack': specifier: workspace:* version: link:../../integrations/slack