···568568 unique (from_at, to_at)
569569 );
570570571571+ create table if not exists webhooks (
572572+ id integer primary key autoincrement,
573573+ repo_at text not null,
574574+ url text not null,
575575+ secret text not null,
576576+ active integer not null default 1,
577577+ events text not null, -- comma-separated list of events
578578+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
579579+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
580580+581581+ foreign key (repo_at) references repos(at_uri) on delete cascade
582582+ );
583583+584584+ create table if not exists webhook_deliveries (
585585+ id integer primary key autoincrement,
586586+ webhook_id integer not null,
587587+ event text not null,
588588+ delivery_id text not null,
589589+ url text not null,
590590+ request_body text not null,
591591+ response_code integer,
592592+ response_body text,
593593+ success integer not null default 0,
594594+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
595595+596596+ foreign key (webhook_id) references webhooks(id) on delete cascade
597597+ );
598598+571599 create table if not exists migrations (
572600 id integer primary key autoincrement,
573601 name text unique
···578606 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
579607 create index if not exists idx_references_from_at on reference_links(from_at);
580608 create index if not exists idx_references_to_at on reference_links(to_at);
609609+ create index if not exists idx_webhooks_repo_at on webhooks(repo_at);
610610+ create index if not exists idx_webhook_deliveries_webhook_id on webhook_deliveries(webhook_id);
611611+ create index if not exists idx_webhook_deliveries_created_at on webhook_deliveries(created_at desc);
581612 `)
582613 if err != nil {
583614 return nil, err
+294
appview/db/webhooks.go
···11+package db
22+33+import (
44+ "database/sql"
55+ "fmt"
66+ "strings"
77+ "time"
88+99+ "github.com/bluesky-social/indigo/atproto/syntax"
1010+ "tangled.org/core/appview/models"
1111+ "tangled.org/core/orm"
1212+)
1313+1414+// GetWebhooks returns all webhooks for a repository
1515+func GetWebhooks(e Execer, filters ...orm.Filter) ([]models.Webhook, error) {
1616+ var conditions []string
1717+ var args []any
1818+ for _, filter := range filters {
1919+ conditions = append(conditions, filter.Condition())
2020+ args = append(args, filter.Arg()...)
2121+ }
2222+2323+ whereClause := ""
2424+ if conditions != nil {
2525+ whereClause = " where " + strings.Join(conditions, " and ")
2626+ }
2727+2828+ query := fmt.Sprintf(`
2929+ select
3030+ id,
3131+ repo_at,
3232+ url,
3333+ secret,
3434+ active,
3535+ events,
3636+ created_at,
3737+ updated_at
3838+ from webhooks
3939+ %s
4040+ order by created_at desc
4141+ `, whereClause)
4242+4343+ rows, err := e.Query(query, args...)
4444+ if err != nil {
4545+ return nil, fmt.Errorf("failed to query webhooks: %w", err)
4646+ }
4747+ defer rows.Close()
4848+4949+ var webhooks []models.Webhook
5050+ for rows.Next() {
5151+ var wh models.Webhook
5252+ var createdAt, updatedAt, eventsStr string
5353+ var active int
5454+5555+ err := rows.Scan(
5656+ &wh.Id,
5757+ &wh.RepoAt,
5858+ &wh.Url,
5959+ &wh.Secret,
6060+ &active,
6161+ &eventsStr,
6262+ &createdAt,
6363+ &updatedAt,
6464+ )
6565+ if err != nil {
6666+ return nil, fmt.Errorf("failed to scan webhook: %w", err)
6767+ }
6868+6969+ wh.Active = active == 1
7070+ if eventsStr != "" {
7171+ wh.Events = strings.Split(eventsStr, ",")
7272+ }
7373+7474+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
7575+ wh.CreatedAt = t
7676+ }
7777+ if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
7878+ wh.UpdatedAt = t
7979+ }
8080+8181+ webhooks = append(webhooks, wh)
8282+ }
8383+8484+ if err = rows.Err(); err != nil {
8585+ return nil, fmt.Errorf("failed to iterate webhooks: %w", err)
8686+ }
8787+8888+ return webhooks, nil
8989+}
9090+9191+// GetWebhook returns a single webhook by ID
9292+func GetWebhook(e Execer, id int64) (*models.Webhook, error) {
9393+ webhooks, err := GetWebhooks(e, orm.FilterEq("id", id))
9494+ if err != nil {
9595+ return nil, err
9696+ }
9797+9898+ if len(webhooks) == 0 {
9999+ return nil, sql.ErrNoRows
100100+ }
101101+102102+ if len(webhooks) != 1 {
103103+ return nil, fmt.Errorf("expected 1 webhook, got %d", len(webhooks))
104104+ }
105105+106106+ return &webhooks[0], nil
107107+}
108108+109109+// AddWebhook creates a new webhook
110110+func AddWebhook(e Execer, webhook *models.Webhook) error {
111111+ eventsStr := strings.Join(webhook.Events, ",")
112112+ active := 0
113113+ if webhook.Active {
114114+ active = 1
115115+ }
116116+117117+ result, err := e.Exec(`
118118+ insert into webhooks (repo_at, url, secret, active, events)
119119+ values (?, ?, ?, ?, ?)
120120+ `, webhook.RepoAt.String(), webhook.Url, webhook.Secret, active, eventsStr)
121121+122122+ if err != nil {
123123+ return fmt.Errorf("failed to insert webhook: %w", err)
124124+ }
125125+126126+ id, err := result.LastInsertId()
127127+ if err != nil {
128128+ return fmt.Errorf("failed to get webhook id: %w", err)
129129+ }
130130+131131+ webhook.Id = id
132132+ return nil
133133+}
134134+135135+// UpdateWebhook updates an existing webhook
136136+func UpdateWebhook(e Execer, webhook *models.Webhook) error {
137137+ eventsStr := strings.Join(webhook.Events, ",")
138138+ active := 0
139139+ if webhook.Active {
140140+ active = 1
141141+ }
142142+143143+ _, err := e.Exec(`
144144+ update webhooks
145145+ set url = ?, secret = ?, active = ?, events = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
146146+ where id = ?
147147+ `, webhook.Url, webhook.Secret, active, eventsStr, webhook.Id)
148148+149149+ if err != nil {
150150+ return fmt.Errorf("failed to update webhook: %w", err)
151151+ }
152152+153153+ return nil
154154+}
155155+156156+// DeleteWebhook deletes a webhook
157157+func DeleteWebhook(e Execer, id int64) error {
158158+ _, err := e.Exec(`delete from webhooks where id = ?`, id)
159159+ if err != nil {
160160+ return fmt.Errorf("failed to delete webhook: %w", err)
161161+ }
162162+ return nil
163163+}
164164+165165+// AddWebhookDelivery records a webhook delivery attempt
166166+func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error {
167167+ success := 0
168168+ if delivery.Success {
169169+ success = 1
170170+ }
171171+172172+ result, err := e.Exec(`
173173+ insert into webhook_deliveries (
174174+ webhook_id,
175175+ event,
176176+ delivery_id,
177177+ url,
178178+ request_body,
179179+ response_code,
180180+ response_body,
181181+ success
182182+ ) values (?, ?, ?, ?, ?, ?, ?, ?)
183183+ `,
184184+ delivery.WebhookId,
185185+ delivery.Event,
186186+ delivery.DeliveryId,
187187+ delivery.Url,
188188+ delivery.RequestBody,
189189+ delivery.ResponseCode,
190190+ delivery.ResponseBody,
191191+ success,
192192+ )
193193+194194+ if err != nil {
195195+ return fmt.Errorf("failed to insert webhook delivery: %w", err)
196196+ }
197197+198198+ id, err := result.LastInsertId()
199199+ if err != nil {
200200+ return fmt.Errorf("failed to get delivery id: %w", err)
201201+ }
202202+203203+ delivery.Id = id
204204+ return nil
205205+}
206206+207207+// GetWebhookDeliveries returns recent deliveries for a webhook
208208+func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) {
209209+ if limit <= 0 {
210210+ limit = 20
211211+ }
212212+213213+ query := `
214214+ select
215215+ id,
216216+ webhook_id,
217217+ event,
218218+ delivery_id,
219219+ url,
220220+ request_body,
221221+ response_code,
222222+ response_body,
223223+ success,
224224+ created_at
225225+ from webhook_deliveries
226226+ where webhook_id = ?
227227+ order by created_at desc
228228+ limit ?
229229+ `
230230+231231+ rows, err := e.Query(query, webhookId, limit)
232232+ if err != nil {
233233+ return nil, fmt.Errorf("failed to query webhook deliveries: %w", err)
234234+ }
235235+ defer rows.Close()
236236+237237+ var deliveries []models.WebhookDelivery
238238+ for rows.Next() {
239239+ var d models.WebhookDelivery
240240+ var createdAt string
241241+ var success int
242242+ var responseCode sql.NullInt64
243243+ var responseBody sql.NullString
244244+245245+ err := rows.Scan(
246246+ &d.Id,
247247+ &d.WebhookId,
248248+ &d.Event,
249249+ &d.DeliveryId,
250250+ &d.Url,
251251+ &d.RequestBody,
252252+ &responseCode,
253253+ &responseBody,
254254+ &success,
255255+ &createdAt,
256256+ )
257257+ if err != nil {
258258+ return nil, fmt.Errorf("failed to scan delivery: %w", err)
259259+ }
260260+261261+ d.Success = success == 1
262262+ if responseCode.Valid {
263263+ d.ResponseCode = int(responseCode.Int64)
264264+ }
265265+ if responseBody.Valid {
266266+ d.ResponseBody = responseBody.String
267267+ }
268268+269269+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
270270+ d.CreatedAt = t
271271+ }
272272+273273+ deliveries = append(deliveries, d)
274274+ }
275275+276276+ if err = rows.Err(); err != nil {
277277+ return nil, fmt.Errorf("failed to iterate deliveries: %w", err)
278278+ }
279279+280280+ return deliveries, nil
281281+}
282282+283283+// GetWebhooksForRepo is a convenience function to get all webhooks for a repository
284284+func GetWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
285285+ return GetWebhooks(e, orm.FilterEq("repo_at", repoAt.String()))
286286+}
287287+288288+// GetActiveWebhooksForRepo returns only active webhooks for a repository
289289+func GetActiveWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
290290+ return GetWebhooks(e,
291291+ orm.FilterEq("repo_at", repoAt.String()),
292292+ orm.FilterEq("active", 1),
293293+ )
294294+}