Init commit
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import { ActorImageType, UserNotificationType_Type } from '@peertube/peertube-models'
|
||||
import { AbstractListQuery, AbstractListQueryOptions } from '@server/models/shared/abstract-list-query.js'
|
||||
import { Sequelize } from 'sequelize'
|
||||
|
||||
export interface ListNotificationsOptions extends AbstractListQueryOptions {
|
||||
userId: number
|
||||
unread?: boolean
|
||||
typeOneOf?: UserNotificationType_Type[]
|
||||
}
|
||||
|
||||
export class UserNotificationListQueryBuilder extends AbstractListQuery {
|
||||
constructor (
|
||||
protected readonly sequelize: Sequelize,
|
||||
protected readonly options: ListNotificationsOptions
|
||||
) {
|
||||
super(sequelize, { modelName: 'UserNotificationModel', tableName: 'userNotification' }, options)
|
||||
}
|
||||
|
||||
protected buildSubQueryWhere () {
|
||||
this.subQueryWhere = 'WHERE "UserNotificationModel"."userId" = :userId '
|
||||
this.replacements.userId = this.options.userId
|
||||
|
||||
if (this.options.unread === true) {
|
||||
this.subQueryWhere += 'AND "UserNotificationModel"."read" IS FALSE '
|
||||
} else if (this.options.unread === false) {
|
||||
this.subQueryWhere += 'AND "UserNotificationModel"."read" IS TRUE '
|
||||
}
|
||||
|
||||
if (this.options.typeOneOf) {
|
||||
this.subQueryWhere += 'AND "UserNotificationModel"."type" IN (:typeOneOf) '
|
||||
this.replacements.typeOneOf = this.options.typeOneOf
|
||||
}
|
||||
}
|
||||
|
||||
protected buildSubQueryJoin () {
|
||||
}
|
||||
|
||||
protected buildSubQueryAttributes () {
|
||||
this.subQueryAttributes = [
|
||||
...this.subQueryAttributes,
|
||||
|
||||
`*`
|
||||
]
|
||||
}
|
||||
|
||||
protected buildQueryAttributes () {
|
||||
this.attributes = [
|
||||
...this.attributes,
|
||||
|
||||
`"UserNotificationModel"."id"`,
|
||||
`"UserNotificationModel"."type"`,
|
||||
`"UserNotificationModel"."read"`,
|
||||
`"UserNotificationModel"."data"`,
|
||||
`"UserNotificationModel"."createdAt"`,
|
||||
`"UserNotificationModel"."updatedAt"`,
|
||||
|
||||
`"Video"."id" AS "Video.id"`,
|
||||
`"Video"."uuid" AS "Video.uuid"`,
|
||||
`"Video"."name" AS "Video.name"`,
|
||||
`"Video"."state" AS "Video.state"`,
|
||||
...this.getAccountOrChannelAttributes('Video->VideoChannel', 'Video.VideoChannel'),
|
||||
|
||||
`"VideoComment"."id" AS "VideoComment.id"`,
|
||||
`"VideoComment"."originCommentId" AS "VideoComment.originCommentId"`,
|
||||
`"VideoComment"."heldForReview" AS "VideoComment.heldForReview"`,
|
||||
`"VideoComment->Video"."id" AS "VideoComment.Video.id"`,
|
||||
`"VideoComment->Video"."uuid" AS "VideoComment.Video.uuid"`,
|
||||
`"VideoComment->Video"."name" AS "VideoComment.Video.name"`,
|
||||
`"VideoComment->Video"."state" AS "VideoComment.Video.state"`,
|
||||
...this.getAccountOrChannelAttributes('VideoComment->Account', 'VideoComment.Account'),
|
||||
|
||||
`"Abuse"."id" AS "Abuse.id"`,
|
||||
`"Abuse"."state" AS "Abuse.state"`,
|
||||
`"Abuse->VideoAbuse"."id" AS "Abuse.VideoAbuse.id"`,
|
||||
`"Abuse->VideoAbuse->Video"."id" AS "Abuse.VideoAbuse.Video.id"`,
|
||||
`"Abuse->VideoAbuse->Video"."uuid" AS "Abuse.VideoAbuse.Video.uuid"`,
|
||||
`"Abuse->VideoAbuse->Video"."name" AS "Abuse.VideoAbuse.Video.name"`,
|
||||
`"Abuse->VideoAbuse->Video"."state" AS "Abuse.VideoAbuse.Video.state"`,
|
||||
`"Abuse->VideoCommentAbuse"."id" AS "Abuse.VideoCommentAbuse.id"`,
|
||||
`"Abuse->VideoCommentAbuse->VideoComment"."id" AS "Abuse.VideoCommentAbuse.VideoComment.id"`,
|
||||
`"Abuse->VideoCommentAbuse->VideoComment"."originCommentId" AS "Abuse.VideoCommentAbuse.VideoComment.originCommentId"`,
|
||||
`"Abuse->VideoCommentAbuse->VideoComment->Video"."id" AS "Abuse.VideoCommentAbuse.VideoComment.Video.id"`,
|
||||
`"Abuse->VideoCommentAbuse->VideoComment->Video"."name" AS "Abuse.VideoCommentAbuse.VideoComment.Video.name"`,
|
||||
`"Abuse->VideoCommentAbuse->VideoComment->Video"."uuid" AS "Abuse.VideoCommentAbuse.VideoComment.Video.uuid"`,
|
||||
`"Abuse->VideoCommentAbuse->VideoComment->Video"."state" AS "Abuse.VideoCommentAbuse.VideoComment.Video.state"`,
|
||||
...this.getAccountOrChannelAttributes('Abuse->FlaggedAccount', 'Abuse.FlaggedAccount'),
|
||||
|
||||
`"VideoBlacklist"."id" AS "VideoBlacklist.id"`,
|
||||
`"VideoBlacklist->Video"."id" AS "VideoBlacklist.Video.id"`,
|
||||
`"VideoBlacklist->Video"."uuid" AS "VideoBlacklist.Video.uuid"`,
|
||||
`"VideoBlacklist->Video"."name" AS "VideoBlacklist.Video.name"`,
|
||||
`"VideoBlacklist->Video"."state" AS "VideoBlacklist.Video.state"`,
|
||||
|
||||
`"VideoImport"."id" AS "VideoImport.id"`,
|
||||
`"VideoImport"."magnetUri" AS "VideoImport.magnetUri"`,
|
||||
`"VideoImport"."targetUrl" AS "VideoImport.targetUrl"`,
|
||||
`"VideoImport"."torrentName" AS "VideoImport.torrentName"`,
|
||||
`"VideoImport->Video"."id" AS "VideoImport.Video.id"`,
|
||||
`"VideoImport->Video"."uuid" AS "VideoImport.Video.uuid"`,
|
||||
`"VideoImport->Video"."name" AS "VideoImport.Video.name"`,
|
||||
`"VideoImport->Video"."state" AS "VideoImport.Video.state"`,
|
||||
|
||||
`"Plugin"."id" AS "Plugin.id"`,
|
||||
`"Plugin"."name" AS "Plugin.name"`,
|
||||
`"Plugin"."type" AS "Plugin.type"`,
|
||||
`"Plugin"."latestVersion" AS "Plugin.latestVersion"`,
|
||||
|
||||
`"Application"."id" AS "Application.id"`,
|
||||
`"Application"."latestPeerTubeVersion" AS "Application.latestPeerTubeVersion"`,
|
||||
|
||||
`"ActorFollow"."id" AS "ActorFollow.id"`,
|
||||
`"ActorFollow"."state" AS "ActorFollow.state"`,
|
||||
`"ActorFollow->ActorFollower"."id" AS "ActorFollow.ActorFollower.id"`,
|
||||
`"ActorFollow->ActorFollower"."preferredUsername" AS "ActorFollow.ActorFollower.preferredUsername"`,
|
||||
`"ActorFollow->ActorFollower->Account"."id" AS "ActorFollow.ActorFollower.Account.id"`,
|
||||
`"ActorFollow->ActorFollower->Account"."name" AS "ActorFollow.ActorFollower.Account.name"`,
|
||||
`"ActorFollow->ActorFollower->Avatars"."id" AS "ActorFollow.ActorFollower.Avatars.id"`,
|
||||
`"ActorFollow->ActorFollower->Avatars"."width" AS "ActorFollow.ActorFollower.Avatars.width"`,
|
||||
`"ActorFollow->ActorFollower->Avatars"."type" AS "ActorFollow.ActorFollower.Avatars.type"`,
|
||||
`"ActorFollow->ActorFollower->Avatars"."filename" AS "ActorFollow.ActorFollower.Avatars.filename"`,
|
||||
`"ActorFollow->ActorFollower->Server"."id" AS "ActorFollow.ActorFollower.Server.id"`,
|
||||
`"ActorFollow->ActorFollower->Server"."host" AS "ActorFollow.ActorFollower.Server.host"`,
|
||||
`"ActorFollow->ActorFollowing"."id" AS "ActorFollow.ActorFollowing.id"`,
|
||||
`"ActorFollow->ActorFollowing"."preferredUsername" AS "ActorFollow.ActorFollowing.preferredUsername"`,
|
||||
`"ActorFollow->ActorFollowing"."type" AS "ActorFollow.ActorFollowing.type"`,
|
||||
`"ActorFollow->ActorFollowing->VideoChannel"."id" AS "ActorFollow.ActorFollowing.VideoChannel.id"`,
|
||||
`"ActorFollow->ActorFollowing->VideoChannel"."name" AS "ActorFollow.ActorFollowing.VideoChannel.name"`,
|
||||
`"ActorFollow->ActorFollowing->Account"."id" AS "ActorFollow.ActorFollowing.Account.id"`,
|
||||
`"ActorFollow->ActorFollowing->Account"."name" AS "ActorFollow.ActorFollowing.Account.name"`,
|
||||
`"ActorFollow->ActorFollowing->Server"."id" AS "ActorFollow.ActorFollowing.Server.id"`,
|
||||
`"ActorFollow->ActorFollowing->Server"."host" AS "ActorFollow.ActorFollowing.Server.host"`,
|
||||
|
||||
...this.getAccountOrChannelAttributes('Account', 'Account'),
|
||||
|
||||
`"UserRegistration"."id" AS "UserRegistration.id"`,
|
||||
`"UserRegistration"."username" AS "UserRegistration.username"`,
|
||||
|
||||
`"VideoCaption"."id" AS "VideoCaption.id"`,
|
||||
`"VideoCaption"."language" AS "VideoCaption.language"`,
|
||||
`"VideoCaption->Video"."id" AS "VideoCaption.Video.id"`,
|
||||
`"VideoCaption->Video"."uuid" AS "VideoCaption.Video.uuid"`,
|
||||
`"VideoCaption->Video"."name" AS "VideoCaption.Video.name"`,
|
||||
`"VideoCaption->Video"."state" AS "VideoCaption.Video.state"`,
|
||||
|
||||
`"ChannelCollab"."id" AS "ChannelCollab.id"`,
|
||||
`"ChannelCollab"."state" AS "ChannelCollab.state"`,
|
||||
...this.getAccountOrChannelAttributes('ChannelCollab->Account', 'ChannelCollab.Account'),
|
||||
...this.getAccountOrChannelAttributes('ChannelCollab->Channel->Account', 'ChannelCollab.Channel.Account'),
|
||||
...this.getAccountOrChannelAttributes('ChannelCollab->Channel', 'ChannelCollab.Channel')
|
||||
]
|
||||
}
|
||||
|
||||
protected buildQueryJoin () {
|
||||
this.join = `
|
||||
LEFT JOIN (
|
||||
"video" AS "Video"
|
||||
${this.getChannelJoin('Video', 'channelId')}
|
||||
) ON "UserNotificationModel"."videoId" = "Video"."id"
|
||||
|
||||
LEFT JOIN (
|
||||
"videoComment" AS "VideoComment"
|
||||
${this.getAccountJoin('VideoComment', 'accountId')}
|
||||
INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
|
||||
) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
|
||||
|
||||
LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
|
||||
LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
|
||||
LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
|
||||
LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
|
||||
LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
|
||||
ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
|
||||
LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
|
||||
ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
|
||||
LEFT JOIN (
|
||||
"account" AS "Abuse->FlaggedAccount"
|
||||
${this.getActorJoin('Abuse->FlaggedAccount', 'accountId')}
|
||||
) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
|
||||
|
||||
LEFT JOIN (
|
||||
"videoBlacklist" AS "VideoBlacklist"
|
||||
INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
|
||||
) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
|
||||
|
||||
LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
|
||||
LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
|
||||
|
||||
LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
|
||||
|
||||
LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
|
||||
|
||||
LEFT JOIN (
|
||||
"actorFollow" AS "ActorFollow"
|
||||
INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
|
||||
INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
|
||||
ON "ActorFollow->ActorFollower"."accountId" = "ActorFollow->ActorFollower->Account"."id"
|
||||
${this.getActorImageJoin('ActorFollow->ActorFollower')}
|
||||
${this.getActorServerJoin('ActorFollow->ActorFollower')}
|
||||
|
||||
INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
|
||||
LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
|
||||
ON "ActorFollow->ActorFollowing"."videoChannelId" = "ActorFollow->ActorFollowing->VideoChannel"."id"
|
||||
LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
|
||||
ON "ActorFollow->ActorFollowing"."accountId" = "ActorFollow->ActorFollowing->Account"."id"
|
||||
${this.getActorServerJoin('ActorFollow->ActorFollowing')}
|
||||
) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
|
||||
|
||||
LEFT JOIN (
|
||||
"account" AS "Account"
|
||||
${this.getActorJoin('Account', 'accountId')}
|
||||
) ON "UserNotificationModel"."accountId" = "Account"."id"
|
||||
|
||||
LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"
|
||||
|
||||
LEFT JOIN (
|
||||
"videoCaption" AS "VideoCaption"
|
||||
INNER JOIN "video" AS "VideoCaption->Video" ON "VideoCaption"."videoId" = "VideoCaption->Video"."id"
|
||||
) ON "UserNotificationModel"."videoCaptionId" = "VideoCaption"."id"
|
||||
|
||||
LEFT JOIN (
|
||||
"videoChannelCollaborator" AS "ChannelCollab"
|
||||
${this.getAccountJoin('ChannelCollab', 'accountId')}
|
||||
${this.getChannelJoin('ChannelCollab', 'channelId', 'Channel')}
|
||||
${this.getAccountJoin('ChannelCollab->Channel', 'accountId')}
|
||||
) ON "UserNotificationModel"."channelCollaboratorId" = "ChannelCollab"."id"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private getAccountOrChannelAttributes (tableName: string, alias: string) {
|
||||
return [
|
||||
`"${tableName}"."id" AS "${alias}.id"`,
|
||||
`"${tableName}"."name" AS "${alias}.name"`,
|
||||
`"${tableName}->Actor"."id" AS "${alias}.Actor.id"`,
|
||||
`"${tableName}->Actor"."preferredUsername" AS "${alias}.Actor.preferredUsername"`,
|
||||
`"${tableName}->Actor->Avatars"."id" AS "${alias}.Actor.Avatars.id"`,
|
||||
`"${tableName}->Actor->Avatars"."width" AS "${alias}.Actor.Avatars.width"`,
|
||||
`"${tableName}->Actor->Avatars"."type" AS "${alias}.Actor.Avatars.type"`,
|
||||
`"${tableName}->Actor->Avatars"."filename" AS "${alias}.Actor.Avatars.filename"`,
|
||||
`"${tableName}->Actor->Server"."id" AS "${alias}.Actor.Server.id"`,
|
||||
`"${tableName}->Actor->Server"."host" AS "${alias}.Actor.Server.host"`
|
||||
]
|
||||
}
|
||||
|
||||
private getAccountJoin (tableName: string, columnJoin: string) {
|
||||
return `INNER JOIN "account" AS "${tableName}->Account" ON "${tableName}"."${columnJoin}" = "${tableName}->Account"."id" ` +
|
||||
this.getActorJoin(`${tableName}->Account`, 'accountId')
|
||||
}
|
||||
|
||||
private getChannelJoin (tableName: string, columnJoin: string, aliasTableName = 'VideoChannel') {
|
||||
// eslint-disable-next-line max-len
|
||||
return `INNER JOIN "videoChannel" AS "${tableName}->${aliasTableName}" ON "${tableName}"."${columnJoin}" = "${tableName}->${aliasTableName}".id ` +
|
||||
this.getActorJoin(`${tableName}->${aliasTableName}`, 'videoChannelId')
|
||||
}
|
||||
|
||||
private getActorJoin (tableName: string, column: string) {
|
||||
return `INNER JOIN "actor" AS "${tableName}->Actor" ON "${tableName}"."id" = "${tableName}->Actor"."${column}" ` +
|
||||
this.getActorImageJoin(`${tableName}->Actor`) +
|
||||
this.getActorServerJoin(`${tableName}->Actor`)
|
||||
}
|
||||
|
||||
private getActorImageJoin (tableName: string) {
|
||||
return `LEFT JOIN "actorImage" AS "${tableName}->Avatars"
|
||||
ON "${tableName}"."id" = "${tableName}->Avatars"."actorId"
|
||||
AND "${tableName}->Avatars"."type" = ${ActorImageType.AVATAR} `
|
||||
}
|
||||
|
||||
private getActorServerJoin (tableName: string) {
|
||||
return `LEFT JOIN "server" AS "${tableName}->Server"
|
||||
ON "${tableName}"."serverId" = "${tableName}->Server"."id" `
|
||||
}
|
||||
}
|
||||
155
server/core/models/user/sql/user/user-list-query-builder.ts
Normal file
155
server/core/models/user/sql/user/user-list-query-builder.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ActorImageType, VideoChannelCollaboratorState, VideoPlaylistType } from '@peertube/peertube-models'
|
||||
import { AbstractListQuery, AbstractListQueryOptions } from '@server/models/shared/abstract-list-query.js'
|
||||
import { Sequelize } from 'sequelize'
|
||||
import { UserTableAttributes } from './user-table-attributes.js'
|
||||
|
||||
export interface ListUserOptions extends AbstractListQueryOptions {
|
||||
userId: number
|
||||
}
|
||||
|
||||
export class UserListQueryBuilder extends AbstractListQuery {
|
||||
private readonly tableAttributes = new UserTableAttributes()
|
||||
|
||||
private builtAccountJoin = false
|
||||
private builtChannelsJoin = false
|
||||
private builtChannelCollabsJoin = false
|
||||
private builtPlaylistsJoin = false
|
||||
private builtNotificationSettingsJoin = false
|
||||
|
||||
constructor (
|
||||
protected readonly sequelize: Sequelize,
|
||||
protected readonly options: ListUserOptions
|
||||
) {
|
||||
super(sequelize, { modelName: 'UserModel', tableName: 'user' }, options)
|
||||
}
|
||||
|
||||
protected buildSubQueryWhere () {
|
||||
const where: string[] = []
|
||||
|
||||
if (this.options.userId) {
|
||||
where.push('"UserModel"."id" = :userId')
|
||||
|
||||
this.replacements.userId = this.options.userId
|
||||
}
|
||||
|
||||
if (where.length !== 0) {
|
||||
this.subQueryWhere = `WHERE ${where.join(' AND ')}`
|
||||
}
|
||||
}
|
||||
|
||||
private buildAccountJoin () {
|
||||
if (this.builtAccountJoin) return
|
||||
|
||||
this.join += ' INNER JOIN "account" "Account" ON "Account"."userId" = "UserModel"."id" ' +
|
||||
'INNER JOIN "actor" "Account->Actor" ON "Account->Actor"."accountId" = "Account"."id" ' +
|
||||
'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" ' +
|
||||
'LEFT JOIN "actorImage" "Account->Actor->Avatars" ON "Account->Actor->Avatars"."actorId" = "Account->Actor"."id" ' +
|
||||
` AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} `
|
||||
|
||||
this.builtAccountJoin = true
|
||||
}
|
||||
|
||||
private buildChannelsJoin () {
|
||||
if (this.builtChannelsJoin) return
|
||||
|
||||
this.join += ' LEFT JOIN "videoChannel" "Account->VideoChannels" ON "Account"."id" = "Account->VideoChannels"."accountId" ' +
|
||||
'LEFT JOIN "actor" "Account->VideoChannels->Actor" ' +
|
||||
' ON "Account->VideoChannels->Actor"."videoChannelId" = "Account->VideoChannels"."id" ' +
|
||||
'LEFT JOIN "server" "Account->VideoChannels->Actor->Server" ' +
|
||||
' ON "Account->VideoChannels->Actor->Server"."id" = "Account->VideoChannels->Actor"."serverId" ' +
|
||||
'LEFT JOIN "actorImage" "Account->VideoChannels->Actor->Avatars" ' +
|
||||
' ON "Account->VideoChannels->Actor->Avatars"."actorId" = "Account->VideoChannels->Actor"."id" ' +
|
||||
` AND "Account->VideoChannels->Actor->Avatars"."type" = ${ActorImageType.AVATAR} ` +
|
||||
'LEFT JOIN "actorImage" "Account->VideoChannels->Actor->Banners" ' +
|
||||
' ON "Account->VideoChannels->Actor->Banners"."actorId" = "Account->VideoChannels->Actor"."id" ' +
|
||||
` AND "Account->VideoChannels->Actor->Banners"."type" = ${ActorImageType.BANNER} `
|
||||
|
||||
this.builtChannelsJoin = true
|
||||
}
|
||||
|
||||
private buildChannelCollabs () {
|
||||
if (this.builtChannelCollabsJoin) return
|
||||
|
||||
this.join += 'LEFT JOIN "videoChannelCollaborator" AS "Account->Collabs" ' +
|
||||
`ON "Account"."id" = "Account->Collabs"."accountId" AND "Account->Collabs"."state" = ${VideoChannelCollaboratorState.ACCEPTED} ` +
|
||||
'LEFT JOIN (' +
|
||||
' "videoChannel" "Account->Collabs->Channel" ' +
|
||||
' INNER JOIN "actor" AS "Account->Collabs->Channel->Actor" ' +
|
||||
' ON "Account->Collabs->Channel"."id" = "Account->Collabs->Channel->Actor"."videoChannelId" ' +
|
||||
' LEFT JOIN "server" AS "Account->Collabs->Channel->Actor->Server" ' +
|
||||
' ON "Account->Collabs->Channel->Actor"."serverId" = "Account->Collabs->Channel->Actor->Server"."id"' +
|
||||
' LEFT JOIN "actorImage" AS "Account->Collabs->Channel->Actor->Avatars" ' +
|
||||
' ON "Account->Collabs->Channel->Actor"."id" = "Account->Collabs->Channel->Actor->Avatars"."actorId"' +
|
||||
` AND "Account->Collabs->Channel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}` +
|
||||
' LEFT JOIN "actorImage" AS "Account->Collabs->Channel->Actor->Banners" ' +
|
||||
' ON "Account->Collabs->Channel->Actor"."id" = "Account->Collabs->Channel->Actor->Banners"."actorId" ' +
|
||||
` AND "Account->Collabs->Channel->Actor->Banners"."type" = ${ActorImageType.BANNER} ` +
|
||||
') ON "Account->Collabs"."channelId" = "Account->Collabs->Channel"."id"'
|
||||
|
||||
this.builtChannelCollabsJoin = true
|
||||
}
|
||||
|
||||
private buildPlaylistsJoin () {
|
||||
if (this.builtPlaylistsJoin) return
|
||||
|
||||
this.join += 'INNER JOIN "videoPlaylist" AS "Account->VideoPlaylists" ' +
|
||||
'ON "Account"."id" = "Account->VideoPlaylists"."ownerAccountId" ' +
|
||||
`AND "Account->VideoPlaylists"."type" = ${VideoPlaylistType.WATCH_LATER} `
|
||||
|
||||
this.builtPlaylistsJoin = true
|
||||
}
|
||||
|
||||
private buildNotificationSettingsJoin () {
|
||||
if (this.builtNotificationSettingsJoin) return
|
||||
|
||||
this.join += 'INNER JOIN "userNotificationSetting" AS "NotificationSetting" ON "UserModel"."id" = "NotificationSetting"."userId"'
|
||||
|
||||
this.builtNotificationSettingsJoin = true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
protected buildQueryJoin () {
|
||||
this.buildAccountJoin()
|
||||
this.buildChannelsJoin()
|
||||
this.buildChannelCollabs()
|
||||
this.buildPlaylistsJoin()
|
||||
this.buildNotificationSettingsJoin()
|
||||
}
|
||||
|
||||
protected buildQueryAttributes () {
|
||||
this.attributes = [
|
||||
...this.attributes,
|
||||
|
||||
this.tableAttributes.getAccountAttributes(),
|
||||
this.tableAttributes.getAccountActorAttributes(),
|
||||
this.tableAttributes.getAccountServerAttributes(),
|
||||
this.tableAttributes.getAccountAvatarAttributes(),
|
||||
this.tableAttributes.getChannelAttributes(),
|
||||
this.tableAttributes.getChannelActorAttributes(),
|
||||
this.tableAttributes.getChannelServerAttributes(),
|
||||
this.tableAttributes.getChannelAvatarAttributes(),
|
||||
this.tableAttributes.getChannelBannerAttributes(),
|
||||
this.tableAttributes.getPlaylistAttributes(),
|
||||
this.tableAttributes.getNotificationSettingAttributes(),
|
||||
this.tableAttributes.getCollabAttributes(),
|
||||
this.tableAttributes.getCollabChannelAttributes(),
|
||||
this.tableAttributes.getCollabChannelActorAttributes(),
|
||||
this.tableAttributes.getCollabChannelActorServerAttributes(),
|
||||
this.tableAttributes.getCollabChannelActorAvatarAttributes(),
|
||||
this.tableAttributes.getCollabChannelActorBannerAttributes()
|
||||
]
|
||||
}
|
||||
|
||||
protected buildSubQueryJoin () {
|
||||
// empty
|
||||
}
|
||||
|
||||
protected buildSubQueryAttributes () {
|
||||
this.subQueryAttributes = [
|
||||
...this.subQueryAttributes,
|
||||
|
||||
this.tableAttributes.getUserAttributes()
|
||||
]
|
||||
}
|
||||
}
|
||||
120
server/core/models/user/sql/user/user-table-attributes.ts
Normal file
120
server/core/models/user/sql/user/user-table-attributes.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Memoize } from '@server/helpers/memoize.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { ServerModel } from '@server/models/server/server.js'
|
||||
import { UserModel } from '../../user.js'
|
||||
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
|
||||
import { UserNotificationSettingModel } from '../../user-notification-setting.js'
|
||||
import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js'
|
||||
|
||||
export class UserTableAttributes {
|
||||
@Memoize()
|
||||
getUserAttributes () {
|
||||
return UserModel.getSQLAttributes('UserModel').join(', ')
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Memoize()
|
||||
getAccountAttributes () {
|
||||
return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getAccountActorAttributes () {
|
||||
return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getAccountServerAttributes () {
|
||||
return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getAccountAvatarAttributes () {
|
||||
return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Memoize()
|
||||
getChannelAttributes () {
|
||||
return VideoChannelModel.getSQLAttributes('Account->VideoChannels', 'Account.VideoChannels.').join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getChannelActorAttributes () {
|
||||
return ActorModel.getSQLAPIAttributes('Account->VideoChannels->Actor', `Account.VideoChannels.Actor.`).join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getChannelServerAttributes () {
|
||||
return ServerModel.getSQLSummaryAttributes('Account->VideoChannels->Actor->Server', `Account.VideoChannels.Actor.Server.`).join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getChannelAvatarAttributes () {
|
||||
return ActorImageModel.getSQLAttributes('Account->VideoChannels->Actor->Avatars', 'Account.VideoChannels.Actor.Avatars.').join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getChannelBannerAttributes () {
|
||||
return ActorImageModel.getSQLAttributes('Account->VideoChannels->Actor->Banners', 'Account.VideoChannels.Actor.Banners.').join(', ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Memoize()
|
||||
getPlaylistAttributes () {
|
||||
return VideoPlaylistModel.getSQLSummaryAttributes('Account->VideoPlaylists', 'Account.VideoPlaylists.').join(', ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Memoize()
|
||||
getNotificationSettingAttributes () {
|
||||
return UserNotificationSettingModel.getSQLAttributes('NotificationSetting', 'NotificationSetting.').join(', ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Memoize()
|
||||
getCollabAttributes () {
|
||||
return VideoChannelCollaboratorModel.getSQLAttributes('Account->Collabs', 'Account.Collabs.').join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getCollabChannelAttributes () {
|
||||
return VideoChannelModel.getSQLAttributes('Account->Collabs->Channel', 'Account.Collabs.Channel.').join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getCollabChannelActorAttributes () {
|
||||
return ActorModel.getSQLAPIAttributes('Account->Collabs->Channel->Actor', 'Account.Collabs.Channel.Actor.').join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getCollabChannelActorServerAttributes () {
|
||||
return ServerModel.getSQLAttributes(
|
||||
'Account->Collabs->Channel->Actor->Server',
|
||||
'Account.Collabs.Channel.Actor.Server.'
|
||||
).join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getCollabChannelActorAvatarAttributes () {
|
||||
return ActorImageModel.getSQLAttributes(
|
||||
'Account->Collabs->Channel->Actor->Avatars',
|
||||
'Account.Collabs.Channel.Actor.Avatars.'
|
||||
).join(', ')
|
||||
}
|
||||
|
||||
@Memoize()
|
||||
getCollabChannelActorBannerAttributes () {
|
||||
return ActorImageModel.getSQLAttributes(
|
||||
'Account->Collabs->Channel->Actor->Banners',
|
||||
'Account.Collabs.Channel.Actor.Banners.'
|
||||
).join(', ')
|
||||
}
|
||||
}
|
||||
228
server/core/models/user/user-export.ts
Normal file
228
server/core/models/user/user-export.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { FileStorage, UserExportState, type FileStorageType, type UserExport, type UserExportStateType } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import {
|
||||
JWT_TOKEN_USER_EXPORT_FILE_LIFETIME,
|
||||
DOWNLOAD_PATHS,
|
||||
USER_EXPORT_FILE_PREFIX,
|
||||
USER_EXPORT_STATES,
|
||||
WEBSERVER
|
||||
} from '@server/initializers/constants.js'
|
||||
import { removeUserExportObjectStorage } from '@server/lib/object-storage/user-export.js'
|
||||
import { getFSUserExportFilePath } from '@server/lib/paths.js'
|
||||
import { MUserAccountId, MUserExport } from '@server/types/models/index.js'
|
||||
import { remove } from 'fs-extra/esm'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { join } from 'path'
|
||||
import { FindOptions, Op } from 'sequelize'
|
||||
import { AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { doesExist } from '../shared/query.js'
|
||||
import { SequelizeModel } from '../shared/sequelize-type.js'
|
||||
import { getSort } from '../shared/sort.js'
|
||||
import { UserModel } from './user.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'userExport',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'userId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'filename' ],
|
||||
unique: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class UserExportModel extends SequelizeModel<UserExportModel> {
|
||||
@CreatedAt
|
||||
declare createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
declare updatedAt: Date
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
declare filename: string
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
declare withVideoFiles: boolean
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
declare state: UserExportStateType
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.TEXT)
|
||||
declare error: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.BIGINT)
|
||||
declare size: number
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
declare storage: FileStorageType
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
declare fileUrl: string
|
||||
|
||||
@ForeignKey(() => UserModel)
|
||||
@Column
|
||||
declare userId: number
|
||||
|
||||
@BelongsTo(() => UserModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
declare User: Awaited<UserModel>
|
||||
|
||||
@BeforeDestroy
|
||||
static removeFile (instance: UserExportModel) {
|
||||
logger.info('Removing user export file %s.', instance.filename)
|
||||
|
||||
if (instance.storage === FileStorage.FILE_SYSTEM) {
|
||||
remove(getFSUserExportFilePath(instance))
|
||||
.catch(err => logger.error('Cannot delete user export archive %s from filesystem.', instance.filename, { err }))
|
||||
} else {
|
||||
removeUserExportObjectStorage(instance)
|
||||
.catch(err => logger.error('Cannot delete user export archive %s from object storage.', instance.filename, { err }))
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
static listByUser (user: MUserAccountId) {
|
||||
const query: FindOptions = {
|
||||
where: {
|
||||
userId: user.id
|
||||
}
|
||||
}
|
||||
|
||||
return UserExportModel.findAll<MUserExport>(query)
|
||||
}
|
||||
|
||||
static listExpired (expirationTimeMS: number) {
|
||||
const query: FindOptions = {
|
||||
where: {
|
||||
createdAt: {
|
||||
[Op.lt]: new Date(new Date().getTime() - expirationTimeMS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return UserExportModel.findAll<MUserExport>(query)
|
||||
}
|
||||
|
||||
static listForApi (options: {
|
||||
user: MUserAccountId
|
||||
start: number
|
||||
count: number
|
||||
}) {
|
||||
const { count, start, user } = options
|
||||
|
||||
const query: FindOptions = {
|
||||
offset: start,
|
||||
limit: count,
|
||||
order: getSort('createdAt'),
|
||||
where: {
|
||||
userId: user.id
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
UserExportModel.count(query),
|
||||
UserExportModel.findAll<MUserExport>(query)
|
||||
]).then(([ total, data ]) => ({ total, data }))
|
||||
}
|
||||
|
||||
static load (id: number | string) {
|
||||
return UserExportModel.findByPk<MUserExport>(id)
|
||||
}
|
||||
|
||||
static loadByFilename (filename: string) {
|
||||
return UserExportModel.findOne<MUserExport>({ where: { filename } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static async doesOwnedFileExist (filename: string, storage: FileStorageType) {
|
||||
const query = 'SELECT 1 FROM "userExport" ' +
|
||||
`WHERE "filename" = $filename AND "storage" = $storage LIMIT 1`
|
||||
|
||||
return doesExist({ sequelize: this.sequelize, query, bind: { filename, storage } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
generateAndSetFilename () {
|
||||
if (!this.userId) throw new Error('Cannot generate filename without userId')
|
||||
if (!this.createdAt) throw new Error('Cannot generate filename without createdAt')
|
||||
|
||||
this.filename = `${USER_EXPORT_FILE_PREFIX}${this.userId}-${this.createdAt.toISOString()}.zip`
|
||||
}
|
||||
|
||||
canBeSafelyRemoved () {
|
||||
const supportedStates = new Set<UserExportStateType>([ UserExportState.COMPLETED, UserExportState.ERRORED, UserExportState.PENDING ])
|
||||
|
||||
return supportedStates.has(this.state)
|
||||
}
|
||||
|
||||
generateJWT () {
|
||||
return jwt.sign(
|
||||
{
|
||||
userExportId: this.id
|
||||
},
|
||||
CONFIG.SECRETS.PEERTUBE,
|
||||
{
|
||||
expiresIn: JWT_TOKEN_USER_EXPORT_FILE_LIFETIME,
|
||||
audience: this.filename,
|
||||
issuer: WEBSERVER.URL
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
isJWTValid (jwtToken: string) {
|
||||
try {
|
||||
const payload = jwt.verify(jwtToken, CONFIG.SECRETS.PEERTUBE, {
|
||||
audience: this.filename,
|
||||
issuer: WEBSERVER.URL
|
||||
})
|
||||
|
||||
if ((payload as any).userExportId !== this.id) return false
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
getFileDownloadUrl () {
|
||||
if (this.state !== UserExportState.COMPLETED) return null
|
||||
|
||||
return WEBSERVER.URL + join(DOWNLOAD_PATHS.USER_EXPORTS, this.filename) + '?jwt=' + this.generateJWT()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
toFormattedJSON (this: MUserExport): UserExport {
|
||||
return {
|
||||
id: this.id,
|
||||
|
||||
state: {
|
||||
id: this.state,
|
||||
label: USER_EXPORT_STATES[this.state]
|
||||
},
|
||||
|
||||
size: this.size,
|
||||
|
||||
fileUrl: this.fileUrl,
|
||||
privateDownloadUrl: this.getFileDownloadUrl(),
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
expiresOn: new Date(this.createdAt.getTime() + CONFIG.EXPORT.USERS.EXPORT_EXPIRATION).toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
88
server/core/models/user/user-import.ts
Normal file
88
server/core/models/user/user-import.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { MUserImport } from '@server/types/models/index.js'
|
||||
import { SequelizeModel } from '../shared/index.js'
|
||||
import { UserModel } from './user.js'
|
||||
import type { UserImportResultSummary, UserImportStateType } from '@peertube/peertube-models'
|
||||
import { getSort } from '../shared/sort.js'
|
||||
import { USER_IMPORT_STATES } from '@server/initializers/constants.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'userImport',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'userId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'filename' ],
|
||||
unique: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class UserImportModel extends SequelizeModel<UserImportModel> {
|
||||
@CreatedAt
|
||||
declare createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
declare updatedAt: Date
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
declare filename: string
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
declare state: UserImportStateType
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.TEXT)
|
||||
declare error: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.JSONB)
|
||||
declare resultSummary: UserImportResultSummary
|
||||
|
||||
@ForeignKey(() => UserModel)
|
||||
@Column
|
||||
declare userId: number
|
||||
|
||||
@BelongsTo(() => UserModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
declare User: Awaited<UserModel>
|
||||
|
||||
static load (id: number | string) {
|
||||
return UserImportModel.findByPk<MUserImport>(id)
|
||||
}
|
||||
|
||||
static loadLatestByUserId (userId: number) {
|
||||
return UserImportModel.findOne<MUserImport>({
|
||||
where: {
|
||||
userId
|
||||
},
|
||||
order: getSort('-createdAt')
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
generateAndSetFilename () {
|
||||
if (!this.userId) throw new Error('Cannot generate filename without userId')
|
||||
if (!this.createdAt) throw new Error('Cannot generate filename without createdAt')
|
||||
|
||||
this.filename = `user-import-${this.userId}-${this.createdAt.toISOString()}.zip`
|
||||
}
|
||||
|
||||
toFormattedJSON () {
|
||||
return {
|
||||
id: this.id,
|
||||
state: {
|
||||
id: this.state,
|
||||
label: USER_IMPORT_STATES[this.state]
|
||||
},
|
||||
createdAt: this.createdAt.toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
261
server/core/models/user/user-notification-setting.ts
Normal file
261
server/core/models/user/user-notification-setting.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { type UserNotificationSetting, type UserNotificationSettingValueType } from '@peertube/peertube-models'
|
||||
import { TokensCache } from '@server/lib/auth/tokens-cache.js'
|
||||
import { MNotificationSettingFormattable } from '@server/types/models/index.js'
|
||||
import {
|
||||
AfterDestroy,
|
||||
AfterUpdate,
|
||||
AllowNull,
|
||||
BelongsTo,
|
||||
Column,
|
||||
CreatedAt,
|
||||
Default,
|
||||
ForeignKey,
|
||||
Is,
|
||||
Table,
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications.js'
|
||||
import { buildSQLAttributes, SequelizeModel, throwIfNotValid } from '../shared/index.js'
|
||||
import { UserModel } from './user.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'userNotificationSetting',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'userId' ],
|
||||
unique: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class UserNotificationSettingModel extends SequelizeModel<UserNotificationSettingModel> {
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingNewVideoFromSubscription',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newVideoFromSubscription')
|
||||
)
|
||||
@Column
|
||||
declare newVideoFromSubscription: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingNewCommentOnMyVideo',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newCommentOnMyVideo')
|
||||
)
|
||||
@Column
|
||||
declare newCommentOnMyVideo: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingAbuseAsModerator',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseAsModerator')
|
||||
)
|
||||
@Column
|
||||
declare abuseAsModerator: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingVideoAutoBlacklistAsModerator',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAutoBlacklistAsModerator')
|
||||
)
|
||||
@Column
|
||||
declare videoAutoBlacklistAsModerator: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingBlacklistOnMyVideo',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo')
|
||||
)
|
||||
@Column
|
||||
declare blacklistOnMyVideo: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingMyVideoPublished',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoPublished')
|
||||
)
|
||||
@Column
|
||||
declare myVideoPublished: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingMyVideoImportFinished',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoImportFinished')
|
||||
)
|
||||
@Column
|
||||
declare myVideoImportFinished: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingNewUserRegistration',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newUserRegistration')
|
||||
)
|
||||
@Column
|
||||
declare newUserRegistration: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingNewInstanceFollower',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newInstanceFollower')
|
||||
)
|
||||
@Column
|
||||
declare newInstanceFollower: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingNewInstanceFollower',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'autoInstanceFollowing')
|
||||
)
|
||||
@Column
|
||||
declare autoInstanceFollowing: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingNewFollow',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow')
|
||||
)
|
||||
@Column
|
||||
declare newFollow: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingCommentMention',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'commentMention')
|
||||
)
|
||||
@Column
|
||||
declare commentMention: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingAbuseStateChange',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseStateChange')
|
||||
)
|
||||
@Column
|
||||
declare abuseStateChange: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingAbuseNewMessage',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseNewMessage')
|
||||
)
|
||||
@Column
|
||||
declare abuseNewMessage: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingNewPeerTubeVersion',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newPeerTubeVersion')
|
||||
)
|
||||
@Column
|
||||
declare newPeerTubeVersion: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingNewPeerPluginVersion',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newPluginVersion')
|
||||
)
|
||||
@Column
|
||||
declare newPluginVersion: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingMyVideoStudioEditionFinished',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoStudioEditionFinished')
|
||||
)
|
||||
@Column
|
||||
declare myVideoStudioEditionFinished: UserNotificationSettingValueType
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is(
|
||||
'UserNotificationSettingTranscriptionGeneratedForOwner',
|
||||
value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoTranscriptionGenerated')
|
||||
)
|
||||
@Column
|
||||
declare myVideoTranscriptionGenerated: UserNotificationSettingValueType
|
||||
|
||||
@ForeignKey(() => UserModel)
|
||||
@Column
|
||||
declare userId: number
|
||||
|
||||
@BelongsTo(() => UserModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare User: Awaited<UserModel>
|
||||
|
||||
@CreatedAt
|
||||
declare createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
declare updatedAt: Date
|
||||
|
||||
@AfterUpdate
|
||||
@AfterDestroy
|
||||
static removeTokenCache (instance: UserNotificationSettingModel) {
|
||||
return TokensCache.Instance.clearCacheByUserId(instance.userId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getSQLAttributes (tableName: string, aliasPrefix = '') {
|
||||
return buildSQLAttributes({
|
||||
model: this,
|
||||
tableName,
|
||||
aliasPrefix
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static updateUserSettings (settings: UserNotificationSetting, userId: number) {
|
||||
const query = {
|
||||
where: {
|
||||
userId
|
||||
}
|
||||
}
|
||||
|
||||
return UserNotificationSettingModel.update(settings, query)
|
||||
}
|
||||
|
||||
toFormattedJSON (this: MNotificationSettingFormattable): UserNotificationSetting {
|
||||
return {
|
||||
newCommentOnMyVideo: this.newCommentOnMyVideo,
|
||||
newVideoFromSubscription: this.newVideoFromSubscription,
|
||||
abuseAsModerator: this.abuseAsModerator,
|
||||
videoAutoBlacklistAsModerator: this.videoAutoBlacklistAsModerator,
|
||||
blacklistOnMyVideo: this.blacklistOnMyVideo,
|
||||
myVideoPublished: this.myVideoPublished,
|
||||
myVideoImportFinished: this.myVideoImportFinished,
|
||||
newUserRegistration: this.newUserRegistration,
|
||||
commentMention: this.commentMention,
|
||||
newFollow: this.newFollow,
|
||||
newInstanceFollower: this.newInstanceFollower,
|
||||
autoInstanceFollowing: this.autoInstanceFollowing,
|
||||
abuseNewMessage: this.abuseNewMessage,
|
||||
abuseStateChange: this.abuseStateChange,
|
||||
newPeerTubeVersion: this.newPeerTubeVersion,
|
||||
myVideoStudioEditionFinished: this.myVideoStudioEditionFinished,
|
||||
myVideoTranscriptionGenerated: this.myVideoTranscriptionGenerated,
|
||||
newPluginVersion: this.newPluginVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
626
server/core/models/user/user-notification.ts
Normal file
626
server/core/models/user/user-notification.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
import { forceNumber, maxBy } from '@peertube/peertube-core-utils'
|
||||
import type { UserNotification, UserNotificationData, UserNotificationType_Type } from '@peertube/peertube-models'
|
||||
import { uuidToShort } from '@peertube/peertube-node-utils'
|
||||
import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user/index.js'
|
||||
import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
|
||||
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { isBooleanValid } from '../../helpers/custom-validators/misc.js'
|
||||
import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications.js'
|
||||
import { AbuseModel } from '../abuse/abuse.js'
|
||||
import { AccountModel } from '../account/account.js'
|
||||
import { ActorFollowModel } from '../actor/actor-follow.js'
|
||||
import { ActorImageModel } from '../actor/actor-image.js'
|
||||
import { ApplicationModel } from '../application/application.js'
|
||||
import { PluginModel } from '../server/plugin.js'
|
||||
import { SequelizeModel, throwIfNotValid } from '../shared/index.js'
|
||||
import { getStateLabel } from '../video/formatter/video-api-format.js'
|
||||
import { VideoBlacklistModel } from '../video/video-blacklist.js'
|
||||
import { VideoCaptionModel } from '../video/video-caption.js'
|
||||
import { VideoChannelCollaboratorModel } from '../video/video-channel-collaborator.js'
|
||||
import { VideoCommentModel } from '../video/video-comment.js'
|
||||
import { VideoImportModel } from '../video/video-import.js'
|
||||
import { VideoModel } from '../video/video.js'
|
||||
import { UserNotificationListQueryBuilder } from './sql/user-notification/user-notification-list-query-builder.js'
|
||||
import { UserRegistrationModel } from './user-registration.js'
|
||||
import { UserModel } from './user.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'userNotification',
|
||||
indexes: [
|
||||
{
|
||||
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: [ 'applicationId' ],
|
||||
where: {
|
||||
applicationId: {
|
||||
[Op.ne]: null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fields: [ 'userRegistrationId' ],
|
||||
where: {
|
||||
userRegistrationId: {
|
||||
[Op.ne]: null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fields: [ 'channelCollaboratorId' ],
|
||||
where: {
|
||||
channelCollaboratorId: {
|
||||
[Op.ne]: null
|
||||
}
|
||||
}
|
||||
}
|
||||
] as (ModelIndexesOptions & { where?: WhereOptions })[]
|
||||
})
|
||||
export class UserNotificationModel extends SequelizeModel<UserNotificationModel> {
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
|
||||
@Column
|
||||
declare type: UserNotificationType_Type
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(false)
|
||||
@Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
|
||||
@Column
|
||||
declare read: boolean
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.JSONB)
|
||||
declare data: UserNotificationData
|
||||
|
||||
@CreatedAt
|
||||
declare createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
declare updatedAt: Date
|
||||
|
||||
@ForeignKey(() => UserModel)
|
||||
@Column
|
||||
declare userId: number
|
||||
|
||||
@BelongsTo(() => UserModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare User: Awaited<UserModel>
|
||||
|
||||
@ForeignKey(() => VideoModel)
|
||||
@Column
|
||||
declare videoId: number
|
||||
|
||||
@BelongsTo(() => VideoModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare Video: Awaited<VideoModel>
|
||||
|
||||
@ForeignKey(() => VideoCommentModel)
|
||||
@Column
|
||||
declare commentId: number
|
||||
|
||||
@BelongsTo(() => VideoCommentModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare VideoComment: Awaited<VideoCommentModel>
|
||||
|
||||
@ForeignKey(() => AbuseModel)
|
||||
@Column
|
||||
declare abuseId: number
|
||||
|
||||
@BelongsTo(() => AbuseModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare Abuse: Awaited<AbuseModel>
|
||||
|
||||
@ForeignKey(() => VideoBlacklistModel)
|
||||
@Column
|
||||
declare videoBlacklistId: number
|
||||
|
||||
@BelongsTo(() => VideoBlacklistModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare VideoBlacklist: Awaited<VideoBlacklistModel>
|
||||
|
||||
@ForeignKey(() => VideoImportModel)
|
||||
@Column
|
||||
declare videoImportId: number
|
||||
|
||||
@BelongsTo(() => VideoImportModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare VideoImport: Awaited<VideoImportModel>
|
||||
|
||||
@ForeignKey(() => AccountModel)
|
||||
@Column
|
||||
declare accountId: number
|
||||
|
||||
@BelongsTo(() => AccountModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare Account: Awaited<AccountModel>
|
||||
|
||||
@ForeignKey(() => ActorFollowModel)
|
||||
@Column
|
||||
declare actorFollowId: number
|
||||
|
||||
@BelongsTo(() => ActorFollowModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare ActorFollow: Awaited<ActorFollowModel>
|
||||
|
||||
@ForeignKey(() => PluginModel)
|
||||
@Column
|
||||
declare pluginId: number
|
||||
|
||||
@BelongsTo(() => PluginModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare Plugin: Awaited<PluginModel>
|
||||
|
||||
@ForeignKey(() => ApplicationModel)
|
||||
@Column
|
||||
declare applicationId: number
|
||||
|
||||
@BelongsTo(() => ApplicationModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare Application: Awaited<ApplicationModel>
|
||||
|
||||
@ForeignKey(() => UserRegistrationModel)
|
||||
@Column
|
||||
declare userRegistrationId: number
|
||||
|
||||
@BelongsTo(() => UserRegistrationModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare UserRegistration: Awaited<UserRegistrationModel>
|
||||
|
||||
@ForeignKey(() => VideoCaptionModel)
|
||||
@Column
|
||||
declare videoCaptionId: number
|
||||
|
||||
@BelongsTo(() => VideoCaptionModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare VideoCaption: Awaited<VideoCaptionModel>
|
||||
|
||||
@ForeignKey(() => VideoChannelCollaboratorModel)
|
||||
@Column
|
||||
declare channelCollaboratorId: number
|
||||
|
||||
@BelongsTo(() => VideoChannelCollaboratorModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare VideoChannelCollaborator: Awaited<VideoChannelCollaboratorModel>
|
||||
|
||||
static listForApi (options: {
|
||||
userId: number
|
||||
start: number
|
||||
count: number
|
||||
sort: string
|
||||
unread?: boolean
|
||||
typeOneOf?: UserNotificationType_Type[]
|
||||
}) {
|
||||
const { userId, start, count, sort, unread, typeOneOf } = options
|
||||
|
||||
const countWhere = { userId }
|
||||
|
||||
const query = {
|
||||
start,
|
||||
count,
|
||||
sort,
|
||||
|
||||
userId,
|
||||
unread,
|
||||
typeOneOf
|
||||
}
|
||||
|
||||
if (unread !== undefined) countWhere['read'] = !unread
|
||||
if (typeOneOf !== undefined) countWhere['type'] = { [Op.in]: typeOneOf }
|
||||
|
||||
return Promise.all([
|
||||
UserNotificationModel.count({ where: countWhere })
|
||||
.then(count => count || 0),
|
||||
|
||||
count === 0
|
||||
? [] as UserNotificationModelForApi[]
|
||||
: new UserNotificationListQueryBuilder(this.sequelize, query).list<UserNotificationModelForApi>()
|
||||
]).then(([ total, data ]) => ({ total, data }))
|
||||
}
|
||||
|
||||
static markAsRead (userId: number, notificationIds: number[]) {
|
||||
const query = {
|
||||
where: {
|
||||
userId,
|
||||
id: {
|
||||
[Op.in]: notificationIds
|
||||
},
|
||||
read: false
|
||||
}
|
||||
}
|
||||
|
||||
return UserNotificationModel.update({ read: true }, query)
|
||||
}
|
||||
|
||||
static markAllAsRead (userId: number) {
|
||||
const query = { where: { userId, read: false } }
|
||||
|
||||
return UserNotificationModel.update({ read: true }, query)
|
||||
}
|
||||
|
||||
static removeNotificationsOf (options: {
|
||||
id: number
|
||||
type: 'account' | 'server'
|
||||
forUserId?: number
|
||||
}) {
|
||||
const id = forceNumber(options.id)
|
||||
|
||||
function buildAccountWhereQuery (base: string) {
|
||||
const whereSuffix = options.forUserId
|
||||
? ` AND "userNotification"."userId" = ${options.forUserId}`
|
||||
: ''
|
||||
|
||||
if (options.type === 'account') {
|
||||
return base +
|
||||
` WHERE "account"."id" = ${id} ${whereSuffix}`
|
||||
}
|
||||
|
||||
return base +
|
||||
` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
|
||||
}
|
||||
|
||||
const queries = [
|
||||
// Remove notifications from muted accounts
|
||||
buildAccountWhereQuery(
|
||||
`SELECT "userNotification"."id" FROM "userNotification" ` +
|
||||
`INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
|
||||
`INNER JOIN actor ON "actor"."accountId" = "account"."id" `
|
||||
),
|
||||
|
||||
// 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."id" = actor."accountId" `
|
||||
),
|
||||
|
||||
// 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."id" = actor."accountId" `
|
||||
),
|
||||
|
||||
// Remove notifications of comments from muted accounts
|
||||
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"."accountId" = "account"."id" `
|
||||
),
|
||||
|
||||
// Remove notifications from muted accounts that invited us to collaborate to a channel
|
||||
buildAccountWhereQuery(
|
||||
`SELECT "userNotification"."id" FROM "userNotification" ` +
|
||||
`INNER JOIN "videoChannelCollaborator" ON "videoChannelCollaborator".id = "userNotification"."channelCollaboratorId" ` +
|
||||
`INNER JOIN "videoChannel" ON "videoChannel".id = "videoChannelCollaborator"."channelId" ` +
|
||||
`INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ` +
|
||||
`INNER JOIN actor ON "actor"."accountId" = "account"."id" `
|
||||
)
|
||||
]
|
||||
|
||||
const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
|
||||
|
||||
return UserNotificationModel.sequelize.query(query)
|
||||
}
|
||||
|
||||
toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
|
||||
const video = this.Video
|
||||
? {
|
||||
...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.VideoComment
|
||||
? {
|
||||
id: this.VideoComment.id,
|
||||
threadId: this.VideoComment.getThreadId(),
|
||||
account: this.formatActor(this.VideoComment.Account),
|
||||
video: this.formatVideo(this.VideoComment.Video),
|
||||
heldForReview: this.VideoComment.heldForReview
|
||||
}
|
||||
: 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,
|
||||
host: this.ActorFollow.ActorFollower.getHost(),
|
||||
|
||||
...this.formatAvatars(this.ActorFollow.ActorFollower.Avatars)
|
||||
},
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
const registration = this.UserRegistration
|
||||
? { id: this.UserRegistration.id, username: this.UserRegistration.username }
|
||||
: undefined
|
||||
|
||||
const videoCaption = this.VideoCaption
|
||||
? {
|
||||
id: this.VideoCaption.id,
|
||||
language: {
|
||||
id: this.VideoCaption.language,
|
||||
label: VideoCaptionModel.getLanguageLabel(this.VideoCaption.language)
|
||||
},
|
||||
video: this.formatVideo(this.VideoCaption.Video)
|
||||
}
|
||||
: undefined
|
||||
|
||||
const videoChannelCollaborator = this.VideoChannelCollaborator
|
||||
? {
|
||||
id: this.VideoChannelCollaborator.id,
|
||||
channelOwner: this.formatActor(this.VideoChannelCollaborator.Channel.Account),
|
||||
channel: this.formatActor(this.VideoChannelCollaborator.Channel),
|
||||
account: this.formatActor(this.VideoChannelCollaborator.Account),
|
||||
state: {
|
||||
id: this.VideoChannelCollaborator.state,
|
||||
label: VideoChannelCollaboratorModel.getStateLabel(this.VideoChannelCollaborator.state)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
read: this.read,
|
||||
data: this.data,
|
||||
video,
|
||||
videoImport,
|
||||
comment,
|
||||
abuse,
|
||||
videoBlacklist,
|
||||
account,
|
||||
actorFollow,
|
||||
plugin,
|
||||
peertube,
|
||||
registration,
|
||||
videoCaption,
|
||||
videoChannelCollaborator,
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
updatedAt: this.updatedAt.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
formatVideo (video: UserNotificationIncludes.VideoInclude) {
|
||||
return {
|
||||
id: video.id,
|
||||
uuid: video.uuid,
|
||||
shortUUID: uuidToShort(video.uuid),
|
||||
name: video.name,
|
||||
state: {
|
||||
id: video.state,
|
||||
label: getStateLabel(video.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatAbuse (abuse: UserNotificationIncludes.AbuseInclude) {
|
||||
const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
|
||||
? {
|
||||
threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
|
||||
|
||||
video: abuse.VideoCommentAbuse.VideoComment.Video
|
||||
? this.formatVideo(abuse.VideoCommentAbuse.VideoComment.Video)
|
||||
: 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 (
|
||||
accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
|
||||
) {
|
||||
return {
|
||||
id: accountOrChannel.id,
|
||||
displayName: accountOrChannel.getDisplayName(),
|
||||
name: accountOrChannel.Actor.preferredUsername,
|
||||
host: accountOrChannel.Actor.getHost(),
|
||||
|
||||
...this.formatAvatars(accountOrChannel.Actor.Avatars)
|
||||
}
|
||||
}
|
||||
|
||||
formatAvatars (avatars: UserNotificationIncludes.ActorImageInclude[]) {
|
||||
if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
|
||||
|
||||
return {
|
||||
avatar: this.formatAvatar(maxBy(avatars, 'width')),
|
||||
|
||||
avatars: avatars.map(a => this.formatAvatar(a))
|
||||
}
|
||||
}
|
||||
|
||||
formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
|
||||
return {
|
||||
fileUrl: ActorImageModel.getImageUrl(a),
|
||||
path: a.getStaticPath(),
|
||||
width: a.width
|
||||
}
|
||||
}
|
||||
}
|
||||
301
server/core/models/user/user-registration.ts
Normal file
301
server/core/models/user/user-registration.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
import { UserRegistration, UserRegistrationState, type UserRegistrationStateType } from '@peertube/peertube-models'
|
||||
import {
|
||||
isRegistrationModerationResponseValid,
|
||||
isRegistrationReasonValid,
|
||||
isRegistrationStateValid
|
||||
} from '@server/helpers/custom-validators/user-registration.js'
|
||||
import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels.js'
|
||||
import { cryptPassword } from '@server/helpers/peertube-crypto.js'
|
||||
import { USER_REGISTRATION_STATES } from '@server/initializers/constants.js'
|
||||
import { MRegistration, MRegistrationFormattable } from '@server/types/models/index.js'
|
||||
import { col, FindOptions, fn, Op, QueryTypes, where, WhereOptions } from 'sequelize'
|
||||
import {
|
||||
AllowNull,
|
||||
BeforeCreate,
|
||||
BelongsTo,
|
||||
Column,
|
||||
CreatedAt,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Is,
|
||||
IsEmail,
|
||||
Table,
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
import { isUserDisplayNameValid, isUserEmailVerifiedValid } from '../../helpers/custom-validators/users.js'
|
||||
import { getSort, parseAggregateResult, SequelizeModel, throwIfNotValid } from '../shared/index.js'
|
||||
import { UserModel } from './user.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'userRegistration',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'username' ],
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: [ 'email' ],
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: [ 'channelHandle' ],
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: [ 'userId' ],
|
||||
unique: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class UserRegistrationModel extends SequelizeModel<UserRegistrationModel> {
|
||||
@AllowNull(false)
|
||||
@Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state'))
|
||||
@Column
|
||||
declare state: UserRegistrationStateType
|
||||
|
||||
@AllowNull(false)
|
||||
@Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason'))
|
||||
@Column(DataType.TEXT)
|
||||
declare registrationReason: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true))
|
||||
@Column(DataType.TEXT)
|
||||
declare moderationResponse: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
declare password: string
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
declare username: string
|
||||
|
||||
@AllowNull(false)
|
||||
@IsEmail
|
||||
@Column(DataType.STRING(400))
|
||||
declare email: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
|
||||
@Column
|
||||
declare emailVerified: boolean
|
||||
|
||||
@AllowNull(true)
|
||||
@Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true))
|
||||
@Column
|
||||
declare accountDisplayName: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true))
|
||||
@Column
|
||||
declare channelHandle: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true))
|
||||
@Column
|
||||
declare channelDisplayName: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
declare processedAt: Date
|
||||
|
||||
@CreatedAt
|
||||
declare createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
declare updatedAt: Date
|
||||
|
||||
@ForeignKey(() => UserModel)
|
||||
@Column
|
||||
declare userId: number
|
||||
|
||||
@BelongsTo(() => UserModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
declare User: Awaited<UserModel>
|
||||
|
||||
@BeforeCreate
|
||||
static async cryptPasswordIfNeeded (instance: UserRegistrationModel) {
|
||||
instance.password = await cryptPassword(instance.password)
|
||||
}
|
||||
|
||||
static load (id: number): Promise<MRegistration> {
|
||||
return UserRegistrationModel.findByPk(id)
|
||||
}
|
||||
|
||||
static listByEmailCaseInsensitive (email: string): Promise<MRegistration[]> {
|
||||
const query = {
|
||||
where: where(
|
||||
fn('LOWER', col('email')),
|
||||
'=',
|
||||
email.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
return UserRegistrationModel.findAll(query)
|
||||
}
|
||||
|
||||
static listByEmailCaseInsensitiveOrUsername (emailOrUsername: string): Promise<MRegistration[]> {
|
||||
const query = {
|
||||
where: {
|
||||
[Op.or]: [
|
||||
where(
|
||||
fn('LOWER', col('email')),
|
||||
'=',
|
||||
emailOrUsername.toLowerCase()
|
||||
),
|
||||
|
||||
{ username: emailOrUsername }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return UserRegistrationModel.findAll(query)
|
||||
}
|
||||
|
||||
static listByEmailCaseInsensitiveOrHandle (options: {
|
||||
email: string
|
||||
username: string
|
||||
channelHandle?: string
|
||||
}): Promise<MRegistration[]> {
|
||||
const { email, username, channelHandle } = options
|
||||
|
||||
let or: WhereOptions = [
|
||||
where(
|
||||
fn('LOWER', col('email')),
|
||||
'=',
|
||||
email.toLowerCase()
|
||||
),
|
||||
{ channelHandle: username },
|
||||
{ username }
|
||||
]
|
||||
|
||||
if (channelHandle) {
|
||||
or = or.concat([
|
||||
{ username: channelHandle },
|
||||
{ channelHandle }
|
||||
])
|
||||
}
|
||||
|
||||
const query = {
|
||||
where: {
|
||||
[Op.or]: or
|
||||
}
|
||||
}
|
||||
|
||||
return UserRegistrationModel.findAll(query)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static listForApi (options: {
|
||||
start: number
|
||||
count: number
|
||||
sort: string
|
||||
search?: string
|
||||
}) {
|
||||
const { start, count, sort, search } = options
|
||||
|
||||
const where: WhereOptions = {}
|
||||
|
||||
if (search) {
|
||||
Object.assign(where, {
|
||||
[Op.or]: [
|
||||
{
|
||||
email: {
|
||||
[Op.iLike]: '%' + search + '%'
|
||||
}
|
||||
},
|
||||
{
|
||||
username: {
|
||||
[Op.iLike]: '%' + search + '%'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const query: FindOptions = {
|
||||
offset: start,
|
||||
limit: count,
|
||||
order: getSort(sort),
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: UserModel.unscoped(),
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
UserRegistrationModel.count(query),
|
||||
UserRegistrationModel.findAll<MRegistrationFormattable>(query)
|
||||
]).then(([ total, data ]) => ({ total, data }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getStats () {
|
||||
const query = `SELECT ` +
|
||||
`AVG(EXTRACT(EPOCH FROM ("processedAt" - "createdAt") * 1000)) ` +
|
||||
`FILTER (WHERE "processedAt" IS NOT NULL AND "createdAt" > CURRENT_DATE - INTERVAL '3 months')` +
|
||||
`AS "avgResponseTime", ` +
|
||||
// "processedAt" has been introduced in PeerTube 6.1 so also check the abuse state to check processed abuses
|
||||
`COUNT(*) FILTER (WHERE "processedAt" IS NOT NULL OR "state" != ${UserRegistrationState.PENDING}) AS "processedRequests", ` +
|
||||
`COUNT(*) AS "totalRequests" ` +
|
||||
`FROM "userRegistration"`
|
||||
|
||||
return UserRegistrationModel.sequelize.query<any>(query, {
|
||||
type: QueryTypes.SELECT,
|
||||
raw: true
|
||||
}).then(([ row ]) => {
|
||||
return {
|
||||
totalRegistrationRequests: parseAggregateResult(row.totalRequests),
|
||||
|
||||
totalRegistrationRequestsProcessed: parseAggregateResult(row.processedRequests),
|
||||
|
||||
averageRegistrationRequestResponseTimeMs: row?.avgResponseTime
|
||||
? forceNumber(row.avgResponseTime)
|
||||
: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
toFormattedJSON (this: MRegistrationFormattable): UserRegistration {
|
||||
return {
|
||||
id: this.id,
|
||||
|
||||
state: {
|
||||
id: this.state,
|
||||
label: USER_REGISTRATION_STATES[this.state]
|
||||
},
|
||||
|
||||
registrationReason: this.registrationReason,
|
||||
moderationResponse: this.moderationResponse,
|
||||
|
||||
username: this.username,
|
||||
email: this.email,
|
||||
emailVerified: this.emailVerified,
|
||||
|
||||
accountDisplayName: this.accountDisplayName,
|
||||
|
||||
channelHandle: this.channelHandle,
|
||||
channelDisplayName: this.channelDisplayName,
|
||||
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
|
||||
user: this.User
|
||||
? { id: this.User.id }
|
||||
: null
|
||||
}
|
||||
}
|
||||
}
|
||||
135
server/core/models/user/user-video-history.ts
Normal file
135
server/core/models/user/user-video-history.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { DestroyOptions, Op, Transaction } from 'sequelize'
|
||||
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { ResultList } from '@peertube/peertube-models'
|
||||
import { MUserAccountId, MUserId } from '@server/types/models/index.js'
|
||||
import { VideoModel } from '../video/video.js'
|
||||
import { UserModel } from './user.js'
|
||||
import { SequelizeModel } from '../shared/sequelize-type.js'
|
||||
import { USER_EXPORT_MAX_ITEMS } from '@server/initializers/constants.js'
|
||||
import { getSort } from '../shared/sort.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'userVideoHistory',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'userId', 'videoId' ],
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: [ 'userId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'videoId' ]
|
||||
}
|
||||
]
|
||||
})
|
||||
export class UserVideoHistoryModel extends SequelizeModel<UserVideoHistoryModel> {
|
||||
@CreatedAt
|
||||
declare createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
declare updatedAt: Date
|
||||
|
||||
@AllowNull(false)
|
||||
@IsInt
|
||||
@Column
|
||||
declare currentTime: number
|
||||
|
||||
@ForeignKey(() => VideoModel)
|
||||
@Column
|
||||
declare videoId: number
|
||||
|
||||
@BelongsTo(() => VideoModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
declare Video: Awaited<VideoModel>
|
||||
|
||||
@ForeignKey(() => UserModel)
|
||||
@Column
|
||||
declare userId: number
|
||||
|
||||
@BelongsTo(() => UserModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
declare User: Awaited<UserModel>
|
||||
|
||||
// FIXME: have to specify the result type to not break peertube typings generation
|
||||
static listForApi (user: MUserAccountId, start: number, count: number, search?: string): Promise<ResultList<VideoModel>> {
|
||||
return VideoModel.listForApi({
|
||||
start,
|
||||
count,
|
||||
search,
|
||||
sort: '-"userVideoHistory"."updatedAt"',
|
||||
nsfw: null, // All
|
||||
displayOnlyForFollower: null,
|
||||
user,
|
||||
historyOfUser: user
|
||||
})
|
||||
}
|
||||
|
||||
static async listForExport (user: MUserId) {
|
||||
const rows = await UserVideoHistoryModel.findAll({
|
||||
attributes: [ 'createdAt', 'updatedAt', 'currentTime' ],
|
||||
where: {
|
||||
userId: user.id
|
||||
},
|
||||
limit: USER_EXPORT_MAX_ITEMS,
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'url' ],
|
||||
model: VideoModel.unscoped(),
|
||||
required: true
|
||||
}
|
||||
],
|
||||
order: getSort('updatedAt')
|
||||
})
|
||||
|
||||
return rows.map(r => ({ createdAt: r.createdAt, updatedAt: r.updatedAt, currentTime: r.currentTime, videoUrl: r.Video.url }))
|
||||
}
|
||||
|
||||
static removeUserHistoryElement (user: MUserId, videoId: number) {
|
||||
const query: DestroyOptions = {
|
||||
where: {
|
||||
userId: user.id,
|
||||
videoId
|
||||
}
|
||||
}
|
||||
|
||||
return UserVideoHistoryModel.destroy(query)
|
||||
}
|
||||
|
||||
static removeUserHistoryBefore (user: MUserId, beforeDate: string, t: Transaction) {
|
||||
const query: DestroyOptions = {
|
||||
where: {
|
||||
userId: user.id
|
||||
},
|
||||
transaction: t
|
||||
}
|
||||
|
||||
if (beforeDate) {
|
||||
query.where['updatedAt'] = {
|
||||
[Op.lt]: beforeDate
|
||||
}
|
||||
}
|
||||
|
||||
return UserVideoHistoryModel.destroy(query)
|
||||
}
|
||||
|
||||
static removeOldHistory (beforeDate: string) {
|
||||
const query: DestroyOptions = {
|
||||
where: {
|
||||
updatedAt: {
|
||||
[Op.lt]: beforeDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return UserVideoHistoryModel.destroy(query)
|
||||
}
|
||||
}
|
||||
1127
server/core/models/user/user.ts
Normal file
1127
server/core/models/user/user.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user