]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/user/user-notification.ts
Merge branch 'release/3.2.0' into develop
[github/Chocobozzz/PeerTube.git] / server / models / user / user-notification.ts
1 import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
2 import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3 import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
4 import { AttributesOnly } from '@shared/core-utils'
5 import { UserNotification, UserNotificationType } from '../../../shared'
6 import { isBooleanValid } from '../../helpers/custom-validators/misc'
7 import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
8 import { AbuseModel } from '../abuse/abuse'
9 import { VideoAbuseModel } from '../abuse/video-abuse'
10 import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
11 import { AccountModel } from '../account/account'
12 import { ActorModel } from '../actor/actor'
13 import { ActorFollowModel } from '../actor/actor-follow'
14 import { ActorImageModel } from '../actor/actor-image'
15 import { ApplicationModel } from '../application/application'
16 import { PluginModel } from '../server/plugin'
17 import { ServerModel } from '../server/server'
18 import { getSort, throwIfNotValid } from '../utils'
19 import { VideoModel } from '../video/video'
20 import { VideoBlacklistModel } from '../video/video-blacklist'
21 import { VideoChannelModel } from '../video/video-channel'
22 import { VideoCommentModel } from '../video/video-comment'
23 import { VideoImportModel } from '../video/video-import'
24 import { UserModel } from './user'
25
26 enum ScopeNames {
27 WITH_ALL = 'WITH_ALL'
28 }
29
30 function buildActorWithAvatarInclude () {
31 return {
32 attributes: [ 'preferredUsername' ],
33 model: ActorModel.unscoped(),
34 required: true,
35 include: [
36 {
37 attributes: [ 'filename' ],
38 as: 'Avatar',
39 model: ActorImageModel.unscoped(),
40 required: false
41 },
42 {
43 attributes: [ 'host' ],
44 model: ServerModel.unscoped(),
45 required: false
46 }
47 ]
48 }
49 }
50
51 function buildVideoInclude (required: boolean) {
52 return {
53 attributes: [ 'id', 'uuid', 'name' ],
54 model: VideoModel.unscoped(),
55 required
56 }
57 }
58
59 function buildChannelInclude (required: boolean, withActor = false) {
60 return {
61 required,
62 attributes: [ 'id', 'name' ],
63 model: VideoChannelModel.unscoped(),
64 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
65 }
66 }
67
68 function buildAccountInclude (required: boolean, withActor = false) {
69 return {
70 required,
71 attributes: [ 'id', 'name' ],
72 model: AccountModel.unscoped(),
73 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
74 }
75 }
76
77 @Scopes(() => ({
78 [ScopeNames.WITH_ALL]: {
79 include: [
80 Object.assign(buildVideoInclude(false), {
81 include: [ buildChannelInclude(true, true) ]
82 }),
83
84 {
85 attributes: [ 'id', 'originCommentId' ],
86 model: VideoCommentModel.unscoped(),
87 required: false,
88 include: [
89 buildAccountInclude(true, true),
90 buildVideoInclude(true)
91 ]
92 },
93
94 {
95 attributes: [ 'id', 'state' ],
96 model: AbuseModel.unscoped(),
97 required: false,
98 include: [
99 {
100 attributes: [ 'id' ],
101 model: VideoAbuseModel.unscoped(),
102 required: false,
103 include: [ buildVideoInclude(false) ]
104 },
105 {
106 attributes: [ 'id' ],
107 model: VideoCommentAbuseModel.unscoped(),
108 required: false,
109 include: [
110 {
111 attributes: [ 'id', 'originCommentId' ],
112 model: VideoCommentModel.unscoped(),
113 required: false,
114 include: [
115 {
116 attributes: [ 'id', 'name', 'uuid' ],
117 model: VideoModel.unscoped(),
118 required: false
119 }
120 ]
121 }
122 ]
123 },
124 {
125 model: AccountModel,
126 as: 'FlaggedAccount',
127 required: false,
128 include: [ buildActorWithAvatarInclude() ]
129 }
130 ]
131 },
132
133 {
134 attributes: [ 'id' ],
135 model: VideoBlacklistModel.unscoped(),
136 required: false,
137 include: [ buildVideoInclude(true) ]
138 },
139
140 {
141 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
142 model: VideoImportModel.unscoped(),
143 required: false,
144 include: [ buildVideoInclude(false) ]
145 },
146
147 {
148 attributes: [ 'id', 'name', 'type', 'latestVersion' ],
149 model: PluginModel.unscoped(),
150 required: false
151 },
152
153 {
154 attributes: [ 'id', 'latestPeerTubeVersion' ],
155 model: ApplicationModel.unscoped(),
156 required: false
157 },
158
159 {
160 attributes: [ 'id', 'state' ],
161 model: ActorFollowModel.unscoped(),
162 required: false,
163 include: [
164 {
165 attributes: [ 'preferredUsername' ],
166 model: ActorModel.unscoped(),
167 required: true,
168 as: 'ActorFollower',
169 include: [
170 {
171 attributes: [ 'id', 'name' ],
172 model: AccountModel.unscoped(),
173 required: true
174 },
175 {
176 attributes: [ 'filename' ],
177 as: 'Avatar',
178 model: ActorImageModel.unscoped(),
179 required: false
180 },
181 {
182 attributes: [ 'host' ],
183 model: ServerModel.unscoped(),
184 required: false
185 }
186 ]
187 },
188 {
189 attributes: [ 'preferredUsername', 'type' ],
190 model: ActorModel.unscoped(),
191 required: true,
192 as: 'ActorFollowing',
193 include: [
194 buildChannelInclude(false),
195 buildAccountInclude(false),
196 {
197 attributes: [ 'host' ],
198 model: ServerModel.unscoped(),
199 required: false
200 }
201 ]
202 }
203 ]
204 },
205
206 buildAccountInclude(false, true)
207 ]
208 }
209 }))
210 @Table({
211 tableName: 'userNotification',
212 indexes: [
213 {
214 fields: [ 'userId' ]
215 },
216 {
217 fields: [ 'videoId' ],
218 where: {
219 videoId: {
220 [Op.ne]: null
221 }
222 }
223 },
224 {
225 fields: [ 'commentId' ],
226 where: {
227 commentId: {
228 [Op.ne]: null
229 }
230 }
231 },
232 {
233 fields: [ 'abuseId' ],
234 where: {
235 abuseId: {
236 [Op.ne]: null
237 }
238 }
239 },
240 {
241 fields: [ 'videoBlacklistId' ],
242 where: {
243 videoBlacklistId: {
244 [Op.ne]: null
245 }
246 }
247 },
248 {
249 fields: [ 'videoImportId' ],
250 where: {
251 videoImportId: {
252 [Op.ne]: null
253 }
254 }
255 },
256 {
257 fields: [ 'accountId' ],
258 where: {
259 accountId: {
260 [Op.ne]: null
261 }
262 }
263 },
264 {
265 fields: [ 'actorFollowId' ],
266 where: {
267 actorFollowId: {
268 [Op.ne]: null
269 }
270 }
271 },
272 {
273 fields: [ 'pluginId' ],
274 where: {
275 pluginId: {
276 [Op.ne]: null
277 }
278 }
279 },
280 {
281 fields: [ 'applicationId' ],
282 where: {
283 applicationId: {
284 [Op.ne]: null
285 }
286 }
287 }
288 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
289 })
290 export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNotificationModel>>> {
291
292 @AllowNull(false)
293 @Default(null)
294 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
295 @Column
296 type: UserNotificationType
297
298 @AllowNull(false)
299 @Default(false)
300 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
301 @Column
302 read: boolean
303
304 @CreatedAt
305 createdAt: Date
306
307 @UpdatedAt
308 updatedAt: Date
309
310 @ForeignKey(() => UserModel)
311 @Column
312 userId: number
313
314 @BelongsTo(() => UserModel, {
315 foreignKey: {
316 allowNull: false
317 },
318 onDelete: 'cascade'
319 })
320 User: UserModel
321
322 @ForeignKey(() => VideoModel)
323 @Column
324 videoId: number
325
326 @BelongsTo(() => VideoModel, {
327 foreignKey: {
328 allowNull: true
329 },
330 onDelete: 'cascade'
331 })
332 Video: VideoModel
333
334 @ForeignKey(() => VideoCommentModel)
335 @Column
336 commentId: number
337
338 @BelongsTo(() => VideoCommentModel, {
339 foreignKey: {
340 allowNull: true
341 },
342 onDelete: 'cascade'
343 })
344 Comment: VideoCommentModel
345
346 @ForeignKey(() => AbuseModel)
347 @Column
348 abuseId: number
349
350 @BelongsTo(() => AbuseModel, {
351 foreignKey: {
352 allowNull: true
353 },
354 onDelete: 'cascade'
355 })
356 Abuse: AbuseModel
357
358 @ForeignKey(() => VideoBlacklistModel)
359 @Column
360 videoBlacklistId: number
361
362 @BelongsTo(() => VideoBlacklistModel, {
363 foreignKey: {
364 allowNull: true
365 },
366 onDelete: 'cascade'
367 })
368 VideoBlacklist: VideoBlacklistModel
369
370 @ForeignKey(() => VideoImportModel)
371 @Column
372 videoImportId: number
373
374 @BelongsTo(() => VideoImportModel, {
375 foreignKey: {
376 allowNull: true
377 },
378 onDelete: 'cascade'
379 })
380 VideoImport: VideoImportModel
381
382 @ForeignKey(() => AccountModel)
383 @Column
384 accountId: number
385
386 @BelongsTo(() => AccountModel, {
387 foreignKey: {
388 allowNull: true
389 },
390 onDelete: 'cascade'
391 })
392 Account: AccountModel
393
394 @ForeignKey(() => ActorFollowModel)
395 @Column
396 actorFollowId: number
397
398 @BelongsTo(() => ActorFollowModel, {
399 foreignKey: {
400 allowNull: true
401 },
402 onDelete: 'cascade'
403 })
404 ActorFollow: ActorFollowModel
405
406 @ForeignKey(() => PluginModel)
407 @Column
408 pluginId: number
409
410 @BelongsTo(() => PluginModel, {
411 foreignKey: {
412 allowNull: true
413 },
414 onDelete: 'cascade'
415 })
416 Plugin: PluginModel
417
418 @ForeignKey(() => ApplicationModel)
419 @Column
420 applicationId: number
421
422 @BelongsTo(() => ApplicationModel, {
423 foreignKey: {
424 allowNull: true
425 },
426 onDelete: 'cascade'
427 })
428 Application: ApplicationModel
429
430 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
431 const where = { userId }
432
433 const query: FindOptions = {
434 offset: start,
435 limit: count,
436 order: getSort(sort),
437 where
438 }
439
440 if (unread !== undefined) query.where['read'] = !unread
441
442 return Promise.all([
443 UserNotificationModel.count({ where })
444 .then(count => count || 0),
445
446 count === 0
447 ? []
448 : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query)
449 ]).then(([ total, data ]) => ({ total, data }))
450 }
451
452 static markAsRead (userId: number, notificationIds: number[]) {
453 const query = {
454 where: {
455 userId,
456 id: {
457 [Op.in]: notificationIds
458 }
459 }
460 }
461
462 return UserNotificationModel.update({ read: true }, query)
463 }
464
465 static markAllAsRead (userId: number) {
466 const query = { where: { userId } }
467
468 return UserNotificationModel.update({ read: true }, query)
469 }
470
471 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
472 const id = parseInt(options.id + '', 10)
473
474 function buildAccountWhereQuery (base: string) {
475 const whereSuffix = options.forUserId
476 ? ` AND "userNotification"."userId" = ${options.forUserId}`
477 : ''
478
479 if (options.type === 'account') {
480 return base +
481 ` WHERE "account"."id" = ${id} ${whereSuffix}`
482 }
483
484 return base +
485 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
486 }
487
488 const queries = [
489 buildAccountWhereQuery(
490 `SELECT "userNotification"."id" FROM "userNotification" ` +
491 `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
492 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
493 ),
494
495 // Remove notifications from muted accounts that followed ours
496 buildAccountWhereQuery(
497 `SELECT "userNotification"."id" FROM "userNotification" ` +
498 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
499 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
500 `INNER JOIN account ON account."actorId" = actor.id `
501 ),
502
503 // Remove notifications from muted accounts that commented something
504 buildAccountWhereQuery(
505 `SELECT "userNotification"."id" FROM "userNotification" ` +
506 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
507 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
508 `INNER JOIN account ON account."actorId" = actor.id `
509 ),
510
511 buildAccountWhereQuery(
512 `SELECT "userNotification"."id" FROM "userNotification" ` +
513 `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
514 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
515 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
516 )
517 ]
518
519 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
520
521 return UserNotificationModel.sequelize.query(query)
522 }
523
524 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
525 const video = this.Video
526 ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) })
527 : undefined
528
529 const videoImport = this.VideoImport
530 ? {
531 id: this.VideoImport.id,
532 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
533 torrentName: this.VideoImport.torrentName,
534 magnetUri: this.VideoImport.magnetUri,
535 targetUrl: this.VideoImport.targetUrl
536 }
537 : undefined
538
539 const comment = this.Comment
540 ? {
541 id: this.Comment.id,
542 threadId: this.Comment.getThreadId(),
543 account: this.formatActor(this.Comment.Account),
544 video: this.formatVideo(this.Comment.Video)
545 }
546 : undefined
547
548 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
549
550 const videoBlacklist = this.VideoBlacklist
551 ? {
552 id: this.VideoBlacklist.id,
553 video: this.formatVideo(this.VideoBlacklist.Video)
554 }
555 : undefined
556
557 const account = this.Account ? this.formatActor(this.Account) : undefined
558
559 const actorFollowingType = {
560 Application: 'instance' as 'instance',
561 Group: 'channel' as 'channel',
562 Person: 'account' as 'account'
563 }
564 const actorFollow = this.ActorFollow
565 ? {
566 id: this.ActorFollow.id,
567 state: this.ActorFollow.state,
568 follower: {
569 id: this.ActorFollow.ActorFollower.Account.id,
570 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
571 name: this.ActorFollow.ActorFollower.preferredUsername,
572 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined,
573 host: this.ActorFollow.ActorFollower.getHost()
574 },
575 following: {
576 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
577 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
578 name: this.ActorFollow.ActorFollowing.preferredUsername,
579 host: this.ActorFollow.ActorFollowing.getHost()
580 }
581 }
582 : undefined
583
584 const plugin = this.Plugin
585 ? {
586 name: this.Plugin.name,
587 type: this.Plugin.type,
588 latestVersion: this.Plugin.latestVersion
589 }
590 : undefined
591
592 const peertube = this.Application
593 ? { latestVersion: this.Application.latestPeerTubeVersion }
594 : undefined
595
596 return {
597 id: this.id,
598 type: this.type,
599 read: this.read,
600 video,
601 videoImport,
602 comment,
603 abuse,
604 videoBlacklist,
605 account,
606 actorFollow,
607 plugin,
608 peertube,
609 createdAt: this.createdAt.toISOString(),
610 updatedAt: this.updatedAt.toISOString()
611 }
612 }
613
614 formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) {
615 return {
616 id: video.id,
617 uuid: video.uuid,
618 name: video.name
619 }
620 }
621
622 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
623 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
624 ? {
625 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
626
627 video: abuse.VideoCommentAbuse.VideoComment.Video
628 ? {
629 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
630 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
631 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
632 }
633 : undefined
634 }
635 : undefined
636
637 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
638
639 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined
640
641 return {
642 id: abuse.id,
643 state: abuse.state,
644 video: videoAbuse,
645 comment: commentAbuse,
646 account: accountAbuse
647 }
648 }
649
650 formatActor (
651 this: UserNotificationModelForApi,
652 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
653 ) {
654 const avatar = accountOrChannel.Actor.Avatar
655 ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
656 : undefined
657
658 return {
659 id: accountOrChannel.id,
660 displayName: accountOrChannel.getDisplayName(),
661 name: accountOrChannel.Actor.preferredUsername,
662 host: accountOrChannel.Actor.getHost(),
663 avatar
664 }
665 }
666 }