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 { UserNotification, UserNotificationType } from '../../../shared'
5 import { isBooleanValid } from '../../helpers/custom-validators/misc'
6 import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
7 import { AbuseModel } from '../abuse/abuse'
8 import { VideoAbuseModel } from '../abuse/video-abuse'
9 import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
10 import { ActorModel } from '../activitypub/actor'
11 import { ActorFollowModel } from '../activitypub/actor-follow'
12 import { ApplicationModel } from '../application/application'
13 import { AvatarModel } from '../avatar/avatar'
14 import { PluginModel } from '../server/plugin'
15 import { ServerModel } from '../server/server'
16 import { getSort, throwIfNotValid } from '../utils'
17 import { VideoModel } from '../video/video'
18 import { VideoBlacklistModel } from '../video/video-blacklist'
19 import { VideoChannelModel } from '../video/video-channel'
20 import { VideoCommentModel } from '../video/video-comment'
21 import { VideoImportModel } from '../video/video-import'
22 import { AccountModel } from './account'
23 import { UserModel } from './user'
29 function buildActorWithAvatarInclude () {
31 attributes: [ 'preferredUsername' ],
32 model: ActorModel.unscoped(),
36 attributes: [ 'filename' ],
37 model: AvatarModel.unscoped(),
41 attributes: [ 'host' ],
42 model: ServerModel.unscoped(),
49 function buildVideoInclude (required: boolean) {
51 attributes: [ 'id', 'uuid', 'name' ],
52 model: VideoModel.unscoped(),
57 function buildChannelInclude (required: boolean, withActor = false) {
60 attributes: [ 'id', 'name' ],
61 model: VideoChannelModel.unscoped(),
62 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
66 function buildAccountInclude (required: boolean, withActor = false) {
69 attributes: [ 'id', 'name' ],
70 model: AccountModel.unscoped(),
71 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
76 [ScopeNames.WITH_ALL]: {
78 Object.assign(buildVideoInclude(false), {
79 include: [ buildChannelInclude(true, true) ]
83 attributes: [ 'id', 'originCommentId' ],
84 model: VideoCommentModel.unscoped(),
87 buildAccountInclude(true, true),
88 buildVideoInclude(true)
93 attributes: [ 'id', 'state' ],
94 model: AbuseModel.unscoped(),
99 model: VideoAbuseModel.unscoped(),
101 include: [ buildVideoInclude(false) ]
104 attributes: [ 'id' ],
105 model: VideoCommentAbuseModel.unscoped(),
109 attributes: [ 'id', 'originCommentId' ],
110 model: VideoCommentModel.unscoped(),
114 attributes: [ 'id', 'name', 'uuid' ],
115 model: VideoModel.unscoped(),
124 as: 'FlaggedAccount',
126 include: [ buildActorWithAvatarInclude() ]
132 attributes: [ 'id' ],
133 model: VideoBlacklistModel.unscoped(),
135 include: [ buildVideoInclude(true) ]
139 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
140 model: VideoImportModel.unscoped(),
142 include: [ buildVideoInclude(false) ]
146 attributes: [ 'id', 'name', 'type', 'latestVersion' ],
147 model: PluginModel.unscoped(),
152 attributes: [ 'id', 'latestPeerTubeVersion' ],
153 model: ApplicationModel.unscoped(),
158 attributes: [ 'id', 'state' ],
159 model: ActorFollowModel.unscoped(),
163 attributes: [ 'preferredUsername' ],
164 model: ActorModel.unscoped(),
169 attributes: [ 'id', 'name' ],
170 model: AccountModel.unscoped(),
174 attributes: [ 'filename' ],
175 model: AvatarModel.unscoped(),
179 attributes: [ 'host' ],
180 model: ServerModel.unscoped(),
186 attributes: [ 'preferredUsername', 'type' ],
187 model: ActorModel.unscoped(),
189 as: 'ActorFollowing',
191 buildChannelInclude(false),
192 buildAccountInclude(false),
194 attributes: [ 'host' ],
195 model: ServerModel.unscoped(),
203 buildAccountInclude(false, true)
208 tableName: 'userNotification',
214 fields: [ 'videoId' ],
222 fields: [ 'commentId' ],
230 fields: [ 'abuseId' ],
238 fields: [ 'videoBlacklistId' ],
246 fields: [ 'videoImportId' ],
254 fields: [ 'accountId' ],
262 fields: [ 'actorFollowId' ],
270 fields: [ 'pluginId' ],
278 fields: [ 'applicationId' ],
285 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
287 export class UserNotificationModel extends Model {
291 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
293 type: UserNotificationType
297 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
307 @ForeignKey(() => UserModel)
311 @BelongsTo(() => UserModel, {
319 @ForeignKey(() => VideoModel)
323 @BelongsTo(() => VideoModel, {
331 @ForeignKey(() => VideoCommentModel)
335 @BelongsTo(() => VideoCommentModel, {
341 Comment: VideoCommentModel
343 @ForeignKey(() => AbuseModel)
347 @BelongsTo(() => AbuseModel, {
355 @ForeignKey(() => VideoBlacklistModel)
357 videoBlacklistId: number
359 @BelongsTo(() => VideoBlacklistModel, {
365 VideoBlacklist: VideoBlacklistModel
367 @ForeignKey(() => VideoImportModel)
369 videoImportId: number
371 @BelongsTo(() => VideoImportModel, {
377 VideoImport: VideoImportModel
379 @ForeignKey(() => AccountModel)
383 @BelongsTo(() => AccountModel, {
389 Account: AccountModel
391 @ForeignKey(() => ActorFollowModel)
393 actorFollowId: number
395 @BelongsTo(() => ActorFollowModel, {
401 ActorFollow: ActorFollowModel
403 @ForeignKey(() => PluginModel)
407 @BelongsTo(() => PluginModel, {
415 @ForeignKey(() => ApplicationModel)
417 applicationId: number
419 @BelongsTo(() => ApplicationModel, {
425 Application: ApplicationModel
427 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
428 const where = { userId }
430 const query: FindOptions = {
433 order: getSort(sort),
437 if (unread !== undefined) query.where['read'] = !unread
440 UserNotificationModel.count({ where })
441 .then(count => count || 0),
445 : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query)
446 ]).then(([ total, data ]) => ({ total, data }))
449 static markAsRead (userId: number, notificationIds: number[]) {
454 [Op.in]: notificationIds
459 return UserNotificationModel.update({ read: true }, query)
462 static markAllAsRead (userId: number) {
463 const query = { where: { userId } }
465 return UserNotificationModel.update({ read: true }, query)
468 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
469 const id = parseInt(options.id + '', 10)
471 function buildAccountWhereQuery (base: string) {
472 const whereSuffix = options.forUserId
473 ? ` AND "userNotification"."userId" = ${options.forUserId}`
476 if (options.type === 'account') {
478 ` WHERE "account"."id" = ${id} ${whereSuffix}`
482 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
486 buildAccountWhereQuery(
487 `SELECT "userNotification"."id" FROM "userNotification" ` +
488 `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
489 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
492 // Remove notifications from muted accounts that followed ours
493 buildAccountWhereQuery(
494 `SELECT "userNotification"."id" FROM "userNotification" ` +
495 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
496 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
497 `INNER JOIN account ON account."actorId" = actor.id `
500 // Remove notifications from muted accounts that commented something
501 buildAccountWhereQuery(
502 `SELECT "userNotification"."id" FROM "userNotification" ` +
503 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
504 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
505 `INNER JOIN account ON account."actorId" = actor.id `
508 buildAccountWhereQuery(
509 `SELECT "userNotification"."id" FROM "userNotification" ` +
510 `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
511 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
512 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
516 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
518 return UserNotificationModel.sequelize.query(query)
521 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
522 const video = this.Video
523 ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) })
526 const videoImport = this.VideoImport
528 id: this.VideoImport.id,
529 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
530 torrentName: this.VideoImport.torrentName,
531 magnetUri: this.VideoImport.magnetUri,
532 targetUrl: this.VideoImport.targetUrl
536 const comment = this.Comment
539 threadId: this.Comment.getThreadId(),
540 account: this.formatActor(this.Comment.Account),
541 video: this.formatVideo(this.Comment.Video)
545 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
547 const videoBlacklist = this.VideoBlacklist
549 id: this.VideoBlacklist.id,
550 video: this.formatVideo(this.VideoBlacklist.Video)
554 const account = this.Account ? this.formatActor(this.Account) : undefined
556 const actorFollowingType = {
557 Application: 'instance' as 'instance',
558 Group: 'channel' as 'channel',
559 Person: 'account' as 'account'
561 const actorFollow = this.ActorFollow
563 id: this.ActorFollow.id,
564 state: this.ActorFollow.state,
566 id: this.ActorFollow.ActorFollower.Account.id,
567 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
568 name: this.ActorFollow.ActorFollower.preferredUsername,
569 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined,
570 host: this.ActorFollow.ActorFollower.getHost()
573 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
574 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
575 name: this.ActorFollow.ActorFollowing.preferredUsername,
576 host: this.ActorFollow.ActorFollowing.getHost()
581 const plugin = this.Plugin
583 name: this.Plugin.name,
584 type: this.Plugin.type,
585 latestVersion: this.Plugin.latestVersion
589 const peertube = this.Application
590 ? { latestVersion: this.Application.latestPeerTubeVersion }
606 createdAt: this.createdAt.toISOString(),
607 updatedAt: this.updatedAt.toISOString()
611 formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) {
619 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
620 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
622 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
624 video: abuse.VideoCommentAbuse.VideoComment.Video
626 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
627 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
628 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
634 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
636 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined
642 comment: commentAbuse,
643 account: accountAbuse
648 this: UserNotificationModelForApi,
649 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
651 const avatar = accountOrChannel.Actor.Avatar
652 ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
656 id: accountOrChannel.id,
657 displayName: accountOrChannel.getDisplayName(),
658 name: accountOrChannel.Actor.preferredUsername,
659 host: accountOrChannel.Actor.getHost(),