1 import * as Sequelize from 'sequelize'
3 AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
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'
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'
28 [ScopeNames.ATTRIBUTES_FOR_API]: {
33 '(SELECT COUNT("replies"."id") ' +
34 'FROM "videoComment" AS "replies" ' +
35 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
42 [ScopeNames.WITH_ACCOUNT]: {
45 model: () => AccountModel,
48 model: () => ActorModel,
51 model: () => ServerModel,
55 model: () => AvatarModel,
64 [ScopeNames.WITH_IN_REPLY_TO]: {
67 model: () => VideoCommentModel,
68 as: 'InReplyToVideoComment'
72 [ScopeNames.WITH_VIDEO]: {
75 model: () => VideoModel,
79 model: () => VideoChannelModel.unscoped(),
83 model: () => AccountModel,
87 model: () => ActorModel,
100 tableName: 'videoComment',
103 fields: [ 'videoId' ]
106 fields: [ 'videoId', 'originCommentId' ]
113 fields: [ 'accountId' ]
117 export class VideoCommentModel extends Model<VideoCommentModel> {
125 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
126 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
130 @Column(DataType.TEXT)
133 @ForeignKey(() => VideoCommentModel)
135 originCommentId: number
137 @BelongsTo(() => VideoCommentModel, {
139 name: 'originCommentId',
142 as: 'OriginVideoComment',
145 OriginVideoComment: VideoCommentModel
147 @ForeignKey(() => VideoCommentModel)
149 inReplyToCommentId: number
151 @BelongsTo(() => VideoCommentModel, {
153 name: 'inReplyToCommentId',
156 as: 'InReplyToVideoComment',
159 InReplyToVideoComment: VideoCommentModel | null
161 @ForeignKey(() => VideoModel)
165 @BelongsTo(() => VideoModel, {
173 @ForeignKey(() => AccountModel)
177 @BelongsTo(() => AccountModel, {
183 Account: AccountModel
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
194 if (!instance.Video) {
195 instance.Video = await instance.$get('Video', {
198 model: VideoChannelModel,
211 transaction: options.transaction
215 if (instance.isOwned()) {
216 await sendDeleteVideoComment(instance, options.transaction)
220 static loadById (id: number, t?: Sequelize.Transaction) {
221 const query: IFindOptions<VideoCommentModel> = {
227 if (t !== undefined) query.transaction = t
229 return VideoCommentModel.findOne(query)
232 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
233 const query: IFindOptions<VideoCommentModel> = {
239 if (t !== undefined) query.transaction = t
241 return VideoCommentModel
242 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
246 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
247 const query: IFindOptions<VideoCommentModel> = {
253 if (t !== undefined) query.transaction = t
255 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
258 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
259 const query: IFindOptions<VideoCommentModel> = {
265 if (t !== undefined) query.transaction = t
267 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
270 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
274 order: getSort(sort),
277 inReplyToCommentId: null
281 return VideoCommentModel
282 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
283 .findAndCountAll(query)
284 .then(({ rows, count }) => {
285 return { total: count, data: rows }
289 static listThreadCommentsForApi (videoId: number, threadId: number) {
291 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
294 [ Sequelize.Op.or ]: [
296 { originCommentId: threadId }
301 return VideoCommentModel
302 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
303 .findAndCountAll(query)
304 .then(({ rows, count }) => {
305 return { total: count, data: rows }
309 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
311 order: [ [ 'createdAt', order ] ],
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' +
321 [ Sequelize.Op.ne ]: comment.id
327 return VideoCommentModel
328 .scope([ ScopeNames.WITH_ACCOUNT ])
332 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
334 order: [ [ 'createdAt', order ] ],
343 return VideoCommentModel.findAndCountAll(query)
346 static listForFeed (start: number, count: number, videoId?: number) {
348 order: [ [ 'createdAt', 'DESC' ] ],
354 attributes: [ 'name', 'uuid' ],
355 model: VideoModel.unscoped(),
361 if (videoId) query.where['videoId'] = videoId
363 return VideoCommentModel
364 .scope([ ScopeNames.WITH_ACCOUNT ])
368 static async getStats () {
369 const totalLocalVideoComments = await VideoCommentModel.count({
386 const totalVideoComments = await VideoCommentModel.count()
389 totalLocalVideoComments,
394 getThreadId (): number {
395 return this.originCommentId || this.id
399 return this.Account.isOwned()
407 threadId: this.originCommentId || this.id,
408 inReplyToCommentId: this.inReplyToCommentId || null,
409 videoId: this.videoId,
410 createdAt: this.createdAt,
411 updatedAt: this.updatedAt,
412 totalReplies: this.get('totalReplies') || 0,
413 account: this.Account.toFormattedJSON()
417 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
418 let inReplyTo: string
419 // New thread, so in AS we reply to the video
420 if (this.inReplyToCommentId === null) {
421 inReplyTo = this.Video.url
423 inReplyTo = this.InReplyToVideoComment.url
426 const tag: ActivityTagObject[] = []
427 for (const parentComment of threadParentComments) {
428 const actor = parentComment.Account.Actor
433 name: `@${actor.preferredUsername}@${actor.getHost()}`
438 type: 'Note' as 'Note',
442 updated: this.updatedAt.toISOString(),
443 published: this.createdAt.toISOString(),
445 attributedTo: this.Account.Actor.url,