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 { AccountModel } from '../account/account'
11 import { ActorModel } from '../actor/actor'
12 import { ActorFollowModel } from '../actor/actor-follow'
13 import { ActorImageModel } from '../actor/actor-image'
14 import { ApplicationModel } from '../application/application'
15 import { PluginModel } from '../server/plugin'
16 import { ServerModel } from '../server/server'
17 import { getSort, throwIfNotValid } from '../utils'
18 import { VideoModel } from '../video/video'
19 import { VideoBlacklistModel } from '../video/video-blacklist'
20 import { VideoChannelModel } from '../video/video-channel'
21 import { VideoCommentModel } from '../video/video-comment'
22 import { VideoImportModel } from '../video/video-import'
23 import { UserModel } from './user'
29 function buildActorWithAvatarInclude () {
31 attributes: [ 'preferredUsername' ],
32 model: ActorModel.unscoped(),
36 attributes: [ 'filename' ],
38 model: ActorImageModel.unscoped(),
42 attributes: [ 'host' ],
43 model: ServerModel.unscoped(),
50 function buildVideoInclude (required: boolean) {
52 attributes: [ 'id', 'uuid', 'name' ],
53 model: VideoModel.unscoped(),
58 function buildChannelInclude (required: boolean, withActor = false) {
61 attributes: [ 'id', 'name' ],
62 model: VideoChannelModel.unscoped(),
63 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
67 function buildAccountInclude (required: boolean, withActor = false) {
70 attributes: [ 'id', 'name' ],
71 model: AccountModel.unscoped(),
72 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
77 [ScopeNames.WITH_ALL]: {
79 Object.assign(buildVideoInclude(false), {
80 include: [ buildChannelInclude(true, true) ]
84 attributes: [ 'id', 'originCommentId' ],
85 model: VideoCommentModel.unscoped(),
88 buildAccountInclude(true, true),
89 buildVideoInclude(true)
94 attributes: [ 'id', 'state' ],
95 model: AbuseModel.unscoped(),
100 model: VideoAbuseModel.unscoped(),
102 include: [ buildVideoInclude(false) ]
105 attributes: [ 'id' ],
106 model: VideoCommentAbuseModel.unscoped(),
110 attributes: [ 'id', 'originCommentId' ],
111 model: VideoCommentModel.unscoped(),
115 attributes: [ 'id', 'name', 'uuid' ],
116 model: VideoModel.unscoped(),
125 as: 'FlaggedAccount',
127 include: [ buildActorWithAvatarInclude() ]
133 attributes: [ 'id' ],
134 model: VideoBlacklistModel.unscoped(),
136 include: [ buildVideoInclude(true) ]
140 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
141 model: VideoImportModel.unscoped(),
143 include: [ buildVideoInclude(false) ]
147 attributes: [ 'id', 'name', 'type', 'latestVersion' ],
148 model: PluginModel.unscoped(),
153 attributes: [ 'id', 'latestPeerTubeVersion' ],
154 model: ApplicationModel.unscoped(),
159 attributes: [ 'id', 'state' ],
160 model: ActorFollowModel.unscoped(),
164 attributes: [ 'preferredUsername' ],
165 model: ActorModel.unscoped(),
170 attributes: [ 'id', 'name' ],
171 model: AccountModel.unscoped(),
175 attributes: [ 'filename' ],
177 model: ActorImageModel.unscoped(),
181 attributes: [ 'host' ],
182 model: ServerModel.unscoped(),
188 attributes: [ 'preferredUsername', 'type' ],
189 model: ActorModel.unscoped(),
191 as: 'ActorFollowing',
193 buildChannelInclude(false),
194 buildAccountInclude(false),
196 attributes: [ 'host' ],
197 model: ServerModel.unscoped(),
205 buildAccountInclude(false, true)
210 tableName: 'userNotification',
216 fields: [ 'videoId' ],
224 fields: [ 'commentId' ],
232 fields: [ 'abuseId' ],
240 fields: [ 'videoBlacklistId' ],
248 fields: [ 'videoImportId' ],
256 fields: [ 'accountId' ],
264 fields: [ 'actorFollowId' ],
272 fields: [ 'pluginId' ],
280 fields: [ 'applicationId' ],
287 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
289 export class UserNotificationModel extends Model {
293 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
295 type: UserNotificationType
299 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
309 @ForeignKey(() => UserModel)
313 @BelongsTo(() => UserModel, {
321 @ForeignKey(() => VideoModel)
325 @BelongsTo(() => VideoModel, {
333 @ForeignKey(() => VideoCommentModel)
337 @BelongsTo(() => VideoCommentModel, {
343 Comment: VideoCommentModel
345 @ForeignKey(() => AbuseModel)
349 @BelongsTo(() => AbuseModel, {
357 @ForeignKey(() => VideoBlacklistModel)
359 videoBlacklistId: number
361 @BelongsTo(() => VideoBlacklistModel, {
367 VideoBlacklist: VideoBlacklistModel
369 @ForeignKey(() => VideoImportModel)
371 videoImportId: number
373 @BelongsTo(() => VideoImportModel, {
379 VideoImport: VideoImportModel
381 @ForeignKey(() => AccountModel)
385 @BelongsTo(() => AccountModel, {
391 Account: AccountModel
393 @ForeignKey(() => ActorFollowModel)
395 actorFollowId: number
397 @BelongsTo(() => ActorFollowModel, {
403 ActorFollow: ActorFollowModel
405 @ForeignKey(() => PluginModel)
409 @BelongsTo(() => PluginModel, {
417 @ForeignKey(() => ApplicationModel)
419 applicationId: number
421 @BelongsTo(() => ApplicationModel, {
427 Application: ApplicationModel
429 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
430 const where = { userId }
432 const query: FindOptions = {
435 order: getSort(sort),
439 if (unread !== undefined) query.where['read'] = !unread
442 UserNotificationModel.count({ where })
443 .then(count => count || 0),
447 : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query)
448 ]).then(([ total, data ]) => ({ total, data }))
451 static markAsRead (userId: number, notificationIds: number[]) {
456 [Op.in]: notificationIds
461 return UserNotificationModel.update({ read: true }, query)
464 static markAllAsRead (userId: number) {
465 const query = { where: { userId } }
467 return UserNotificationModel.update({ read: true }, query)
470 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
471 const id = parseInt(options.id + '', 10)
473 function buildAccountWhereQuery (base: string) {
474 const whereSuffix = options.forUserId
475 ? ` AND "userNotification"."userId" = ${options.forUserId}`
478 if (options.type === 'account') {
480 ` WHERE "account"."id" = ${id} ${whereSuffix}`
484 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
488 buildAccountWhereQuery(
489 `SELECT "userNotification"."id" FROM "userNotification" ` +
490 `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
491 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
494 // Remove notifications from muted accounts that followed ours
495 buildAccountWhereQuery(
496 `SELECT "userNotification"."id" FROM "userNotification" ` +
497 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
498 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
499 `INNER JOIN account ON account."actorId" = actor.id `
502 // Remove notifications from muted accounts that commented something
503 buildAccountWhereQuery(
504 `SELECT "userNotification"."id" FROM "userNotification" ` +
505 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
506 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
507 `INNER JOIN account ON account."actorId" = actor.id `
510 buildAccountWhereQuery(
511 `SELECT "userNotification"."id" FROM "userNotification" ` +
512 `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
513 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
514 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
518 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
520 return UserNotificationModel.sequelize.query(query)
523 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
524 const video = this.Video
525 ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) })
528 const videoImport = this.VideoImport
530 id: this.VideoImport.id,
531 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
532 torrentName: this.VideoImport.torrentName,
533 magnetUri: this.VideoImport.magnetUri,
534 targetUrl: this.VideoImport.targetUrl
538 const comment = this.Comment
541 threadId: this.Comment.getThreadId(),
542 account: this.formatActor(this.Comment.Account),
543 video: this.formatVideo(this.Comment.Video)
547 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
549 const videoBlacklist = this.VideoBlacklist
551 id: this.VideoBlacklist.id,
552 video: this.formatVideo(this.VideoBlacklist.Video)
556 const account = this.Account ? this.formatActor(this.Account) : undefined
558 const actorFollowingType = {
559 Application: 'instance' as 'instance',
560 Group: 'channel' as 'channel',
561 Person: 'account' as 'account'
563 const actorFollow = this.ActorFollow
565 id: this.ActorFollow.id,
566 state: this.ActorFollow.state,
568 id: this.ActorFollow.ActorFollower.Account.id,
569 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
570 name: this.ActorFollow.ActorFollower.preferredUsername,
571 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined,
572 host: this.ActorFollow.ActorFollower.getHost()
575 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
576 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
577 name: this.ActorFollow.ActorFollowing.preferredUsername,
578 host: this.ActorFollow.ActorFollowing.getHost()
583 const plugin = this.Plugin
585 name: this.Plugin.name,
586 type: this.Plugin.type,
587 latestVersion: this.Plugin.latestVersion
591 const peertube = this.Application
592 ? { latestVersion: this.Application.latestPeerTubeVersion }
608 createdAt: this.createdAt.toISOString(),
609 updatedAt: this.updatedAt.toISOString()
613 formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) {
621 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
622 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
624 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
626 video: abuse.VideoCommentAbuse.VideoComment.Video
628 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
629 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
630 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
636 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
638 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined
644 comment: commentAbuse,
645 account: accountAbuse
650 this: UserNotificationModelForApi,
651 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
653 const avatar = accountOrChannel.Actor.Avatar
654 ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
658 id: accountOrChannel.id,
659 displayName: accountOrChannel.getDisplayName(),
660 name: accountOrChannel.Actor.preferredUsername,
661 host: accountOrChannel.Actor.getHost(),