]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video-comment.ts
Fix last commit
[github/Chocobozzz/PeerTube.git] / server / models / video / video-comment.ts
CommitLineData
6d852470
C
1import * as Sequelize from 'sequelize'
2import {
f05a1c30 3 AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
6d852470
C
4 UpdatedAt
5} from 'sequelize-typescript'
d7e70384 6import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
ea44f375 7import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
bf1f6508 8import { VideoComment } from '../../../shared/models/videos/video-comment.model'
da854ddd 9import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
6d852470 10import { CONSTRAINTS_FIELDS } from '../../initializers'
4cb6d457 11import { sendDeleteVideoComment } from '../../lib/activitypub/send'
d3ea8975 12import { AccountModel } from '../account/account'
4635f59d 13import { ActorModel } from '../activitypub/actor'
cf117aaa 14import { AvatarModel } from '../avatar/avatar'
4635f59d 15import { ServerModel } from '../server/server'
bf1f6508 16import { getSort, throwIfNotValid } from '../utils'
6d852470 17import { VideoModel } from './video'
4cb6d457 18import { VideoChannelModel } from './video-channel'
6d852470 19
bf1f6508 20enum ScopeNames {
ea44f375 21 WITH_ACCOUNT = 'WITH_ACCOUNT',
4635f59d 22 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
da854ddd 23 WITH_VIDEO = 'WITH_VIDEO',
4635f59d 24 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
bf1f6508
C
25}
26
27@Scopes({
4635f59d
C
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 },
d3ea8975 42 [ScopeNames.WITH_ACCOUNT]: {
bf1f6508 43 include: [
4635f59d
C
44 {
45 model: () => AccountModel,
46 include: [
47 {
48 model: () => ActorModel,
49 include: [
50 {
51 model: () => ServerModel,
52 required: false
cf117aaa
C
53 },
54 {
55 model: () => AvatarModel,
56 required: false
4635f59d
C
57 }
58 ]
59 }
60 ]
61 }
bf1f6508 62 ]
ea44f375
C
63 },
64 [ScopeNames.WITH_IN_REPLY_TO]: {
65 include: [
66 {
67 model: () => VideoCommentModel,
da854ddd
C
68 as: 'InReplyToVideoComment'
69 }
70 ]
71 },
72 [ScopeNames.WITH_VIDEO]: {
73 include: [
74 {
75 model: () => VideoModel,
4cb6d457
C
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 ]
ea44f375
C
95 }
96 ]
bf1f6508
C
97 }
98})
6d852470
C
99@Table({
100 tableName: 'videoComment',
101 indexes: [
102 {
103 fields: [ 'videoId' ]
bf1f6508
C
104 },
105 {
106 fields: [ 'videoId', 'originCommentId' ]
0776d83f
C
107 },
108 {
109 fields: [ 'url' ],
110 unique: true
8cd72bd3
C
111 },
112 {
113 fields: [ 'accountId' ]
6d852470
C
114 }
115 ]
116})
117export class VideoCommentModel extends Model<VideoCommentModel> {
118 @CreatedAt
119 createdAt: Date
120
121 @UpdatedAt
122 updatedAt: Date
123
124 @AllowNull(false)
125 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
126 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
127 url: string
128
129 @AllowNull(false)
130 @Column(DataType.TEXT)
131 text: string
132
133 @ForeignKey(() => VideoCommentModel)
134 @Column
135 originCommentId: number
136
137 @BelongsTo(() => VideoCommentModel, {
138 foreignKey: {
db799da3 139 name: 'originCommentId',
6d852470
C
140 allowNull: true
141 },
db799da3 142 as: 'OriginVideoComment',
6d852470
C
143 onDelete: 'CASCADE'
144 })
145 OriginVideoComment: VideoCommentModel
146
147 @ForeignKey(() => VideoCommentModel)
148 @Column
149 inReplyToCommentId: number
150
151 @BelongsTo(() => VideoCommentModel, {
152 foreignKey: {
db799da3 153 name: 'inReplyToCommentId',
6d852470
C
154 allowNull: true
155 },
da854ddd 156 as: 'InReplyToVideoComment',
6d852470
C
157 onDelete: 'CASCADE'
158 })
c1e791ba 159 InReplyToVideoComment: VideoCommentModel | null
6d852470
C
160
161 @ForeignKey(() => VideoModel)
162 @Column
163 videoId: number
164
165 @BelongsTo(() => VideoModel, {
166 foreignKey: {
167 allowNull: false
168 },
169 onDelete: 'CASCADE'
170 })
171 Video: VideoModel
172
d3ea8975 173 @ForeignKey(() => AccountModel)
6d852470 174 @Column
d3ea8975 175 accountId: number
6d852470 176
d3ea8975 177 @BelongsTo(() => AccountModel, {
6d852470
C
178 foreignKey: {
179 allowNull: false
180 },
181 onDelete: 'CASCADE'
182 })
d3ea8975 183 Account: AccountModel
6d852470 184
f05a1c30
C
185 @BeforeDestroy
186 static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
187 if (!instance.Account || !instance.Account.Actor) {
188 instance.Account = await instance.$get('Account', {
189 include: [ ActorModel ],
190 transaction: options.transaction
191 }) as AccountModel
192 }
193
29c6b829
C
194 if (!instance.Video) {
195 instance.Video = await instance.$get('Video', {
196 include: [
197 {
198 model: VideoChannelModel,
199 include: [
200 {
201 model: AccountModel,
202 include: [
203 {
204 model: ActorModel
205 }
206 ]
207 }
208 ]
209 }
210 ],
211 transaction: options.transaction
212 }) as VideoModel
213 }
214
4cb6d457 215 if (instance.isOwned()) {
f05a1c30 216 await sendDeleteVideoComment(instance, options.transaction)
4cb6d457 217 }
bf1f6508
C
218 }
219
220 static loadById (id: number, t?: Sequelize.Transaction) {
221 const query: IFindOptions<VideoCommentModel> = {
222 where: {
223 id
224 }
225 }
226
227 if (t !== undefined) query.transaction = t
228
229 return VideoCommentModel.findOne(query)
230 }
231
da854ddd
C
232 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
233 const query: IFindOptions<VideoCommentModel> = {
234 where: {
235 id
236 }
237 }
238
239 if (t !== undefined) query.transaction = t
240
241 return VideoCommentModel
242 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
243 .findOne(query)
244 }
245
2ccaeeb3 246 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
6d852470
C
247 const query: IFindOptions<VideoCommentModel> = {
248 where: {
249 url
250 }
251 }
252
253 if (t !== undefined) query.transaction = t
254
2ccaeeb3 255 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
6d852470 256 }
bf1f6508 257
2ccaeeb3 258 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
4cb6d457
C
259 const query: IFindOptions<VideoCommentModel> = {
260 where: {
261 url
262 }
263 }
264
265 if (t !== undefined) query.transaction = t
266
2ccaeeb3 267 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
4cb6d457
C
268 }
269
bf1f6508
C
270 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
271 const query = {
272 offset: start,
273 limit: count,
3bb6c526 274 order: getSort(sort),
bf1f6508 275 where: {
d3ea8975
C
276 videoId,
277 inReplyToCommentId: null
bf1f6508
C
278 }
279 }
280
281 return VideoCommentModel
4635f59d 282 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
bf1f6508
C
283 .findAndCountAll(query)
284 .then(({ rows, count }) => {
285 return { total: count, data: rows }
286 })
287 }
288
289 static listThreadCommentsForApi (videoId: number, threadId: number) {
290 const query = {
a3fd560d 291 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
bf1f6508
C
292 where: {
293 videoId,
294 [ Sequelize.Op.or ]: [
295 { id: threadId },
296 { originCommentId: threadId }
297 ]
298 }
299 }
300
301 return VideoCommentModel
4635f59d 302 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
bf1f6508
C
303 .findAndCountAll(query)
304 .then(({ rows, count }) => {
305 return { total: count, data: rows }
306 })
307 }
308
2ccaeeb3 309 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
d7e70384 310 const query = {
2ccaeeb3 311 order: [ [ 'createdAt', order ] ],
d7e70384 312 where: {
d7e70384 313 id: {
a3cffab4
C
314 [ Sequelize.Op.in ]: Sequelize.literal('(' +
315 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
316 'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' +
317 'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' +
318 'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' +
319 'SELECT id FROM children' +
320 ')'),
d7e70384
C
321 [ Sequelize.Op.ne ]: comment.id
322 }
323 },
324 transaction: t
325 }
326
327 return VideoCommentModel
328 .scope([ ScopeNames.WITH_ACCOUNT ])
329 .findAll(query)
330 }
331
8fffe21a
C
332 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
333 const query = {
334 order: [ [ 'createdAt', order ] ],
9a4a9b6c
C
335 offset: start,
336 limit: count,
8fffe21a
C
337 where: {
338 videoId
339 },
340 transaction: t
341 }
342
343 return VideoCommentModel.findAndCountAll(query)
344 }
345
fe3a55b0
C
346 static listForFeed (start: number, count: number, videoId?: number) {
347 const query = {
348 order: [ [ 'createdAt', 'DESC' ] ],
9a4a9b6c
C
349 offset: start,
350 limit: count,
fe3a55b0
C
351 where: {},
352 include: [
353 {
4dae00e6 354 attributes: [ 'name', 'uuid' ],
fe3a55b0
C
355 model: VideoModel.unscoped(),
356 required: true
357 }
358 ]
359 }
360
361 if (videoId) query.where['videoId'] = videoId
362
363 return VideoCommentModel
364 .scope([ ScopeNames.WITH_ACCOUNT ])
365 .findAll(query)
366 }
367
09cababd
C
368 static async getStats () {
369 const totalLocalVideoComments = await VideoCommentModel.count({
370 include: [
371 {
372 model: AccountModel,
373 required: true,
374 include: [
375 {
376 model: ActorModel,
377 required: true,
378 where: {
379 serverId: null
380 }
381 }
382 ]
383 }
384 ]
385 })
386 const totalVideoComments = await VideoCommentModel.count()
387
388 return {
389 totalLocalVideoComments,
390 totalVideoComments
391 }
392 }
393
d7e70384
C
394 getThreadId (): number {
395 return this.originCommentId || this.id
396 }
397
4cb6d457
C
398 isOwned () {
399 return this.Account.isOwned()
400 }
401
bf1f6508
C
402 toFormattedJSON () {
403 return {
404 id: this.id,
405 url: this.url,
406 text: this.text,
407 threadId: this.originCommentId || this.id,
d50acfab 408 inReplyToCommentId: this.inReplyToCommentId || null,
bf1f6508
C
409 videoId: this.videoId,
410 createdAt: this.createdAt,
d3ea8975 411 updatedAt: this.updatedAt,
4635f59d 412 totalReplies: this.get('totalReplies') || 0,
cf117aaa 413 account: this.Account.toFormattedJSON()
bf1f6508
C
414 } as VideoComment
415 }
ea44f375 416
d7e70384 417 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
ea44f375
C
418 let inReplyTo: string
419 // New thread, so in AS we reply to the video
2cebd797 420 if (this.inReplyToCommentId === null) {
ea44f375
C
421 inReplyTo = this.Video.url
422 } else {
423 inReplyTo = this.InReplyToVideoComment.url
424 }
425
d7e70384
C
426 const tag: ActivityTagObject[] = []
427 for (const parentComment of threadParentComments) {
428 const actor = parentComment.Account.Actor
429
430 tag.push({
431 type: 'Mention',
432 href: actor.url,
433 name: `@${actor.preferredUsername}@${actor.getHost()}`
434 })
435 }
436
ea44f375
C
437 return {
438 type: 'Note' as 'Note',
439 id: this.url,
440 content: this.text,
441 inReplyTo,
da854ddd 442 updated: this.updatedAt.toISOString(),
ea44f375 443 published: this.createdAt.toISOString(),
da854ddd 444 url: this.url,
d7e70384
C
445 attributedTo: this.Account.Actor.url,
446 tag
ea44f375
C
447 }
448 }
6d852470 449}