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'
30 function buildActorWithAvatarInclude () {
32 attributes: [ 'preferredUsername' ],
33 model: ActorModel.unscoped(),
37 attributes: [ 'filename' ],
39 model: ActorImageModel.unscoped(),
43 attributes: [ 'host' ],
44 model: ServerModel.unscoped(),
51 function buildVideoInclude (required: boolean) {
53 attributes: [ 'id', 'uuid', 'name' ],
54 model: VideoModel.unscoped(),
59 function buildChannelInclude (required: boolean, withActor = false) {
62 attributes: [ 'id', 'name' ],
63 model: VideoChannelModel.unscoped(),
64 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
68 function buildAccountInclude (required: boolean, withActor = false) {
71 attributes: [ 'id', 'name' ],
72 model: AccountModel.unscoped(),
73 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
78 [ScopeNames.WITH_ALL]: {
80 Object.assign(buildVideoInclude(false), {
81 include: [ buildChannelInclude(true, true) ]
85 attributes: [ 'id', 'originCommentId' ],
86 model: VideoCommentModel.unscoped(),
89 buildAccountInclude(true, true),
90 buildVideoInclude(true)
95 attributes: [ 'id', 'state' ],
96 model: AbuseModel.unscoped(),
100 attributes: [ 'id' ],
101 model: VideoAbuseModel.unscoped(),
103 include: [ buildVideoInclude(false) ]
106 attributes: [ 'id' ],
107 model: VideoCommentAbuseModel.unscoped(),
111 attributes: [ 'id', 'originCommentId' ],
112 model: VideoCommentModel.unscoped(),
116 attributes: [ 'id', 'name', 'uuid' ],
117 model: VideoModel.unscoped(),
126 as: 'FlaggedAccount',
128 include: [ buildActorWithAvatarInclude() ]
134 attributes: [ 'id' ],
135 model: VideoBlacklistModel.unscoped(),
137 include: [ buildVideoInclude(true) ]
141 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
142 model: VideoImportModel.unscoped(),
144 include: [ buildVideoInclude(false) ]
148 attributes: [ 'id', 'name', 'type', 'latestVersion' ],
149 model: PluginModel.unscoped(),
154 attributes: [ 'id', 'latestPeerTubeVersion' ],
155 model: ApplicationModel.unscoped(),
160 attributes: [ 'id', 'state' ],
161 model: ActorFollowModel.unscoped(),
165 attributes: [ 'preferredUsername' ],
166 model: ActorModel.unscoped(),
171 attributes: [ 'id', 'name' ],
172 model: AccountModel.unscoped(),
176 attributes: [ 'filename' ],
178 model: ActorImageModel.unscoped(),
182 attributes: [ 'host' ],
183 model: ServerModel.unscoped(),
189 attributes: [ 'preferredUsername', 'type' ],
190 model: ActorModel.unscoped(),
192 as: 'ActorFollowing',
194 buildChannelInclude(false),
195 buildAccountInclude(false),
197 attributes: [ 'host' ],
198 model: ServerModel.unscoped(),
206 buildAccountInclude(false, true)
211 tableName: 'userNotification',
217 fields: [ 'videoId' ],
225 fields: [ 'commentId' ],
233 fields: [ 'abuseId' ],
241 fields: [ 'videoBlacklistId' ],
249 fields: [ 'videoImportId' ],
257 fields: [ 'accountId' ],
265 fields: [ 'actorFollowId' ],
273 fields: [ 'pluginId' ],
281 fields: [ 'applicationId' ],
288 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
290 export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNotificationModel>>> {
294 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
296 type: UserNotificationType
300 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
310 @ForeignKey(() => UserModel)
314 @BelongsTo(() => UserModel, {
322 @ForeignKey(() => VideoModel)
326 @BelongsTo(() => VideoModel, {
334 @ForeignKey(() => VideoCommentModel)
338 @BelongsTo(() => VideoCommentModel, {
344 Comment: VideoCommentModel
346 @ForeignKey(() => AbuseModel)
350 @BelongsTo(() => AbuseModel, {
358 @ForeignKey(() => VideoBlacklistModel)
360 videoBlacklistId: number
362 @BelongsTo(() => VideoBlacklistModel, {
368 VideoBlacklist: VideoBlacklistModel
370 @ForeignKey(() => VideoImportModel)
372 videoImportId: number
374 @BelongsTo(() => VideoImportModel, {
380 VideoImport: VideoImportModel
382 @ForeignKey(() => AccountModel)
386 @BelongsTo(() => AccountModel, {
392 Account: AccountModel
394 @ForeignKey(() => ActorFollowModel)
396 actorFollowId: number
398 @BelongsTo(() => ActorFollowModel, {
404 ActorFollow: ActorFollowModel
406 @ForeignKey(() => PluginModel)
410 @BelongsTo(() => PluginModel, {
418 @ForeignKey(() => ApplicationModel)
420 applicationId: number
422 @BelongsTo(() => ApplicationModel, {
428 Application: ApplicationModel
430 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
431 const where = { userId }
433 const query: FindOptions = {
436 order: getSort(sort),
440 if (unread !== undefined) query.where['read'] = !unread
443 UserNotificationModel.count({ where })
444 .then(count => count || 0),
448 : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query)
449 ]).then(([ total, data ]) => ({ total, data }))
452 static markAsRead (userId: number, notificationIds: number[]) {
457 [Op.in]: notificationIds
462 return UserNotificationModel.update({ read: true }, query)
465 static markAllAsRead (userId: number) {
466 const query = { where: { userId } }
468 return UserNotificationModel.update({ read: true }, query)
471 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
472 const id = parseInt(options.id + '', 10)
474 function buildAccountWhereQuery (base: string) {
475 const whereSuffix = options.forUserId
476 ? ` AND "userNotification"."userId" = ${options.forUserId}`
479 if (options.type === 'account') {
481 ` WHERE "account"."id" = ${id} ${whereSuffix}`
485 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
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" `
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 `
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 `
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" `
519 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
521 return UserNotificationModel.sequelize.query(query)
524 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
525 const video = this.Video
526 ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) })
529 const videoImport = this.VideoImport
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
539 const comment = this.Comment
542 threadId: this.Comment.getThreadId(),
543 account: this.formatActor(this.Comment.Account),
544 video: this.formatVideo(this.Comment.Video)
548 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
550 const videoBlacklist = this.VideoBlacklist
552 id: this.VideoBlacklist.id,
553 video: this.formatVideo(this.VideoBlacklist.Video)
557 const account = this.Account ? this.formatActor(this.Account) : undefined
559 const actorFollowingType = {
560 Application: 'instance' as 'instance',
561 Group: 'channel' as 'channel',
562 Person: 'account' as 'account'
564 const actorFollow = this.ActorFollow
566 id: this.ActorFollow.id,
567 state: this.ActorFollow.state,
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()
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()
584 const plugin = this.Plugin
586 name: this.Plugin.name,
587 type: this.Plugin.type,
588 latestVersion: this.Plugin.latestVersion
592 const peertube = this.Application
593 ? { latestVersion: this.Application.latestPeerTubeVersion }
609 createdAt: this.createdAt.toISOString(),
610 updatedAt: this.updatedAt.toISOString()
614 formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) {
622 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
623 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
625 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
627 video: abuse.VideoCommentAbuse.VideoComment.Video
629 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
630 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
631 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
637 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
639 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined
645 comment: commentAbuse,
646 account: accountAbuse
651 this: UserNotificationModelForApi,
652 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
654 const avatar = accountOrChannel.Actor.Avatar
655 ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
659 id: accountOrChannel.id,
660 displayName: accountOrChannel.getDisplayName(),
661 name: accountOrChannel.Actor.preferredUsername,
662 host: accountOrChannel.Actor.getHost(),