Add ability to delete comments
[oweals/peertube.git] / server / models / video / video-comment.ts
1 import * as Sequelize from 'sequelize'
2 import {
3   AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
4   UpdatedAt
5 } from 'sequelize-typescript'
6 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
7 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
8 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
9 import { CONSTRAINTS_FIELDS } from '../../initializers'
10 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
11 import { AccountModel } from '../account/account'
12 import { ActorModel } from '../activitypub/actor'
13 import { AvatarModel } from '../avatar/avatar'
14 import { ServerModel } from '../server/server'
15 import { getSort, throwIfNotValid } from '../utils'
16 import { VideoModel } from './video'
17 import { VideoChannelModel } from './video-channel'
18
19 enum ScopeNames {
20   WITH_ACCOUNT = 'WITH_ACCOUNT',
21   WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
22   WITH_VIDEO = 'WITH_VIDEO',
23   ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
24 }
25
26 @Scopes({
27   [ScopeNames.ATTRIBUTES_FOR_API]: {
28     attributes: {
29       include: [
30         [
31           Sequelize.literal(
32             '(SELECT COUNT("replies"."id") ' +
33             'FROM "videoComment" AS "replies" ' +
34             'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
35           ),
36           'totalReplies'
37         ]
38       ]
39     }
40   },
41   [ScopeNames.WITH_ACCOUNT]: {
42     include: [
43       {
44         model: () => AccountModel,
45         include: [
46           {
47             model: () => ActorModel,
48             include: [
49               {
50                 model: () => ServerModel,
51                 required: false
52               },
53               {
54                 model: () => AvatarModel,
55                 required: false
56               }
57             ]
58           }
59         ]
60       }
61     ]
62   },
63   [ScopeNames.WITH_IN_REPLY_TO]: {
64     include: [
65       {
66         model: () => VideoCommentModel,
67         as: 'InReplyToVideoComment'
68       }
69     ]
70   },
71   [ScopeNames.WITH_VIDEO]: {
72     include: [
73       {
74         model: () => VideoModel,
75         required: true,
76         include: [
77           {
78             model: () => VideoChannelModel.unscoped(),
79             required: true,
80             include: [
81               {
82                 model: () => AccountModel,
83                 required: true,
84                 include: [
85                   {
86                     model: () => ActorModel,
87                     required: true
88                   }
89                 ]
90               }
91             ]
92           }
93         ]
94       }
95     ]
96   }
97 })
98 @Table({
99   tableName: 'videoComment',
100   indexes: [
101     {
102       fields: [ 'videoId' ]
103     },
104     {
105       fields: [ 'videoId', 'originCommentId' ]
106     }
107   ]
108 })
109 export class VideoCommentModel extends Model<VideoCommentModel> {
110   @CreatedAt
111   createdAt: Date
112
113   @UpdatedAt
114   updatedAt: Date
115
116   @AllowNull(false)
117   @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
118   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
119   url: string
120
121   @AllowNull(false)
122   @Column(DataType.TEXT)
123   text: string
124
125   @ForeignKey(() => VideoCommentModel)
126   @Column
127   originCommentId: number
128
129   @BelongsTo(() => VideoCommentModel, {
130     foreignKey: {
131       name: 'originCommentId',
132       allowNull: true
133     },
134     as: 'OriginVideoComment',
135     onDelete: 'CASCADE'
136   })
137   OriginVideoComment: VideoCommentModel
138
139   @ForeignKey(() => VideoCommentModel)
140   @Column
141   inReplyToCommentId: number
142
143   @BelongsTo(() => VideoCommentModel, {
144     foreignKey: {
145       name: 'inReplyToCommentId',
146       allowNull: true
147     },
148     as: 'InReplyToVideoComment',
149     onDelete: 'CASCADE'
150   })
151   InReplyToVideoComment: VideoCommentModel
152
153   @ForeignKey(() => VideoModel)
154   @Column
155   videoId: number
156
157   @BelongsTo(() => VideoModel, {
158     foreignKey: {
159       allowNull: false
160     },
161     onDelete: 'CASCADE'
162   })
163   Video: VideoModel
164
165   @ForeignKey(() => AccountModel)
166   @Column
167   accountId: number
168
169   @BelongsTo(() => AccountModel, {
170     foreignKey: {
171       allowNull: false
172     },
173     onDelete: 'CASCADE'
174   })
175   Account: AccountModel
176
177   @AfterDestroy
178   static async sendDeleteIfOwned (instance: VideoCommentModel) {
179     if (instance.isOwned()) {
180       await sendDeleteVideoComment(instance, undefined)
181     }
182   }
183
184   static loadById (id: number, t?: Sequelize.Transaction) {
185     const query: IFindOptions<VideoCommentModel> = {
186       where: {
187         id
188       }
189     }
190
191     if (t !== undefined) query.transaction = t
192
193     return VideoCommentModel.findOne(query)
194   }
195
196   static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
197     const query: IFindOptions<VideoCommentModel> = {
198       where: {
199         id
200       }
201     }
202
203     if (t !== undefined) query.transaction = t
204
205     return VideoCommentModel
206       .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
207       .findOne(query)
208   }
209
210   static loadByUrl (url: string, t?: Sequelize.Transaction) {
211     const query: IFindOptions<VideoCommentModel> = {
212       where: {
213         url
214       }
215     }
216
217     if (t !== undefined) query.transaction = t
218
219     return VideoCommentModel.findOne(query)
220   }
221
222   static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
223     const query: IFindOptions<VideoCommentModel> = {
224       where: {
225         url
226       }
227     }
228
229     if (t !== undefined) query.transaction = t
230
231     return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
232   }
233
234   static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
235     const query = {
236       offset: start,
237       limit: count,
238       order: [ getSort(sort) ],
239       where: {
240         videoId,
241         inReplyToCommentId: null
242       }
243     }
244
245     return VideoCommentModel
246       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
247       .findAndCountAll(query)
248       .then(({ rows, count }) => {
249         return { total: count, data: rows }
250       })
251   }
252
253   static listThreadCommentsForApi (videoId: number, threadId: number) {
254     const query = {
255       order: [ [ 'createdAt', 'ASC' ] ],
256       where: {
257         videoId,
258         [ Sequelize.Op.or ]: [
259           { id: threadId },
260           { originCommentId: threadId }
261         ]
262       }
263     }
264
265     return VideoCommentModel
266       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
267       .findAndCountAll(query)
268       .then(({ rows, count }) => {
269         return { total: count, data: rows }
270       })
271   }
272
273   isOwned () {
274     return this.Account.isOwned()
275   }
276
277   toFormattedJSON () {
278     return {
279       id: this.id,
280       url: this.url,
281       text: this.text,
282       threadId: this.originCommentId || this.id,
283       inReplyToCommentId: this.inReplyToCommentId || null,
284       videoId: this.videoId,
285       createdAt: this.createdAt,
286       updatedAt: this.updatedAt,
287       totalReplies: this.get('totalReplies') || 0,
288       account: this.Account.toFormattedJSON()
289     } as VideoComment
290   }
291
292   toActivityPubObject (): VideoCommentObject {
293     let inReplyTo: string
294     // New thread, so in AS we reply to the video
295     if (this.inReplyToCommentId === null) {
296       inReplyTo = this.Video.url
297     } else {
298       inReplyTo = this.InReplyToVideoComment.url
299     }
300
301     return {
302       type: 'Note' as 'Note',
303       id: this.url,
304       content: this.text,
305       inReplyTo,
306       updated: this.updatedAt.toISOString(),
307       published: this.createdAt.toISOString(),
308       url: this.url,
309       attributedTo: this.Account.Actor.url
310     }
311   }
312 }