]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-comment.ts
Fix deleting a video with comments
[github/Chocobozzz/PeerTube.git] / server / models / video / video-comment.ts
1 import * as Sequelize from 'sequelize'
2 import {
3 AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
4 UpdatedAt
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'
19
20 enum ScopeNames {
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'
25 }
26
27 @Scopes({
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 },
42 [ScopeNames.WITH_ACCOUNT]: {
43 include: [
44 {
45 model: () => AccountModel,
46 include: [
47 {
48 model: () => ActorModel,
49 include: [
50 {
51 model: () => ServerModel,
52 required: false
53 },
54 {
55 model: () => AvatarModel,
56 required: false
57 }
58 ]
59 }
60 ]
61 }
62 ]
63 },
64 [ScopeNames.WITH_IN_REPLY_TO]: {
65 include: [
66 {
67 model: () => VideoCommentModel,
68 as: 'InReplyToVideoComment'
69 }
70 ]
71 },
72 [ScopeNames.WITH_VIDEO]: {
73 include: [
74 {
75 model: () => VideoModel,
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 ]
95 }
96 ]
97 }
98 })
99 @Table({
100 tableName: 'videoComment',
101 indexes: [
102 {
103 fields: [ 'videoId' ]
104 },
105 {
106 fields: [ 'videoId', 'originCommentId' ]
107 },
108 {
109 fields: [ 'url' ],
110 unique: true
111 }
112 ]
113 })
114 export class VideoCommentModel extends Model<VideoCommentModel> {
115 @CreatedAt
116 createdAt: Date
117
118 @UpdatedAt
119 updatedAt: Date
120
121 @AllowNull(false)
122 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
123 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
124 url: string
125
126 @AllowNull(false)
127 @Column(DataType.TEXT)
128 text: string
129
130 @ForeignKey(() => VideoCommentModel)
131 @Column
132 originCommentId: number
133
134 @BelongsTo(() => VideoCommentModel, {
135 foreignKey: {
136 name: 'originCommentId',
137 allowNull: true
138 },
139 as: 'OriginVideoComment',
140 onDelete: 'CASCADE'
141 })
142 OriginVideoComment: VideoCommentModel
143
144 @ForeignKey(() => VideoCommentModel)
145 @Column
146 inReplyToCommentId: number
147
148 @BelongsTo(() => VideoCommentModel, {
149 foreignKey: {
150 name: 'inReplyToCommentId',
151 allowNull: true
152 },
153 as: 'InReplyToVideoComment',
154 onDelete: 'CASCADE'
155 })
156 InReplyToVideoComment: VideoCommentModel
157
158 @ForeignKey(() => VideoModel)
159 @Column
160 videoId: number
161
162 @BelongsTo(() => VideoModel, {
163 foreignKey: {
164 allowNull: false
165 },
166 onDelete: 'CASCADE'
167 })
168 Video: VideoModel
169
170 @ForeignKey(() => AccountModel)
171 @Column
172 accountId: number
173
174 @BelongsTo(() => AccountModel, {
175 foreignKey: {
176 allowNull: false
177 },
178 onDelete: 'CASCADE'
179 })
180 Account: AccountModel
181
182 @BeforeDestroy
183 static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
184 if (!instance.Account || !instance.Account.Actor) {
185 instance.Account = await instance.$get('Account', {
186 include: [ ActorModel ],
187 transaction: options.transaction
188 }) as AccountModel
189 }
190
191 if (!instance.Video) {
192 instance.Video = await instance.$get('Video', {
193 include: [
194 {
195 model: VideoChannelModel,
196 include: [
197 {
198 model: AccountModel,
199 include: [
200 {
201 model: ActorModel
202 }
203 ]
204 }
205 ]
206 }
207 ],
208 transaction: options.transaction
209 }) as VideoModel
210 }
211
212 if (instance.isOwned()) {
213 await sendDeleteVideoComment(instance, options.transaction)
214 }
215 }
216
217 static loadById (id: number, t?: Sequelize.Transaction) {
218 const query: IFindOptions<VideoCommentModel> = {
219 where: {
220 id
221 }
222 }
223
224 if (t !== undefined) query.transaction = t
225
226 return VideoCommentModel.findOne(query)
227 }
228
229 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
230 const query: IFindOptions<VideoCommentModel> = {
231 where: {
232 id
233 }
234 }
235
236 if (t !== undefined) query.transaction = t
237
238 return VideoCommentModel
239 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
240 .findOne(query)
241 }
242
243 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
244 const query: IFindOptions<VideoCommentModel> = {
245 where: {
246 url
247 }
248 }
249
250 if (t !== undefined) query.transaction = t
251
252 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
253 }
254
255 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
256 const query: IFindOptions<VideoCommentModel> = {
257 where: {
258 url
259 }
260 }
261
262 if (t !== undefined) query.transaction = t
263
264 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
265 }
266
267 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
268 const query = {
269 offset: start,
270 limit: count,
271 order: [ getSort(sort) ],
272 where: {
273 videoId,
274 inReplyToCommentId: null
275 }
276 }
277
278 return VideoCommentModel
279 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
280 .findAndCountAll(query)
281 .then(({ rows, count }) => {
282 return { total: count, data: rows }
283 })
284 }
285
286 static listThreadCommentsForApi (videoId: number, threadId: number) {
287 const query = {
288 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
289 where: {
290 videoId,
291 [ Sequelize.Op.or ]: [
292 { id: threadId },
293 { originCommentId: threadId }
294 ]
295 }
296 }
297
298 return VideoCommentModel
299 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
300 .findAndCountAll(query)
301 .then(({ rows, count }) => {
302 return { total: count, data: rows }
303 })
304 }
305
306 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
307 const query = {
308 order: [ [ 'createdAt', order ] ],
309 where: {
310 [ Sequelize.Op.or ]: [
311 { id: comment.getThreadId() },
312 { originCommentId: comment.getThreadId() }
313 ],
314 id: {
315 [ Sequelize.Op.ne ]: comment.id
316 },
317 createdAt: {
318 [ Sequelize.Op.lt ]: comment.createdAt
319 }
320 },
321 transaction: t
322 }
323
324 return VideoCommentModel
325 .scope([ ScopeNames.WITH_ACCOUNT ])
326 .findAll(query)
327 }
328
329 getThreadId (): number {
330 return this.originCommentId || this.id
331 }
332
333 isOwned () {
334 return this.Account.isOwned()
335 }
336
337 toFormattedJSON () {
338 return {
339 id: this.id,
340 url: this.url,
341 text: this.text,
342 threadId: this.originCommentId || this.id,
343 inReplyToCommentId: this.inReplyToCommentId || null,
344 videoId: this.videoId,
345 createdAt: this.createdAt,
346 updatedAt: this.updatedAt,
347 totalReplies: this.get('totalReplies') || 0,
348 account: this.Account.toFormattedJSON()
349 } as VideoComment
350 }
351
352 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
353 let inReplyTo: string
354 // New thread, so in AS we reply to the video
355 if (this.inReplyToCommentId === null) {
356 inReplyTo = this.Video.url
357 } else {
358 inReplyTo = this.InReplyToVideoComment.url
359 }
360
361 const tag: ActivityTagObject[] = []
362 for (const parentComment of threadParentComments) {
363 const actor = parentComment.Account.Actor
364
365 tag.push({
366 type: 'Mention',
367 href: actor.url,
368 name: `@${actor.preferredUsername}@${actor.getHost()}`
369 })
370 }
371
372 return {
373 type: 'Note' as 'Note',
374 id: this.url,
375 content: this.text,
376 inReplyTo,
377 updated: this.updatedAt.toISOString(),
378 published: this.createdAt.toISOString(),
379 url: this.url,
380 attributedTo: this.Account.Actor.url,
381 tag
382 }
383 }
384 }