Use raw sql for comments
This commit is contained in:
parent
458685e0d0
commit
cde3d90ded
|
@ -309,7 +309,7 @@ async function videoCommentsController (req: express.Request, res: express.Respo
|
||||||
if (redirectIfNotOwned(video.url, res)) return
|
if (redirectIfNotOwned(video.url, res)) return
|
||||||
|
|
||||||
const handler = async (start: number, count: number) => {
|
const handler = async (start: number, count: number) => {
|
||||||
const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count)
|
const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: result.total,
|
total: result.total,
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { MCommentFormattable } from '@server/types/models'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
|
||||||
import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
|
import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
|
||||||
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
|
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
|
||||||
import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
|
import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
|
||||||
|
@ -109,7 +111,7 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
|
||||||
const video = res.locals.onlyVideo
|
const video = res.locals.onlyVideo
|
||||||
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
|
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
|
||||||
|
|
||||||
let resultList: ThreadsResultList<VideoCommentModel>
|
let resultList: ThreadsResultList<MCommentFormattable>
|
||||||
|
|
||||||
if (video.commentsEnabled === true) {
|
if (video.commentsEnabled === true) {
|
||||||
const apiOptions = await Hooks.wrapObject({
|
const apiOptions = await Hooks.wrapObject({
|
||||||
|
@ -144,12 +146,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
|
||||||
const video = res.locals.onlyVideo
|
const video = res.locals.onlyVideo
|
||||||
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
|
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
|
||||||
|
|
||||||
let resultList: ResultList<VideoCommentModel>
|
let resultList: ResultList<MCommentFormattable>
|
||||||
|
|
||||||
if (video.commentsEnabled === true) {
|
if (video.commentsEnabled === true) {
|
||||||
const apiOptions = await Hooks.wrapObject({
|
const apiOptions = await Hooks.wrapObject({
|
||||||
videoId: video.id,
|
videoId: video.id,
|
||||||
isVideoOwned: video.isOwned(),
|
|
||||||
threadId: res.locals.videoCommentThread.id,
|
threadId: res.locals.videoCommentThread.id,
|
||||||
user
|
user
|
||||||
}, 'filter:api.video-thread-comments.list.params')
|
}, 'filter:api.video-thread-comments.list.params')
|
||||||
|
|
|
@ -1,31 +1,42 @@
|
||||||
|
import express from 'express'
|
||||||
import { cloneDeep } from 'lodash'
|
import { cloneDeep } from 'lodash'
|
||||||
import * as Sequelize from 'sequelize'
|
import * as Sequelize from 'sequelize'
|
||||||
import express from 'express'
|
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
import { sequelizeTypescript } from '@server/initializers/database'
|
import { sequelizeTypescript } from '@server/initializers/database'
|
||||||
import { ResultList } from '../../shared/models'
|
import { ResultList } from '../../shared/models'
|
||||||
import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
|
import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
|
||||||
import { VideoCommentModel } from '../models/video/video-comment'
|
import { VideoCommentModel } from '../models/video/video-comment'
|
||||||
import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models'
|
import {
|
||||||
|
MAccountDefault,
|
||||||
|
MComment,
|
||||||
|
MCommentFormattable,
|
||||||
|
MCommentOwnerVideo,
|
||||||
|
MCommentOwnerVideoReply,
|
||||||
|
MVideoFullLight
|
||||||
|
} from '../types/models'
|
||||||
import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
|
import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
|
||||||
import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
|
import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
|
||||||
import { Hooks } from './plugins/hooks'
|
import { Hooks } from './plugins/hooks'
|
||||||
|
|
||||||
async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) {
|
async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
|
||||||
const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
|
let videoCommentInstanceBefore: MCommentOwnerVideo
|
||||||
|
|
||||||
await sequelizeTypescript.transaction(async t => {
|
await sequelizeTypescript.transaction(async t => {
|
||||||
if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
|
const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
|
||||||
await sendDeleteVideoComment(videoCommentInstance, t)
|
|
||||||
|
videoCommentInstanceBefore = cloneDeep(comment)
|
||||||
|
|
||||||
|
if (comment.isOwned() || comment.Video.isOwned()) {
|
||||||
|
await sendDeleteVideoComment(comment, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
videoCommentInstance.markAsDeleted()
|
comment.markAsDeleted()
|
||||||
|
|
||||||
await videoCommentInstance.save({ transaction: t })
|
await comment.save({ transaction: t })
|
||||||
|
|
||||||
|
logger.info('Video comment %d deleted.', comment.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info('Video comment %d deleted.', videoCommentInstance.id)
|
|
||||||
|
|
||||||
Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
|
Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +75,7 @@ async function createVideoComment (obj: {
|
||||||
return savedComment
|
return savedComment
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree {
|
function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
|
||||||
// Comments are sorted by id ASC
|
// Comments are sorted by id ASC
|
||||||
const comments = resultList.data
|
const comments = resultList.data
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,24 @@
|
||||||
import { isPlainObject } from 'lodash'
|
import { isPlainObject } from 'lodash'
|
||||||
import { Model as SequelizeModel, Sequelize } from 'sequelize'
|
import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Build Sequelize models from sequelize raw query (that must use { nest: true } options)
|
||||||
|
*
|
||||||
|
* In order to sequelize to correctly build the JSON this class will ingest,
|
||||||
|
* the columns selected in the raw query should be in the following form:
|
||||||
|
* * All tables must be Pascal Cased (for example "VideoChannel")
|
||||||
|
* * Root table must end with `Model` (for example "VideoCommentModel")
|
||||||
|
* * Joined tables must contain the origin table name + '->JoinedTable'. For example:
|
||||||
|
* * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
|
||||||
|
* * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
|
||||||
|
* * Selected columns must be renamed to contain the JSON path:
|
||||||
|
* * "videoComment"."id": "VideoCommentModel"."id"
|
||||||
|
* * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
|
||||||
|
* * All tables must contain the row id
|
||||||
|
*/
|
||||||
|
|
||||||
export class ModelBuilder <T extends SequelizeModel> {
|
export class ModelBuilder <T extends SequelizeModel> {
|
||||||
private readonly modelRegistry = new Map<string, T>()
|
private readonly modelRegistry = new Map<string, T>()
|
||||||
|
|
||||||
|
@ -72,18 +89,18 @@ export class ModelBuilder <T extends SequelizeModel> {
|
||||||
'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
|
'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
|
||||||
{ existing: this.sequelize.modelManager.all.map(m => m.name) }
|
{ existing: this.sequelize.modelManager.all.map(m => m.name) }
|
||||||
)
|
)
|
||||||
return undefined
|
return { created: false, model: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: typings
|
const model = Model.build(json, { raw: true, isNewRecord: false })
|
||||||
const model = new (Model as any)(json)
|
|
||||||
this.modelRegistry.set(registryKey, model)
|
this.modelRegistry.set(registryKey, model)
|
||||||
|
|
||||||
return { created: true, model }
|
return { created: true, model }
|
||||||
}
|
}
|
||||||
|
|
||||||
private findModelBuilder (modelName: string) {
|
private findModelBuilder (modelName: string) {
|
||||||
return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName))
|
return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSequelizeModelName (modelName: string) {
|
private buildSequelizeModelName (modelName: string) {
|
||||||
|
|
|
@ -231,12 +231,12 @@ function parseRowCountResult (result: any) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
|
function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
|
||||||
return stringArr.map(t => {
|
return toEscape.map(t => {
|
||||||
return t === null
|
return t === null
|
||||||
? null
|
? null
|
||||||
: sequelize.escape('' + t)
|
: sequelize.escape('' + t)
|
||||||
}).join(', ')
|
}).concat(additionalUnescaped).join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLocalAccountIdsIn () {
|
function buildLocalAccountIdsIn () {
|
||||||
|
|
|
@ -0,0 +1,394 @@
|
||||||
|
import { Model, Sequelize, Transaction } from 'sequelize'
|
||||||
|
import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
|
||||||
|
import { createSafeIn, getCommentSort, parseRowCountResult } from '@server/models/utils'
|
||||||
|
import { ActorImageType, VideoPrivacy } from '@shared/models'
|
||||||
|
import { VideoCommentTableAttributes } from './video-comment-table-attributes'
|
||||||
|
|
||||||
|
export interface ListVideoCommentsOptions {
|
||||||
|
selectType: 'api' | 'feed' | 'comment-only'
|
||||||
|
|
||||||
|
start?: number
|
||||||
|
count?: number
|
||||||
|
sort?: string
|
||||||
|
|
||||||
|
videoId?: number
|
||||||
|
threadId?: number
|
||||||
|
accountId?: number
|
||||||
|
videoChannelId?: number
|
||||||
|
|
||||||
|
blockerAccountIds?: number[]
|
||||||
|
|
||||||
|
isThread?: boolean
|
||||||
|
notDeleted?: boolean
|
||||||
|
isLocal?: boolean
|
||||||
|
onLocalVideo?: boolean
|
||||||
|
onPublicVideo?: boolean
|
||||||
|
videoAccountOwnerId?: boolean
|
||||||
|
|
||||||
|
search?: string
|
||||||
|
searchAccount?: string
|
||||||
|
searchVideo?: string
|
||||||
|
|
||||||
|
includeReplyCounters?: boolean
|
||||||
|
|
||||||
|
transaction?: Transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VideoCommentListQueryBuilder extends AbstractRunQuery {
|
||||||
|
private readonly tableAttributes = new VideoCommentTableAttributes()
|
||||||
|
|
||||||
|
private innerQuery: string
|
||||||
|
|
||||||
|
private select = ''
|
||||||
|
private joins = ''
|
||||||
|
|
||||||
|
private innerSelect = ''
|
||||||
|
private innerJoins = ''
|
||||||
|
private innerWhere = ''
|
||||||
|
|
||||||
|
private readonly built = {
|
||||||
|
cte: false,
|
||||||
|
accountJoin: false,
|
||||||
|
videoJoin: false,
|
||||||
|
videoChannelJoin: false,
|
||||||
|
avatarJoin: false
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
protected readonly sequelize: Sequelize,
|
||||||
|
private readonly options: ListVideoCommentsOptions
|
||||||
|
) {
|
||||||
|
super(sequelize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listComments <T extends Model> () {
|
||||||
|
this.buildListQuery()
|
||||||
|
|
||||||
|
const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
|
||||||
|
const modelBuilder = new ModelBuilder<T>(this.sequelize)
|
||||||
|
|
||||||
|
return modelBuilder.createModels(results, 'VideoComment')
|
||||||
|
}
|
||||||
|
|
||||||
|
async countComments () {
|
||||||
|
this.buildCountQuery()
|
||||||
|
|
||||||
|
const result = await this.runQuery({ transaction: this.options.transaction })
|
||||||
|
|
||||||
|
return parseRowCountResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private buildListQuery () {
|
||||||
|
this.buildInnerListQuery()
|
||||||
|
this.buildListSelect()
|
||||||
|
|
||||||
|
this.query = `${this.select} ` +
|
||||||
|
`FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
|
||||||
|
`${this.joins} ` +
|
||||||
|
`${this.getOrder()} ` +
|
||||||
|
`${this.getLimit()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildInnerListQuery () {
|
||||||
|
this.buildWhere()
|
||||||
|
this.buildInnerListSelect()
|
||||||
|
|
||||||
|
this.innerQuery = `${this.innerSelect} ` +
|
||||||
|
`FROM "videoComment" AS "VideoCommentModel" ` +
|
||||||
|
`${this.innerJoins} ` +
|
||||||
|
`${this.innerWhere} ` +
|
||||||
|
`${this.getOrder()} ` +
|
||||||
|
`${this.getInnerLimit()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private buildCountQuery () {
|
||||||
|
this.buildWhere()
|
||||||
|
|
||||||
|
this.query = `SELECT COUNT(*) AS "total" ` +
|
||||||
|
`FROM "videoComment" AS "VideoCommentModel" ` +
|
||||||
|
`${this.innerJoins} ` +
|
||||||
|
`${this.innerWhere}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private buildWhere () {
|
||||||
|
let where: string[] = []
|
||||||
|
|
||||||
|
if (this.options.videoId) {
|
||||||
|
this.replacements.videoId = this.options.videoId
|
||||||
|
|
||||||
|
where.push('"VideoCommentModel"."videoId" = :videoId')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.threadId) {
|
||||||
|
this.replacements.threadId = this.options.threadId
|
||||||
|
|
||||||
|
where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.accountId) {
|
||||||
|
this.replacements.accountId = this.options.accountId
|
||||||
|
|
||||||
|
where.push('"VideoCommentModel"."accountId" = :accountId')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.videoChannelId) {
|
||||||
|
this.buildVideoChannelJoin()
|
||||||
|
|
||||||
|
this.replacements.videoChannelId = this.options.videoChannelId
|
||||||
|
|
||||||
|
where.push('"Account->VideoChannel"."id" = :videoChannelId')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.blockerAccountIds) {
|
||||||
|
this.buildVideoChannelJoin()
|
||||||
|
|
||||||
|
where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.isThread === true) {
|
||||||
|
where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.notDeleted === true) {
|
||||||
|
where.push('"VideoCommentModel"."deletedAt" IS NULL')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.isLocal === true) {
|
||||||
|
this.buildAccountJoin()
|
||||||
|
|
||||||
|
where.push('"Account->Actor"."serverId" IS NULL')
|
||||||
|
} else if (this.options.isLocal === false) {
|
||||||
|
this.buildAccountJoin()
|
||||||
|
|
||||||
|
where.push('"Account->Actor"."serverId" IS NOT NULL')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.onLocalVideo === true) {
|
||||||
|
this.buildVideoJoin()
|
||||||
|
|
||||||
|
where.push('"Video"."remote" IS FALSE')
|
||||||
|
} else if (this.options.onLocalVideo === false) {
|
||||||
|
this.buildVideoJoin()
|
||||||
|
|
||||||
|
where.push('"Video"."remote" IS TRUE')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.onPublicVideo === true) {
|
||||||
|
this.buildVideoJoin()
|
||||||
|
|
||||||
|
where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.videoAccountOwnerId) {
|
||||||
|
this.buildVideoChannelJoin()
|
||||||
|
|
||||||
|
this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
|
||||||
|
|
||||||
|
where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.search) {
|
||||||
|
this.buildVideoJoin()
|
||||||
|
this.buildAccountJoin()
|
||||||
|
|
||||||
|
const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
|
||||||
|
|
||||||
|
where.push(
|
||||||
|
`(` +
|
||||||
|
`"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
|
||||||
|
`"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
|
||||||
|
`"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
|
||||||
|
`"Video"."name" ILIKE ${escapedLikeSearch} ` +
|
||||||
|
`)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.searchAccount) {
|
||||||
|
this.buildAccountJoin()
|
||||||
|
|
||||||
|
const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
|
||||||
|
|
||||||
|
where.push(
|
||||||
|
`(` +
|
||||||
|
`"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
|
||||||
|
`"Account"."name" ILIKE ${escapedLikeSearch} ` +
|
||||||
|
`)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.searchVideo) {
|
||||||
|
this.buildVideoJoin()
|
||||||
|
|
||||||
|
const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
|
||||||
|
|
||||||
|
where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (where.length !== 0) {
|
||||||
|
this.innerWhere = `WHERE ${where.join(' AND ')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAccountJoin () {
|
||||||
|
if (this.built.accountJoin) return
|
||||||
|
|
||||||
|
this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
|
||||||
|
'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
|
||||||
|
'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
|
||||||
|
|
||||||
|
this.built.accountJoin = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildVideoJoin () {
|
||||||
|
if (this.built.videoJoin) return
|
||||||
|
|
||||||
|
this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
|
||||||
|
|
||||||
|
this.built.videoJoin = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildVideoChannelJoin () {
|
||||||
|
if (this.built.videoChannelJoin) return
|
||||||
|
|
||||||
|
this.buildVideoJoin()
|
||||||
|
|
||||||
|
this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
|
||||||
|
|
||||||
|
this.built.videoChannelJoin = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAvatarsJoin () {
|
||||||
|
if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
|
||||||
|
if (this.built.avatarJoin) return
|
||||||
|
|
||||||
|
this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
|
||||||
|
`ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
|
||||||
|
`AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
|
||||||
|
|
||||||
|
this.built.avatarJoin = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private buildListSelect () {
|
||||||
|
const toSelect = [ '"VideoCommentModel".*' ]
|
||||||
|
|
||||||
|
if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
|
||||||
|
this.buildAvatarsJoin()
|
||||||
|
|
||||||
|
toSelect.push(this.tableAttributes.getAvatarAttributes())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.includeReplyCounters === true) {
|
||||||
|
toSelect.push(this.getTotalRepliesSelect())
|
||||||
|
toSelect.push(this.getAuthorTotalRepliesSelect())
|
||||||
|
}
|
||||||
|
|
||||||
|
this.select = this.buildSelect(toSelect)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildInnerListSelect () {
|
||||||
|
let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
|
||||||
|
|
||||||
|
if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
|
||||||
|
this.buildAccountJoin()
|
||||||
|
this.buildVideoJoin()
|
||||||
|
|
||||||
|
toSelect = toSelect.concat([
|
||||||
|
this.tableAttributes.getVideoAttributes(),
|
||||||
|
this.tableAttributes.getAccountAttributes(),
|
||||||
|
this.tableAttributes.getActorAttributes(),
|
||||||
|
this.tableAttributes.getServerAttributes()
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
this.innerSelect = this.buildSelect(toSelect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private getBlockWhere (commentTableName: string, channelTableName: string) {
|
||||||
|
const where: string[] = []
|
||||||
|
|
||||||
|
const blockerIdsString = createSafeIn(
|
||||||
|
this.sequelize,
|
||||||
|
this.options.blockerAccountIds,
|
||||||
|
[ `"${channelTableName}"."accountId"` ]
|
||||||
|
)
|
||||||
|
|
||||||
|
where.push(
|
||||||
|
`NOT EXISTS (` +
|
||||||
|
`SELECT 1 FROM "accountBlocklist" ` +
|
||||||
|
`WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
|
||||||
|
`AND "accountId" IN (${blockerIdsString})` +
|
||||||
|
`)`
|
||||||
|
)
|
||||||
|
|
||||||
|
where.push(
|
||||||
|
`NOT EXISTS (` +
|
||||||
|
`SELECT 1 FROM "account" ` +
|
||||||
|
`INNER JOIN "actor" ON account."actorId" = actor.id ` +
|
||||||
|
`INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
|
||||||
|
`WHERE "account"."id" = "${commentTableName}"."accountId" ` +
|
||||||
|
`AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
|
||||||
|
`)`
|
||||||
|
)
|
||||||
|
|
||||||
|
return where
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private getTotalRepliesSelect () {
|
||||||
|
const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
|
||||||
|
|
||||||
|
return `(` +
|
||||||
|
`SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
|
||||||
|
`LEFT JOIN "video" ON "video"."id" = "replies"."videoId" ` +
|
||||||
|
`LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
|
||||||
|
`WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
|
||||||
|
`AND "deletedAt" IS NULL ` +
|
||||||
|
`AND ${blockWhereString} ` +
|
||||||
|
`) AS "totalReplies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAuthorTotalRepliesSelect () {
|
||||||
|
return `(` +
|
||||||
|
`SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
|
||||||
|
`INNER JOIN "video" ON "video"."id" = "replies"."videoId" ` +
|
||||||
|
`INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
|
||||||
|
`WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
|
||||||
|
`) AS "totalRepliesFromVideoAuthor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrder () {
|
||||||
|
if (!this.options.sort) return ''
|
||||||
|
|
||||||
|
const orders = getCommentSort(this.options.sort)
|
||||||
|
|
||||||
|
return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLimit () {
|
||||||
|
if (!this.options.count) return ''
|
||||||
|
|
||||||
|
this.replacements.limit = this.options.count
|
||||||
|
|
||||||
|
return `LIMIT :limit `
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInnerLimit () {
|
||||||
|
if (!this.options.count) return ''
|
||||||
|
|
||||||
|
this.replacements.limit = this.options.count
|
||||||
|
this.replacements.offset = this.options.start || 0
|
||||||
|
|
||||||
|
return `LIMIT :limit OFFSET :offset `
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
export class VideoCommentTableAttributes {
|
||||||
|
|
||||||
|
getVideoCommentAttributes () {
|
||||||
|
return [
|
||||||
|
'"VideoCommentModel"."id"',
|
||||||
|
'"VideoCommentModel"."url"',
|
||||||
|
'"VideoCommentModel"."deletedAt"',
|
||||||
|
'"VideoCommentModel"."updatedAt"',
|
||||||
|
'"VideoCommentModel"."createdAt"',
|
||||||
|
'"VideoCommentModel"."text"',
|
||||||
|
'"VideoCommentModel"."originCommentId"',
|
||||||
|
'"VideoCommentModel"."inReplyToCommentId"',
|
||||||
|
'"VideoCommentModel"."videoId"',
|
||||||
|
'"VideoCommentModel"."accountId"'
|
||||||
|
].join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccountAttributes () {
|
||||||
|
return [
|
||||||
|
`"Account"."id" AS "Account.id"`,
|
||||||
|
`"Account"."name" AS "Account.name"`,
|
||||||
|
`"Account"."description" AS "Account.description"`,
|
||||||
|
`"Account"."createdAt" AS "Account.createdAt"`,
|
||||||
|
`"Account"."updatedAt" AS "Account.updatedAt"`,
|
||||||
|
`"Account"."actorId" AS "Account.actorId"`,
|
||||||
|
`"Account"."userId" AS "Account.userId"`,
|
||||||
|
`"Account"."applicationId" AS "Account.applicationId"`
|
||||||
|
].join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoAttributes () {
|
||||||
|
return [
|
||||||
|
`"Video"."id" AS "Video.id"`,
|
||||||
|
`"Video"."uuid" AS "Video.uuid"`,
|
||||||
|
`"Video"."name" AS "Video.name"`
|
||||||
|
].join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
getActorAttributes () {
|
||||||
|
return [
|
||||||
|
`"Account->Actor"."id" AS "Account.Actor.id"`,
|
||||||
|
`"Account->Actor"."type" AS "Account.Actor.type"`,
|
||||||
|
`"Account->Actor"."preferredUsername" AS "Account.Actor.preferredUsername"`,
|
||||||
|
`"Account->Actor"."url" AS "Account.Actor.url"`,
|
||||||
|
`"Account->Actor"."followersCount" AS "Account.Actor.followersCount"`,
|
||||||
|
`"Account->Actor"."followingCount" AS "Account.Actor.followingCount"`,
|
||||||
|
`"Account->Actor"."remoteCreatedAt" AS "Account.Actor.remoteCreatedAt"`,
|
||||||
|
`"Account->Actor"."serverId" AS "Account.Actor.serverId"`,
|
||||||
|
`"Account->Actor"."createdAt" AS "Account.Actor.createdAt"`,
|
||||||
|
`"Account->Actor"."updatedAt" AS "Account.Actor.updatedAt"`
|
||||||
|
].join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerAttributes () {
|
||||||
|
return [
|
||||||
|
`"Account->Actor->Server"."id" AS "Account.Actor.Server.id"`,
|
||||||
|
`"Account->Actor->Server"."host" AS "Account.Actor.Server.host"`,
|
||||||
|
`"Account->Actor->Server"."redundancyAllowed" AS "Account.Actor.Server.redundancyAllowed"`,
|
||||||
|
`"Account->Actor->Server"."createdAt" AS "Account.Actor.Server.createdAt"`,
|
||||||
|
`"Account->Actor->Server"."updatedAt" AS "Account.Actor.Server.updatedAt"`
|
||||||
|
].join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvatarAttributes () {
|
||||||
|
return [
|
||||||
|
`"Account->Actor->Avatars"."id" AS "Account.Actor.Avatars.id"`,
|
||||||
|
`"Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename"`,
|
||||||
|
`"Account->Actor->Avatars"."height" AS "Account.Actor.Avatars.height"`,
|
||||||
|
`"Account->Actor->Avatars"."width" AS "Account.Actor.Avatars.width"`,
|
||||||
|
`"Account->Actor->Avatars"."fileUrl" AS "Account.Actor.Avatars.fileUrl"`,
|
||||||
|
`"Account->Actor->Avatars"."onDisk" AS "Account.Actor.Avatars.onDisk"`,
|
||||||
|
`"Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type"`,
|
||||||
|
`"Account->Actor->Avatars"."actorId" AS "Account.Actor.Avatars.actorId"`,
|
||||||
|
`"Account->Actor->Avatars"."createdAt" AS "Account.Actor.Avatars.createdAt"`,
|
||||||
|
`"Account->Actor->Avatars"."updatedAt" AS "Account.Actor.Avatars.updatedAt"`
|
||||||
|
].join(', ')
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
|
import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
|
||||||
import {
|
import {
|
||||||
AllowNull,
|
AllowNull,
|
||||||
BelongsTo,
|
BelongsTo,
|
||||||
|
@ -13,11 +13,9 @@ import {
|
||||||
Table,
|
Table,
|
||||||
UpdatedAt
|
UpdatedAt
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
import { exists } from '@server/helpers/custom-validators/misc'
|
|
||||||
import { getServerActor } from '@server/models/application/application'
|
import { getServerActor } from '@server/models/application/application'
|
||||||
import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
|
import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
|
||||||
import { uniqify } from '@shared/core-utils'
|
import { pick, uniqify } from '@shared/core-utils'
|
||||||
import { VideoPrivacy } from '@shared/models'
|
|
||||||
import { AttributesOnly } from '@shared/typescript-utils'
|
import { AttributesOnly } from '@shared/typescript-utils'
|
||||||
import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
|
import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
|
||||||
import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
|
import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
|
||||||
|
@ -41,61 +39,19 @@ import {
|
||||||
} from '../../types/models/video'
|
} from '../../types/models/video'
|
||||||
import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
|
import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
|
||||||
import { AccountModel } from '../account/account'
|
import { AccountModel } from '../account/account'
|
||||||
import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
|
import { ActorModel } from '../actor/actor'
|
||||||
import {
|
import { buildLocalAccountIdsIn, throwIfNotValid } from '../utils'
|
||||||
buildBlockedAccountSQL,
|
import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
|
||||||
buildBlockedAccountSQLOptimized,
|
|
||||||
buildLocalAccountIdsIn,
|
|
||||||
getCommentSort,
|
|
||||||
searchAttribute,
|
|
||||||
throwIfNotValid
|
|
||||||
} from '../utils'
|
|
||||||
import { VideoModel } from './video'
|
import { VideoModel } from './video'
|
||||||
import { VideoChannelModel } from './video-channel'
|
import { VideoChannelModel } from './video-channel'
|
||||||
|
|
||||||
export enum ScopeNames {
|
export enum ScopeNames {
|
||||||
WITH_ACCOUNT = 'WITH_ACCOUNT',
|
WITH_ACCOUNT = 'WITH_ACCOUNT',
|
||||||
WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
|
|
||||||
WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
|
WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
|
||||||
WITH_VIDEO = 'WITH_VIDEO',
|
WITH_VIDEO = 'WITH_VIDEO'
|
||||||
ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scopes(() => ({
|
@Scopes(() => ({
|
||||||
[ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
|
|
||||||
return {
|
|
||||||
attributes: {
|
|
||||||
include: [
|
|
||||||
[
|
|
||||||
Sequelize.literal(
|
|
||||||
'(' +
|
|
||||||
'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
|
|
||||||
'SELECT COUNT("replies"."id") ' +
|
|
||||||
'FROM "videoComment" AS "replies" ' +
|
|
||||||
'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
|
|
||||||
'AND "deletedAt" IS NULL ' +
|
|
||||||
'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
|
|
||||||
')'
|
|
||||||
),
|
|
||||||
'totalReplies'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
Sequelize.literal(
|
|
||||||
'(' +
|
|
||||||
'SELECT COUNT("replies"."id") ' +
|
|
||||||
'FROM "videoComment" AS "replies" ' +
|
|
||||||
'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
|
|
||||||
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
|
|
||||||
'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
|
|
||||||
'AND "replies"."accountId" = "videoChannel"."accountId"' +
|
|
||||||
')'
|
|
||||||
),
|
|
||||||
'totalRepliesFromVideoAuthor'
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} as FindOptions
|
|
||||||
},
|
|
||||||
[ScopeNames.WITH_ACCOUNT]: {
|
[ScopeNames.WITH_ACCOUNT]: {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
@ -103,22 +59,6 @@ export enum ScopeNames {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
[ScopeNames.WITH_ACCOUNT_FOR_API]: {
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: AccountModel.unscoped(),
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
attributes: {
|
|
||||||
exclude: unusedActorAttributesForAPI
|
|
||||||
},
|
|
||||||
model: ActorModel, // Default scope includes avatar and server
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
[ScopeNames.WITH_IN_REPLY_TO]: {
|
[ScopeNames.WITH_IN_REPLY_TO]: {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
@ -319,93 +259,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
|
||||||
searchAccount?: string
|
searchAccount?: string
|
||||||
searchVideo?: string
|
searchVideo?: string
|
||||||
}) {
|
}) {
|
||||||
const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters
|
const queryOptions: ListVideoCommentsOptions = {
|
||||||
|
...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
|
||||||
|
|
||||||
const where: WhereOptions = {
|
selectType: 'api',
|
||||||
deletedAt: null
|
notDeleted: true
|
||||||
}
|
|
||||||
|
|
||||||
const whereAccount: WhereOptions = {}
|
|
||||||
const whereActor: WhereOptions = {}
|
|
||||||
const whereVideo: WhereOptions = {}
|
|
||||||
|
|
||||||
if (isLocal === true) {
|
|
||||||
Object.assign(whereActor, {
|
|
||||||
serverId: null
|
|
||||||
})
|
|
||||||
} else if (isLocal === false) {
|
|
||||||
Object.assign(whereActor, {
|
|
||||||
serverId: {
|
|
||||||
[Op.ne]: null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
Object.assign(where, {
|
|
||||||
[Op.or]: [
|
|
||||||
searchAttribute(search, 'text'),
|
|
||||||
searchAttribute(search, '$Account.Actor.preferredUsername$'),
|
|
||||||
searchAttribute(search, '$Account.name$'),
|
|
||||||
searchAttribute(search, '$Video.name$')
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchAccount) {
|
|
||||||
Object.assign(whereActor, {
|
|
||||||
[Op.or]: [
|
|
||||||
searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
|
|
||||||
searchAttribute(searchAccount, '$Account.name$')
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchVideo) {
|
|
||||||
Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exists(onLocalVideo)) {
|
|
||||||
Object.assign(whereVideo, { remote: !onLocalVideo })
|
|
||||||
}
|
|
||||||
|
|
||||||
const getQuery = (forCount: boolean) => {
|
|
||||||
return {
|
|
||||||
offset: start,
|
|
||||||
limit: count,
|
|
||||||
order: getCommentSort(sort),
|
|
||||||
where,
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: AccountModel.unscoped(),
|
|
||||||
required: true,
|
|
||||||
where: whereAccount,
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
attributes: {
|
|
||||||
exclude: unusedActorAttributesForAPI
|
|
||||||
},
|
|
||||||
model: forCount === true
|
|
||||||
? ActorModel.unscoped() // Default scope includes avatar and server
|
|
||||||
: ActorModel,
|
|
||||||
required: true,
|
|
||||||
where: whereActor
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: VideoModel.unscoped(),
|
|
||||||
required: true,
|
|
||||||
where: whereVideo
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
VideoCommentModel.count(getQuery(true)),
|
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
|
||||||
VideoCommentModel.findAll(getQuery(false))
|
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
|
||||||
]).then(([ total, data ]) => ({ total, data }))
|
]).then(([ rows, count ]) => {
|
||||||
|
return { total: count, data: rows }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static async listThreadsForApi (parameters: {
|
static async listThreadsForApi (parameters: {
|
||||||
|
@ -416,67 +282,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
|
||||||
sort: string
|
sort: string
|
||||||
user?: MUserAccountId
|
user?: MUserAccountId
|
||||||
}) {
|
}) {
|
||||||
const { videoId, isVideoOwned, start, count, sort, user } = parameters
|
const { videoId, user } = parameters
|
||||||
|
|
||||||
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
|
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
|
||||||
|
|
||||||
const accountBlockedWhere = {
|
const commonOptions: ListVideoCommentsOptions = {
|
||||||
accountId: {
|
selectType: 'api',
|
||||||
[Op.notIn]: Sequelize.literal(
|
videoId,
|
||||||
'(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
|
blockerAccountIds
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryList = {
|
const listOptions: ListVideoCommentsOptions = {
|
||||||
offset: start,
|
...commonOptions,
|
||||||
limit: count,
|
...pick(parameters, [ 'sort', 'start', 'count' ]),
|
||||||
order: getCommentSort(sort),
|
|
||||||
where: {
|
isThread: true,
|
||||||
[Op.and]: [
|
includeReplyCounters: true
|
||||||
{
|
|
||||||
videoId
|
|
||||||
},
|
|
||||||
{
|
|
||||||
inReplyToCommentId: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[Op.or]: [
|
|
||||||
accountBlockedWhere,
|
|
||||||
{
|
|
||||||
accountId: null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const findScopesList: (string | ScopeOptions)[] = [
|
const countOptions: ListVideoCommentsOptions = {
|
||||||
ScopeNames.WITH_ACCOUNT_FOR_API,
|
...commonOptions,
|
||||||
{
|
|
||||||
method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const countScopesList: ScopeOptions[] = [
|
isThread: true
|
||||||
{
|
}
|
||||||
method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const notDeletedQueryCount = {
|
const notDeletedCountOptions: ListVideoCommentsOptions = {
|
||||||
where: {
|
...commonOptions,
|
||||||
videoId,
|
|
||||||
deletedAt: null,
|
notDeleted: true
|
||||||
...accountBlockedWhere
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
VideoCommentModel.scope(findScopesList).findAll(queryList),
|
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
|
||||||
VideoCommentModel.scope(countScopesList).count(queryList),
|
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
|
||||||
VideoCommentModel.count(notDeletedQueryCount)
|
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
|
||||||
]).then(([ rows, count, totalNotDeletedComments ]) => {
|
]).then(([ rows, count, totalNotDeletedComments ]) => {
|
||||||
return { total: count, data: rows, totalNotDeletedComments }
|
return { total: count, data: rows, totalNotDeletedComments }
|
||||||
})
|
})
|
||||||
|
@ -484,54 +323,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
|
||||||
|
|
||||||
static async listThreadCommentsForApi (parameters: {
|
static async listThreadCommentsForApi (parameters: {
|
||||||
videoId: number
|
videoId: number
|
||||||
isVideoOwned: boolean
|
|
||||||
threadId: number
|
threadId: number
|
||||||
user?: MUserAccountId
|
user?: MUserAccountId
|
||||||
}) {
|
}) {
|
||||||
const { videoId, threadId, user, isVideoOwned } = parameters
|
const { user } = parameters
|
||||||
|
|
||||||
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
|
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
|
||||||
|
|
||||||
const query = {
|
const queryOptions: ListVideoCommentsOptions = {
|
||||||
order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
|
...pick(parameters, [ 'videoId', 'threadId' ]),
|
||||||
where: {
|
|
||||||
videoId,
|
selectType: 'api',
|
||||||
[Op.and]: [
|
sort: 'createdAt',
|
||||||
{
|
|
||||||
[Op.or]: [
|
blockerAccountIds,
|
||||||
{ id: threadId },
|
includeReplyCounters: true
|
||||||
{ originCommentId: threadId }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
accountId: {
|
|
||||||
[Op.notIn]: Sequelize.literal(
|
|
||||||
'(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accountId: null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopes: any[] = [
|
|
||||||
ScopeNames.WITH_ACCOUNT_FOR_API,
|
|
||||||
{
|
|
||||||
method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
VideoCommentModel.count(query),
|
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
|
||||||
VideoCommentModel.scope(scopes).findAll(query)
|
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
|
||||||
]).then(([ total, data ]) => ({ total, data }))
|
]).then(([ rows, count ]) => {
|
||||||
|
return { total: count, data: rows }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
|
static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
|
||||||
|
@ -559,31 +373,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
|
||||||
.findAll(query)
|
.findAll(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) {
|
static async listAndCountByVideoForAP (parameters: {
|
||||||
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({
|
video: MVideoImmutable
|
||||||
videoId: video.id,
|
start: number
|
||||||
isVideoOwned: video.isOwned()
|
count: number
|
||||||
})
|
}) {
|
||||||
|
const { video } = parameters
|
||||||
|
|
||||||
const query = {
|
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
|
||||||
order: [ [ 'createdAt', 'ASC' ] ] as Order,
|
|
||||||
offset: start,
|
const queryOptions: ListVideoCommentsOptions = {
|
||||||
limit: count,
|
...pick(parameters, [ 'start', 'count' ]),
|
||||||
where: {
|
|
||||||
videoId: video.id,
|
selectType: 'comment-only',
|
||||||
accountId: {
|
videoId: video.id,
|
||||||
[Op.notIn]: Sequelize.literal(
|
sort: 'createdAt',
|
||||||
'(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
|
|
||||||
)
|
blockerAccountIds
|
||||||
}
|
|
||||||
},
|
|
||||||
transaction: t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
VideoCommentModel.count(query),
|
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
|
||||||
VideoCommentModel.findAll<MComment>(query)
|
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
|
||||||
]).then(([ total, data ]) => ({ total, data }))
|
]).then(([ rows, count ]) => {
|
||||||
|
return { total: count, data: rows }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static async listForFeed (parameters: {
|
static async listForFeed (parameters: {
|
||||||
|
@ -592,97 +406,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
|
||||||
videoId?: number
|
videoId?: number
|
||||||
accountId?: number
|
accountId?: number
|
||||||
videoChannelId?: number
|
videoChannelId?: number
|
||||||
}): Promise<MCommentOwnerVideoFeed[]> {
|
}) {
|
||||||
const serverActor = await getServerActor()
|
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
|
||||||
const { start, count, videoId, accountId, videoChannelId } = parameters
|
|
||||||
|
|
||||||
const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
|
const queryOptions: ListVideoCommentsOptions = {
|
||||||
'"VideoCommentModel"."accountId"',
|
...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
|
||||||
[ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (accountId) {
|
selectType: 'feed',
|
||||||
whereAnd.push({
|
|
||||||
accountId
|
sort: '-createdAt',
|
||||||
})
|
onPublicVideo: true,
|
||||||
|
notDeleted: true,
|
||||||
|
|
||||||
|
blockerAccountIds
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountWhere = {
|
return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
|
||||||
[Op.and]: whereAnd
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined
|
|
||||||
|
|
||||||
const query = {
|
|
||||||
order: [ [ 'createdAt', 'DESC' ] ] as Order,
|
|
||||||
offset: start,
|
|
||||||
limit: count,
|
|
||||||
where: {
|
|
||||||
deletedAt: null,
|
|
||||||
accountId: accountWhere
|
|
||||||
},
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
attributes: [ 'name', 'uuid' ],
|
|
||||||
model: VideoModel.unscoped(),
|
|
||||||
required: true,
|
|
||||||
where: {
|
|
||||||
privacy: VideoPrivacy.PUBLIC
|
|
||||||
},
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
attributes: [ 'accountId' ],
|
|
||||||
model: VideoChannelModel.unscoped(),
|
|
||||||
required: true,
|
|
||||||
where: videoChannelWhere
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoId) query.where['videoId'] = videoId
|
|
||||||
|
|
||||||
return VideoCommentModel
|
|
||||||
.scope([ ScopeNames.WITH_ACCOUNT ])
|
|
||||||
.findAll(query)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
|
static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
|
||||||
const accountWhere = filter.onVideosOfAccount
|
const queryOptions: ListVideoCommentsOptions = {
|
||||||
? { id: filter.onVideosOfAccount.id }
|
selectType: 'comment-only',
|
||||||
: {}
|
|
||||||
|
|
||||||
const query = {
|
accountId: ofAccount.id,
|
||||||
limit: 1000,
|
videoAccountOwnerId: filter.onVideosOfAccount?.id,
|
||||||
where: {
|
|
||||||
deletedAt: null,
|
notDeleted: true,
|
||||||
accountId: ofAccount.id
|
count: 5000
|
||||||
},
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: VideoModel,
|
|
||||||
required: true,
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: VideoChannelModel,
|
|
||||||
required: true,
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: AccountModel,
|
|
||||||
required: true,
|
|
||||||
where: accountWhere
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return VideoCommentModel
|
return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
|
||||||
.scope([ ScopeNames.WITH_ACCOUNT ])
|
|
||||||
.findAll(query)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getStats () {
|
static async getStats () {
|
||||||
|
@ -750,9 +503,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
|
||||||
}
|
}
|
||||||
|
|
||||||
isOwned () {
|
isOwned () {
|
||||||
if (!this.Account) {
|
if (!this.Account) return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.Account.isOwned()
|
return this.Account.isOwned()
|
||||||
}
|
}
|
||||||
|
@ -906,22 +657,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async buildBlockerAccountIds (options: {
|
private static async buildBlockerAccountIds (options: {
|
||||||
videoId: number
|
user: MUserAccountId
|
||||||
isVideoOwned: boolean
|
}): Promise<number[]> {
|
||||||
user?: MUserAccountId
|
const { user } = options
|
||||||
}) {
|
|
||||||
const { videoId, user, isVideoOwned } = options
|
|
||||||
|
|
||||||
const serverActor = await getServerActor()
|
const serverActor = await getServerActor()
|
||||||
const blockerAccountIds = [ serverActor.Account.id ]
|
const blockerAccountIds = [ serverActor.Account.id ]
|
||||||
|
|
||||||
if (user) blockerAccountIds.push(user.Account.id)
|
if (user) blockerAccountIds.push(user.Account.id)
|
||||||
|
|
||||||
if (isVideoOwned) {
|
|
||||||
const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
|
|
||||||
if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return blockerAccountIds
|
return blockerAccountIds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -232,7 +232,8 @@ describe('Test video comments', function () {
|
||||||
await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
|
await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
|
||||||
|
|
||||||
const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
|
const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
|
||||||
expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1)
|
expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1)
|
||||||
|
expect(tree.comment.totalReplies).to.equal(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue