From 31a2a4748df126e152e29ea31eb36cbaf63628c7 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Tue, 24 Feb 2026 10:49:19 +0300 Subject: [PATCH 01/41] feat: multi-channel notification platform (subscribers, channels, workflows, templates, hooks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transform NitroPing from push-only to a full multi-channel notification platform. ## Database (Phase 1) - 6 new pg enums: channelType, workflowStatus, workflowStepType, workflowTriggerType, workflowExecutionStatus, hookEvent - 9 new tables: subscriber, subscriberDevice, subscriberPreference, channel, template, workflow, workflowStep, workflowExecution, hook - Nullable subscriberId FK on device for subscriber linkage - Migration: 20260224073747_unusual_spirit ## Channel Abstraction (Phase 2) - Channel interface with ChannelMessage/ChannelResult types - EmailChannel: SMTP (nodemailer) + Resend providers, lazy-imported - PushChannel: wraps existing getProviderForApp() provider system - Factory functions: getChannelById, getChannelForApp - templateRenderer utility for {{variable}} substitution ## Workflow Engine (Phase 3) - BullMQ workflow queue (addTriggerWorkflowJob, addExecuteWorkflowStepJob) - Step executor worker: SEND / DELAY (re-queue with delay) / FILTER / DIGEST - HMAC-SHA256 webhook dispatcher with Promise.allSettled fan-out - Worker registered in server/plugins/worker.ts ## GraphQL (Phase 4) - 5 new SDL domains: subscribers, channels, templates, workflows, hooks - Full CRUD resolvers for all domains; triggerWorkflow creates execution + enqueues job - Shared enums in shared.graphql to avoid ordering conflicts - 5 new DataLoaders: subscriber, channel, template, workflow, preference - Channel.config and Hook.secret always resolve to null (security) ## Frontend (Phase 5) - Vue Flow workflow canvas editor with TriggerNode, SendNode, DelayNode, FilterNode - StepConfigPanel with SendConfigPanel, DelayConfigPanel, FilterConfigPanel - Pages: /subscribers, /channels, /templates, /templates/create, /workflows, /workflows/:wid (Vue Flow editor), /workflows/:wid/runs, /hooks - AppNavigation updated with 5 new nav items - GraphQL SDK extended: channels, subscribers, templates, workflows, hooks operations - nitro-graphql-client.d.ts and types/ updated with new query/mutation types ## SDK - NitroPingClient.identify(externalId, options) — upsert subscriber - NitroPingClient.updatePreference(input) — manage channel opt-in/out Co-Authored-By: Claude Sonnet 4.6 --- app/.graphql/nitro-graphql-client.d.ts | 28 + app/.graphql/nitro-graphql-server.d.ts | 631 +++ app/.graphql/schema.graphql | 279 ++ app/.graphql/types/nitro-graphql-client.d.ts | 112 + app/.graphql/types/nitro-graphql-server.d.ts | 645 ++- app/package.json | 7 + app/pnpm-lock.yaml | 210 + app/sdk/src/client.ts | 96 + app/sdk/src/index.ts | 4 + app/sdk/src/types.ts | 36 + app/server/channels/email.channel.ts | 90 + app/server/channels/index.ts | 83 + app/server/channels/push.channel.ts | 52 + app/server/channels/types.ts | 18 + .../migration.sql | 153 + .../snapshot.json | 3462 +++++++++++++++++ app/server/database/schema/channel.ts | 23 + app/server/database/schema/deliveryLog.ts | 2 +- app/server/database/schema/device.ts | 4 +- app/server/database/schema/enums.ts | 20 + app/server/database/schema/hook.ts | 23 + app/server/database/schema/index.ts | 9 + app/server/database/schema/relations.ts | 140 +- app/server/database/schema/subscriber.ts | 23 + .../database/schema/subscriberDevice.ts | 18 + .../database/schema/subscriberPreference.ts | 26 + app/server/database/schema/template.ts | 26 + app/server/database/schema/workflow.ts | 25 + .../database/schema/workflowExecution.ts | 29 + app/server/database/schema/workflowStep.ts | 24 + app/server/graphql/channels/channel.graphql | 36 + .../channels/mutation/channels.resolver.ts | 77 + .../channels/query/channels.resolver.ts | 31 + .../channels/type/channel-fields.resolver.ts | 10 + app/server/graphql/hooks/hook.graphql | 39 + .../graphql/hooks/mutation/hooks.resolver.ts | 74 + .../graphql/hooks/query/hooks.resolver.ts | 25 + .../hooks/type/hook-fields.resolver.ts | 10 + app/server/graphql/loaders/channel.loader.ts | 28 + app/server/graphql/loaders/index.ts | 23 +- .../graphql/loaders/preference.loader.ts | 16 + .../graphql/loaders/subscriber.loader.ts | 57 + app/server/graphql/loaders/template.loader.ts | 28 + app/server/graphql/loaders/types.ts | 14 +- app/server/graphql/loaders/workflow.loader.ts | 29 + app/server/graphql/schema.ts | 18 + app/server/graphql/share/shared.graphql | 46 + .../mutation/subscribers.resolver.ts | 133 + .../subscribers/query/subscribers.resolver.ts | 49 + .../graphql/subscribers/subscriber.graphql | 69 + .../type/subscriber-fields.resolver.ts | 18 + .../templates/mutation/templates.resolver.ts | 66 + .../templates/query/templates.resolver.ts | 31 + app/server/graphql/templates/template.graphql | 41 + .../workflows/mutation/workflows.resolver.ts | 146 + .../workflows/query/workflows.resolver.ts | 46 + .../type/workflow-fields.resolver.ts | 12 + app/server/graphql/workflows/workflow.graphql | 84 + app/server/middleware/auth.ts | 2 +- app/server/plugins/worker.ts | 18 +- app/server/queues/workflow.queue.ts | 65 + app/server/utils/templateRenderer.ts | 18 + app/server/utils/webhookDispatcher.ts | 64 + app/server/workers/workflow.worker.ts | 269 ++ app/src/components/app/AppNavigation.vue | 32 +- .../components/workflow/nodes/DelayNode.vue | 39 + .../components/workflow/nodes/FilterNode.vue | 24 + .../components/workflow/nodes/SendNode.vue | 31 + .../components/workflow/nodes/TriggerNode.vue | 23 + .../workflow/panels/DelayConfigPanel.vue | 63 + .../workflow/panels/FilterConfigPanel.vue | 51 + .../workflow/panels/SendConfigPanel.vue | 72 + .../workflow/panels/StepConfigPanel.vue | 58 + app/src/graphql/channels/index.ts | 71 + app/src/graphql/channels/mutations.graphql | 25 + app/src/graphql/channels/queries.graphql | 23 + app/src/graphql/default/sdk.ts | 92 +- app/src/graphql/hooks/index.ts | 62 + app/src/graphql/hooks/mutations.graphql | 27 + app/src/graphql/hooks/queries.graphql | 25 + app/src/graphql/subscribers/index.ts | 92 + app/src/graphql/subscribers/mutations.graphql | 42 + app/src/graphql/subscribers/queries.graphql | 47 + app/src/graphql/templates/index.ts | 78 + app/src/graphql/templates/mutations.graphql | 29 + app/src/graphql/templates/queries.graphql | 29 + app/src/graphql/workflows/index.ts | 102 + app/src/graphql/workflows/mutations.graphql | 47 + app/src/graphql/workflows/queries.graphql | 58 + app/src/pages/apps/[id]/channels.vue | 196 + app/src/pages/apps/[id]/hooks.vue | 178 + app/src/pages/apps/[id]/subscribers.vue | 144 + app/src/pages/apps/[id]/templates/create.vue | 113 + app/src/pages/apps/[id]/templates/index.vue | 90 + .../pages/apps/[id]/workflows/[wid]/index.vue | 200 + .../pages/apps/[id]/workflows/[wid]/runs.vue | 101 + app/src/pages/apps/[id]/workflows/index.vue | 155 + app/src/router.ts | 55 + 98 files changed, 10447 insertions(+), 17 deletions(-) create mode 100644 app/server/channels/email.channel.ts create mode 100644 app/server/channels/index.ts create mode 100644 app/server/channels/push.channel.ts create mode 100644 app/server/channels/types.ts create mode 100644 app/server/database/migrations/20260224073747_unusual_spirit/migration.sql create mode 100644 app/server/database/migrations/20260224073747_unusual_spirit/snapshot.json create mode 100644 app/server/database/schema/channel.ts create mode 100644 app/server/database/schema/hook.ts create mode 100644 app/server/database/schema/subscriber.ts create mode 100644 app/server/database/schema/subscriberDevice.ts create mode 100644 app/server/database/schema/subscriberPreference.ts create mode 100644 app/server/database/schema/template.ts create mode 100644 app/server/database/schema/workflow.ts create mode 100644 app/server/database/schema/workflowExecution.ts create mode 100644 app/server/database/schema/workflowStep.ts create mode 100644 app/server/graphql/channels/channel.graphql create mode 100644 app/server/graphql/channels/mutation/channels.resolver.ts create mode 100644 app/server/graphql/channels/query/channels.resolver.ts create mode 100644 app/server/graphql/channels/type/channel-fields.resolver.ts create mode 100644 app/server/graphql/hooks/hook.graphql create mode 100644 app/server/graphql/hooks/mutation/hooks.resolver.ts create mode 100644 app/server/graphql/hooks/query/hooks.resolver.ts create mode 100644 app/server/graphql/hooks/type/hook-fields.resolver.ts create mode 100644 app/server/graphql/loaders/channel.loader.ts create mode 100644 app/server/graphql/loaders/preference.loader.ts create mode 100644 app/server/graphql/loaders/subscriber.loader.ts create mode 100644 app/server/graphql/loaders/template.loader.ts create mode 100644 app/server/graphql/loaders/workflow.loader.ts create mode 100644 app/server/graphql/subscribers/mutation/subscribers.resolver.ts create mode 100644 app/server/graphql/subscribers/query/subscribers.resolver.ts create mode 100644 app/server/graphql/subscribers/subscriber.graphql create mode 100644 app/server/graphql/subscribers/type/subscriber-fields.resolver.ts create mode 100644 app/server/graphql/templates/mutation/templates.resolver.ts create mode 100644 app/server/graphql/templates/query/templates.resolver.ts create mode 100644 app/server/graphql/templates/template.graphql create mode 100644 app/server/graphql/workflows/mutation/workflows.resolver.ts create mode 100644 app/server/graphql/workflows/query/workflows.resolver.ts create mode 100644 app/server/graphql/workflows/type/workflow-fields.resolver.ts create mode 100644 app/server/graphql/workflows/workflow.graphql create mode 100644 app/server/queues/workflow.queue.ts create mode 100644 app/server/utils/templateRenderer.ts create mode 100644 app/server/utils/webhookDispatcher.ts create mode 100644 app/server/workers/workflow.worker.ts create mode 100644 app/src/components/workflow/nodes/DelayNode.vue create mode 100644 app/src/components/workflow/nodes/FilterNode.vue create mode 100644 app/src/components/workflow/nodes/SendNode.vue create mode 100644 app/src/components/workflow/nodes/TriggerNode.vue create mode 100644 app/src/components/workflow/panels/DelayConfigPanel.vue create mode 100644 app/src/components/workflow/panels/FilterConfigPanel.vue create mode 100644 app/src/components/workflow/panels/SendConfigPanel.vue create mode 100644 app/src/components/workflow/panels/StepConfigPanel.vue create mode 100644 app/src/graphql/channels/index.ts create mode 100644 app/src/graphql/channels/mutations.graphql create mode 100644 app/src/graphql/channels/queries.graphql create mode 100644 app/src/graphql/hooks/index.ts create mode 100644 app/src/graphql/hooks/mutations.graphql create mode 100644 app/src/graphql/hooks/queries.graphql create mode 100644 app/src/graphql/subscribers/index.ts create mode 100644 app/src/graphql/subscribers/mutations.graphql create mode 100644 app/src/graphql/subscribers/queries.graphql create mode 100644 app/src/graphql/templates/index.ts create mode 100644 app/src/graphql/templates/mutations.graphql create mode 100644 app/src/graphql/templates/queries.graphql create mode 100644 app/src/graphql/workflows/index.ts create mode 100644 app/src/graphql/workflows/mutations.graphql create mode 100644 app/src/graphql/workflows/queries.graphql create mode 100644 app/src/pages/apps/[id]/channels.vue create mode 100644 app/src/pages/apps/[id]/hooks.vue create mode 100644 app/src/pages/apps/[id]/subscribers.vue create mode 100644 app/src/pages/apps/[id]/templates/create.vue create mode 100644 app/src/pages/apps/[id]/templates/index.vue create mode 100644 app/src/pages/apps/[id]/workflows/[wid]/index.vue create mode 100644 app/src/pages/apps/[id]/workflows/[wid]/runs.vue create mode 100644 app/src/pages/apps/[id]/workflows/index.vue diff --git a/app/.graphql/nitro-graphql-client.d.ts b/app/.graphql/nitro-graphql-client.d.ts index 136c0f7..ac95ff8 100644 --- a/app/.graphql/nitro-graphql-client.d.ts +++ b/app/.graphql/nitro-graphql-client.d.ts @@ -34,4 +34,32 @@ export type { UpdateAppInput, UpdateDeviceInput, VapidKeys, + Channel, + ChannelType, + CreateChannelInput, + UpdateChannelInput, + Hook, + HookEvent, + CreateHookInput, + UpdateHookInput, + Subscriber, + SubscriberPreference, + CreateSubscriberInput, + UpdateSubscriberInput, + UpdateSubscriberPreferenceInput, + UpsertSubscriberDeviceInput, + Template, + CreateTemplateInput, + UpdateTemplateInput, + Workflow, + WorkflowStep, + WorkflowStepInput, + WorkflowExecution, + WorkflowStatus, + WorkflowStepType, + WorkflowTriggerType, + WorkflowExecutionStatus, + CreateWorkflowInput, + UpdateWorkflowInput, + TriggerWorkflowInput, } from './nitro-graphql-server.d' diff --git a/app/.graphql/nitro-graphql-server.d.ts b/app/.graphql/nitro-graphql-server.d.ts index 5fd20d7..1de21f8 100644 --- a/app/.graphql/nitro-graphql-server.d.ts +++ b/app/.graphql/nitro-graphql-server.d.ts @@ -141,6 +141,24 @@ export interface AppStats { apiCalls: Scalars['Int']['output']; } +export interface Channel { + __typename?: 'Channel'; + id: Scalars['ID']['output']; + appId: Scalars['ID']['output']; + name: Scalars['String']['output']; + type: ChannelType; + config?: Maybe; + isActive: Scalars['Boolean']['output']; + createdAt: Scalars['Timestamp']['output']; + updatedAt: Scalars['Timestamp']['output']; +} + +export type ChannelType = + | 'PUSH' + | 'EMAIL' + | 'SMS' + | 'IN_APP'; + export interface ConfigureApNsInput { keyId: Scalars['String']['input']; teamId: Scalars['String']['input']; @@ -166,6 +184,49 @@ export interface CreateAppInput { description?: InputMaybe; } +export interface CreateChannelInput { + appId: Scalars['ID']['input']; + name: Scalars['String']['input']; + type: ChannelType; + config?: InputMaybe; +} + +export interface CreateHookInput { + appId: Scalars['ID']['input']; + name: Scalars['String']['input']; + url: Scalars['String']['input']; + secret?: InputMaybe; + events?: InputMaybe; +} + +export interface CreateSubscriberInput { + appId: Scalars['ID']['input']; + externalId: Scalars['String']['input']; + email?: InputMaybe; + phone?: InputMaybe; + locale?: InputMaybe; + metadata?: InputMaybe; +} + +export interface CreateTemplateInput { + appId: Scalars['ID']['input']; + channelId?: InputMaybe; + name: Scalars['String']['input']; + channelType: ChannelType; + subject?: InputMaybe; + body: Scalars['String']['input']; + htmlBody?: InputMaybe; +} + +export interface CreateWorkflowInput { + appId: Scalars['ID']['input']; + name: Scalars['String']['input']; + triggerIdentifier: Scalars['String']['input']; + triggerType?: InputMaybe; + steps?: InputMaybe>; + flowLayout?: InputMaybe; +} + export interface DashboardStats { __typename?: 'DashboardStats'; totalApps: Scalars['Int']['output']; @@ -244,6 +305,27 @@ export interface EngagementMetrics { platformBreakdown: Array; } +export interface Hook { + __typename?: 'Hook'; + id: Scalars['ID']['output']; + appId: Scalars['ID']['output']; + name: Scalars['String']['output']; + url: Scalars['String']['output']; + secret?: Maybe; + events?: Maybe; + isActive: Scalars['Boolean']['output']; + createdAt: Scalars['Timestamp']['output']; + updatedAt: Scalars['Timestamp']['output']; +} + +export type HookEvent = + | 'NOTIFICATION_SENT' + | 'NOTIFICATION_DELIVERED' + | 'NOTIFICATION_FAILED' + | 'NOTIFICATION_CLICKED' + | 'WORKFLOW_COMPLETED' + | 'WORKFLOW_FAILED'; + export interface Mutation { __typename?: 'Mutation'; _empty?: Maybe; @@ -252,8 +334,18 @@ export interface Mutation { configureFCM: App; configureWebPush: App; createApp: App; + createChannel: Channel; + createHook: Hook; + createSubscriber: Subscriber; + createTemplate: Template; + createWorkflow: Workflow; deleteApp: Scalars['Boolean']['output']; + deleteChannel: Scalars['Boolean']['output']; deleteDevice: Scalars['Boolean']['output']; + deleteHook: Scalars['Boolean']['output']; + deleteSubscriber: Scalars['Boolean']['output']; + deleteTemplate: Scalars['Boolean']['output']; + deleteWorkflow: Scalars['Boolean']['output']; regenerateApiKey: App; registerDevice: Device; scheduleNotification: Notification; @@ -261,8 +353,16 @@ export interface Mutation { trackNotificationClicked: TrackEventResponse; trackNotificationDelivered: TrackEventResponse; trackNotificationOpened: TrackEventResponse; + triggerWorkflow: WorkflowExecution; updateApp: App; + updateChannel: Channel; updateDevice: Device; + updateHook: Hook; + updateSubscriber: Subscriber; + updateSubscriberPreference: SubscriberPreference; + updateTemplate: Template; + updateWorkflow: Workflow; + upsertSubscriberDevice: Scalars['Boolean']['output']; } @@ -294,16 +394,66 @@ export interface MutationCreateAppArgs { } +export interface MutationCreateChannelArgs { + input: CreateChannelInput; +} + + +export interface MutationCreateHookArgs { + input: CreateHookInput; +} + + +export interface MutationCreateSubscriberArgs { + input: CreateSubscriberInput; +} + + +export interface MutationCreateTemplateArgs { + input: CreateTemplateInput; +} + + +export interface MutationCreateWorkflowArgs { + input: CreateWorkflowInput; +} + + export interface MutationDeleteAppArgs { id: Scalars['ID']['input']; } +export interface MutationDeleteChannelArgs { + id: Scalars['ID']['input']; +} + + export interface MutationDeleteDeviceArgs { id: Scalars['ID']['input']; } +export interface MutationDeleteHookArgs { + id: Scalars['ID']['input']; +} + + +export interface MutationDeleteSubscriberArgs { + id: Scalars['ID']['input']; +} + + +export interface MutationDeleteTemplateArgs { + id: Scalars['ID']['input']; +} + + +export interface MutationDeleteWorkflowArgs { + id: Scalars['ID']['input']; +} + + export interface MutationRegenerateApiKeyArgs { id: Scalars['ID']['input']; } @@ -339,17 +489,62 @@ export interface MutationTrackNotificationOpenedArgs { } +export interface MutationTriggerWorkflowArgs { + input: TriggerWorkflowInput; +} + + export interface MutationUpdateAppArgs { id: Scalars['ID']['input']; input: UpdateAppInput; } +export interface MutationUpdateChannelArgs { + id: Scalars['ID']['input']; + input: UpdateChannelInput; +} + + export interface MutationUpdateDeviceArgs { id: Scalars['ID']['input']; input: UpdateDeviceInput; } + +export interface MutationUpdateHookArgs { + id: Scalars['ID']['input']; + input: UpdateHookInput; +} + + +export interface MutationUpdateSubscriberArgs { + id: Scalars['ID']['input']; + input: UpdateSubscriberInput; +} + + +export interface MutationUpdateSubscriberPreferenceArgs { + input: UpdateSubscriberPreferenceInput; +} + + +export interface MutationUpdateTemplateArgs { + id: Scalars['ID']['input']; + input: UpdateTemplateInput; +} + + +export interface MutationUpdateWorkflowArgs { + id: Scalars['ID']['input']; + input: UpdateWorkflowInput; +} + + +export interface MutationUpsertSubscriberDeviceArgs { + input: UpsertSubscriberDeviceInput; +} + export interface Notification { __typename?: 'Notification'; id: Scalars['ID']['output']; @@ -436,6 +631,8 @@ export interface Query { appExists: Scalars['Boolean']['output']; appStats?: Maybe; apps: Array; + channel?: Maybe; + channels: Array; dashboardStats: DashboardStats; deliveryLogs: Array; device?: Maybe; @@ -444,9 +641,19 @@ export interface Query { generateVapidKeys: VapidKeys; getEngagementMetrics?: Maybe; getNotificationAnalytics?: Maybe; + hook?: Maybe; + hooks: Array; notification?: Maybe; notifications: Array; platformStats: Array; + subscriber?: Maybe; + subscriberByExternalId?: Maybe; + subscribers: Array; + template?: Maybe