- 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
359 lines
13 KiB
Go
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
|
|
}
|