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