X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Faccount%2Fuser-notification.ts;h=25c5232032132ea977217697b4553456b0c49d3a;hb=32a18cbf33a7cdbbe3d4885d32e4b67e19cdc1cf;hp=9e4f982a335aa4b735e36ac870212555519a8738;hpb=2f1548fda32c3ba9e53913270394eedfacd55986;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts index 9e4f982a3..25c523203 100644 --- a/server/models/account/user-notification.ts +++ b/server/models/account/user-notification.ts @@ -1,145 +1,290 @@ -import { - AllowNull, - BelongsTo, - Column, - CreatedAt, - Default, - ForeignKey, - IFindOptions, - Is, - Model, - Scopes, - Table, - UpdatedAt -} from 'sequelize-typescript' +import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize' +import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' +import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user' import { UserNotification, UserNotificationType } from '../../../shared' -import { getSort, throwIfNotValid } from '../utils' import { isBooleanValid } from '../../helpers/custom-validators/misc' import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' -import { UserModel } from './user' +import { AbuseModel } from '../abuse/abuse' +import { VideoAbuseModel } from '../abuse/video-abuse' +import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' +import { ActorModel } from '../activitypub/actor' +import { ActorFollowModel } from '../activitypub/actor-follow' +import { ApplicationModel } from '../application/application' +import { AvatarModel } from '../avatar/avatar' +import { PluginModel } from '../server/plugin' +import { ServerModel } from '../server/server' +import { getSort, throwIfNotValid } from '../utils' import { VideoModel } from '../video/video' -import { VideoCommentModel } from '../video/video-comment' -import { Op } from 'sequelize' -import { VideoChannelModel } from '../video/video-channel' -import { AccountModel } from './account' -import { VideoAbuseModel } from '../video/video-abuse' import { VideoBlacklistModel } from '../video/video-blacklist' +import { VideoChannelModel } from '../video/video-channel' +import { VideoCommentModel } from '../video/video-comment' import { VideoImportModel } from '../video/video-import' -import { ActorModel } from '../activitypub/actor' -import { ActorFollowModel } from '../activitypub/actor-follow' +import { AccountModel } from './account' +import { UserModel } from './user' enum ScopeNames { WITH_ALL = 'WITH_ALL' } +function buildActorWithAvatarInclude () { + return { + attributes: [ 'preferredUsername' ], + model: ActorModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'filename' ], + model: AvatarModel.unscoped(), + required: false + }, + { + attributes: [ 'host' ], + model: ServerModel.unscoped(), + required: false + } + ] + } +} + function buildVideoInclude (required: boolean) { return { attributes: [ 'id', 'uuid', 'name' ], - model: () => VideoModel.unscoped(), + model: VideoModel.unscoped(), required } } -function buildChannelInclude (required: boolean) { +function buildChannelInclude (required: boolean, withActor = false) { return { required, attributes: [ 'id', 'name' ], - model: () => VideoChannelModel.unscoped() + model: VideoChannelModel.unscoped(), + include: withActor === true ? [ buildActorWithAvatarInclude() ] : [] } } -function buildAccountInclude (required: boolean) { +function buildAccountInclude (required: boolean, withActor = false) { return { required, attributes: [ 'id', 'name' ], - model: () => AccountModel.unscoped() + model: AccountModel.unscoped(), + include: withActor === true ? [ buildActorWithAvatarInclude() ] : [] } } -@Scopes({ +@Scopes(() => ({ [ScopeNames.WITH_ALL]: { include: [ Object.assign(buildVideoInclude(false), { - include: [ buildChannelInclude(true) ] + include: [ buildChannelInclude(true, true) ] }), + { attributes: [ 'id', 'originCommentId' ], - model: () => VideoCommentModel.unscoped(), + model: VideoCommentModel.unscoped(), required: false, include: [ - buildAccountInclude(true), + buildAccountInclude(true, true), buildVideoInclude(true) ] }, + { - attributes: [ 'id' ], - model: () => VideoAbuseModel.unscoped(), + attributes: [ 'id', 'state' ], + model: AbuseModel.unscoped(), required: false, - include: [ buildVideoInclude(true) ] + include: [ + { + attributes: [ 'id' ], + model: VideoAbuseModel.unscoped(), + required: false, + include: [ buildVideoInclude(false) ] + }, + { + attributes: [ 'id' ], + model: VideoCommentAbuseModel.unscoped(), + required: false, + include: [ + { + attributes: [ 'id', 'originCommentId' ], + model: VideoCommentModel.unscoped(), + required: false, + include: [ + { + attributes: [ 'id', 'name', 'uuid' ], + model: VideoModel.unscoped(), + required: false + } + ] + } + ] + }, + { + model: AccountModel, + as: 'FlaggedAccount', + required: false, + include: [ buildActorWithAvatarInclude() ] + } + ] }, + { attributes: [ 'id' ], - model: () => VideoBlacklistModel.unscoped(), + model: VideoBlacklistModel.unscoped(), required: false, include: [ buildVideoInclude(true) ] }, + { attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ], - model: () => VideoImportModel.unscoped(), + model: VideoImportModel.unscoped(), required: false, include: [ buildVideoInclude(false) ] }, + { - attributes: [ 'id', 'name' ], - model: () => AccountModel.unscoped(), - required: false, - include: [ - { - attributes: [ 'id', 'preferredUsername' ], - model: () => ActorModel.unscoped(), - required: true - } - ] + attributes: [ 'id', 'name', 'type', 'latestVersion' ], + model: PluginModel.unscoped(), + required: false }, + { - attributes: [ 'id' ], - model: () => ActorFollowModel.unscoped(), + attributes: [ 'id', 'latestPeerTubeVersion' ], + model: ApplicationModel.unscoped(), + required: false + }, + + { + attributes: [ 'id', 'state' ], + model: ActorFollowModel.unscoped(), required: false, include: [ { attributes: [ 'preferredUsername' ], - model: () => ActorModel.unscoped(), + model: ActorModel.unscoped(), required: true, as: 'ActorFollower', - include: [ buildAccountInclude(true) ] + include: [ + { + attributes: [ 'id', 'name' ], + model: AccountModel.unscoped(), + required: true + }, + { + attributes: [ 'filename' ], + model: AvatarModel.unscoped(), + required: false + }, + { + attributes: [ 'host' ], + model: ServerModel.unscoped(), + required: false + } + ] }, { - attributes: [ 'preferredUsername' ], - model: () => ActorModel.unscoped(), + attributes: [ 'preferredUsername', 'type' ], + model: ActorModel.unscoped(), required: true, as: 'ActorFollowing', include: [ buildChannelInclude(false), - buildAccountInclude(false) + buildAccountInclude(false), + { + attributes: [ 'host' ], + model: ServerModel.unscoped(), + required: false + } ] } ] - } + }, + + buildAccountInclude(false, true) ] } -}) +})) @Table({ tableName: 'userNotification', indexes: [ { - fields: [ 'videoId' ] + fields: [ 'userId' ] + }, + { + fields: [ 'videoId' ], + where: { + videoId: { + [Op.ne]: null + } + } + }, + { + fields: [ 'commentId' ], + where: { + commentId: { + [Op.ne]: null + } + } + }, + { + fields: [ 'abuseId' ], + where: { + abuseId: { + [Op.ne]: null + } + } + }, + { + fields: [ 'videoBlacklistId' ], + where: { + videoBlacklistId: { + [Op.ne]: null + } + } + }, + { + fields: [ 'videoImportId' ], + where: { + videoImportId: { + [Op.ne]: null + } + } + }, + { + fields: [ 'accountId' ], + where: { + accountId: { + [Op.ne]: null + } + } + }, + { + fields: [ 'actorFollowId' ], + where: { + actorFollowId: { + [Op.ne]: null + } + } + }, + { + fields: [ 'pluginId' ], + where: { + pluginId: { + [Op.ne]: null + } + } }, { - fields: [ 'commentId' ] + fields: [ 'applicationId' ], + where: { + applicationId: { + [Op.ne]: null + } + } } - ] + ] as (ModelIndexesOptions & { where?: WhereOptions })[] }) -export class UserNotificationModel extends Model { +export class UserNotificationModel extends Model { @AllowNull(false) @Default(null) @@ -195,17 +340,17 @@ export class UserNotificationModel extends Model { }) Comment: VideoCommentModel - @ForeignKey(() => VideoAbuseModel) + @ForeignKey(() => AbuseModel) @Column - videoAbuseId: number + abuseId: number - @BelongsTo(() => VideoAbuseModel, { + @BelongsTo(() => AbuseModel, { foreignKey: { allowNull: true }, onDelete: 'cascade' }) - VideoAbuse: VideoAbuseModel + Abuse: AbuseModel @ForeignKey(() => VideoBlacklistModel) @Column @@ -255,26 +400,50 @@ export class UserNotificationModel extends Model { }) ActorFollow: ActorFollowModel + @ForeignKey(() => PluginModel) + @Column + pluginId: number + + @BelongsTo(() => PluginModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'cascade' + }) + Plugin: PluginModel + + @ForeignKey(() => ApplicationModel) + @Column + applicationId: number + + @BelongsTo(() => ApplicationModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'cascade' + }) + Application: ApplicationModel + static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { - const query: IFindOptions = { + const where = { userId } + + const query: FindOptions = { offset: start, limit: count, order: getSort(sort), - where: { - userId - } + where } if (unread !== undefined) query.where['read'] = !unread - return UserNotificationModel.scope(ScopeNames.WITH_ALL) - .findAndCountAll(query) - .then(({ rows, count }) => { - return { - data: rows, - total: count - } - }) + return Promise.all([ + UserNotificationModel.count({ where }) + .then(count => count || 0), + + count === 0 + ? [] + : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query) + ]).then(([ total, data ]) => ({ total, data })) } static markAsRead (userId: number, notificationIds: number[]) { @@ -282,7 +451,7 @@ export class UserNotificationModel extends Model { where: { userId, id: { - [Op.any]: notificationIds + [Op.in]: notificationIds } } } @@ -296,60 +465,130 @@ export class UserNotificationModel extends Model { return UserNotificationModel.update({ read: true }, query) } - toFormattedJSON (): UserNotification { - const video = this.Video ? Object.assign(this.formatVideo(this.Video), { - channel: { - id: this.Video.VideoChannel.id, - displayName: this.Video.VideoChannel.getDisplayName() + static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) { + const id = parseInt(options.id + '', 10) + + function buildAccountWhereQuery (base: string) { + const whereSuffix = options.forUserId + ? ` AND "userNotification"."userId" = ${options.forUserId}` + : '' + + if (options.type === 'account') { + return base + + ` WHERE "account"."id" = ${id} ${whereSuffix}` } - }) : undefined - - const videoImport = this.VideoImport ? { - id: this.VideoImport.id, - video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined, - torrentName: this.VideoImport.torrentName, - magnetUri: this.VideoImport.magnetUri, - targetUrl: this.VideoImport.targetUrl - } : undefined - - const comment = this.Comment ? { - id: this.Comment.id, - threadId: this.Comment.getThreadId(), - account: { - id: this.Comment.Account.id, - displayName: this.Comment.Account.getDisplayName() - }, - video: this.formatVideo(this.Comment.Video) - } : undefined - - const videoAbuse = this.VideoAbuse ? { - id: this.VideoAbuse.id, - video: this.formatVideo(this.VideoAbuse.Video) - } : undefined - - const videoBlacklist = this.VideoBlacklist ? { - id: this.VideoBlacklist.id, - video: this.formatVideo(this.VideoBlacklist.Video) - } : undefined - - const account = this.Account ? { - id: this.Account.id, - displayName: this.Account.getDisplayName(), - name: this.Account.Actor.preferredUsername - } : undefined - - const actorFollow = this.ActorFollow ? { - id: this.ActorFollow.id, - follower: { - displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(), - name: this.ActorFollow.ActorFollower.preferredUsername - }, - following: { - type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account', - displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(), - name: this.ActorFollow.ActorFollowing.preferredUsername + + return base + + ` WHERE "actor"."serverId" = ${id} ${whereSuffix}` + } + + const queries = [ + buildAccountWhereQuery( + `SELECT "userNotification"."id" FROM "userNotification" ` + + `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` + + `INNER JOIN actor ON "actor"."id" = "account"."actorId" ` + ), + + // Remove notifications from muted accounts that followed ours + buildAccountWhereQuery( + `SELECT "userNotification"."id" FROM "userNotification" ` + + `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` + + `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` + + `INNER JOIN account ON account."actorId" = actor.id ` + ), + + // Remove notifications from muted accounts that commented something + buildAccountWhereQuery( + `SELECT "userNotification"."id" FROM "userNotification" ` + + `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` + + `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` + + `INNER JOIN account ON account."actorId" = actor.id ` + ), + + buildAccountWhereQuery( + `SELECT "userNotification"."id" FROM "userNotification" ` + + `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` + + `INNER JOIN account ON account.id = "videoComment"."accountId" ` + + `INNER JOIN actor ON "actor"."id" = "account"."actorId" ` + ) + ] + + const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})` + + return UserNotificationModel.sequelize.query(query) + } + + toFormattedJSON (this: UserNotificationModelForApi): UserNotification { + const video = this.Video + ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) }) + : undefined + + const videoImport = this.VideoImport + ? { + id: this.VideoImport.id, + video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined, + torrentName: this.VideoImport.torrentName, + magnetUri: this.VideoImport.magnetUri, + targetUrl: this.VideoImport.targetUrl + } + : undefined + + const comment = this.Comment + ? { + id: this.Comment.id, + threadId: this.Comment.getThreadId(), + account: this.formatActor(this.Comment.Account), + video: this.formatVideo(this.Comment.Video) + } + : undefined + + const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined + + const videoBlacklist = this.VideoBlacklist + ? { + id: this.VideoBlacklist.id, + video: this.formatVideo(this.VideoBlacklist.Video) + } + : undefined + + const account = this.Account ? this.formatActor(this.Account) : undefined + + const actorFollowingType = { + Application: 'instance' as 'instance', + Group: 'channel' as 'channel', + Person: 'account' as 'account' + } + const actorFollow = this.ActorFollow + ? { + id: this.ActorFollow.id, + state: this.ActorFollow.state, + follower: { + id: this.ActorFollow.ActorFollower.Account.id, + displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(), + name: this.ActorFollow.ActorFollower.preferredUsername, + avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined, + host: this.ActorFollow.ActorFollower.getHost() + }, + following: { + type: actorFollowingType[this.ActorFollow.ActorFollowing.type], + displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(), + name: this.ActorFollow.ActorFollowing.preferredUsername, + host: this.ActorFollow.ActorFollowing.getHost() + } } - } : undefined + : undefined + + const plugin = this.Plugin + ? { + name: this.Plugin.name, + type: this.Plugin.type, + latestVersion: this.Plugin.latestVersion + } + : undefined + + const peertube = this.Application + ? { latestVersion: this.Application.latestPeerTubeVersion } + : undefined return { id: this.id, @@ -358,20 +597,67 @@ export class UserNotificationModel extends Model { video, videoImport, comment, - videoAbuse, + abuse, videoBlacklist, account, actorFollow, + plugin, + peertube, createdAt: this.createdAt.toISOString(), updatedAt: this.updatedAt.toISOString() } } - private formatVideo (video: VideoModel) { + formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) { return { id: video.id, uuid: video.uuid, name: video.name } } + + formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) { + const commentAbuse = abuse.VideoCommentAbuse?.VideoComment + ? { + threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(), + + video: abuse.VideoCommentAbuse.VideoComment.Video + ? { + id: abuse.VideoCommentAbuse.VideoComment.Video.id, + name: abuse.VideoCommentAbuse.VideoComment.Video.name, + uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid + } + : undefined + } + : undefined + + const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined + + const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined + + return { + id: abuse.id, + state: abuse.state, + video: videoAbuse, + comment: commentAbuse, + account: accountAbuse + } + } + + formatActor ( + this: UserNotificationModelForApi, + accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor + ) { + const avatar = accountOrChannel.Actor.Avatar + ? { path: accountOrChannel.Actor.Avatar.getStaticPath() } + : undefined + + return { + id: accountOrChannel.id, + displayName: accountOrChannel.getDisplayName(), + name: accountOrChannel.Actor.preferredUsername, + host: accountOrChannel.Actor.getHost(), + avatar + } + } }