d2d8945c3cd8eec776735dbe2ed7af364b02626a
[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 { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
7 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
8 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
9 import { CONSTRAINTS_FIELDS } from '../../initializers'
10 import { AccountModel } from '../account/account'
11 import { ActorModel } from '../activitypub/actor'
12 import { AvatarModel } from '../avatar/avatar'
13 import { ServerModel } from '../server/server'
14 import { getSort, throwIfNotValid } from '../utils'
15 import { VideoModel } from './video'
16
17 enum ScopeNames {
18   WITH_ACCOUNT = 'WITH_ACCOUNT',
19   WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
20   WITH_VIDEO = 'WITH_VIDEO',
21   ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
22 }
23
24 @Scopes({
25   [ScopeNames.ATTRIBUTES_FOR_API]: {
26     attributes: {
27       include: [
28         [
29           Sequelize.literal(
30             '(SELECT COUNT("replies"."id") ' +
31             'FROM "videoComment" AS "replies" ' +
32             'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
33           ),
34           'totalReplies'
35         ]
36       ]
37     }
38   },
39   [ScopeNames.WITH_ACCOUNT]: {
40     include: [
41       {
42         model: () => AccountModel,
43         include: [
44           {
45             model: () => ActorModel,
46             include: [
47               {
48                 model: () => ServerModel,
49                 required: false
50               },
51               {
52                 model: () => AvatarModel,
53                 required: false
54               }
55             ]
56           }
57         ]
58       }
59     ]
60   },
61   [ScopeNames.WITH_IN_REPLY_TO]: {
62     include: [
63       {
64         model: () => VideoCommentModel,
65         as: 'InReplyToVideoComment'
66       }
67     ]
68   },
69   [ScopeNames.WITH_VIDEO]: {
70     include: [
71       {
72         model: () => VideoModel,
73         required: false
74       }
75     ]
76   }
77 })
78 @Table({
79   tableName: 'videoComment',
80   indexes: [
81     {
82       fields: [ 'videoId' ]
83     },
84     {
85       fields: [ 'videoId', 'originCommentId' ]
86     }
87   ]
88 })
89 export class VideoCommentModel extends Model<VideoCommentModel> {
90   @CreatedAt
91   createdAt: Date
92
93   @UpdatedAt
94   updatedAt: Date
95
96   @AllowNull(false)
97   @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
98   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
99   url: string
100
101   @AllowNull(false)
102   @Column(DataType.TEXT)
103   text: string
104
105   @ForeignKey(() => VideoCommentModel)
106   @Column
107   originCommentId: number
108
109   @BelongsTo(() => VideoCommentModel, {
110     foreignKey: {
111       name: 'originCommentId',
112       allowNull: true
113     },
114     as: 'OriginVideoComment',
115     onDelete: 'CASCADE'
116   })
117   OriginVideoComment: VideoCommentModel
118
119   @ForeignKey(() => VideoCommentModel)
120   @Column
121   inReplyToCommentId: number
122
123   @BelongsTo(() => VideoCommentModel, {
124     foreignKey: {
125       name: 'inReplyToCommentId',
126       allowNull: true
127     },
128     as: 'InReplyToVideoComment',
129     onDelete: 'CASCADE'
130   })
131   InReplyToVideoComment: VideoCommentModel
132
133   @ForeignKey(() => VideoModel)
134   @Column
135   videoId: number
136
137   @BelongsTo(() => VideoModel, {
138     foreignKey: {
139       allowNull: false
140     },
141     onDelete: 'CASCADE'
142   })
143   Video: VideoModel
144
145   @ForeignKey(() => AccountModel)
146   @Column
147   accountId: number
148
149   @BelongsTo(() => AccountModel, {
150     foreignKey: {
151       allowNull: false
152     },
153     onDelete: 'CASCADE'
154   })
155   Account: AccountModel
156
157   @AfterDestroy
158   static sendDeleteIfOwned (instance: VideoCommentModel) {
159     // TODO
160     return undefined
161   }
162
163   static loadById (id: number, t?: Sequelize.Transaction) {
164     const query: IFindOptions<VideoCommentModel> = {
165       where: {
166         id
167       }
168     }
169
170     if (t !== undefined) query.transaction = t
171
172     return VideoCommentModel.findOne(query)
173   }
174
175   static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
176     const query: IFindOptions<VideoCommentModel> = {
177       where: {
178         id
179       }
180     }
181
182     if (t !== undefined) query.transaction = t
183
184     return VideoCommentModel
185       .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
186       .findOne(query)
187   }
188
189   static loadByUrl (url: string, t?: Sequelize.Transaction) {
190     const query: IFindOptions<VideoCommentModel> = {
191       where: {
192         url
193       }
194     }
195
196     if (t !== undefined) query.transaction = t
197
198     return VideoCommentModel.findOne(query)
199   }
200
201   static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
202     const query = {
203       offset: start,
204       limit: count,
205       order: [ getSort(sort) ],
206       where: {
207         videoId,
208         inReplyToCommentId: null
209       }
210     }
211
212     return VideoCommentModel
213       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
214       .findAndCountAll(query)
215       .then(({ rows, count }) => {
216         return { total: count, data: rows }
217       })
218   }
219
220   static listThreadCommentsForApi (videoId: number, threadId: number) {
221     const query = {
222       order: [ [ 'createdAt', 'ASC' ] ],
223       where: {
224         videoId,
225         [ Sequelize.Op.or ]: [
226           { id: threadId },
227           { originCommentId: threadId }
228         ]
229       }
230     }
231
232     return VideoCommentModel
233       .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
234       .findAndCountAll(query)
235       .then(({ rows, count }) => {
236         return { total: count, data: rows }
237       })
238   }
239
240   toFormattedJSON () {
241     return {
242       id: this.id,
243       url: this.url,
244       text: this.text,
245       threadId: this.originCommentId || this.id,
246       inReplyToCommentId: this.inReplyToCommentId || null,
247       videoId: this.videoId,
248       createdAt: this.createdAt,
249       updatedAt: this.updatedAt,
250       totalReplies: this.get('totalReplies') || 0,
251       account: this.Account.toFormattedJSON()
252     } as VideoComment
253   }
254
255   toActivityPubObject (): VideoCommentObject {
256     let inReplyTo: string
257     // New thread, so in AS we reply to the video
258     if (this.inReplyToCommentId === null) {
259       inReplyTo = this.Video.url
260     } else {
261       inReplyTo = this.InReplyToVideoComment.url
262     }
263
264     return {
265       type: 'Note' as 'Note',
266       id: this.url,
267       content: this.text,
268       inReplyTo,
269       updated: this.updatedAt.toISOString(),
270       published: this.createdAt.toISOString(),
271       url: this.url,
272       attributedTo: this.Account.Actor.url
273     }
274   }
275 }