Fix announces when fetching the actor outbox
[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.isOwned()) {
192       await sendDeleteVideoComment(instance, options.transaction)
193     }
194   }
195
196   static loadById (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.findOne(query)
206   }
207
208   static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
209     const query: IFindOptions<VideoCommentModel> = {
210       where: {
211         id
212       }
213     }
214
215     if (t !== undefined) query.transaction = t
216
217     return VideoCommentModel
218       .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
219       .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 loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
235     const query: IFindOptions<VideoCommentModel> = {
236       where: {
237         url
238       }
239     }
240
241     if (t !== undefined) query.transaction = t
242
243     return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
244   }
245
246   static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
247     const query = {
248       offset: start,
249       limit: count,
250       order: [ getSort(sort) ],
251       where: {
252         videoId,
253         inReplyToCommentId: null
254       }
255     }
256
257     return VideoCommentModel
258       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
259       .findAndCountAll(query)
260       .then(({ rows, count }) => {
261         return { total: count, data: rows }
262       })
263   }
264
265   static listThreadCommentsForApi (videoId: number, threadId: number) {
266     const query = {
267       order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
268       where: {
269         videoId,
270         [ Sequelize.Op.or ]: [
271           { id: threadId },
272           { originCommentId: threadId }
273         ]
274       }
275     }
276
277     return VideoCommentModel
278       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
279       .findAndCountAll(query)
280       .then(({ rows, count }) => {
281         return { total: count, data: rows }
282       })
283   }
284
285   static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
286     const query = {
287       order: [ [ 'createdAt', order ] ],
288       where: {
289         [ Sequelize.Op.or ]: [
290           { id: comment.getThreadId() },
291           { originCommentId: comment.getThreadId() }
292         ],
293         id: {
294           [ Sequelize.Op.ne ]: comment.id
295         },
296         createdAt: {
297           [ Sequelize.Op.lt ]: comment.createdAt
298         }
299       },
300       transaction: t
301     }
302
303     return VideoCommentModel
304       .scope([ ScopeNames.WITH_ACCOUNT ])
305       .findAll(query)
306   }
307
308   getThreadId (): number {
309     return this.originCommentId || this.id
310   }
311
312   isOwned () {
313     return this.Account.isOwned()
314   }
315
316   toFormattedJSON () {
317     return {
318       id: this.id,
319       url: this.url,
320       text: this.text,
321       threadId: this.originCommentId || this.id,
322       inReplyToCommentId: this.inReplyToCommentId || null,
323       videoId: this.videoId,
324       createdAt: this.createdAt,
325       updatedAt: this.updatedAt,
326       totalReplies: this.get('totalReplies') || 0,
327       account: this.Account.toFormattedJSON()
328     } as VideoComment
329   }
330
331   toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
332     let inReplyTo: string
333     // New thread, so in AS we reply to the video
334     if (this.inReplyToCommentId === null) {
335       inReplyTo = this.Video.url
336     } else {
337       inReplyTo = this.InReplyToVideoComment.url
338     }
339
340     const tag: ActivityTagObject[] = []
341     for (const parentComment of threadParentComments) {
342       const actor = parentComment.Account.Actor
343
344       tag.push({
345         type: 'Mention',
346         href: actor.url,
347         name: `@${actor.preferredUsername}@${actor.getHost()}`
348       })
349     }
350
351     return {
352       type: 'Note' as 'Note',
353       id: this.url,
354       content: this.text,
355       inReplyTo,
356       updated: this.updatedAt.toISOString(),
357       published: this.createdAt.toISOString(),
358       url: this.url,
359       attributedTo: this.Account.Actor.url,
360       tag
361     }
362   }
363 }