Monorepo for Tangled tangled.org

appview/{db,models}: webhook tables and crud ops

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

anirudh.fi d9d18448 e1783620

verified
+412
+30
appview/db/db.go
··· 568 unique (from_at, to_at) 569 ); 570 571 create table if not exists migrations ( 572 id integer primary key autoincrement, 573 name text unique ··· 578 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 579 create index if not exists idx_references_from_at on reference_links(from_at); 580 create index if not exists idx_references_to_at on reference_links(to_at); 581 `) 582 if err != nil { 583 return nil, err
··· 568 unique (from_at, to_at) 569 ); 570 571 + create table if not exists webhooks ( 572 + id integer primary key autoincrement, 573 + repo_at text not null, 574 + url text not null, 575 + secret text, 576 + active integer not null default 1, 577 + events text not null, -- comma-separated list of events 578 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 579 + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 580 + 581 + foreign key (repo_at) references repos(at_uri) on delete cascade 582 + ); 583 + 584 + create table if not exists webhook_deliveries ( 585 + id integer primary key autoincrement, 586 + webhook_id integer not null, 587 + event text not null, 588 + delivery_id text not null, 589 + url text not null, 590 + request_body text not null, 591 + response_code integer, 592 + response_body text, 593 + success integer not null default 0, 594 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 595 + 596 + foreign key (webhook_id) references webhooks(id) on delete cascade 597 + ); 598 + 599 create table if not exists migrations ( 600 id integer primary key autoincrement, 601 name text unique ··· 606 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 607 create index if not exists idx_references_from_at on reference_links(from_at); 608 create index if not exists idx_references_to_at on reference_links(to_at); 609 + create index if not exists idx_webhooks_repo_at on webhooks(repo_at); 610 + create index if not exists idx_webhook_deliveries_webhook_id on webhook_deliveries(webhook_id); 611 `) 612 if err != nil { 613 return nil, err
+308
appview/db/webhooks.go
···
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 12 + ) 13 + 14 + // GetWebhooks returns all webhooks for a repository 15 + func GetWebhooks(e Execer, filters ...orm.Filter) ([]models.Webhook, error) { 16 + var conditions []string 17 + var args []any 18 + for _, filter := range filters { 19 + conditions = append(conditions, filter.Condition()) 20 + args = append(args, filter.Arg()...) 21 + } 22 + 23 + whereClause := "" 24 + if conditions != nil { 25 + whereClause = " where " + strings.Join(conditions, " and ") 26 + } 27 + 28 + query := fmt.Sprintf(` 29 + select 30 + id, 31 + repo_at, 32 + url, 33 + secret, 34 + active, 35 + events, 36 + created_at, 37 + updated_at 38 + from webhooks 39 + %s 40 + order by created_at desc 41 + `, whereClause) 42 + 43 + rows, err := e.Query(query, args...) 44 + if err != nil { 45 + return nil, fmt.Errorf("failed to query webhooks: %w", err) 46 + } 47 + defer rows.Close() 48 + 49 + var webhooks []models.Webhook 50 + for rows.Next() { 51 + var wh models.Webhook 52 + var createdAt, updatedAt, eventsStr string 53 + var secret sql.NullString 54 + var active int 55 + 56 + err := rows.Scan( 57 + &wh.Id, 58 + &wh.RepoAt, 59 + &wh.Url, 60 + &secret, 61 + &active, 62 + &eventsStr, 63 + &createdAt, 64 + &updatedAt, 65 + ) 66 + if err != nil { 67 + return nil, fmt.Errorf("failed to scan webhook: %w", err) 68 + } 69 + 70 + if secret.Valid { 71 + wh.Secret = secret.String 72 + } 73 + wh.Active = active == 1 74 + if eventsStr != "" { 75 + wh.Events = strings.Split(eventsStr, ",") 76 + } 77 + 78 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 79 + wh.CreatedAt = t 80 + } 81 + if t, err := time.Parse(time.RFC3339, updatedAt); err == nil { 82 + wh.UpdatedAt = t 83 + } 84 + 85 + webhooks = append(webhooks, wh) 86 + } 87 + 88 + if err = rows.Err(); err != nil { 89 + return nil, fmt.Errorf("failed to iterate webhooks: %w", err) 90 + } 91 + 92 + return webhooks, nil 93 + } 94 + 95 + // GetWebhook returns a single webhook by ID 96 + func GetWebhook(e Execer, id int64) (*models.Webhook, error) { 97 + webhooks, err := GetWebhooks(e, orm.FilterEq("id", id)) 98 + if err != nil { 99 + return nil, err 100 + } 101 + 102 + if len(webhooks) == 0 { 103 + return nil, sql.ErrNoRows 104 + } 105 + 106 + if len(webhooks) != 1 { 107 + return nil, fmt.Errorf("expected 1 webhook, got %d", len(webhooks)) 108 + } 109 + 110 + return &webhooks[0], nil 111 + } 112 + 113 + // AddWebhook creates a new webhook 114 + func AddWebhook(e Execer, webhook *models.Webhook) error { 115 + eventsStr := strings.Join(webhook.Events, ",") 116 + active := 0 117 + if webhook.Active { 118 + active = 1 119 + } 120 + 121 + secret := sql.NullString{ 122 + String: webhook.Secret, 123 + Valid: webhook.Secret != "", 124 + } 125 + 126 + result, err := e.Exec(` 127 + insert into webhooks (repo_at, url, secret, active, events) 128 + values (?, ?, ?, ?, ?) 129 + `, webhook.RepoAt.String(), webhook.Url, secret, active, eventsStr) 130 + 131 + if err != nil { 132 + return fmt.Errorf("failed to insert webhook: %w", err) 133 + } 134 + 135 + id, err := result.LastInsertId() 136 + if err != nil { 137 + return fmt.Errorf("failed to get webhook id: %w", err) 138 + } 139 + 140 + webhook.Id = id 141 + return nil 142 + } 143 + 144 + // UpdateWebhook updates an existing webhook 145 + func UpdateWebhook(e Execer, webhook *models.Webhook) error { 146 + eventsStr := strings.Join(webhook.Events, ",") 147 + active := 0 148 + if webhook.Active { 149 + active = 1 150 + } 151 + 152 + secret := sql.NullString{ 153 + String: webhook.Secret, 154 + Valid: webhook.Secret != "", 155 + } 156 + 157 + _, err := e.Exec(` 158 + update webhooks 159 + set url = ?, secret = ?, active = ?, events = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 160 + where id = ? 161 + `, webhook.Url, secret, active, eventsStr, webhook.Id) 162 + 163 + if err != nil { 164 + return fmt.Errorf("failed to update webhook: %w", err) 165 + } 166 + 167 + return nil 168 + } 169 + 170 + // DeleteWebhook deletes a webhook 171 + func DeleteWebhook(e Execer, id int64) error { 172 + _, err := e.Exec(`delete from webhooks where id = ?`, id) 173 + if err != nil { 174 + return fmt.Errorf("failed to delete webhook: %w", err) 175 + } 176 + return nil 177 + } 178 + 179 + // AddWebhookDelivery records a webhook delivery attempt 180 + func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error { 181 + success := 0 182 + if delivery.Success { 183 + success = 1 184 + } 185 + 186 + result, err := e.Exec(` 187 + insert into webhook_deliveries ( 188 + webhook_id, 189 + event, 190 + delivery_id, 191 + url, 192 + request_body, 193 + response_code, 194 + response_body, 195 + success 196 + ) values (?, ?, ?, ?, ?, ?, ?, ?) 197 + `, 198 + delivery.WebhookId, 199 + delivery.Event, 200 + delivery.DeliveryId, 201 + delivery.Url, 202 + delivery.RequestBody, 203 + delivery.ResponseCode, 204 + delivery.ResponseBody, 205 + success, 206 + ) 207 + 208 + if err != nil { 209 + return fmt.Errorf("failed to insert webhook delivery: %w", err) 210 + } 211 + 212 + id, err := result.LastInsertId() 213 + if err != nil { 214 + return fmt.Errorf("failed to get delivery id: %w", err) 215 + } 216 + 217 + delivery.Id = id 218 + return nil 219 + } 220 + 221 + // GetWebhookDeliveries returns recent deliveries for a webhook 222 + func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) { 223 + if limit <= 0 { 224 + limit = 20 225 + } 226 + 227 + query := ` 228 + select 229 + id, 230 + webhook_id, 231 + event, 232 + delivery_id, 233 + url, 234 + request_body, 235 + response_code, 236 + response_body, 237 + success, 238 + created_at 239 + from webhook_deliveries 240 + where webhook_id = ? 241 + order by created_at desc 242 + limit ? 243 + ` 244 + 245 + rows, err := e.Query(query, webhookId, limit) 246 + if err != nil { 247 + return nil, fmt.Errorf("failed to query webhook deliveries: %w", err) 248 + } 249 + defer rows.Close() 250 + 251 + var deliveries []models.WebhookDelivery 252 + for rows.Next() { 253 + var d models.WebhookDelivery 254 + var createdAt string 255 + var success int 256 + var responseCode sql.NullInt64 257 + var responseBody sql.NullString 258 + 259 + err := rows.Scan( 260 + &d.Id, 261 + &d.WebhookId, 262 + &d.Event, 263 + &d.DeliveryId, 264 + &d.Url, 265 + &d.RequestBody, 266 + &responseCode, 267 + &responseBody, 268 + &success, 269 + &createdAt, 270 + ) 271 + if err != nil { 272 + return nil, fmt.Errorf("failed to scan delivery: %w", err) 273 + } 274 + 275 + d.Success = success == 1 276 + if responseCode.Valid { 277 + d.ResponseCode = int(responseCode.Int64) 278 + } 279 + if responseBody.Valid { 280 + d.ResponseBody = responseBody.String 281 + } 282 + 283 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 284 + d.CreatedAt = t 285 + } 286 + 287 + deliveries = append(deliveries, d) 288 + } 289 + 290 + if err = rows.Err(); err != nil { 291 + return nil, fmt.Errorf("failed to iterate deliveries: %w", err) 292 + } 293 + 294 + return deliveries, nil 295 + } 296 + 297 + // GetWebhooksForRepo is a convenience function to get all webhooks for a repository 298 + func GetWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) { 299 + return GetWebhooks(e, orm.FilterEq("repo_at", repoAt.String())) 300 + } 301 + 302 + // GetActiveWebhooksForRepo returns only active webhooks for a repository 303 + func GetActiveWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) { 304 + return GetWebhooks(e, 305 + orm.FilterEq("repo_at", repoAt.String()), 306 + orm.FilterEq("active", 1), 307 + ) 308 + }
+74
appview/models/webhook.go
···
··· 1 + package models 2 + 3 + import ( 4 + "slices" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type WebhookEvent string 11 + 12 + const ( 13 + WebhookEventPush WebhookEvent = "push" 14 + ) 15 + 16 + type Webhook struct { 17 + Id int64 18 + RepoAt syntax.ATURI 19 + Url string 20 + Secret string 21 + Active bool 22 + Events []string // comma-separated event types 23 + CreatedAt time.Time 24 + UpdatedAt time.Time 25 + } 26 + 27 + // HasEvent checks if the webhook is subscribed to a specific event 28 + func (w *Webhook) HasEvent(event WebhookEvent) bool { 29 + return slices.Contains(w.Events, string(event)) 30 + } 31 + 32 + type WebhookDelivery struct { 33 + Id int64 34 + WebhookId int64 35 + Event string 36 + DeliveryId string // UUID for tracking 37 + Url string 38 + RequestBody string 39 + ResponseCode int 40 + ResponseBody string 41 + Success bool 42 + CreatedAt time.Time 43 + } 44 + 45 + // WebhookPayload represents the webhook payload structure 46 + type WebhookPayload struct { 47 + Ref string `json:"ref"` 48 + Before string `json:"before"` 49 + After string `json:"after"` 50 + Repository WebhookRepository `json:"repository"` 51 + Pusher WebhookUser `json:"pusher"` 52 + } 53 + 54 + // WebhookRepository represents repository information in webhook payload 55 + type WebhookRepository struct { 56 + Name string `json:"name"` 57 + FullName string `json:"full_name"` 58 + Description string `json:"description"` 59 + Fork bool `json:"fork"` 60 + HtmlUrl string `json:"html_url"` 61 + CloneUrl string `json:"clone_url"` 62 + SshUrl string `json:"ssh_url"` 63 + Website string `json:"website,omitempty"` 64 + StarsCount int `json:"stars_count,omitempty"` 65 + OpenIssues int `json:"open_issues_count,omitempty"` 66 + CreatedAt string `json:"created_at"` 67 + UpdatedAt string `json:"updated_at"` 68 + Owner WebhookUser `json:"owner"` 69 + } 70 + 71 + // WebhookUser represents user information in webhook payload 72 + type WebhookUser struct { 73 + Did string `json:"did"` 74 + }