diff --git a/apps/console/src/__generated__/core/WebhooksSettingsPageQuery.graphql.ts b/apps/console/src/__generated__/core/WebhooksSettingsPageQuery.graphql.ts index c56ea5b3c..1dba7e35a 100644 --- a/apps/console/src/__generated__/core/WebhooksSettingsPageQuery.graphql.ts +++ b/apps/console/src/__generated__/core/WebhooksSettingsPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<164efe490915ac140a48e42cbd17e915>> + * @generated SignedSource<<72029a8e627a0b60cb1e2c226af0d535>> * @lightSyntaxTransform * @nogrep */ @@ -9,7 +9,7 @@ // @ts-nocheck import { ConcreteRequest } from 'relay-runtime'; -export type WebhookEventType = "MEETING_CREATED" | "MEETING_DELETED" | "MEETING_UPDATED" | "VENDOR_CREATED" | "VENDOR_DELETED" | "VENDOR_UPDATED"; +export type WebhookEventType = "MEETING_CREATED" | "MEETING_DELETED" | "MEETING_UPDATED" | "USER_CREATED" | "USER_DELETED" | "USER_UPDATED" | "VENDOR_CREATED" | "VENDOR_DELETED" | "VENDOR_UPDATED"; export type WebhooksSettingsPageQuery$variables = { organizationId: string; }; diff --git a/apps/console/src/__generated__/core/WebhooksSettingsPage_createMutation.graphql.ts b/apps/console/src/__generated__/core/WebhooksSettingsPage_createMutation.graphql.ts index 7228877f2..d2df166ae 100644 --- a/apps/console/src/__generated__/core/WebhooksSettingsPage_createMutation.graphql.ts +++ b/apps/console/src/__generated__/core/WebhooksSettingsPage_createMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<240851e53a2871e13a6e19a5b327684c>> + * @generated SignedSource<<310671de1c8bca322875bfca9102fcbf>> * @lightSyntaxTransform * @nogrep */ @@ -9,7 +9,7 @@ // @ts-nocheck import { ConcreteRequest } from 'relay-runtime'; -export type WebhookEventType = "MEETING_CREATED" | "MEETING_DELETED" | "MEETING_UPDATED" | "VENDOR_CREATED" | "VENDOR_DELETED" | "VENDOR_UPDATED"; +export type WebhookEventType = "MEETING_CREATED" | "MEETING_DELETED" | "MEETING_UPDATED" | "USER_CREATED" | "USER_DELETED" | "USER_UPDATED" | "VENDOR_CREATED" | "VENDOR_DELETED" | "VENDOR_UPDATED"; export type CreateWebhookSubscriptionInput = { endpointUrl: string; organizationId: string; diff --git a/apps/console/src/__generated__/core/WebhooksSettingsPage_updateMutation.graphql.ts b/apps/console/src/__generated__/core/WebhooksSettingsPage_updateMutation.graphql.ts index a0bb4eb8a..24cb7d3e9 100644 --- a/apps/console/src/__generated__/core/WebhooksSettingsPage_updateMutation.graphql.ts +++ b/apps/console/src/__generated__/core/WebhooksSettingsPage_updateMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<864603efd6135475b827a1234b128b7c>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -9,7 +9,7 @@ // @ts-nocheck import { ConcreteRequest } from 'relay-runtime'; -export type WebhookEventType = "MEETING_CREATED" | "MEETING_DELETED" | "MEETING_UPDATED" | "VENDOR_CREATED" | "VENDOR_DELETED" | "VENDOR_UPDATED"; +export type WebhookEventType = "MEETING_CREATED" | "MEETING_DELETED" | "MEETING_UPDATED" | "USER_CREATED" | "USER_DELETED" | "USER_UPDATED" | "VENDOR_CREATED" | "VENDOR_DELETED" | "VENDOR_UPDATED"; export type UpdateWebhookSubscriptionInput = { endpointUrl?: string | null | undefined; id: string; diff --git a/apps/console/src/pages/organizations/settings/WebhooksSettingsPage.tsx b/apps/console/src/pages/organizations/settings/WebhooksSettingsPage.tsx index 4d390fe20..cfa1c31f7 100644 --- a/apps/console/src/pages/organizations/settings/WebhooksSettingsPage.tsx +++ b/apps/console/src/pages/organizations/settings/WebhooksSettingsPage.tsx @@ -147,6 +147,9 @@ const EVENT_TYPES = [ { value: "VENDOR_CREATED", label: "vendor:created" }, { value: "VENDOR_UPDATED", label: "vendor:updated" }, { value: "VENDOR_DELETED", label: "vendor:deleted" }, + { value: "USER_CREATED", label: "user:created" }, + { value: "USER_UPDATED", label: "user:updated" }, + { value: "USER_DELETED", label: "user:deleted" }, ] as const; type WebhookEventType = (typeof EVENT_TYPES)[number]["value"]; diff --git a/pkg/coredata/migrations/20260224T120000Z.sql b/pkg/coredata/migrations/20260224T120000Z.sql new file mode 100644 index 000000000..7610b99af --- /dev/null +++ b/pkg/coredata/migrations/20260224T120000Z.sql @@ -0,0 +1,3 @@ +ALTER TYPE webhook_event_type ADD VALUE 'user:created'; +ALTER TYPE webhook_event_type ADD VALUE 'user:updated'; +ALTER TYPE webhook_event_type ADD VALUE 'user:deleted'; diff --git a/pkg/coredata/webhook_event_type.go b/pkg/coredata/webhook_event_type.go index d3baaca5d..c90029a54 100644 --- a/pkg/coredata/webhook_event_type.go +++ b/pkg/coredata/webhook_event_type.go @@ -29,6 +29,9 @@ const ( WebhookEventTypeVendorCreated WebhookEventType = "vendor:created" WebhookEventTypeVendorUpdated WebhookEventType = "vendor:updated" WebhookEventTypeVendorDeleted WebhookEventType = "vendor:deleted" + WebhookEventTypeUserCreated WebhookEventType = "user:created" + WebhookEventTypeUserUpdated WebhookEventType = "user:updated" + WebhookEventTypeUserDeleted WebhookEventType = "user:deleted" ) func (w WebhookEventType) String() string { @@ -38,7 +41,8 @@ func (w WebhookEventType) String() string { func (w WebhookEventType) IsValid() bool { switch w { case WebhookEventTypeMeetingCreated, WebhookEventTypeMeetingUpdated, WebhookEventTypeMeetingDeleted, - WebhookEventTypeVendorCreated, WebhookEventTypeVendorUpdated, WebhookEventTypeVendorDeleted: + WebhookEventTypeVendorCreated, WebhookEventTypeVendorUpdated, WebhookEventTypeVendorDeleted, + WebhookEventTypeUserCreated, WebhookEventTypeUserUpdated, WebhookEventTypeUserDeleted: return true } return false diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 4b9400233..0e629b755 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -33,6 +33,8 @@ import ( "go.probo.inc/probo/pkg/slug" "go.probo.inc/probo/pkg/statelesstoken" "go.probo.inc/probo/pkg/validator" + "go.probo.inc/probo/pkg/webhook" + webhooktypes "go.probo.inc/probo/pkg/webhook/types" ) type ( @@ -320,6 +322,10 @@ func (s *OrganizationService) RemoveUser( } } + if err := webhook.InsertData(ctx, tx, scope, organizationID, coredata.WebhookEventTypeUserDeleted, webhooktypes.NewUser(&profile)); err != nil { + return fmt.Errorf("cannot insert webhook event: %w", err) + } + if err := profile.Delete(ctx, tx, scope, profileID); err != nil { return fmt.Errorf("cannot delete profile: %w", err) } @@ -920,6 +926,10 @@ func (s *OrganizationService) CreateUser(ctx context.Context, req *CreateUserReq return fmt.Errorf("cannot insert membership: %w", err) } + if err := webhook.InsertData(ctx, conn, scope, req.OrganizationID, coredata.WebhookEventTypeUserCreated, webhooktypes.NewUser(profile)); err != nil { + return fmt.Errorf("cannot insert webhook event: %w", err) + } + return nil }, ) @@ -941,7 +951,7 @@ func (s *OrganizationService) UpdateUser(ctx context.Context, req *UpdateUserReq profile = &coredata.MembershipProfile{} ) - err := s.pg.WithConn( + err := s.pg.WithTx( ctx, func(conn pg.Conn) error { if err := profile.LoadByID(ctx, conn, scope, req.ID); err != nil { @@ -967,6 +977,10 @@ func (s *OrganizationService) UpdateUser(ctx context.Context, req *UpdateUserReq return fmt.Errorf("cannot update profile: %w", err) } + if err := webhook.InsertData(ctx, conn, scope, profile.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile)); err != nil { + return fmt.Errorf("cannot insert webhook event: %w", err) + } + return nil }, ) diff --git a/pkg/iam/scim/service.go b/pkg/iam/scim/service.go index 614772423..f71479f98 100644 --- a/pkg/iam/scim/service.go +++ b/pkg/iam/scim/service.go @@ -39,6 +39,8 @@ import ( "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/webhook" + webhooktypes "go.probo.inc/probo/pkg/webhook/types" ) type ( @@ -174,6 +176,7 @@ func (s *Service) CreateUser( } // Check if profile exists + eventType := coredata.WebhookEventTypeUserUpdated profile = &coredata.MembershipProfile{} if err := profile.LoadByIdentityIDAndOrganizationID( ctx, @@ -187,6 +190,7 @@ func (s *Service) CreateUser( ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipProfileEntityType), IdentityID: identity.ID, OrganizationID: config.OrganizationID, + EmailAddress: emailAddr, Source: coredata.ProfileSourceSCIM, State: profileState, FullName: fullName, @@ -199,6 +203,7 @@ func (s *Service) CreateUser( if err != nil { return fmt.Errorf("cannot insert profile: %w", err) } + eventType = coredata.WebhookEventTypeUserCreated } else { return fmt.Errorf("cannot load profile: %w", err) } @@ -257,6 +262,10 @@ func (s *Service) CreateUser( } } + if err := webhook.InsertData(ctx, tx, scope, config.OrganizationID, eventType, webhooktypes.NewUser(profile)); err != nil { + return fmt.Errorf("cannot insert webhook event: %w", err) + } + return nil }) @@ -509,6 +518,10 @@ func (s *Service) updateUser( } } + if err := webhook.InsertData(ctx, tx, scope, config.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile)); err != nil { + return fmt.Errorf("cannot insert webhook event: %w", err) + } + return nil }, ) diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 82bc029d4..d421d00a8 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -2282,6 +2282,12 @@ enum WebhookEventType @goEnum(value: "go.probo.inc/probo/pkg/coredata.WebhookEventTypeVendorUpdated") VENDOR_DELETED @goEnum(value: "go.probo.inc/probo/pkg/coredata.WebhookEventTypeVendorDeleted") + USER_CREATED + @goEnum(value: "go.probo.inc/probo/pkg/coredata.WebhookEventTypeUserCreated") + USER_UPDATED + @goEnum(value: "go.probo.inc/probo/pkg/coredata.WebhookEventTypeUserUpdated") + USER_DELETED + @goEnum(value: "go.probo.inc/probo/pkg/coredata.WebhookEventTypeUserDeleted") } type WebhookSubscription implements Node { diff --git a/pkg/webhook/types/user.go b/pkg/webhook/types/user.go new file mode 100644 index 000000000..d5ca80d31 --- /dev/null +++ b/pkg/webhook/types/user.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package types + +import ( + "time" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/mail" +) + +type User struct { + ID gid.GID `json:"id"` + OrganizationID gid.GID `json:"organizationId"` + EmailAddress mail.Addr `json:"emailAddress"` + FullName string `json:"fullName"` + Kind coredata.MembershipProfileKind `json:"kind"` + Source coredata.ProfileSource `json:"source"` + State coredata.ProfileState `json:"state"` + AdditionalEmailAddresses mail.Addrs `json:"additionalEmailAddresses"` + Position *string `json:"position"` + ContractStartDate *time.Time `json:"contractStartDate"` + ContractEndDate *time.Time `json:"contractEndDate"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func NewUser(p *coredata.MembershipProfile) *User { + return &User{ + ID: p.ID, + OrganizationID: p.OrganizationID, + EmailAddress: p.EmailAddress, + FullName: p.FullName, + Kind: p.Kind, + Source: p.Source, + State: p.State, + AdditionalEmailAddresses: p.AdditionalEmailAddresses, + Position: p.Position, + ContractStartDate: p.ContractStartDate, + ContractEndDate: p.ContractEndDate, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } +}