Add ability to list all local videos
[oweals/peertube.git] / server / models / video / video-comment.ts
1 import * as Sequelize from 'sequelize'
2 import {
3   AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
4   UpdatedAt
5 } from 'sequelize-typescript'
6 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
7 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
8 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
9 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10 import { CONSTRAINTS_FIELDS } from '../../initializers'
11 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
12 import { AccountModel } from '../account/account'
13 import { ActorModel } from '../activitypub/actor'
14 import { AvatarModel } from '../avatar/avatar'
15 import { ServerModel } from '../server/server'
16 import { getSort, throwIfNotValid } from '../utils'
17 import { VideoModel } from './video'
18 import { VideoChannelModel } from './video-channel'
19
20 enum ScopeNames {
21   WITH_ACCOUNT = 'WITH_ACCOUNT',
22   WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
23   WITH_VIDEO = 'WITH_VIDEO',
24   ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
25 }
26
27 @Scopes({
28   [ScopeNames.ATTRIBUTES_FOR_API]: {
29     attributes: {
30       include: [
31         [
32           Sequelize.literal(
33             '(SELECT COUNT("replies"."id") ' +
34             'FROM "videoComment" AS "replies" ' +
35             'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
36           ),
37           'totalReplies'
38         ]
39       ]
40     }
41   },
42   [ScopeNames.WITH_ACCOUNT]: {
43     include: [
44       {
45         model: () => AccountModel,
46         include: [
47           {
48             model: () => ActorModel,
49             include: [
50               {
51                 model: () => ServerModel,
52                 required: false
53               },
54               {
55                 model: () => AvatarModel,
56                 required: false
57               }
58             ]
59           }
60         ]
61       }
62     ]
63   },
64   [ScopeNames.WITH_IN_REPLY_TO]: {
65     include: [
66       {
67         model: () => VideoCommentModel,
68         as: 'InReplyToVideoComment'
69       }
70     ]
71   },
72   [ScopeNames.WITH_VIDEO]: {
73     include: [
74       {
75         model: () => VideoModel,
76         required: true,
77         include: [
78           {
79             model: () => VideoChannelModel.unscoped(),
80             required: true,
81             include: [
82               {
83                 model: () => AccountModel,
84                 required: true,
85                 include: [
86                   {
87                     model: () => ActorModel,
88                     required: true
89                   }
90                 ]
91               }
92             ]
93           }
94         ]
95       }
96     ]
97   }
98 })
99 @Table({
100   tableName: 'videoComment',
101   indexes: [
102     {
103       fields: [ 'videoId' ]
104     },
105     {
106       fields: [ 'videoId', 'originCommentId' ]
107     },
108     {
109       fields: [ 'url' ],
110       unique: true
111     },
112     {
113       fields: [ 'accountId' ]
114     }
115   ]
116 })
117 export class VideoCommentModel extends Model<VideoCommentModel> {
118   @CreatedAt
119   createdAt: Date
120
121   @UpdatedAt
122   updatedAt: Date
123
124   @AllowNull(false)
125   @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
126   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
127   url: string
128
129   @AllowNull(false)
130   @Column(DataType.TEXT)
131   text: string
132
133   @ForeignKey(() => VideoCommentModel)
134   @Column
135   originCommentId: number
136
137   @BelongsTo(() => VideoCommentModel, {
138     foreignKey: {
139       name: 'originCommentId',
140       allowNull: true
141     },
142     as: 'OriginVideoComment',
143     onDelete: 'CASCADE'
144   })
145   OriginVideoComment: VideoCommentModel
146
147   @ForeignKey(() => VideoCommentModel)
148   @Column
149   inReplyToCommentId: number
150
151   @BelongsTo(() => VideoCommentModel, {
152     foreignKey: {
153       name: 'inReplyToCommentId',
154       allowNull: true
155     },
156     as: 'InReplyToVideoComment',
157     onDelete: 'CASCADE'
158   })
159   InReplyToVideoComment: VideoCommentModel | null
160
161   @ForeignKey(() => VideoModel)
162   @Column
163   videoId: number
164
165   @BelongsTo(() => VideoModel, {
166     foreignKey: {
167       allowNull: false
168     },
169     onDelete: 'CASCADE'
170   })
171   Video: VideoModel
172
173   @ForeignKey(() => AccountModel)
174   @Column
175   accountId: number
176
177   @BelongsTo(() => AccountModel, {
178     foreignKey: {
179       allowNull: false
180     },
181     onDelete: 'CASCADE'
182   })
183   Account: AccountModel
184
185   @BeforeDestroy
186   static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
187     if (!instance.Account || !instance.Account.Actor) {
188       instance.Account = await instance.$get('Account', {
189         include: [ ActorModel ],
190         transaction: options.transaction
191       }) as AccountModel
192     }
193
194     if (!instance.Video) {
195       instance.Video = await instance.$get('Video', {
196         include: [
197           {
198             model: VideoChannelModel,
199             include: [
200               {
201                 model: AccountModel,
202                 include: [
203                   {
204                     model: ActorModel
205                   }
206                 ]
207               }
208             ]
209           }
210         ],
211         transaction: options.transaction
212       }) as VideoModel
213     }
214
215     if (instance.isOwned()) {
216       await sendDeleteVideoComment(instance, options.transaction)
217     }
218   }
219
220   static loadById (id: number, t?: Sequelize.Transaction) {
221     const query: IFindOptions<VideoCommentModel> = {
222       where: {
223         id
224       }
225     }
226
227     if (t !== undefined) query.transaction = t
228
229     return VideoCommentModel.findOne(query)
230   }
231
232   static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
233     const query: IFindOptions<VideoCommentModel> = {
234       where: {
235         id
236       }
237     }
238
239     if (t !== undefined) query.transaction = t
240
241     return VideoCommentModel
242       .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
243       .findOne(query)
244   }
245
246   static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
247     const query: IFindOptions<VideoCommentModel> = {
248       where: {
249         url
250       }
251     }
252
253     if (t !== undefined) query.transaction = t
254
255     return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
256   }
257
258   static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
259     const query: IFindOptions<VideoCommentModel> = {
260       where: {
261         url
262       }
263     }
264
265     if (t !== undefined) query.transaction = t
266
267     return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
268   }
269
270   static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
271     const query = {
272       offset: start,
273       limit: count,
274       order: getSort(sort),
275       where: {
276         videoId,
277         inReplyToCommentId: null
278       }
279     }
280
281     return VideoCommentModel
282       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
283       .findAndCountAll(query)
284       .then(({ rows, count }) => {
285         return { total: count, data: rows }
286       })
287   }
288
289   static listThreadCommentsForApi (videoId: number, threadId: number) {
290     const query = {
291       order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
292       where: {
293         videoId,
294         [ Sequelize.Op.or ]: [
295           { id: threadId },
296           { originCommentId: threadId }
297         ]
298       }
299     }
300
301     return VideoCommentModel
302       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
303       .findAndCountAll(query)
304       .then(({ rows, count }) => {
305         return { total: count, data: rows }
306       })
307   }
308
309   static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
310     const query = {
311       order: [ [ 'createdAt', order ] ],
312       where: {
313         id: {
314           [ Sequelize.Op.in ]: Sequelize.literal('(' +
315             'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
316             'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' +
317             'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' +
318             'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' +
319             'SELECT id FROM children' +
320           ')'),
321           [ Sequelize.Op.ne ]: comment.id
322         }
323       },
324       transaction: t
325     }
326
327     return VideoCommentModel
328       .scope([ ScopeNames.WITH_ACCOUNT ])
329       .findAll(query)
330   }
331
332   static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
333     const query = {
334       order: [ [ 'createdAt', order ] ],
335       offset: start,
336       limit: count,
337       where: {
338         videoId
339       },
340       transaction: t
341     }
342
343     return VideoCommentModel.findAndCountAll(query)
344   }
345
346   static listForFeed (start: number, count: number, videoId?: number) {
347     const query = {
348       order: [ [ 'createdAt', 'DESC' ] ],
349       offset: start,
350       limit: count,
351       where: {},
352       include: [
353         {
354           attributes: [ 'name', 'uuid' ],
355           model: VideoModel.unscoped(),
356           required: true
357         }
358       ]
359     }
360
361     if (videoId) query.where['videoId'] = videoId
362
363     return VideoCommentModel
364       .scope([ ScopeNames.WITH_ACCOUNT ])
365       .findAll(query)
366   }
367
368   static async getStats () {
369     const totalLocalVideoComments = await VideoCommentModel.count({
370       include: [
371         {
372           model: AccountModel,
373           required: true,
374           include: [
375             {
376               model: ActorModel,
377               required: true,
378               where: {
379                 serverId: null
380               }
381             }
382           ]
383         }
384       ]
385     })
386     const totalVideoComments = await VideoCommentModel.count()
387
388     return {
389       totalLocalVideoComments,
390       totalVideoComments
391     }
392   }
393
394   getThreadId (): number {
395     return this.originCommentId || this.id
396   }
397
398   isOwned () {
399     return this.Account.isOwned()
400   }
401
402   toFormattedJSON () {
403     return {
404       id: this.id,
405       url: this.url,
406       text: this.text,
407       threadId: this.originCommentId || this.id,
408       inReplyToCommentId: this.inReplyToCommentId || null,
409       videoId: this.videoId,
410       createdAt: this.createdAt,
411       updatedAt: this.updatedAt,
412       totalReplies: this.get('totalReplies') || 0,
413       account: this.Account.toFormattedJSON()
414     } as VideoComment
415   }
416
417   toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
418     let inReplyTo: string
419     // New thread, so in AS we reply to the video
420     if (this.inReplyToCommentId === null) {
421       inReplyTo = this.Video.url
422     } else {
423       inReplyTo = this.InReplyToVideoComment.url
424     }
425
426     const tag: ActivityTagObject[] = []
427     for (const parentComment of threadParentComments) {
428       const actor = parentComment.Account.Actor
429
430       tag.push({
431         type: 'Mention',
432         href: actor.url,
433         name: `@${actor.preferredUsername}@${actor.getHost()}`
434       })
435     }
436
437     return {
438       type: 'Note' as 'Note',
439       id: this.url,
440       content: this.text,
441       inReplyTo,
442       updated: this.updatedAt.toISOString(),
443       published: this.createdAt.toISOString(),
444       url: this.url,
445       attributedTo: this.Account.Actor.url,
446       tag
447     }
448   }
449 }