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