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 { AvatarModel } from '../avatar/avatar'
13 import { ServerModel } from '../server/server'
14 import { getSort, throwIfNotValid } from '../utils'
15 import { VideoModel } from '../video/video'
16 import { VideoBlacklistModel } from '../video/video-blacklist'
17 import { VideoChannelModel } from '../video/video-channel'
18 import { VideoCommentModel } from '../video/video-comment'
19 import { VideoImportModel } from '../video/video-import'
20 import { AccountModel } from './account'
21 import { UserModel } from './user'
27 function buildActorWithAvatarInclude () {
29 attributes: [ 'preferredUsername' ],
30 model: ActorModel.unscoped(),
34 attributes: [ 'filename' ],
35 model: AvatarModel.unscoped(),
39 attributes: [ 'host' ],
40 model: ServerModel.unscoped(),
47 function buildVideoInclude (required: boolean) {
49 attributes: [ 'id', 'uuid', 'name' ],
50 model: VideoModel.unscoped(),
55 function buildChannelInclude (required: boolean, withActor = false) {
58 attributes: [ 'id', 'name' ],
59 model: VideoChannelModel.unscoped(),
60 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
64 function buildAccountInclude (required: boolean, withActor = false) {
67 attributes: [ 'id', 'name' ],
68 model: AccountModel.unscoped(),
69 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
74 [ScopeNames.WITH_ALL]: {
76 Object.assign(buildVideoInclude(false), {
77 include: [ buildChannelInclude(true, true) ]
81 attributes: [ 'id', 'originCommentId' ],
82 model: VideoCommentModel.unscoped(),
85 buildAccountInclude(true, true),
86 buildVideoInclude(true)
91 attributes: [ 'id', 'state' ],
92 model: AbuseModel.unscoped(),
97 model: VideoAbuseModel.unscoped(),
99 include: [ buildVideoInclude(true) ]
102 attributes: [ 'id' ],
103 model: VideoCommentAbuseModel.unscoped(),
107 attributes: [ 'id', 'originCommentId' ],
108 model: VideoCommentModel.unscoped(),
112 attributes: [ 'id', 'name', 'uuid' ],
113 model: VideoModel.unscoped(),
122 as: 'FlaggedAccount',
124 include: [ buildActorWithAvatarInclude() ]
130 attributes: [ 'id' ],
131 model: VideoBlacklistModel.unscoped(),
133 include: [ buildVideoInclude(true) ]
137 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
138 model: VideoImportModel.unscoped(),
140 include: [ buildVideoInclude(false) ]
144 attributes: [ 'id', 'state' ],
145 model: ActorFollowModel.unscoped(),
149 attributes: [ 'preferredUsername' ],
150 model: ActorModel.unscoped(),
155 attributes: [ 'id', 'name' ],
156 model: AccountModel.unscoped(),
160 attributes: [ 'filename' ],
161 model: AvatarModel.unscoped(),
165 attributes: [ 'host' ],
166 model: ServerModel.unscoped(),
172 attributes: [ 'preferredUsername', 'type' ],
173 model: ActorModel.unscoped(),
175 as: 'ActorFollowing',
177 buildChannelInclude(false),
178 buildAccountInclude(false),
180 attributes: [ 'host' ],
181 model: ServerModel.unscoped(),
189 buildAccountInclude(false, true)
194 tableName: 'userNotification',
200 fields: [ 'videoId' ],
208 fields: [ 'commentId' ],
216 fields: [ 'abuseId' ],
224 fields: [ 'videoBlacklistId' ],
232 fields: [ 'videoImportId' ],
240 fields: [ 'accountId' ],
248 fields: [ 'actorFollowId' ],
255 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
257 export class UserNotificationModel extends Model<UserNotificationModel> {
261 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
263 type: UserNotificationType
267 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
277 @ForeignKey(() => UserModel)
281 @BelongsTo(() => UserModel, {
289 @ForeignKey(() => VideoModel)
293 @BelongsTo(() => VideoModel, {
301 @ForeignKey(() => VideoCommentModel)
305 @BelongsTo(() => VideoCommentModel, {
311 Comment: VideoCommentModel
313 @ForeignKey(() => AbuseModel)
317 @BelongsTo(() => AbuseModel, {
325 @ForeignKey(() => VideoBlacklistModel)
327 videoBlacklistId: number
329 @BelongsTo(() => VideoBlacklistModel, {
335 VideoBlacklist: VideoBlacklistModel
337 @ForeignKey(() => VideoImportModel)
339 videoImportId: number
341 @BelongsTo(() => VideoImportModel, {
347 VideoImport: VideoImportModel
349 @ForeignKey(() => AccountModel)
353 @BelongsTo(() => AccountModel, {
359 Account: AccountModel
361 @ForeignKey(() => ActorFollowModel)
363 actorFollowId: number
365 @BelongsTo(() => ActorFollowModel, {
371 ActorFollow: ActorFollowModel
373 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
374 const where = { userId }
376 const query: FindOptions = {
379 order: getSort(sort),
383 if (unread !== undefined) query.where['read'] = !unread
386 UserNotificationModel.count({ where })
387 .then(count => count || 0),
391 : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query)
392 ]).then(([ total, data ]) => ({ total, data }))
395 static markAsRead (userId: number, notificationIds: number[]) {
400 [Op.in]: notificationIds
405 return UserNotificationModel.update({ read: true }, query)
408 static markAllAsRead (userId: number) {
409 const query = { where: { userId } }
411 return UserNotificationModel.update({ read: true }, query)
414 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
415 const id = parseInt(options.id + '', 10)
417 function buildAccountWhereQuery (base: string) {
418 const whereSuffix = options.forUserId
419 ? ` AND "userNotification"."userId" = ${options.forUserId}`
422 if (options.type === 'account') {
424 ` WHERE "account"."id" = ${id} ${whereSuffix}`
428 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
432 buildAccountWhereQuery(
433 `SELECT "userNotification"."id" FROM "userNotification" ` +
434 `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
435 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
438 // Remove notifications from muted accounts that followed ours
439 buildAccountWhereQuery(
440 `SELECT "userNotification"."id" FROM "userNotification" ` +
441 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
442 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
443 `INNER JOIN account ON account."actorId" = actor.id `
446 // Remove notifications from muted accounts that commented something
447 buildAccountWhereQuery(
448 `SELECT "userNotification"."id" FROM "userNotification" ` +
449 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
450 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
451 `INNER JOIN account ON account."actorId" = actor.id `
454 buildAccountWhereQuery(
455 `SELECT "userNotification"."id" FROM "userNotification" ` +
456 `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
457 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
458 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
462 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
464 return UserNotificationModel.sequelize.query(query)
467 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
468 const video = this.Video
469 ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) })
472 const videoImport = this.VideoImport ? {
473 id: this.VideoImport.id,
474 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
475 torrentName: this.VideoImport.torrentName,
476 magnetUri: this.VideoImport.magnetUri,
477 targetUrl: this.VideoImport.targetUrl
480 const comment = this.Comment ? {
482 threadId: this.Comment.getThreadId(),
483 account: this.formatActor(this.Comment.Account),
484 video: this.formatVideo(this.Comment.Video)
487 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
489 const videoBlacklist = this.VideoBlacklist ? {
490 id: this.VideoBlacklist.id,
491 video: this.formatVideo(this.VideoBlacklist.Video)
494 const account = this.Account ? this.formatActor(this.Account) : undefined
496 const actorFollowingType = {
497 Application: 'instance' as 'instance',
498 Group: 'channel' as 'channel',
499 Person: 'account' as 'account'
501 const actorFollow = this.ActorFollow ? {
502 id: this.ActorFollow.id,
503 state: this.ActorFollow.state,
505 id: this.ActorFollow.ActorFollower.Account.id,
506 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
507 name: this.ActorFollow.ActorFollower.preferredUsername,
508 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined,
509 host: this.ActorFollow.ActorFollower.getHost()
512 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
513 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
514 name: this.ActorFollow.ActorFollowing.preferredUsername,
515 host: this.ActorFollow.ActorFollowing.getHost()
530 createdAt: this.createdAt.toISOString(),
531 updatedAt: this.updatedAt.toISOString()
535 formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) {
543 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
544 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment ? {
545 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
548 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
549 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
550 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
554 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
556 const accountAbuse = (!commentAbuse && !videoAbuse) ? this.formatActor(abuse.FlaggedAccount) : undefined
562 comment: commentAbuse,
563 account: accountAbuse
568 this: UserNotificationModelForApi,
569 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
571 const avatar = accountOrChannel.Actor.Avatar
572 ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
576 id: accountOrChannel.id,
577 displayName: accountOrChannel.getDisplayName(),
578 name: accountOrChannel.Actor.preferredUsername,
579 host: accountOrChannel.Actor.getHost(),