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 { uuidToShort } from '@shared/extra-utils'
6 import { UserNotification, UserNotificationType } from '@shared/models'
7 import { AttributesOnly } from '@shared/typescript-utils'
8 import { isBooleanValid } from '../../helpers/custom-validators/misc'
9 import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
10 import { AbuseModel } from '../abuse/abuse'
11 import { AccountModel } from '../account/account'
12 import { ActorFollowModel } from '../actor/actor-follow'
13 import { ApplicationModel } from '../application/application'
14 import { PluginModel } from '../server/plugin'
15 import { throwIfNotValid } from '../utils'
16 import { VideoModel } from '../video/video'
17 import { VideoBlacklistModel } from '../video/video-blacklist'
18 import { VideoCommentModel } from '../video/video-comment'
19 import { VideoImportModel } from '../video/video-import'
20 import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
21 import { UserModel } from './user'
24 tableName: 'userNotification',
30 fields: [ 'videoId' ],
38 fields: [ 'commentId' ],
46 fields: [ 'abuseId' ],
54 fields: [ 'videoBlacklistId' ],
62 fields: [ 'videoImportId' ],
70 fields: [ 'accountId' ],
78 fields: [ 'actorFollowId' ],
86 fields: [ 'pluginId' ],
94 fields: [ 'applicationId' ],
101 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
103 export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNotificationModel>>> {
107 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
109 type: UserNotificationType
113 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
123 @ForeignKey(() => UserModel)
127 @BelongsTo(() => UserModel, {
135 @ForeignKey(() => VideoModel)
139 @BelongsTo(() => VideoModel, {
147 @ForeignKey(() => VideoCommentModel)
151 @BelongsTo(() => VideoCommentModel, {
157 VideoComment: VideoCommentModel
159 @ForeignKey(() => AbuseModel)
163 @BelongsTo(() => AbuseModel, {
171 @ForeignKey(() => VideoBlacklistModel)
173 videoBlacklistId: number
175 @BelongsTo(() => VideoBlacklistModel, {
181 VideoBlacklist: VideoBlacklistModel
183 @ForeignKey(() => VideoImportModel)
185 videoImportId: number
187 @BelongsTo(() => VideoImportModel, {
193 VideoImport: VideoImportModel
195 @ForeignKey(() => AccountModel)
199 @BelongsTo(() => AccountModel, {
205 Account: AccountModel
207 @ForeignKey(() => ActorFollowModel)
209 actorFollowId: number
211 @BelongsTo(() => ActorFollowModel, {
217 ActorFollow: ActorFollowModel
219 @ForeignKey(() => PluginModel)
223 @BelongsTo(() => PluginModel, {
231 @ForeignKey(() => ApplicationModel)
233 applicationId: number
235 @BelongsTo(() => ApplicationModel, {
241 Application: ApplicationModel
243 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
244 const where = { userId }
255 if (unread !== undefined) query.where['read'] = !unread
258 UserNotificationModel.count({ where })
259 .then(count => count || 0),
262 ? [] as UserNotificationModelForApi[]
263 : new UserNotificationListQueryBuilder(this.sequelize, query).listNotifications()
264 ]).then(([ total, data ]) => ({ total, data }))
267 static markAsRead (userId: number, notificationIds: number[]) {
272 [Op.in]: notificationIds
277 return UserNotificationModel.update({ read: true }, query)
280 static markAllAsRead (userId: number) {
281 const query = { where: { userId } }
283 return UserNotificationModel.update({ read: true }, query)
286 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
287 const id = parseInt(options.id + '', 10)
289 function buildAccountWhereQuery (base: string) {
290 const whereSuffix = options.forUserId
291 ? ` AND "userNotification"."userId" = ${options.forUserId}`
294 if (options.type === 'account') {
296 ` WHERE "account"."id" = ${id} ${whereSuffix}`
300 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
304 buildAccountWhereQuery(
305 `SELECT "userNotification"."id" FROM "userNotification" ` +
306 `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
307 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
310 // Remove notifications from muted accounts that followed ours
311 buildAccountWhereQuery(
312 `SELECT "userNotification"."id" FROM "userNotification" ` +
313 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
314 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
315 `INNER JOIN account ON account."actorId" = actor.id `
318 // Remove notifications from muted accounts that commented something
319 buildAccountWhereQuery(
320 `SELECT "userNotification"."id" FROM "userNotification" ` +
321 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
322 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
323 `INNER JOIN account ON account."actorId" = actor.id `
326 buildAccountWhereQuery(
327 `SELECT "userNotification"."id" FROM "userNotification" ` +
328 `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
329 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
330 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
334 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
336 return UserNotificationModel.sequelize.query(query)
339 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
340 const video = this.Video
342 ...this.formatVideo(this.Video),
344 channel: this.formatActor(this.Video.VideoChannel)
348 const videoImport = this.VideoImport
350 id: this.VideoImport.id,
351 video: this.VideoImport.Video
352 ? this.formatVideo(this.VideoImport.Video)
354 torrentName: this.VideoImport.torrentName,
355 magnetUri: this.VideoImport.magnetUri,
356 targetUrl: this.VideoImport.targetUrl
360 const comment = this.VideoComment
362 id: this.VideoComment.id,
363 threadId: this.VideoComment.getThreadId(),
364 account: this.formatActor(this.VideoComment.Account),
365 video: this.formatVideo(this.VideoComment.Video)
369 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
371 const videoBlacklist = this.VideoBlacklist
373 id: this.VideoBlacklist.id,
374 video: this.formatVideo(this.VideoBlacklist.Video)
378 const account = this.Account ? this.formatActor(this.Account) : undefined
380 const actorFollowingType = {
381 Application: 'instance' as 'instance',
382 Group: 'channel' as 'channel',
383 Person: 'account' as 'account'
385 const actorFollow = this.ActorFollow
387 id: this.ActorFollow.id,
388 state: this.ActorFollow.state,
390 id: this.ActorFollow.ActorFollower.Account.id,
391 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
392 name: this.ActorFollow.ActorFollower.preferredUsername,
393 host: this.ActorFollow.ActorFollower.getHost(),
395 ...this.formatAvatars(this.ActorFollow.ActorFollower.Avatars)
398 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
399 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
400 name: this.ActorFollow.ActorFollowing.preferredUsername,
401 host: this.ActorFollow.ActorFollowing.getHost()
406 const plugin = this.Plugin
408 name: this.Plugin.name,
409 type: this.Plugin.type,
410 latestVersion: this.Plugin.latestVersion
414 const peertube = this.Application
415 ? { latestVersion: this.Application.latestPeerTubeVersion }
431 createdAt: this.createdAt.toISOString(),
432 updatedAt: this.updatedAt.toISOString()
436 formatVideo (video: UserNotificationIncludes.VideoInclude) {
440 shortUUID: uuidToShort(video.uuid),
445 formatAbuse (abuse: UserNotificationIncludes.AbuseInclude) {
446 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
448 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
450 video: abuse.VideoCommentAbuse.VideoComment.Video
452 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
453 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
454 shortUUID: uuidToShort(abuse.VideoCommentAbuse.VideoComment.Video.uuid),
455 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
461 const videoAbuse = abuse.VideoAbuse?.Video
462 ? this.formatVideo(abuse.VideoAbuse.Video)
465 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount)
466 ? this.formatActor(abuse.FlaggedAccount)
473 comment: commentAbuse,
474 account: accountAbuse
479 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
482 id: accountOrChannel.id,
483 displayName: accountOrChannel.getDisplayName(),
484 name: accountOrChannel.Actor.preferredUsername,
485 host: accountOrChannel.Actor.getHost(),
487 ...this.formatAvatars(accountOrChannel.Actor.Avatars)
491 formatAvatars (avatars: UserNotificationIncludes.ActorImageInclude[]) {
492 if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
495 avatar: this.formatAvatar(getBiggestActorImage(avatars)),
497 avatars: avatars.map(a => this.formatAvatar(a))
501 formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
503 path: a.getStaticPath(),