1 import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
2 import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3 import { getBiggestActorImage } from '@server/lib/actor-image'
4 import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
5 import { forceNumber } from '@shared/core-utils'
6 import { uuidToShort } from '@shared/extra-utils'
7 import { UserNotification, UserNotificationType } from '@shared/models'
8 import { AttributesOnly } from '@shared/typescript-utils'
9 import { isBooleanValid } from '../../helpers/custom-validators/misc'
10 import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
11 import { AbuseModel } from '../abuse/abuse'
12 import { AccountModel } from '../account/account'
13 import { ActorFollowModel } from '../actor/actor-follow'
14 import { ApplicationModel } from '../application/application'
15 import { PluginModel } from '../server/plugin'
16 import { throwIfNotValid } from '../utils'
17 import { VideoModel } from '../video/video'
18 import { VideoBlacklistModel } from '../video/video-blacklist'
19 import { VideoCommentModel } from '../video/video-comment'
20 import { VideoImportModel } from '../video/video-import'
21 import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
22 import { UserModel } from './user'
25 tableName: 'userNotification',
31 fields: [ 'videoId' ],
39 fields: [ 'commentId' ],
47 fields: [ 'abuseId' ],
55 fields: [ 'videoBlacklistId' ],
63 fields: [ 'videoImportId' ],
71 fields: [ 'accountId' ],
79 fields: [ 'actorFollowId' ],
87 fields: [ 'pluginId' ],
95 fields: [ 'applicationId' ],
102 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
104 export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNotificationModel>>> {
108 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
110 type: UserNotificationType
114 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
124 @ForeignKey(() => UserModel)
128 @BelongsTo(() => UserModel, {
136 @ForeignKey(() => VideoModel)
140 @BelongsTo(() => VideoModel, {
148 @ForeignKey(() => VideoCommentModel)
152 @BelongsTo(() => VideoCommentModel, {
158 VideoComment: VideoCommentModel
160 @ForeignKey(() => AbuseModel)
164 @BelongsTo(() => AbuseModel, {
172 @ForeignKey(() => VideoBlacklistModel)
174 videoBlacklistId: number
176 @BelongsTo(() => VideoBlacklistModel, {
182 VideoBlacklist: VideoBlacklistModel
184 @ForeignKey(() => VideoImportModel)
186 videoImportId: number
188 @BelongsTo(() => VideoImportModel, {
194 VideoImport: VideoImportModel
196 @ForeignKey(() => AccountModel)
200 @BelongsTo(() => AccountModel, {
206 Account: AccountModel
208 @ForeignKey(() => ActorFollowModel)
210 actorFollowId: number
212 @BelongsTo(() => ActorFollowModel, {
218 ActorFollow: ActorFollowModel
220 @ForeignKey(() => PluginModel)
224 @BelongsTo(() => PluginModel, {
232 @ForeignKey(() => ApplicationModel)
234 applicationId: number
236 @BelongsTo(() => ApplicationModel, {
242 Application: ApplicationModel
244 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
245 const where = { userId }
256 if (unread !== undefined) query.where['read'] = !unread
259 UserNotificationModel.count({ where })
260 .then(count => count || 0),
263 ? [] as UserNotificationModelForApi[]
264 : new UserNotificationListQueryBuilder(this.sequelize, query).listNotifications()
265 ]).then(([ total, data ]) => ({ total, data }))
268 static markAsRead (userId: number, notificationIds: number[]) {
273 [Op.in]: notificationIds
278 return UserNotificationModel.update({ read: true }, query)
281 static markAllAsRead (userId: number) {
282 const query = { where: { userId } }
284 return UserNotificationModel.update({ read: true }, query)
287 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
288 const id = forceNumber(options.id)
290 function buildAccountWhereQuery (base: string) {
291 const whereSuffix = options.forUserId
292 ? ` AND "userNotification"."userId" = ${options.forUserId}`
295 if (options.type === 'account') {
297 ` WHERE "account"."id" = ${id} ${whereSuffix}`
301 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
305 buildAccountWhereQuery(
306 `SELECT "userNotification"."id" FROM "userNotification" ` +
307 `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
308 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
311 // Remove notifications from muted accounts that followed ours
312 buildAccountWhereQuery(
313 `SELECT "userNotification"."id" FROM "userNotification" ` +
314 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
315 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
316 `INNER JOIN account ON account."actorId" = actor.id `
319 // Remove notifications from muted accounts that commented something
320 buildAccountWhereQuery(
321 `SELECT "userNotification"."id" FROM "userNotification" ` +
322 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
323 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
324 `INNER JOIN account ON account."actorId" = actor.id `
327 buildAccountWhereQuery(
328 `SELECT "userNotification"."id" FROM "userNotification" ` +
329 `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
330 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
331 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
335 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
337 return UserNotificationModel.sequelize.query(query)
340 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
341 const video = this.Video
343 ...this.formatVideo(this.Video),
345 channel: this.formatActor(this.Video.VideoChannel)
349 const videoImport = this.VideoImport
351 id: this.VideoImport.id,
352 video: this.VideoImport.Video
353 ? this.formatVideo(this.VideoImport.Video)
355 torrentName: this.VideoImport.torrentName,
356 magnetUri: this.VideoImport.magnetUri,
357 targetUrl: this.VideoImport.targetUrl
361 const comment = this.VideoComment
363 id: this.VideoComment.id,
364 threadId: this.VideoComment.getThreadId(),
365 account: this.formatActor(this.VideoComment.Account),
366 video: this.formatVideo(this.VideoComment.Video)
370 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
372 const videoBlacklist = this.VideoBlacklist
374 id: this.VideoBlacklist.id,
375 video: this.formatVideo(this.VideoBlacklist.Video)
379 const account = this.Account ? this.formatActor(this.Account) : undefined
381 const actorFollowingType = {
382 Application: 'instance' as 'instance',
383 Group: 'channel' as 'channel',
384 Person: 'account' as 'account'
386 const actorFollow = this.ActorFollow
388 id: this.ActorFollow.id,
389 state: this.ActorFollow.state,
391 id: this.ActorFollow.ActorFollower.Account.id,
392 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
393 name: this.ActorFollow.ActorFollower.preferredUsername,
394 host: this.ActorFollow.ActorFollower.getHost(),
396 ...this.formatAvatars(this.ActorFollow.ActorFollower.Avatars)
399 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
400 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
401 name: this.ActorFollow.ActorFollowing.preferredUsername,
402 host: this.ActorFollow.ActorFollowing.getHost()
407 const plugin = this.Plugin
409 name: this.Plugin.name,
410 type: this.Plugin.type,
411 latestVersion: this.Plugin.latestVersion
415 const peertube = this.Application
416 ? { latestVersion: this.Application.latestPeerTubeVersion }
432 createdAt: this.createdAt.toISOString(),
433 updatedAt: this.updatedAt.toISOString()
437 formatVideo (video: UserNotificationIncludes.VideoInclude) {
441 shortUUID: uuidToShort(video.uuid),
446 formatAbuse (abuse: UserNotificationIncludes.AbuseInclude) {
447 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
449 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
451 video: abuse.VideoCommentAbuse.VideoComment.Video
453 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
454 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
455 shortUUID: uuidToShort(abuse.VideoCommentAbuse.VideoComment.Video.uuid),
456 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
462 const videoAbuse = abuse.VideoAbuse?.Video
463 ? this.formatVideo(abuse.VideoAbuse.Video)
466 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount)
467 ? this.formatActor(abuse.FlaggedAccount)
474 comment: commentAbuse,
475 account: accountAbuse
480 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
483 id: accountOrChannel.id,
484 displayName: accountOrChannel.getDisplayName(),
485 name: accountOrChannel.Actor.preferredUsername,
486 host: accountOrChannel.Actor.getHost(),
488 ...this.formatAvatars(accountOrChannel.Actor.Avatars)
492 formatAvatars (avatars: UserNotificationIncludes.ActorImageInclude[]) {
493 if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
496 avatar: this.formatAvatar(getBiggestActorImage(avatars)),
498 avatars: avatars.map(a => this.formatAvatar(a))
502 formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
504 path: a.getStaticPath(),