···9292 primary key (user_did, subject_did),
9393 check (user_did <> subject_did)
9494 );
9595+ create table if not exists vouches (
9696+ did text not null,
9797+ subject_did text not null,
9898+ cid text not null,
9999+ kind text not null default 'vouch',
100100+ reason text,
101101+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
102102+ primary key (did, subject_did),
103103+ check (did <> subject_did),
104104+ check (kind in ('vouch', 'denounce'))
105105+ );
95106 create table if not exists issues (
96107 id integer primary key autoincrement,
97108 owner_did text not null,
+360
appview/db/vouch.go
···11+package db
22+33+import (
44+ "database/sql"
55+ "fmt"
66+ "log"
77+ "strings"
88+ "time"
99+1010+ "github.com/bluesky-social/indigo/atproto/syntax"
1111+ "github.com/ipfs/go-cid"
1212+ "tangled.org/core/appview/models"
1313+ "tangled.org/core/appview/pagination"
1414+ "tangled.org/core/orm"
1515+)
1616+1717+func AddVouch(e Execer, vouch *models.Vouch) error {
1818+ query := `insert or replace into vouches (did, subject_did, cid, kind, reason) values (?, ?, ?, ?, ?)`
1919+ _, err := e.Exec(query, vouch.Did, vouch.SubjectDid, vouch.Cid.String(), vouch.Kind, vouch.Reason)
2020+ return err
2121+}
2222+2323+func GetVouch(e Execer, did, subjectDid string) (*models.Vouch, error) {
2424+ vouches, err := GetVouches(e, pagination.Page{Limit: 1},
2525+ orm.FilterEq("did", did),
2626+ orm.FilterEq("subject_did", subjectDid),
2727+ )
2828+ if err != nil {
2929+ return nil, err
3030+ }
3131+ if len(vouches) == 0 {
3232+ return nil, sql.ErrNoRows
3333+ }
3434+ return &vouches[0], nil
3535+}
3636+3737+func GetVouches(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Vouch, error) {
3838+ var conditions []string
3939+ var args []any
4040+ for _, filter := range filters {
4141+ conditions = append(conditions, filter.Condition())
4242+ args = append(args, filter.Arg()...)
4343+ }
4444+4545+ whereClause := ""
4646+ if len(conditions) > 0 {
4747+ whereClause = "where " + strings.Join(conditions, " and ")
4848+ }
4949+5050+ pageClause := ""
5151+ if page.Limit > 0 {
5252+ pageClause = fmt.Sprintf("limit %d offset %d", page.Limit, page.Offset)
5353+ }
5454+5555+ query := fmt.Sprintf(
5656+ `select did, subject_did, cid, kind, reason, created_at
5757+ from vouches
5858+ %s
5959+ order by created_at desc
6060+ %s`,
6161+ whereClause, pageClause)
6262+6363+ rows, err := e.Query(query, args...)
6464+ if err != nil {
6565+ return nil, err
6666+ }
6767+ defer rows.Close()
6868+6969+ var vouches []models.Vouch
7070+ for rows.Next() {
7171+ var v models.Vouch
7272+ var cidStr string
7373+ var createdAt string
7474+ var reason sql.NullString
7575+7676+ if err := rows.Scan(&v.Did, &v.SubjectDid, &cidStr, &v.Kind, &reason, &createdAt); err != nil {
7777+ log.Println("error scanning vouch:", err)
7878+ continue
7979+ }
8080+8181+ v.Cid, err = cid.Parse(cidStr)
8282+ if err != nil {
8383+ log.Println("unable to parse CID:", err)
8484+ continue
8585+ }
8686+8787+ t, err := time.Parse(time.RFC3339, createdAt)
8888+ if err != nil {
8989+ log.Println("unable to determine created at time")
9090+ v.CreatedAt = time.Now()
9191+ } else {
9292+ v.CreatedAt = t
9393+ }
9494+9595+ if reason.Valid {
9696+ v.Reason = &reason.String
9797+ }
9898+9999+ vouches = append(vouches, v)
100100+ }
101101+ return vouches, nil
102102+}
103103+104104+func DeleteVouch(e Execer, did, subjectDid string) error {
105105+ _, err := e.Exec(`delete from vouches where did = ? and subject_did = ?`, did, subjectDid)
106106+ return err
107107+}
108108+109109+func DeleteVouchByRkey(e Execer, did, rkey string) error {
110110+ _, err := e.Exec(`delete from vouches where did = ? and subject_did = ?`, did, rkey)
111111+ return err
112112+}
113113+114114+func GetNetworkVouchTimeline(e Execer, viewerDid, profileDid string, page pagination.Page) ([]models.Vouch, error) {
115115+ pageClause := ""
116116+ if page.Limit > 0 {
117117+ pageClause = fmt.Sprintf("limit %d offset %d", page.Limit, page.Offset)
118118+ }
119119+120120+ query := fmt.Sprintf(
121121+ `select did, subject_did, cid, kind, reason, created_at
122122+ from vouches
123123+ where (
124124+ subject_did = ? and did in (select subject_did from vouches where did = ? and kind = 'vouch')
125125+ ) or (
126126+ did = ? and subject_did in (select subject_did from vouches where did = ? and kind = 'vouch')
127127+ )
128128+ order by created_at desc
129129+ %s`,
130130+ pageClause)
131131+132132+ rows, err := e.Query(query, profileDid, viewerDid, profileDid, viewerDid)
133133+ if err != nil {
134134+ return nil, err
135135+ }
136136+ defer rows.Close()
137137+138138+ var vouches []models.Vouch
139139+ for rows.Next() {
140140+ var v models.Vouch
141141+ var cidStr string
142142+ var createdAt string
143143+ var reason sql.NullString
144144+145145+ if err := rows.Scan(&v.Did, &v.SubjectDid, &cidStr, &v.Kind, &reason, &createdAt); err != nil {
146146+ log.Println("error scanning vouch:", err)
147147+ continue
148148+ }
149149+150150+ v.Cid, err = cid.Parse(cidStr)
151151+ if err != nil {
152152+ log.Println("unable to parse CID:", err)
153153+ continue
154154+ }
155155+156156+ t, err := time.Parse(time.RFC3339, createdAt)
157157+ if err != nil {
158158+ log.Println("unable to determine created at time")
159159+ v.CreatedAt = time.Now()
160160+ } else {
161161+ v.CreatedAt = t
162162+ }
163163+164164+ if reason.Valid {
165165+ v.Reason = &reason.String
166166+ }
167167+168168+ vouches = append(vouches, v)
169169+ }
170170+ return vouches, nil
171171+}
172172+173173+func GetVouchRelationshipsBatch(e Execer, viewerDid syntax.DID, subjectDids []syntax.DID) (map[syntax.DID]*models.VouchRelationship, error) {
174174+ if viewerDid == "" {
175175+ return nil, fmt.Errorf("viewerDid cannot be empty")
176176+ }
177177+178178+ result := make(map[syntax.DID]*models.VouchRelationship)
179179+ for _, subjectDid := range subjectDids {
180180+ result[subjectDid] = &models.VouchRelationship{
181181+ ViewerDid: viewerDid,
182182+ SubjectDid: subjectDid,
183183+ NetworkVouches: []models.Vouch{},
184184+ }
185185+ }
186186+187187+ if len(subjectDids) == 0 {
188188+ return result, nil
189189+ }
190190+191191+ directVouches, err := GetVouches(e, pagination.Page{},
192192+ orm.FilterEq("did", viewerDid),
193193+ orm.FilterIn("subject_did", subjectDids),
194194+ )
195195+ if err != nil {
196196+ return nil, err
197197+ }
198198+ for _, v := range directVouches {
199199+ if rel, ok := result[v.SubjectDid]; ok {
200200+ rel.NetworkVouches = append(rel.NetworkVouches, v)
201201+ }
202202+ }
203203+204204+ networkVouches, err := GetVouches(e, pagination.Page{},
205205+ orm.FilterEq("did", viewerDid),
206206+ orm.FilterEq("kind", string(models.VouchKindVouch)),
207207+ )
208208+ if err != nil {
209209+ return nil, err
210210+ }
211211+212212+ network := make([]syntax.DID, 0, len(networkVouches))
213213+ for _, v := range networkVouches {
214214+ network = append(network, v.SubjectDid)
215215+ }
216216+217217+ if len(network) > 0 {
218218+ networkToSubject, err := GetVouches(e, pagination.Page{},
219219+ orm.FilterIn("subject_did", subjectDids),
220220+ orm.FilterIn("did", network),
221221+ )
222222+ if err != nil {
223223+ return nil, err
224224+ }
225225+ for _, v := range networkToSubject {
226226+ if rel, ok := result[v.SubjectDid]; ok {
227227+ rel.NetworkVouches = append(rel.NetworkVouches, v)
228228+ }
229229+ }
230230+ }
231231+232232+ return result, nil
233233+}
234234+235235+func GetVouchRelationship(e Execer, viewerDid, subjectDid syntax.DID) (*models.VouchRelationship, error) {
236236+ batch, err := GetVouchRelationshipsBatch(e, viewerDid, []syntax.DID{subjectDid})
237237+ if err != nil {
238238+ return nil, err
239239+ }
240240+ return batch[subjectDid], nil
241241+}
242242+243243+// priority:
244244+// 1. collaborator invites sent
245245+// 2. knot member invites sent
246246+// 3. PR authors on FOO's repositories
247247+// 4. issue authors on FOO's repositories
248248+// 5. PR comment authors on FOO's repositories
249249+// 6. issue comment authors on FOO's repositories
250250+// 7. users FOO recently followed
251251+// 8. owners of repositories FOO recently starred
252252+func GetVouchSuggestions(e Execer, did string, limit int) ([]models.VouchSuggestion, error) {
253253+ query := `
254254+ select did, reason from (
255255+ select subject_did as did, 1 as priority, created,
256256+ 'You invited this user to collaborate on a repository' as reason
257257+ from collaborators
258258+ where collaborators.did = ?
259259+ and subject_did != ?
260260+261261+ union all
262262+263263+ select subject as did, 2 as priority, created,
264264+ 'You invited this user to your knot' as reason
265265+ from spindle_members
266266+ where spindle_members.did = ?
267267+ and subject != ?
268268+269269+ union all
270270+271271+ select p.owner_did as did, 3 as priority, p.created,
272272+ 'This user opened a pull request on your repository' as reason
273273+ from pulls p
274274+ join repos r on r.at_uri = p.repo_at
275275+ where r.did = ?
276276+ and p.owner_did != ?
277277+278278+ union all
279279+280280+ select i.did as did, 4 as priority, i.created,
281281+ 'This user opened an issue on your repository' as reason
282282+ from issues i
283283+ join repos r on r.at_uri = i.repo_at
284284+ where r.did = ?
285285+ and i.did != ?
286286+287287+ union all
288288+289289+ select pc.owner_did as did, 5 as priority, pc.created,
290290+ 'This user commented on a pull request on your repository' as reason
291291+ from pull_comments pc
292292+ join repos r on r.at_uri = pc.repo_at
293293+ where r.did = ?
294294+ and pc.owner_did != ?
295295+296296+ union all
297297+298298+ select ic.did as did, 6 as priority, ic.created,
299299+ 'This user commented on an issue on your repository' as reason
300300+ from issue_comments ic
301301+ join issues i on i.at_uri = ic.issue_at
302302+ join repos r on r.at_uri = i.repo_at
303303+ where r.did = ?
304304+ and ic.did != ?
305305+306306+ union all
307307+308308+ select f.subject_did as did, 7 as priority, f.followed_at as created,
309309+ 'You recently followed this user' as reason
310310+ from follows f
311311+ where f.user_did = ?
312312+ and f.subject_did != ?
313313+314314+ union all
315315+316316+ select r.did as did, 8 as priority, s.created,
317317+ 'You recently starred a repository by this user' as reason
318318+ from stars s
319319+ join repos r on r.at_uri = s.subject_at
320320+ where s.did = ?
321321+ and r.did != ?
322322+ )
323323+ where did not in (
324324+ select subject_did from vouches where vouches.did = ?
325325+ )
326326+ group by did
327327+ order by min(priority) asc, max(created) desc
328328+ limit ?
329329+ `
330330+331331+ args := []any{
332332+ did, did, // collaborators
333333+ did, did, // spindle_members
334334+ did, did, // pulls
335335+ did, did, // issues
336336+ did, did, // pull_comments
337337+ did, did, // issue_comments
338338+ did, did, // follows
339339+ did, did, // stars
340340+ did, // vouches exclusion
341341+ limit,
342342+ }
343343+344344+ rows, err := e.Query(query, args...)
345345+ if err != nil {
346346+ return nil, fmt.Errorf("GetVouchSuggestions: %w", err)
347347+ }
348348+ defer rows.Close()
349349+350350+ var suggestions []models.VouchSuggestion
351351+ for rows.Next() {
352352+ var s models.VouchSuggestion
353353+ if err := rows.Scan(&s.Did, &s.Reason); err != nil {
354354+ log.Println("error scanning vouch suggestion:", err)
355355+ continue
356356+ }
357357+ suggestions = append(suggestions, s)
358358+ }
359359+ return suggestions, nil
360360+}