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