@@ -79,6 +79,9 @@ const (
|
||||
SCIMEventEntityType uint16 = 53
|
||||
TokenEntityType uint16 = 54
|
||||
SCIMBridgeEntityType uint16 = 55
|
||||
WebhookConfigurationEntityType uint16 = 56
|
||||
WebhookEventEntityType uint16 = 57
|
||||
WebhookCallEntityType uint16 = 58
|
||||
)
|
||||
|
||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
@@ -191,6 +194,12 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
return &Token{ID: id}, true
|
||||
case SCIMBridgeEntityType:
|
||||
return &SCIMBridge{ID: id}, true
|
||||
case WebhookConfigurationEntityType:
|
||||
return &WebhookConfiguration{ID: id}, true
|
||||
case WebhookEventEntityType:
|
||||
return &WebhookEvent{ID: id}, true
|
||||
case WebhookCallEntityType:
|
||||
return &WebhookCall{ID: id}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
52
pkg/coredata/migrations/20260210T135740Z.sql
Normal file
52
pkg/coredata/migrations/20260210T135740Z.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
CREATE TYPE webhook_event_type AS ENUM (
|
||||
'meeting:created',
|
||||
'meeting:updated',
|
||||
'meeting:deleted',
|
||||
'vendor:created',
|
||||
'vendor:updated',
|
||||
'vendor:deleted'
|
||||
);
|
||||
|
||||
CREATE TABLE webhook_configurations (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
endpoint_url TEXT NOT NULL,
|
||||
selected_events webhook_event_type[] NOT NULL,
|
||||
encrypted_signing_secret BYTEA NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TYPE webhook_event_status AS ENUM (
|
||||
'PENDING',
|
||||
'PROCESSING',
|
||||
'DELIVERED'
|
||||
);
|
||||
|
||||
CREATE TABLE webhook_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
event_type webhook_event_type NOT NULL,
|
||||
status webhook_event_status NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
processed_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE TYPE webhook_call_status AS ENUM (
|
||||
'SUCCEEDED',
|
||||
'FAILED'
|
||||
);
|
||||
|
||||
CREATE TABLE webhook_calls (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
webhook_event_id TEXT NOT NULL REFERENCES webhook_events(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
webhook_configuration_id TEXT NOT NULL REFERENCES webhook_configurations(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
endpoint_url TEXT NOT NULL,
|
||||
status webhook_call_status NOT NULL,
|
||||
response JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
87
pkg/coredata/webhook_call.go
Normal file
87
pkg/coredata/webhook_call.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
WebhookCall struct {
|
||||
ID gid.GID `db:"id"`
|
||||
WebhookEventID gid.GID `db:"webhook_event_id"`
|
||||
WebhookConfigurationID gid.GID `db:"webhook_configuration_id"`
|
||||
EndpointURL string `db:"endpoint_url"`
|
||||
Status WebhookCallStatus `db:"status"`
|
||||
Response json.RawMessage `db:"response"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
WebhookCalls []*WebhookCall
|
||||
)
|
||||
|
||||
func (w *WebhookCall) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO webhook_calls (
|
||||
id,
|
||||
tenant_id,
|
||||
webhook_event_id,
|
||||
webhook_configuration_id,
|
||||
endpoint_url,
|
||||
status,
|
||||
response,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@webhook_event_id,
|
||||
@webhook_configuration_id,
|
||||
@endpoint_url,
|
||||
@status,
|
||||
@response,
|
||||
@created_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": w.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"webhook_event_id": w.WebhookEventID,
|
||||
"webhook_configuration_id": w.WebhookConfigurationID,
|
||||
"endpoint_url": w.EndpointURL,
|
||||
"status": w.Status,
|
||||
"response": w.Response,
|
||||
"created_at": w.CreatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert webhook call: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
64
pkg/coredata/webhook_call_status.go
Normal file
64
pkg/coredata/webhook_call_status.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type WebhookCallStatus string
|
||||
|
||||
const (
|
||||
WebhookCallStatusSucceeded WebhookCallStatus = "SUCCEEDED"
|
||||
WebhookCallStatusFailed WebhookCallStatus = "FAILED"
|
||||
)
|
||||
|
||||
func (s WebhookCallStatus) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func (s WebhookCallStatus) IsValid() bool {
|
||||
switch s {
|
||||
case WebhookCallStatusSucceeded, WebhookCallStatusFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s WebhookCallStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *WebhookCallStatus) UnmarshalText(text []byte) error {
|
||||
*s = WebhookCallStatus(text)
|
||||
if !s.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid WebhookCallStatus", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebhookCallStatus) Scan(value any) error {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported type for WebhookCallStatus: %T", value)
|
||||
}
|
||||
|
||||
return s.UnmarshalText([]byte(str))
|
||||
}
|
||||
|
||||
func (s WebhookCallStatus) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
411
pkg/coredata/webhook_configuration.go
Normal file
411
pkg/coredata/webhook_configuration.go
Normal file
@@ -0,0 +1,411 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
WebhookConfiguration struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
EndpointURL string `db:"endpoint_url"`
|
||||
SelectedEvents WebhookEventTypes `db:"selected_events"`
|
||||
EncryptedSigningSecret []byte `db:"encrypted_signing_secret"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
WebhookConfigurations []*WebhookConfiguration
|
||||
)
|
||||
|
||||
func (w *WebhookConfiguration) GenerateSigningSecret(encryptionKey cipher.EncryptionKey) (string, error) {
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return "", fmt.Errorf("cannot generate signing secret: %w", err)
|
||||
}
|
||||
|
||||
signingSecret := "whsec_" + hex.EncodeToString(secret)
|
||||
|
||||
encrypted, err := cipher.Encrypt([]byte(signingSecret), encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot encrypt signing secret: %w", err)
|
||||
}
|
||||
|
||||
w.EncryptedSigningSecret = encrypted
|
||||
|
||||
return signingSecret, nil
|
||||
}
|
||||
|
||||
func (w *WebhookConfiguration) DecryptSigningSecret(encryptionKey cipher.EncryptionKey) (string, error) {
|
||||
if len(w.EncryptedSigningSecret) == 0 {
|
||||
return "", fmt.Errorf("no encrypted signing secret")
|
||||
}
|
||||
|
||||
plaintext, err := cipher.Decrypt(w.EncryptedSigningSecret, encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot decrypt signing secret: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func (w WebhookConfiguration) CursorKey(orderBy WebhookConfigurationOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case WebhookConfigurationOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(w.ID, w.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||
func (w *WebhookConfiguration) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id FROM webhook_configurations WHERE id = $1 LIMIT 1;`
|
||||
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, w.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query webhook configuration authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
|
||||
func (w *WebhookConfiguration) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
webhookConfigurationID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
endpoint_url,
|
||||
selected_events,
|
||||
encrypted_signing_secret,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
webhook_configurations
|
||||
WHERE
|
||||
%s
|
||||
AND id = @webhook_configuration_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"webhook_configuration_id": webhookConfigurationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query webhook configurations: %w", err)
|
||||
}
|
||||
|
||||
wc, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[WebhookConfiguration])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect webhook configuration: %w", err)
|
||||
}
|
||||
|
||||
*w = wc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookConfigurations) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[WebhookConfigurationOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
endpoint_url,
|
||||
selected_events,
|
||||
encrypted_signing_secret,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
webhook_configurations
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query webhook configurations: %w", err)
|
||||
}
|
||||
|
||||
configurations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[WebhookConfiguration])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect webhook configurations: %w", err)
|
||||
}
|
||||
|
||||
*w = configurations
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookConfigurations) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
webhook_configurations
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count webhook configurations: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (w *WebhookConfigurations) ExistsByOrganizationIDAndEventType(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
eventType WebhookEventType,
|
||||
) (bool, error) {
|
||||
q := `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM webhook_configurations
|
||||
WHERE %s
|
||||
AND organization_id = @organization_id
|
||||
AND @event_type = ANY(selected_events)
|
||||
)
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
"event_type": eventType.String(),
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var exists bool
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("cannot check webhook configuration existence: %w", err)
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (w *WebhookConfiguration) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
webhook_configurations (
|
||||
tenant_id,
|
||||
id,
|
||||
organization_id,
|
||||
endpoint_url,
|
||||
selected_events,
|
||||
encrypted_signing_secret,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@webhook_configuration_id,
|
||||
@organization_id,
|
||||
@endpoint_url,
|
||||
@selected_events,
|
||||
@encrypted_signing_secret,
|
||||
@created_at,
|
||||
@updated_at
|
||||
);
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"webhook_configuration_id": w.ID,
|
||||
"organization_id": w.OrganizationID,
|
||||
"endpoint_url": w.EndpointURL,
|
||||
"selected_events": w.SelectedEvents,
|
||||
"encrypted_signing_secret": w.EncryptedSigningSecret,
|
||||
"created_at": w.CreatedAt,
|
||||
"updated_at": w.UpdatedAt,
|
||||
}
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert webhook configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookConfiguration) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE webhook_configurations
|
||||
SET
|
||||
endpoint_url = @endpoint_url,
|
||||
selected_events = @selected_events,
|
||||
updated_at = @updated_at
|
||||
WHERE %s
|
||||
AND id = @webhook_configuration_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"webhook_configuration_id": w.ID,
|
||||
"endpoint_url": w.EndpointURL,
|
||||
"selected_events": w.SelectedEvents,
|
||||
"updated_at": w.UpdatedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update webhook configuration: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookConfigurations) LoadMatchingByOrganizationIDAndEventType(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
eventType WebhookEventType,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
endpoint_url,
|
||||
selected_events,
|
||||
encrypted_signing_secret,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
webhook_configurations
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND @event_type = ANY(selected_events)
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
"event_type": eventType.String(),
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query matching webhook configurations: %w", err)
|
||||
}
|
||||
|
||||
configurations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[WebhookConfiguration])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect matching webhook configurations: %w", err)
|
||||
}
|
||||
|
||||
*w = configurations
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookConfiguration) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM webhook_configurations
|
||||
WHERE %s
|
||||
AND id = @webhook_configuration_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"webhook_configuration_id": w.ID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete webhook configuration: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
55
pkg/coredata/webhook_configuration_order_field.go
Normal file
55
pkg/coredata/webhook_configuration_order_field.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 coredata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
WebhookConfigurationOrderField string
|
||||
)
|
||||
|
||||
const (
|
||||
WebhookConfigurationOrderFieldCreatedAt WebhookConfigurationOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (p WebhookConfigurationOrderField) Column() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p WebhookConfigurationOrderField) String() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p WebhookConfigurationOrderField) IsValid() bool {
|
||||
switch p {
|
||||
case WebhookConfigurationOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p WebhookConfigurationOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(p.String()), nil
|
||||
}
|
||||
|
||||
func (p *WebhookConfigurationOrderField) UnmarshalText(text []byte) error {
|
||||
*p = WebhookConfigurationOrderField(text)
|
||||
if !p.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid WebhookConfigurationOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
160
pkg/coredata/webhook_event.go
Normal file
160
pkg/coredata/webhook_event.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
WebhookEvent struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
EventType WebhookEventType `db:"event_type"`
|
||||
Status WebhookEventStatus `db:"status"`
|
||||
Data json.RawMessage `db:"data"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ProcessedAt *time.Time `db:"processed_at"`
|
||||
}
|
||||
|
||||
WebhookEvents []*WebhookEvent
|
||||
|
||||
)
|
||||
|
||||
func (w *WebhookEvent) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO webhook_events (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
event_type,
|
||||
status,
|
||||
data,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@event_type,
|
||||
@status,
|
||||
@data,
|
||||
@created_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": w.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": w.OrganizationID,
|
||||
"event_type": w.EventType,
|
||||
"status": w.Status,
|
||||
"data": w.Data,
|
||||
"created_at": w.CreatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert webhook event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookEvent) LoadNextPendingForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
event_type,
|
||||
status,
|
||||
data,
|
||||
created_at,
|
||||
processed_at
|
||||
FROM webhook_events
|
||||
WHERE status = 'PENDING'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query pending webhook events: %w", err)
|
||||
}
|
||||
|
||||
event, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[WebhookEvent])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect webhook event: %w", err)
|
||||
}
|
||||
|
||||
*w = event
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookEvent) UpdateStatus(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE webhook_events
|
||||
SET
|
||||
status = @status,
|
||||
processed_at = @processed_at
|
||||
WHERE %s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": w.ID,
|
||||
"status": w.Status.String(),
|
||||
"processed_at": w.ProcessedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update webhook event: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
65
pkg/coredata/webhook_event_status.go
Normal file
65
pkg/coredata/webhook_event_status.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type WebhookEventStatus string
|
||||
|
||||
const (
|
||||
WebhookEventStatusPending WebhookEventStatus = "PENDING"
|
||||
WebhookEventStatusProcessing WebhookEventStatus = "PROCESSING"
|
||||
WebhookEventStatusDelivered WebhookEventStatus = "DELIVERED"
|
||||
)
|
||||
|
||||
func (s WebhookEventStatus) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func (s WebhookEventStatus) IsValid() bool {
|
||||
switch s {
|
||||
case WebhookEventStatusPending, WebhookEventStatusProcessing, WebhookEventStatusDelivered:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s WebhookEventStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *WebhookEventStatus) UnmarshalText(text []byte) error {
|
||||
*s = WebhookEventStatus(text)
|
||||
if !s.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid WebhookEventStatus", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebhookEventStatus) Scan(value any) error {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported type for WebhookEventStatus: %T", value)
|
||||
}
|
||||
|
||||
return s.UnmarshalText([]byte(str))
|
||||
}
|
||||
|
||||
func (s WebhookEventStatus) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
133
pkg/coredata/webhook_event_type.go
Normal file
133
pkg/coredata/webhook_event_type.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type WebhookEventType string
|
||||
|
||||
const (
|
||||
WebhookEventTypeMeetingCreated WebhookEventType = "meeting:created"
|
||||
WebhookEventTypeMeetingUpdated WebhookEventType = "meeting:updated"
|
||||
WebhookEventTypeMeetingDeleted WebhookEventType = "meeting:deleted"
|
||||
WebhookEventTypeVendorCreated WebhookEventType = "vendor:created"
|
||||
WebhookEventTypeVendorUpdated WebhookEventType = "vendor:updated"
|
||||
WebhookEventTypeVendorDeleted WebhookEventType = "vendor:deleted"
|
||||
)
|
||||
|
||||
func (w WebhookEventType) String() string {
|
||||
return string(w)
|
||||
}
|
||||
|
||||
func (w WebhookEventType) IsValid() bool {
|
||||
switch w {
|
||||
case WebhookEventTypeMeetingCreated, WebhookEventTypeMeetingUpdated, WebhookEventTypeMeetingDeleted,
|
||||
WebhookEventTypeVendorCreated, WebhookEventTypeVendorUpdated, WebhookEventTypeVendorDeleted:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (w WebhookEventType) MarshalText() ([]byte, error) {
|
||||
return []byte(w.String()), nil
|
||||
}
|
||||
|
||||
func (w *WebhookEventType) UnmarshalText(text []byte) error {
|
||||
*w = WebhookEventType(text)
|
||||
if !w.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid WebhookEventType", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookEventType) Scan(value any) error {
|
||||
var s string
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
s = v
|
||||
case []byte:
|
||||
s = string(v)
|
||||
default:
|
||||
return fmt.Errorf("unsupported type for WebhookEventType: %T", value)
|
||||
}
|
||||
|
||||
return w.UnmarshalText([]byte(s))
|
||||
}
|
||||
|
||||
func (w WebhookEventType) Value() (driver.Value, error) {
|
||||
return w.String(), nil
|
||||
}
|
||||
|
||||
type WebhookEventTypes []WebhookEventType
|
||||
|
||||
func (s *WebhookEventTypes) Scan(value any) error {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return s.scanFromString(v)
|
||||
case []byte:
|
||||
return s.scanFromString(string(v))
|
||||
default:
|
||||
return fmt.Errorf("unsupported type for WebhookEventTypes: %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebhookEventTypes) scanFromString(str string) error {
|
||||
str = strings.TrimSpace(str)
|
||||
if str == "{}" || str == "" {
|
||||
*s = []WebhookEventType{}
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(str, "{") && strings.HasSuffix(str, "}") {
|
||||
str = str[1 : len(str)-1]
|
||||
}
|
||||
|
||||
parts := strings.Split(str, ",")
|
||||
result := make([]WebhookEventType, len(parts))
|
||||
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
|
||||
if strings.HasPrefix(part, `"`) && strings.HasSuffix(part, `"`) {
|
||||
part = part[1 : len(part)-1]
|
||||
}
|
||||
|
||||
var et WebhookEventType
|
||||
if err := et.Scan(part); err != nil {
|
||||
return fmt.Errorf("invalid webhook event type in array: %s", part)
|
||||
}
|
||||
result[i] = et
|
||||
}
|
||||
|
||||
*s = result
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s WebhookEventTypes) Value() (driver.Value, error) {
|
||||
if len(s) == 0 {
|
||||
return "{}", nil
|
||||
}
|
||||
|
||||
values := make([]string, len(s))
|
||||
for i, et := range s {
|
||||
values[i] = et.String()
|
||||
}
|
||||
|
||||
return "{" + strings.Join(values, ",") + "}", nil
|
||||
}
|
||||
Reference in New Issue
Block a user