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