Use ISO 639 for languages
[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 })
114 export class VideoCommentModel extends Model<VideoCommentModel> {
115   @CreatedAt
116   createdAt: Date
117
118   @UpdatedAt
119   updatedAt: Date
120
121   @AllowNull(false)
122   @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
123   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
124   url: string
125
126   @AllowNull(false)
127   @Column(DataType.TEXT)
128   text: string
129
130   @ForeignKey(() => VideoCommentModel)
131   @Column
132   originCommentId: number
133
134   @BelongsTo(() => VideoCommentModel, {
135     foreignKey: {
136       name: 'originCommentId',
137       allowNull: true
138     },
139     as: 'OriginVideoComment',
140     onDelete: 'CASCADE'
141   })
142   OriginVideoComment: VideoCommentModel
143
144   @ForeignKey(() => VideoCommentModel)
145   @Column
146   inReplyToCommentId: number
147
148   @BelongsTo(() => VideoCommentModel, {
149     foreignKey: {
150       name: 'inReplyToCommentId',
151       allowNull: true
152     },
153     as: 'InReplyToVideoComment',
154     onDelete: 'CASCADE'
155   })
156   InReplyToVideoComment: VideoCommentModel
157
158   @ForeignKey(() => VideoModel)
159   @Column
160   videoId: number
161
162   @BelongsTo(() => VideoModel, {
163     foreignKey: {
164       allowNull: false
165     },
166     onDelete: 'CASCADE'
167   })
168   Video: VideoModel
169
170   @ForeignKey(() => AccountModel)
171   @Column
172   accountId: number
173
174   @BelongsTo(() => AccountModel, {
175     foreignKey: {
176       allowNull: false
177     },
178     onDelete: 'CASCADE'
179   })
180   Account: AccountModel
181
182   @BeforeDestroy
183   static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
184     if (!instance.Account || !instance.Account.Actor) {
185       instance.Account = await instance.$get('Account', {
186         include: [ ActorModel ],
187         transaction: options.transaction
188       }) as AccountModel
189     }
190
191     if (!instance.Video) {
192       instance.Video = await instance.$get('Video', {
193         include: [
194           {
195             model: VideoChannelModel,
196             include: [
197               {
198                 model: AccountModel,
199                 include: [
200                   {
201                     model: ActorModel
202                   }
203                 ]
204               }
205             ]
206           }
207         ],
208         transaction: options.transaction
209       }) as VideoModel
210     }
211
212     if (instance.isOwned()) {
213       await sendDeleteVideoComment(instance, options.transaction)
214     }
215   }
216
217   static loadById (id: number, t?: Sequelize.Transaction) {
218     const query: IFindOptions<VideoCommentModel> = {
219       where: {
220         id
221       }
222     }
223
224     if (t !== undefined) query.transaction = t
225
226     return VideoCommentModel.findOne(query)
227   }
228
229   static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
230     const query: IFindOptions<VideoCommentModel> = {
231       where: {
232         id
233       }
234     }
235
236     if (t !== undefined) query.transaction = t
237
238     return VideoCommentModel
239       .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
240       .findOne(query)
241   }
242
243   static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
244     const query: IFindOptions<VideoCommentModel> = {
245       where: {
246         url
247       }
248     }
249
250     if (t !== undefined) query.transaction = t
251
252     return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
253   }
254
255   static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
256     const query: IFindOptions<VideoCommentModel> = {
257       where: {
258         url
259       }
260     }
261
262     if (t !== undefined) query.transaction = t
263
264     return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
265   }
266
267   static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
268     const query = {
269       offset: start,
270       limit: count,
271       order: getSort(sort),
272       where: {
273         videoId,
274         inReplyToCommentId: null
275       }
276     }
277
278     return VideoCommentModel
279       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
280       .findAndCountAll(query)
281       .then(({ rows, count }) => {
282         return { total: count, data: rows }
283       })
284   }
285
286   static listThreadCommentsForApi (videoId: number, threadId: number) {
287     const query = {
288       order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
289       where: {
290         videoId,
291         [ Sequelize.Op.or ]: [
292           { id: threadId },
293           { originCommentId: threadId }
294         ]
295       }
296     }
297
298     return VideoCommentModel
299       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
300       .findAndCountAll(query)
301       .then(({ rows, count }) => {
302         return { total: count, data: rows }
303       })
304   }
305
306   static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
307     const query = {
308       order: [ [ 'createdAt', order ] ],
309       where: {
310         id: {
311           [ Sequelize.Op.in ]: Sequelize.literal('(' +
312             'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
313             'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' +
314             'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' +
315             'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' +
316             'SELECT id FROM children' +
317           ')'),
318           [ Sequelize.Op.ne ]: comment.id
319         }
320       },
321       transaction: t
322     }
323
324     return VideoCommentModel
325       .scope([ ScopeNames.WITH_ACCOUNT ])
326       .findAll(query)
327   }
328
329   static async getStats () {
330     const totalLocalVideoComments = await VideoCommentModel.count({
331       include: [
332         {
333           model: AccountModel,
334           required: true,
335           include: [
336             {
337               model: ActorModel,
338               required: true,
339               where: {
340                 serverId: null
341               }
342             }
343           ]
344         }
345       ]
346     })
347     const totalVideoComments = await VideoCommentModel.count()
348
349     return {
350       totalLocalVideoComments,
351       totalVideoComments
352     }
353   }
354
355   getThreadId (): number {
356     return this.originCommentId || this.id
357   }
358
359   isOwned () {
360     return this.Account.isOwned()
361   }
362
363   toFormattedJSON () {
364     return {
365       id: this.id,
366       url: this.url,
367       text: this.text,
368       threadId: this.originCommentId || this.id,
369       inReplyToCommentId: this.inReplyToCommentId || null,
370       videoId: this.videoId,
371       createdAt: this.createdAt,
372       updatedAt: this.updatedAt,
373       totalReplies: this.get('totalReplies') || 0,
374       account: this.Account.toFormattedJSON()
375     } as VideoComment
376   }
377
378   toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
379     let inReplyTo: string
380     // New thread, so in AS we reply to the video
381     if (this.inReplyToCommentId === null) {
382       inReplyTo = this.Video.url
383     } else {
384       inReplyTo = this.InReplyToVideoComment.url
385     }
386
387     const tag: ActivityTagObject[] = []
388     for (const parentComment of threadParentComments) {
389       const actor = parentComment.Account.Actor
390
391       tag.push({
392         type: 'Mention',
393         href: actor.url,
394         name: `@${actor.preferredUsername}@${actor.getHost()}`
395       })
396     }
397
398     return {
399       type: 'Note' as 'Note',
400       id: this.url,
401       content: this.text,
402       inReplyTo,
403       updated: this.updatedAt.toISOString(),
404       published: this.createdAt.toISOString(),
405       url: this.url,
406       attributedTo: this.Account.Actor.url,
407       tag
408     }
409   }
410 }