]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-comment.ts
Add unique index on video comment url
[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.isOwned()) {
192 await sendDeleteVideoComment(instance, options.transaction)
193 }
194 }
195
196 static loadById (id: number, t?: Sequelize.Transaction) {
197 const query: IFindOptions<VideoCommentModel> = {
198 where: {
199 id
200 }
201 }
202
203 if (t !== undefined) query.transaction = t
204
205 return VideoCommentModel.findOne(query)
206 }
207
208 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
209 const query: IFindOptions<VideoCommentModel> = {
210 where: {
211 id
212 }
213 }
214
215 if (t !== undefined) query.transaction = t
216
217 return VideoCommentModel
218 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
219 .findOne(query)
220 }
221
222 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
223 const query: IFindOptions<VideoCommentModel> = {
224 where: {
225 url
226 }
227 }
228
229 if (t !== undefined) query.transaction = t
230
231 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
232 }
233
234 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
235 const query: IFindOptions<VideoCommentModel> = {
236 where: {
237 url
238 }
239 }
240
241 if (t !== undefined) query.transaction = t
242
243 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
244 }
245
246 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
247 const query = {
248 offset: start,
249 limit: count,
250 order: [ getSort(sort) ],
251 where: {
252 videoId,
253 inReplyToCommentId: null
254 }
255 }
256
257 return VideoCommentModel
258 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
259 .findAndCountAll(query)
260 .then(({ rows, count }) => {
261 return { total: count, data: rows }
262 })
263 }
264
265 static listThreadCommentsForApi (videoId: number, threadId: number) {
266 const query = {
267 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
268 where: {
269 videoId,
270 [ Sequelize.Op.or ]: [
271 { id: threadId },
272 { originCommentId: threadId }
273 ]
274 }
275 }
276
277 return VideoCommentModel
278 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
279 .findAndCountAll(query)
280 .then(({ rows, count }) => {
281 return { total: count, data: rows }
282 })
283 }
284
285 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
286 const query = {
287 order: [ [ 'createdAt', order ] ],
288 where: {
289 [ Sequelize.Op.or ]: [
290 { id: comment.getThreadId() },
291 { originCommentId: comment.getThreadId() }
292 ],
293 id: {
294 [ Sequelize.Op.ne ]: comment.id
295 },
296 createdAt: {
297 [ Sequelize.Op.lt ]: comment.createdAt
298 }
299 },
300 transaction: t
301 }
302
303 return VideoCommentModel
304 .scope([ ScopeNames.WITH_ACCOUNT ])
305 .findAll(query)
306 }
307
308 getThreadId (): number {
309 return this.originCommentId || this.id
310 }
311
312 isOwned () {
313 return this.Account.isOwned()
314 }
315
316 toFormattedJSON () {
317 return {
318 id: this.id,
319 url: this.url,
320 text: this.text,
321 threadId: this.originCommentId || this.id,
322 inReplyToCommentId: this.inReplyToCommentId || null,
323 videoId: this.videoId,
324 createdAt: this.createdAt,
325 updatedAt: this.updatedAt,
326 totalReplies: this.get('totalReplies') || 0,
327 account: this.Account.toFormattedJSON()
328 } as VideoComment
329 }
330
331 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
332 let inReplyTo: string
333 // New thread, so in AS we reply to the video
334 if (this.inReplyToCommentId === null) {
335 inReplyTo = this.Video.url
336 } else {
337 inReplyTo = this.InReplyToVideoComment.url
338 }
339
340 const tag: ActivityTagObject[] = []
341 for (const parentComment of threadParentComments) {
342 const actor = parentComment.Account.Actor
343
344 tag.push({
345 type: 'Mention',
346 href: actor.url,
347 name: `@${actor.preferredUsername}@${actor.getHost()}`
348 })
349 }
350
351 return {
352 type: 'Note' as 'Note',
353 id: this.url,
354 content: this.text,
355 inReplyTo,
356 updated: this.updatedAt.toISOString(),
357 published: this.createdAt.toISOString(),
358 url: this.url,
359 attributedTo: this.Account.Actor.url,
360 tag
361 }
362 }
363 }