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 { uuidToShort } from '@shared/core-utils'
5 import { AttributesOnly } from '@shared/typescript-utils'
6 import { UserNotification, UserNotificationType } from '../../../shared'
7 import { isBooleanValid } from '../../helpers/custom-validators/misc'
8 import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
9 import { AbuseModel } from '../abuse/abuse'
10 import { VideoAbuseModel } from '../abuse/video-abuse'
11 import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
12 import { AccountModel } from '../account/account'
13 import { ActorModel } from '../actor/actor'
14 import { ActorFollowModel } from '../actor/actor-follow'
15 import { ActorImageModel } from '../actor/actor-image'
16 import { ApplicationModel } from '../application/application'
17 import { PluginModel } from '../server/plugin'
18 import { ServerModel } from '../server/server'
19 import { getSort, throwIfNotValid } from '../utils'
20 import { VideoModel } from '../video/video'
21 import { VideoBlacklistModel } from '../video/video-blacklist'
22 import { VideoChannelModel } from '../video/video-channel'
23 import { VideoCommentModel } from '../video/video-comment'
24 import { VideoImportModel } from '../video/video-import'
25 import { UserModel } from './user'
31 function buildActorWithAvatarInclude () {
33 attributes: [ 'preferredUsername' ],
34 model: ActorModel.unscoped(),
38 attributes: [ 'filename' ],
40 model: ActorImageModel.unscoped(),
44 attributes: [ 'host' ],
45 model: ServerModel.unscoped(),
52 function buildVideoInclude (required: boolean) {
54 attributes: [ 'id', 'uuid', 'name' ],
55 model: VideoModel.unscoped(),
60 function buildChannelInclude (required: boolean, withActor = false) {
63 attributes: [ 'id', 'name' ],
64 model: VideoChannelModel.unscoped(),
65 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
69 function buildAccountInclude (required: boolean, withActor = false) {
72 attributes: [ 'id', 'name' ],
73 model: AccountModel.unscoped(),
74 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
79 [ScopeNames.WITH_ALL]: {
81 Object.assign(buildVideoInclude(false), {
82 include: [ buildChannelInclude(true, true) ]
86 attributes: [ 'id', 'originCommentId' ],
87 model: VideoCommentModel.unscoped(),
90 buildAccountInclude(true, true),
91 buildVideoInclude(true)
96 attributes: [ 'id', 'state' ],
97 model: AbuseModel.unscoped(),
101 attributes: [ 'id' ],
102 model: VideoAbuseModel.unscoped(),
104 include: [ buildVideoInclude(false) ]
107 attributes: [ 'id' ],
108 model: VideoCommentAbuseModel.unscoped(),
112 attributes: [ 'id', 'originCommentId' ],
113 model: VideoCommentModel.unscoped(),
117 attributes: [ 'id', 'name', 'uuid' ],
118 model: VideoModel.unscoped(),
127 as: 'FlaggedAccount',
129 include: [ buildActorWithAvatarInclude() ]
135 attributes: [ 'id' ],
136 model: VideoBlacklistModel.unscoped(),
138 include: [ buildVideoInclude(true) ]
142 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
143 model: VideoImportModel.unscoped(),
145 include: [ buildVideoInclude(false) ]
149 attributes: [ 'id', 'name', 'type', 'latestVersion' ],
150 model: PluginModel.unscoped(),
155 attributes: [ 'id', 'latestPeerTubeVersion' ],
156 model: ApplicationModel.unscoped(),
161 attributes: [ 'id', 'state' ],
162 model: ActorFollowModel.unscoped(),
166 attributes: [ 'preferredUsername' ],
167 model: ActorModel.unscoped(),
172 attributes: [ 'id', 'name' ],
173 model: AccountModel.unscoped(),
177 attributes: [ 'filename' ],
179 model: ActorImageModel.unscoped(),
183 attributes: [ 'host' ],
184 model: ServerModel.unscoped(),
190 attributes: [ 'preferredUsername', 'type' ],
191 model: ActorModel.unscoped(),
193 as: 'ActorFollowing',
195 buildChannelInclude(false),
196 buildAccountInclude(false),
198 attributes: [ 'host' ],
199 model: ServerModel.unscoped(),
207 buildAccountInclude(false, true)
212 tableName: 'userNotification',
218 fields: [ 'videoId' ],
226 fields: [ 'commentId' ],
234 fields: [ 'abuseId' ],
242 fields: [ 'videoBlacklistId' ],
250 fields: [ 'videoImportId' ],
258 fields: [ 'accountId' ],
266 fields: [ 'actorFollowId' ],
274 fields: [ 'pluginId' ],
282 fields: [ 'applicationId' ],
289 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
291 export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNotificationModel>>> {
295 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
297 type: UserNotificationType
301 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
311 @ForeignKey(() => UserModel)
315 @BelongsTo(() => UserModel, {
323 @ForeignKey(() => VideoModel)
327 @BelongsTo(() => VideoModel, {
335 @ForeignKey(() => VideoCommentModel)
339 @BelongsTo(() => VideoCommentModel, {
345 Comment: VideoCommentModel
347 @ForeignKey(() => AbuseModel)
351 @BelongsTo(() => AbuseModel, {
359 @ForeignKey(() => VideoBlacklistModel)
361 videoBlacklistId: number
363 @BelongsTo(() => VideoBlacklistModel, {
369 VideoBlacklist: VideoBlacklistModel
371 @ForeignKey(() => VideoImportModel)
373 videoImportId: number
375 @BelongsTo(() => VideoImportModel, {
381 VideoImport: VideoImportModel
383 @ForeignKey(() => AccountModel)
387 @BelongsTo(() => AccountModel, {
393 Account: AccountModel
395 @ForeignKey(() => ActorFollowModel)
397 actorFollowId: number
399 @BelongsTo(() => ActorFollowModel, {
405 ActorFollow: ActorFollowModel
407 @ForeignKey(() => PluginModel)
411 @BelongsTo(() => PluginModel, {
419 @ForeignKey(() => ApplicationModel)
421 applicationId: number
423 @BelongsTo(() => ApplicationModel, {
429 Application: ApplicationModel
431 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
432 const where = { userId }
434 const query: FindOptions = {
437 order: getSort(sort),
441 if (unread !== undefined) query.where['read'] = !unread
444 UserNotificationModel.count({ where })
445 .then(count => count || 0),
449 : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query)
450 ]).then(([ total, data ]) => ({ total, data }))
453 static markAsRead (userId: number, notificationIds: number[]) {
458 [Op.in]: notificationIds
463 return UserNotificationModel.update({ read: true }, query)
466 static markAllAsRead (userId: number) {
467 const query = { where: { userId } }
469 return UserNotificationModel.update({ read: true }, query)
472 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
473 const id = parseInt(options.id + '', 10)
475 function buildAccountWhereQuery (base: string) {
476 const whereSuffix = options.forUserId
477 ? ` AND "userNotification"."userId" = ${options.forUserId}`
480 if (options.type === 'account') {
482 ` WHERE "account"."id" = ${id} ${whereSuffix}`
486 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
490 buildAccountWhereQuery(
491 `SELECT "userNotification"."id" FROM "userNotification" ` +
492 `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
493 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
496 // Remove notifications from muted accounts that followed ours
497 buildAccountWhereQuery(
498 `SELECT "userNotification"."id" FROM "userNotification" ` +
499 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
500 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
501 `INNER JOIN account ON account."actorId" = actor.id `
504 // Remove notifications from muted accounts that commented something
505 buildAccountWhereQuery(
506 `SELECT "userNotification"."id" FROM "userNotification" ` +
507 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
508 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
509 `INNER JOIN account ON account."actorId" = actor.id `
512 buildAccountWhereQuery(
513 `SELECT "userNotification"."id" FROM "userNotification" ` +
514 `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
515 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
516 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
520 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
522 return UserNotificationModel.sequelize.query(query)
525 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
526 const video = this.Video
527 ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) })
530 const videoImport = this.VideoImport
532 id: this.VideoImport.id,
533 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
534 torrentName: this.VideoImport.torrentName,
535 magnetUri: this.VideoImport.magnetUri,
536 targetUrl: this.VideoImport.targetUrl
540 const comment = this.Comment
543 threadId: this.Comment.getThreadId(),
544 account: this.formatActor(this.Comment.Account),
545 video: this.formatVideo(this.Comment.Video)
549 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
551 const videoBlacklist = this.VideoBlacklist
553 id: this.VideoBlacklist.id,
554 video: this.formatVideo(this.VideoBlacklist.Video)
558 const account = this.Account ? this.formatActor(this.Account) : undefined
560 const actorFollowingType = {
561 Application: 'instance' as 'instance',
562 Group: 'channel' as 'channel',
563 Person: 'account' as 'account'
565 const actorFollow = this.ActorFollow
567 id: this.ActorFollow.id,
568 state: this.ActorFollow.state,
570 id: this.ActorFollow.ActorFollower.Account.id,
571 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
572 name: this.ActorFollow.ActorFollower.preferredUsername,
573 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined,
574 host: this.ActorFollow.ActorFollower.getHost()
577 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
578 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
579 name: this.ActorFollow.ActorFollowing.preferredUsername,
580 host: this.ActorFollow.ActorFollowing.getHost()
585 const plugin = this.Plugin
587 name: this.Plugin.name,
588 type: this.Plugin.type,
589 latestVersion: this.Plugin.latestVersion
593 const peertube = this.Application
594 ? { latestVersion: this.Application.latestPeerTubeVersion }
610 createdAt: this.createdAt.toISOString(),
611 updatedAt: this.updatedAt.toISOString()
615 formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) {
619 shortUUID: uuidToShort(video.uuid),
624 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
625 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
627 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
629 video: abuse.VideoCommentAbuse.VideoComment.Video
631 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
632 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
633 shortUUID: uuidToShort(abuse.VideoCommentAbuse.VideoComment.Video.uuid),
634 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
640 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
642 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined
648 comment: commentAbuse,
649 account: accountAbuse
654 this: UserNotificationModelForApi,
655 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
657 const avatar = accountOrChannel.Actor.Avatar
658 ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
662 id: accountOrChannel.id,
663 displayName: accountOrChannel.getDisplayName(),
664 name: accountOrChannel.Actor.preferredUsername,
665 host: accountOrChannel.Actor.getHost(),