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