Adds replies to Yoten
+15
-6
internal/consumer/ingester.go
+15
-6
internal/consumer/ingester.go
···
607
607
return fmt.Errorf("failed to start transaction: %w", err)
608
608
}
609
609
610
-
// TODO: Parse reply
610
+
var parentCommentUri *syntax.ATURI = nil
611
+
reply := record.Reply
612
+
if reply != nil {
613
+
parentUri, err := syntax.ParseATURI(reply.Parent)
614
+
if err != nil {
615
+
return fmt.Errorf("failed to parse parent at-uri: %w", err)
616
+
}
617
+
parentCommentUri = &parentUri
618
+
}
611
619
612
620
comment := db.Comment{
613
-
Did: did,
614
-
Rkey: e.Commit.RKey,
615
-
StudySessionUri: subjectUri,
616
-
Body: body,
617
-
CreatedAt: createdAt,
621
+
Did: did,
622
+
Rkey: e.Commit.RKey,
623
+
StudySessionUri: subjectUri,
624
+
ParentCommentUri: parentCommentUri,
625
+
Body: body,
626
+
CreatedAt: createdAt,
618
627
}
619
628
620
629
log.Println("upserting comment from pds request")
+9
-1
internal/db/comment.go
+9
-1
internal/db/comment.go
···
104
104
err := e.QueryRow(`
105
105
select id, did, rkey, study_session_uri, parent_comment_uri, body, is_deleted, created_at
106
106
from comments
107
-
where did is ? and rkey = ?`,
107
+
where did = ? and rkey = ?`,
108
108
did, rkey,
109
109
).Scan(&comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr, &parentCommentUri, &comment.Body, &comment.IsDeleted, &createdAtStr)
110
110
if err != nil {
···
124
124
return Comment{}, fmt.Errorf("failed to parse study session at-uri: %w", err)
125
125
}
126
126
127
+
if parentCommentUri.Valid {
128
+
parsedParentUri, err := syntax.ParseATURI(parentCommentUri.String)
129
+
if err != nil {
130
+
return Comment{}, fmt.Errorf("failed to parse at-uri: %w", err)
131
+
}
132
+
comment.ParentCommentUri = &parsedParentUri
133
+
}
134
+
127
135
return comment, nil
128
136
}
129
137
+97
-38
internal/server/handlers/comment.go
+97
-38
internal/server/handlers/comment.go
···
65
65
return
66
66
}
67
67
68
+
var reply *yoten.FeedComment_Reply = nil
69
+
var parentCommentUri *string = nil
70
+
parentCommentUriStr := r.FormValue("parent_uri")
71
+
if len(parentCommentUriStr) != 0 {
72
+
parentCommentUri = &parentCommentUriStr
73
+
reply = &yoten.FeedComment_Reply{
74
+
Parent: parentCommentUriStr,
75
+
Root: studySessionUri,
76
+
}
77
+
}
78
+
68
79
newComment := db.Comment{
69
-
Rkey: atproto.TID(),
70
-
Did: user.Did,
71
-
StudySessionUri: syntax.ATURI(studySessionUri),
72
-
Body: commentBody,
73
-
CreatedAt: time.Now(),
80
+
Rkey: atproto.TID(),
81
+
Did: user.Did,
82
+
ParentCommentUri: (*syntax.ATURI)(parentCommentUri),
83
+
StudySessionUri: syntax.ATURI(studySessionUri),
84
+
Body: commentBody,
85
+
CreatedAt: time.Now(),
74
86
}
75
87
76
88
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
82
94
LexiconTypeID: yoten.FeedCommentNSID,
83
95
Body: newComment.Body,
84
96
Subject: newComment.StudySessionUri.String(),
97
+
Reply: reply,
85
98
CreatedAt: newComment.CreatedAt.Format(time.RFC3339),
86
99
},
87
100
},
···
112
125
}
113
126
}
114
127
115
-
partials.Comment(partials.CommentProps{
116
-
Comment: db.CommentFeedItem{
117
-
CommentWithBskyProfile: db.CommentWithBskyProfile{
128
+
if newComment.ParentCommentUri == nil {
129
+
partials.Comment(partials.CommentProps{
130
+
User: user,
131
+
Comment: db.CommentFeedItem{
132
+
CommentWithBskyProfile: db.CommentWithBskyProfile{
133
+
Comment: newComment,
134
+
ProfileLevel: profile.Level,
135
+
ProfileDisplayName: profile.DisplayName,
136
+
BskyProfile: user.BskyProfile,
137
+
},
138
+
Replies: []db.CommentWithBskyProfile{},
139
+
},
140
+
DoesOwn: true,
141
+
}).Render(r.Context(), w)
142
+
} else {
143
+
partials.Reply(partials.ReplyProps{
144
+
Reply: db.CommentWithBskyProfile{
118
145
Comment: newComment,
119
146
ProfileLevel: profile.Level,
120
147
ProfileDisplayName: profile.DisplayName,
121
148
BskyProfile: user.BskyProfile,
122
149
},
123
-
Replies: []db.CommentWithBskyProfile{},
124
-
},
125
-
DoesOwn: true,
126
-
}).Render(r.Context(), w)
150
+
DoesOwn: true,
151
+
}).Render(r.Context(), w)
152
+
}
127
153
}
128
154
129
155
func (h *Handler) HandleDeleteComment(w http.ResponseWriter, r *http.Request) {
···
224
250
return
225
251
}
226
252
227
-
profile, err := db.GetProfile(h.Db, user.Did)
228
-
if err != nil {
229
-
log.Println("failed to get logged-in user:", err)
230
-
htmx.HxRedirect(w, "/login")
231
-
return
232
-
}
233
-
234
253
err = r.ParseForm()
235
254
if err != nil {
236
255
log.Println("invalid comment form:", err)
···
246
265
}
247
266
248
267
updatedComment := db.Comment{
249
-
Rkey: comment.Rkey,
250
-
Did: comment.Did,
251
-
StudySessionUri: comment.StudySessionUri,
252
-
Body: commentBody,
253
-
CreatedAt: comment.CreatedAt,
268
+
Rkey: comment.Rkey,
269
+
Did: comment.Did,
270
+
StudySessionUri: comment.StudySessionUri,
271
+
ParentCommentUri: comment.ParentCommentUri,
272
+
Body: commentBody,
273
+
CreatedAt: comment.CreatedAt,
274
+
}
275
+
276
+
var reply *yoten.FeedComment_Reply = nil
277
+
if comment.ParentCommentUri != nil {
278
+
reply = &yoten.FeedComment_Reply{
279
+
Parent: comment.ParentCommentUri.String(),
280
+
Root: comment.StudySessionUri.String(),
281
+
}
254
282
}
255
283
256
284
ex, _ := client.RepoGetRecord(r.Context(), "", yoten.FeedCommentNSID, user.Did, updatedComment.Rkey)
···
268
296
LexiconTypeID: yoten.FeedCommentNSID,
269
297
Body: updatedComment.Body,
270
298
Subject: updatedComment.StudySessionUri.String(),
299
+
Reply: reply,
271
300
CreatedAt: updatedComment.CreatedAt.Format(time.RFC3339),
272
301
},
273
302
},
···
299
328
}
300
329
}
301
330
302
-
partials.Comment(partials.CommentProps{
303
-
Comment: db.CommentFeedItem{
304
-
CommentWithBskyProfile: db.CommentWithBskyProfile{
305
-
Comment: updatedComment,
306
-
ProfileLevel: profile.Level,
307
-
ProfileDisplayName: profile.DisplayName,
308
-
BskyProfile: user.BskyProfile,
309
-
},
310
-
// Replies are not needed to be populated as this response will
311
-
// replace just the edited comment.
312
-
Replies: []db.CommentWithBskyProfile{},
313
-
},
314
-
DoesOwn: true,
315
-
}).Render(r.Context(), w)
331
+
w.WriteHeader(http.StatusOK)
332
+
w.Write([]byte(updatedComment.Body))
316
333
}
317
334
}
318
335
···
368
385
369
386
return feed
370
387
}
388
+
389
+
func (h *Handler) HandleReply(w http.ResponseWriter, r *http.Request) {
390
+
user := h.Oauth.GetUser(r)
391
+
if user == nil {
392
+
log.Println("failed to get logged-in user")
393
+
htmx.HxRedirect(w, "/login")
394
+
return
395
+
}
396
+
397
+
studySessionUri := r.URL.Query().Get("root")
398
+
parentCommentUri := r.URL.Query().Get("parent")
399
+
if len(studySessionUri) == 0 || len(parentCommentUri) == 0 {
400
+
log.Println("invalid reply form: study session uri or parent comment uri is empty")
401
+
htmx.HxError(w, http.StatusBadRequest, "Unable to process comment, please try again later.")
402
+
return
403
+
}
404
+
405
+
partials.NewReply(partials.NewReplyProps{
406
+
StudySessionUri: studySessionUri,
407
+
ParentUri: parentCommentUri,
408
+
}).Render(r.Context(), w)
409
+
}
410
+
411
+
func (h *Handler) HandleCancelCommentEdit(w http.ResponseWriter, r *http.Request) {
412
+
user, err := bsky.GetUserWithBskyProfile(h.Oauth, r)
413
+
if err != nil {
414
+
log.Println("failed to get logged-in user:", err)
415
+
htmx.HxRedirect(w, "/login")
416
+
return
417
+
}
418
+
419
+
rkey := chi.URLParam(r, "rkey")
420
+
comment, err := db.GetCommentByRkey(h.Db, user.Did, rkey)
421
+
if err != nil {
422
+
log.Println("failed to get comment from db:", err)
423
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to update comment, try again later.")
424
+
return
425
+
}
426
+
427
+
w.WriteHeader(http.StatusOK)
428
+
w.Write([]byte(comment.Body))
429
+
}
+2
internal/server/handlers/router.go
+2
internal/server/handlers/router.go
···
88
88
89
89
r.Route("/comment", func(r chi.Router) {
90
90
r.Use(middleware.AuthMiddleware(h.Oauth))
91
+
r.Get("/cancel/{rkey}", h.HandleCancelCommentEdit)
92
+
r.Get("/reply", h.HandleReply)
91
93
r.Post("/new", h.HandleNewComment)
92
94
r.Get("/edit/{rkey}", h.HandleEditCommentPage)
93
95
r.Post("/edit/{rkey}", h.HandleEditCommentPage)
+23
-4
internal/server/handlers/study-session.go
+23
-4
internal/server/handlers/study-session.go
···
718
718
page = 1
719
719
}
720
720
721
-
const pageSize = 2
721
+
const pageSize = 10
722
722
offset := (page - 1) * pageSize
723
723
724
724
commentFeed, err := db.GetCommentsForSession(h.Db, studySessionUri.String(), pageSize+1, int(offset))
···
732
732
return !cwlp.IsDeleted
733
733
})
734
734
735
+
topLevelComments := utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool {
736
+
return cwlp.ParentCommentUri == nil
737
+
})
738
+
735
739
nextPage := 0
736
-
if len(commentFeed) > pageSize {
740
+
if len(topLevelComments) > pageSize {
737
741
nextPage = int(page + 1)
738
-
commentFeed = commentFeed[:pageSize]
742
+
topLevelComments = topLevelComments[:pageSize]
743
+
}
744
+
745
+
topLevelURIs := make(map[string]struct{})
746
+
for _, tlc := range topLevelComments {
747
+
topLevelURIs[tlc.CommentAt().String()] = struct{}{}
739
748
}
740
749
741
-
populatedCommentFeed, err := h.BuildCommentFeed(commentFeed)
750
+
finalFeed := utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool {
751
+
if cwlp.ParentCommentUri == nil {
752
+
_, ok := topLevelURIs[cwlp.CommentAt().String()]
753
+
return ok
754
+
} else {
755
+
_, ok := topLevelURIs[cwlp.ParentCommentUri.String()]
756
+
return ok
757
+
}
758
+
})
759
+
760
+
populatedCommentFeed, err := h.BuildCommentFeed(finalFeed)
742
761
if err != nil {
743
762
log.Println("failed to populate comment feed:", err)
744
763
htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.")
+21
-39
internal/server/views/partials/comment.templ
+21
-39
internal/server/views/partials/comment.templ
···
1
1
package partials
2
2
3
-
import (
4
-
"fmt"
5
-
"yoten.app/internal/db"
6
-
)
7
-
8
-
templ Reply(reply db.CommentWithBskyProfile) {
9
-
{{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", reply.Did, reply.Rkey)) }}
10
-
<div id={ replyId } class="flex flex-col gap-3 pl-4 py-2 border-l-2 border-gray-200">
11
-
<div class="flex items-center gap-3">
12
-
if reply.BskyProfile.Avatar == "" {
13
-
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary">
14
-
<i class="w-7 h-7" data-lucide="user"></i>
15
-
</div>
16
-
} else {
17
-
<img src={ reply.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/>
18
-
}
19
-
<div>
20
-
<div class="flex items-center gap-2">
21
-
<a href={ templ.URL(fmt.Sprintf("/@%s", reply.Did)) } class="font-semibold">
22
-
{ reply.ProfileDisplayName }
23
-
</a>
24
-
<p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex">
25
-
<i class="w-3.5 h-3.5" data-lucide="star"></i>
26
-
<span class="text-xs">{ reply.ProfileLevel }</span>
27
-
</p>
28
-
<span class="text-xs text-text-muted">{ reply.CreatedAt.Format("2006-01-02") }</span>
29
-
</div>
30
-
<p class="text-text-muted text-sm">@{ reply.BskyProfile.Handle }</p>
31
-
</div>
32
-
</div>
33
-
<p class="leading-relaxed">
34
-
{ reply.Body }
35
-
</p>
36
-
</div>
37
-
}
3
+
import "fmt"
38
4
39
5
templ Comment(params CommentProps) {
40
6
{{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }}
···
75
41
type="button"
76
42
id="edit-button"
77
43
hx-disabled-elt="#delete-button,#edit-button"
78
-
hx-target={ "#" + elementId }
79
-
hx-swap="outerHTML"
44
+
hx-target={ fmt.Sprintf("#comment-body-%s-%s", SanitiseHtmlId(params.Comment.Did), SanitiseHtmlId(params.Comment.Rkey)) }
45
+
hx-swap="innerHTML"
80
46
hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) }
81
47
>
82
48
<i class="w-4 h-4" data-lucide="square-pen"></i>
···
100
66
</details>
101
67
}
102
68
</div>
103
-
<p class="leading-relaxed break-words">
69
+
<p
70
+
id={ fmt.Sprintf("comment-body-%s-%s", SanitiseHtmlId(params.Comment.Did), SanitiseHtmlId(params.Comment.Rkey)) }
71
+
class="leading-relaxed break-words"
72
+
>
104
73
{ params.Comment.Body }
105
74
</p>
75
+
<button
76
+
hx-swap="afterend"
77
+
id="reply-button"
78
+
type="button"
79
+
hx-get={ templ.URL(fmt.Sprintf("/comment/reply?root=%s&parent=%s", params.Comment.StudySessionUri.String(), params.Comment.CommentAt().String())) }
80
+
class="btn text-xs text-text-muted self-start w-fit p-0"
81
+
>
82
+
Reply
83
+
</button>
106
84
<div class="flex flex-col mt-2">
107
85
for _, reply := range params.Comment.Replies {
108
-
@Reply(reply)
86
+
{{ isSelf := params.User != nil && params.User.Did == reply.Did }}
87
+
@Reply(ReplyProps{
88
+
Reply: reply,
89
+
DoesOwn: isSelf,
90
+
})
109
91
}
110
92
</div>
111
93
</div>
+8
-1
internal/server/views/partials/discussion.templ
+8
-1
internal/server/views/partials/discussion.templ
···
31
31
<div class="text-sm text-text-muted">
32
32
<span x-text="text.length"></span> / 256
33
33
</div>
34
-
<button type="submit" id="post-comment-button" class="btn btn-primary w-fit">
34
+
<button
35
+
if params.User == nil {
36
+
disabled
37
+
}
38
+
type="submit"
39
+
id="post-comment-button"
40
+
class="btn btn-primary w-fit"
41
+
>
35
42
Post Comment
36
43
</button>
37
44
</div>
+9
-4
internal/server/views/partials/edit-comment.templ
+9
-4
internal/server/views/partials/edit-comment.templ
···
3
3
import "fmt"
4
4
5
5
templ EditComment(params EditCommentProps) {
6
-
{{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }}
7
-
<div id={ elementId } class="flex flex-col gap-3" x-init="lucide.createIcons()">
6
+
<div class="flex flex-col gap-3" x-init="lucide.createIcons()">
8
7
<form
9
8
hx-post={ templ.SafeURL("/comment/edit/" + params.Comment.Rkey) }
10
-
hx-target={ "#" + elementId }
11
9
hx-swap="outerHTML"
12
10
hx-disabled-elt="#update-comment-button,#cancel-comment-button"
13
11
x-data="{ text: '' }"
···
33
31
<button type="submit" id="update-comment-button" class="btn btn-primary w-fit">
34
32
Update Comment
35
33
</button>
36
-
<button id="cancel-comment-button" class="btn btn-muted w-fit">
34
+
<button
35
+
hx-get={ templ.SafeURL("/comment/cancel/" + params.Comment.Rkey) }
36
+
hx-target={ fmt.Sprintf("#comment-body-%s-%s", SanitiseHtmlId(params.Comment.Did), SanitiseHtmlId(params.Comment.Rkey)) }
37
+
hx-swap="innerHTML"
38
+
type="button"
39
+
id="cancel-comment-button"
40
+
class="btn btn-muted w-fit"
41
+
>
37
42
Cancel
38
43
</button>
39
44
</div>
+14
internal/server/views/partials/partials.go
+14
internal/server/views/partials/partials.go
···
220
220
}
221
221
222
222
type DiscussionProps struct {
223
+
// The current logged in user
224
+
User *types.User
223
225
StudySessionUri string
224
226
StudySessionDid string
225
227
StudySessionRkey string
226
228
}
227
229
228
230
type CommentProps struct {
231
+
// The current logged in user
232
+
User *types.User
229
233
Comment db.CommentFeedItem
230
234
DoesOwn bool
231
235
}
···
242
246
StudySessionDid string
243
247
StudySessionRkey string
244
248
}
249
+
250
+
type NewReplyProps struct {
251
+
StudySessionUri string
252
+
ParentUri string
253
+
}
254
+
255
+
type ReplyProps struct {
256
+
Reply db.CommentWithBskyProfile
257
+
DoesOwn bool
258
+
}
+76
internal/server/views/partials/reply.templ
+76
internal/server/views/partials/reply.templ
···
1
+
package partials
2
+
3
+
import "fmt"
4
+
5
+
templ Reply(props ReplyProps) {
6
+
{{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", props.Reply.Did, props.Reply.Rkey)) }}
7
+
<div id={ replyId } class="flex flex-col gap-3 pl-4 py-2 border-l-2 border-gray-200" x-init="lucide.createIcons()">
8
+
<div class="flex items-center justify-between">
9
+
<div class="flex items-center gap-3">
10
+
if props.Reply.BskyProfile.Avatar == "" {
11
+
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary">
12
+
<i class="w-7 h-7" data-lucide="user"></i>
13
+
</div>
14
+
} else {
15
+
<img src={ props.Reply.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/>
16
+
}
17
+
<div>
18
+
<div class="flex items-center gap-2">
19
+
<a href={ templ.URL(fmt.Sprintf("/@%s", props.Reply.Did)) } class="font-semibold">
20
+
{ props.Reply.ProfileDisplayName }
21
+
</a>
22
+
<p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex">
23
+
<i class="w-3.5 h-3.5" data-lucide="star"></i>
24
+
<span class="text-xs">{ props.Reply.ProfileLevel }</span>
25
+
</p>
26
+
<span class="text-xs text-text-muted">{ props.Reply.CreatedAt.Format("2006-01-02") }</span>
27
+
</div>
28
+
<p class="text-text-muted text-sm">@{ props.Reply.BskyProfile.Handle }</p>
29
+
</div>
30
+
</div>
31
+
if props.DoesOwn {
32
+
<details class="relative inline-block text-left">
33
+
<summary class="cursor-pointer list-none">
34
+
<div class="btn btn-muted p-2">
35
+
<i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i>
36
+
</div>
37
+
</summary>
38
+
<div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark">
39
+
<button
40
+
class="btn hover:bg-bg group justify-start px-2"
41
+
type="button"
42
+
id="edit-button"
43
+
hx-disabled-elt="#delete-button,#edit-button"
44
+
hx-target={ fmt.Sprintf("#comment-body-%s-%s", SanitiseHtmlId(props.Reply.Did), SanitiseHtmlId(props.Reply.Rkey)) }
45
+
hx-swap="innerHTML"
46
+
hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", props.Reply.Rkey)) }
47
+
>
48
+
<i class="w-4 h-4" data-lucide="square-pen"></i>
49
+
<span class="text-sm">Edit</span>
50
+
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
51
+
</button>
52
+
<button
53
+
class="btn text-red-600 hover:bg-bg group justify-start px-2"
54
+
type="button"
55
+
id="delete-button"
56
+
hx-disabled-elt="#delete-button,#edit-button"
57
+
hx-target={ "#" + replyId }
58
+
hx-swap="outerHTML"
59
+
hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", props.Reply.Rkey)) }
60
+
>
61
+
<i class="w-4 h-4" data-lucide="trash-2"></i>
62
+
<span class="text-sm">Delete</span>
63
+
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
64
+
</button>
65
+
</div>
66
+
</details>
67
+
}
68
+
</div>
69
+
<p
70
+
id={ fmt.Sprintf("comment-body-%s-%s", SanitiseHtmlId(props.Reply.Did), SanitiseHtmlId(props.Reply.Rkey)) }
71
+
class="leading-relaxed break-words"
72
+
>
73
+
{ props.Reply.Body }
74
+
</p>
75
+
</div>
76
+
}
+1
internal/server/views/study-session.templ
+1
internal/server/views/study-session.templ
+26
migrations/update_notification_type.sql
+26
migrations/update_notification_type.sql
···
1
+
-- This script should be used and updated whenever a new notification type
2
+
-- constraint needs to be added.
3
+
4
+
BEGIN TRANSACTION;
5
+
6
+
ALTER TABLE notifications RENAME TO notifications_old;
7
+
8
+
CREATE TABLE notifications (
9
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+
recipient_did TEXT NOT NULL,
11
+
actor_did TEXT NOT NULL,
12
+
subject_uri TEXT NOT NULL,
13
+
state TEXT NOT NULL DEFAULT 'unread' CHECK(state IN ('unread', 'read')),
14
+
type TEXT NOT NULL CHECK(type IN ('follow', 'reaction', 'comment')),
15
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
16
+
FOREIGN KEY (recipient_did) REFERENCES profiles(did) ON DELETE CASCADE,
17
+
FOREIGN KEY (actor_did) REFERENCES profiles(did) ON DELETE CASCADE
18
+
);
19
+
20
+
INSERT INTO notifications (id, recipient_did, actor_did, subject_uri, state, type, created_at)
21
+
SELECT id, recipient_did, actor_did, subject_uri, state, type, created_at
22
+
FROM notifications_old;
23
+
24
+
DROP TABLE notifications_old;
25
+
26
+
COMMIT;