Add webhooks

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-02-11 09:34:06 +01:00
parent 4945c022a1
commit f9de9c4834
34 changed files with 5932 additions and 4 deletions

View File

@@ -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
}

View 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
);

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}