Files
silo-server/internal/webhooksync/repo.go
T
Silo Server Migration 5f5e404d93 Rename webhook sync actors to profile mappings
- Rename actor-oriented API, storage, and webhook fields to user/profile terminology
- Switch dev compose helpers to use docker-compose.yml
- Update frontend types and webhook sync settings for the new endpoints
2026-05-24 00:04:25 -04:00

359 lines
13 KiB
Go

package webhooksync
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrConnectionNotFound = errors.New("webhook sync connection not found")
type Repository struct {
pool *pgxpool.Pool
}
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
func (r *Repository) ProfileExistsForUser(ctx context.Context, userID int, profileID string) (bool, error) {
var exists bool
if err := r.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM user_profiles WHERE user_id = $1 AND id = $2)`,
userID, profileID,
).Scan(&exists); err != nil {
return false, fmt.Errorf("checking profile ownership: %w", err)
}
return exists, nil
}
func (r *Repository) CreateConnection(ctx context.Context, conn Connection) (*Connection, error) {
row := r.pool.QueryRow(ctx, `
INSERT INTO webhook_sync_connections (
id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id, webhook_secret
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
webhook_secret, account_discovery_available,
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
created_at, updated_at`,
conn.ID, conn.UserID, conn.Provider, conn.ServerID, conn.ServerName, conn.BaseURL, conn.AccessToken, conn.DefaultProfileID, conn.WebhookSecret,
)
return scanConnection(row)
}
func (r *Repository) ListConnections(ctx context.Context, userID int) ([]Connection, error) {
rows, err := r.pool.Query(ctx, `
SELECT c.id, c.user_id, c.provider, c.server_id, c.server_name, c.base_url, c.access_token, c.default_profile_id,
c.webhook_secret, c.account_discovery_available,
c.last_webhook_received_at, c.last_webhook_error_at, COALESCE(c.last_webhook_error_message, ''),
c.created_at, c.updated_at,
COUNT(m.id)::integer AS user_count
FROM webhook_sync_connections c
LEFT JOIN webhook_sync_profile_mappings m ON m.connection_id = c.id
WHERE c.user_id = $1
GROUP BY c.id
ORDER BY c.created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("listing webhook sync connections: %w", err)
}
defer rows.Close()
var out []Connection
for rows.Next() {
var c Connection
if err := rows.Scan(
&c.ID, &c.UserID, &c.Provider, &c.ServerID, &c.ServerName, &c.BaseURL, &c.AccessToken, &c.DefaultProfileID,
&c.WebhookSecret, &c.AccountDiscoveryAvailable,
&c.LastWebhookReceivedAt, &c.LastWebhookErrorAt, &c.LastWebhookErrorMessage,
&c.CreatedAt, &c.UpdatedAt, &c.UserCount,
); err != nil {
return nil, fmt.Errorf("scanning webhook sync connection: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating webhook sync connections: %w", err)
}
return out, nil
}
func (r *Repository) GetConnection(ctx context.Context, userID int, id string) (*Connection, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
webhook_secret, account_discovery_available,
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
created_at, updated_at
FROM webhook_sync_connections
WHERE id = $1 AND user_id = $2`, id, userID)
return scanConnection(row)
}
func (r *Repository) GetConnectionBySecret(ctx context.Context, secret string) (*Connection, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
webhook_secret, account_discovery_available,
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
created_at, updated_at
FROM webhook_sync_connections
WHERE webhook_secret = $1`, secret)
return scanConnection(row)
}
func scanConnection(row pgx.Row) (*Connection, error) {
var c Connection
if err := row.Scan(
&c.ID, &c.UserID, &c.Provider, &c.ServerID, &c.ServerName, &c.BaseURL, &c.AccessToken, &c.DefaultProfileID,
&c.WebhookSecret, &c.AccountDiscoveryAvailable,
&c.LastWebhookReceivedAt, &c.LastWebhookErrorAt, &c.LastWebhookErrorMessage,
&c.CreatedAt, &c.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrConnectionNotFound
}
return nil, fmt.Errorf("scanning webhook sync connection: %w", err)
}
return &c, nil
}
func (r *Repository) UpdateConnection(ctx context.Context, userID int, id string, input UpdateConnectionInput) (*Connection, error) {
row := r.pool.QueryRow(ctx, `
UPDATE webhook_sync_connections
SET server_name = COALESCE($3, server_name),
default_profile_id = COALESCE($4, default_profile_id),
updated_at = NOW()
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
webhook_secret, account_discovery_available,
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
created_at, updated_at`,
id, userID, input.ServerName, input.DefaultProfileID,
)
return scanConnection(row)
}
func (r *Repository) DeleteConnection(ctx context.Context, userID int, id string) error {
result, err := r.pool.Exec(ctx, `DELETE FROM webhook_sync_connections WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("deleting webhook sync connection: %w", err)
}
if result.RowsAffected() == 0 {
return ErrConnectionNotFound
}
return nil
}
func (r *Repository) UpdateWebhookSecret(ctx context.Context, userID int, id, secret string) error {
result, err := r.pool.Exec(ctx, `
UPDATE webhook_sync_connections
SET webhook_secret = $3, updated_at = NOW()
WHERE id = $1 AND user_id = $2`, id, userID, secret)
if err != nil {
return fmt.Errorf("updating webhook secret: %w", err)
}
if result.RowsAffected() == 0 {
return ErrConnectionNotFound
}
return nil
}
func (r *Repository) SetDiscoveryAvailable(ctx context.Context, connectionID string, available bool) error {
_, err := r.pool.Exec(ctx, `
UPDATE webhook_sync_connections
SET account_discovery_available = $2, updated_at = NOW()
WHERE id = $1`, connectionID, available)
if err != nil {
return fmt.Errorf("updating discovery availability: %w", err)
}
return nil
}
func (r *Repository) ListMappings(ctx context.Context, connectionID string) ([]ProfileMapping, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at
FROM webhook_sync_profile_mappings
WHERE connection_id = $1
ORDER BY external_user_name ASC, id ASC`, connectionID)
if err != nil {
return nil, fmt.Errorf("listing webhook sync mappings: %w", err)
}
defer rows.Close()
var out []ProfileMapping
for rows.Next() {
var m ProfileMapping
if err := rows.Scan(
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scanning webhook sync mapping: %w", err)
}
out = append(out, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating webhook sync mappings: %w", err)
}
return out, nil
}
func (r *Repository) GetMappingByUser(ctx context.Context, connectionID, externalUserID string) (*ProfileMapping, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at
FROM webhook_sync_profile_mappings
WHERE connection_id = $1 AND external_user_id = $2`, connectionID, externalUserID)
var m ProfileMapping
if err := row.Scan(
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("getting webhook sync mapping: %w", err)
}
return &m, nil
}
func (r *Repository) ReplaceMappings(ctx context.Context, connectionID string, mappings []UpdateProfileMapping) ([]ProfileMapping, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin replace webhook mappings: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx, `DELETE FROM webhook_sync_profile_mappings WHERE connection_id = $1`, connectionID); err != nil {
return nil, fmt.Errorf("deleting webhook sync mappings: %w", err)
}
out := make([]ProfileMapping, 0, len(mappings))
for _, input := range mappings {
row := tx.QueryRow(ctx, `
INSERT INTO webhook_sync_profile_mappings (
connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at
) VALUES ($1, $2, $3, $4, NOW())
RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at`,
connectionID, input.ExternalUserID, input.ExternalUserName, input.SiloProfileID,
)
var m ProfileMapping
if err := row.Scan(
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("creating webhook sync mapping: %w", err)
}
out = append(out, m)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit replace webhook mappings: %w", err)
}
return out, nil
}
func (r *Repository) CreateDefaultMapping(ctx context.Context, connectionID, externalUserID, externalUserName, profileID string) (*ProfileMapping, error) {
row := r.pool.QueryRow(ctx, `
INSERT INTO webhook_sync_profile_mappings (
connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at
) VALUES ($1, $2, $3, $4, NOW())
RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at`,
connectionID, externalUserID, externalUserName, &profileID,
)
var m ProfileMapping
if err := row.Scan(
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("creating default webhook mapping: %w", err)
}
return &m, nil
}
func (r *Repository) UpsertSeenUser(ctx context.Context, connectionID, externalUserID, externalUserName string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO webhook_sync_profile_mappings (
connection_id, external_user_id, external_user_name, last_seen_at
) VALUES ($1, $2, $3, NOW())
ON CONFLICT (connection_id, external_user_id) DO UPDATE SET
external_user_name = EXCLUDED.external_user_name,
last_seen_at = NOW(),
updated_at = NOW()`,
connectionID, externalUserID, externalUserName,
)
if err != nil {
return fmt.Errorf("upserting seen external user: %w", err)
}
return nil
}
func (r *Repository) GetItemState(ctx context.Context, connectionID, externalUserID, externalItemID string) (*ItemState, error) {
row := r.pool.QueryRow(ctx, `
SELECT connection_id, external_user_id, external_item_id, COALESCE(media_item_id, ''),
last_event_at, last_completed, last_position_seconds, updated_at
FROM webhook_sync_item_state
WHERE connection_id = $1 AND external_user_id = $2 AND external_item_id = $3`,
connectionID, externalUserID, externalItemID,
)
var state ItemState
if err := row.Scan(
&state.ConnectionID, &state.ExternalUserID, &state.ExternalItemID, &state.MediaItemID,
&state.LastEventAt, &state.LastCompleted, &state.LastPositionSecond, &state.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("getting webhook item state: %w", err)
}
return &state, nil
}
func (r *Repository) UpsertItemState(ctx context.Context, state ItemState) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO webhook_sync_item_state (
connection_id, external_user_id, external_item_id, media_item_id,
last_event_at, last_completed, last_position_seconds, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (connection_id, external_user_id, external_item_id) DO UPDATE SET
media_item_id = EXCLUDED.media_item_id,
last_event_at = EXCLUDED.last_event_at,
last_completed = EXCLUDED.last_completed,
last_position_seconds = EXCLUDED.last_position_seconds,
updated_at = NOW()`,
state.ConnectionID, state.ExternalUserID, state.ExternalItemID, state.MediaItemID,
state.LastEventAt, state.LastCompleted, state.LastPositionSecond,
)
if err != nil {
return fmt.Errorf("upserting webhook item state: %w", err)
}
return nil
}
func (r *Repository) MarkWebhookReceived(ctx context.Context, connectionID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE webhook_sync_connections
SET last_webhook_received_at = NOW(),
last_webhook_error_at = NULL,
last_webhook_error_message = NULL,
updated_at = NOW()
WHERE id = $1`, connectionID)
if err != nil {
return fmt.Errorf("marking webhook receipt: %w", err)
}
return nil
}
func (r *Repository) MarkWebhookError(ctx context.Context, connectionID, message string) error {
_, err := r.pool.Exec(ctx, `
UPDATE webhook_sync_connections
SET last_webhook_error_at = NOW(), last_webhook_error_message = $2, updated_at = NOW()
WHERE id = $1`, connectionID, message)
if err != nil {
return fmt.Errorf("marking webhook error: %w", err)
}
return nil
}