Alessandro/web 2945 comments hide body (#4385)
* chore(core): move limits logic into shared * feat(comments): limit text and rawText for comments * chore(core): removed test moved to shared * chore(comments): generate gql types * feat(comments): rework comment history limits * chore(comments): fix tests * chore(shared): add dayjs as dependency --------- Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com>
This commit is contained in:
committed by
GitHub
parent
b5ca404d00
commit
0c18acc452
@@ -139,11 +139,11 @@ type Comment {
|
||||
authorId: String!
|
||||
archived: Boolean!
|
||||
screenshot: String
|
||||
text: SmartTextEditorValue!
|
||||
text: SmartTextEditorValue
|
||||
"""
|
||||
Plain-text version of the comment text, ideal for previews
|
||||
"""
|
||||
rawText: String!
|
||||
rawText: String
|
||||
"""
|
||||
Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects.
|
||||
"""
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
getCommitsAndTheirBranchIdsFactory,
|
||||
getSpecificBranchCommitsFactory
|
||||
} from '@/modules/core/repositories/commits'
|
||||
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
|
||||
import {
|
||||
getBranchLatestCommitsFactory,
|
||||
getStreamBranchesByNameFactory
|
||||
@@ -90,7 +91,15 @@ import { getStreamFactory } from '@/modules/core/repositories/streams'
|
||||
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
|
||||
import { Knex } from 'knex'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { StreamNotFoundError } from '@/modules/core/errors/stream'
|
||||
import { isCreatedBeyondHistoryLimitCutoff, getProjectLimitDate } from '@speckle/shared'
|
||||
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
|
||||
import { Authz } from '@speckle/shared'
|
||||
|
||||
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
|
||||
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
|
||||
? () => Promise.resolve(Authz.PersonalProjectsLimits)
|
||||
: () => Promise.resolve(null)
|
||||
|
||||
// We can use the main DB for these
|
||||
const getStream = getStreamFactory({ db })
|
||||
@@ -203,15 +212,49 @@ export = {
|
||||
/**
|
||||
* Format comment.text for output, since it can have multiple formats
|
||||
*/
|
||||
text(parent) {
|
||||
const commentText = parent?.text || ''
|
||||
async text(parent, _args, ctx) {
|
||||
const project = await ctx.loaders.streams.getStream.load(parent.streamId)
|
||||
|
||||
if (!project) {
|
||||
throw new StreamNotFoundError('Project not found', {
|
||||
info: { streamId: parent.streamId }
|
||||
})
|
||||
}
|
||||
|
||||
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
|
||||
getProjectLimitDate: getProjectLimitDate({
|
||||
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
|
||||
getPersonalProjectLimits
|
||||
})
|
||||
})({ entity: parent, limitType: 'commentHistory', project })
|
||||
// null is for out of limits
|
||||
if (isBeyondLimit) return null
|
||||
// why is the text nullable in the DB record?
|
||||
if (!parent.text) return ''
|
||||
return {
|
||||
...ensureCommentSchema(commentText),
|
||||
...ensureCommentSchema(parent.text),
|
||||
projectId: parent.streamId
|
||||
}
|
||||
},
|
||||
|
||||
rawText(parent) {
|
||||
async rawText(parent, _args, ctx) {
|
||||
const project = await ctx.loaders.streams.getStream.load(parent.streamId)
|
||||
|
||||
if (!project) {
|
||||
throw new StreamNotFoundError('Project not found', {
|
||||
info: { streamId: parent.streamId }
|
||||
})
|
||||
}
|
||||
|
||||
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
|
||||
getProjectLimitDate: getProjectLimitDate({
|
||||
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
|
||||
getPersonalProjectLimits
|
||||
})
|
||||
})({ entity: parent, limitType: 'commentHistory', project })
|
||||
// null is for out of limits
|
||||
if (isBeyondLimit) return null
|
||||
// why us the text nullable in the DB?
|
||||
const { doc } = ensureCommentSchema(parent.text || '')
|
||||
return documentToBasicString(doc)
|
||||
},
|
||||
|
||||
@@ -30,7 +30,11 @@ import {
|
||||
purgeNotifications
|
||||
} from '@/test/notificationsHelper'
|
||||
import { NotificationType } from '@/modules/notifications/helpers/types'
|
||||
import { EmailSendingServiceMock, CommentsRepositoryMock } from '@/test/mocks/global'
|
||||
import {
|
||||
EmailSendingServiceMock,
|
||||
CommentsRepositoryMock,
|
||||
StreamsRepositoryMock
|
||||
} from '@/test/mocks/global'
|
||||
import { createAuthedTestContext, ServerAndContext } from '@/test/graphqlHelper'
|
||||
import {
|
||||
checkStreamResourceAccessFactory,
|
||||
@@ -125,6 +129,7 @@ import {
|
||||
getViewerResourcesForCommentsFactory,
|
||||
getViewerResourcesFromLegacyIdentifiersFactory
|
||||
} from '@/modules/core/services/commit/viewerResources'
|
||||
import { StreamRecord } from '@/modules/core/helpers/types'
|
||||
|
||||
type LegacyCommentRecord = CommentRecord & {
|
||||
total_count: string
|
||||
@@ -287,6 +292,7 @@ function generateRandomCommentText() {
|
||||
|
||||
const mailerMock = EmailSendingServiceMock
|
||||
const commentRepoMock = CommentsRepositoryMock
|
||||
const streamsRepoMock = StreamsRepositoryMock
|
||||
|
||||
describe('Comments @comments', () => {
|
||||
let app: express.Express
|
||||
@@ -366,6 +372,7 @@ describe('Comments @comments', () => {
|
||||
after(() => {
|
||||
notificationsState.destroy()
|
||||
commentRepoMock.destroy()
|
||||
streamsRepoMock.destroy()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1688,8 +1695,8 @@ describe('Comments @comments', () => {
|
||||
|
||||
expect(errors?.length || 0).to.eq(0)
|
||||
expect(data?.comment).to.be.ok
|
||||
expect(data?.comment?.text.doc).to.be.null
|
||||
expect(data?.comment?.text.attachments?.length).to.be.greaterThan(0)
|
||||
expect(data?.comment?.text?.doc).to.be.null
|
||||
expect(data?.comment?.text?.attachments?.length).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
const unexpectedValDataset = [
|
||||
@@ -1698,9 +1705,18 @@ describe('Comments @comments', () => {
|
||||
]
|
||||
unexpectedValDataset.forEach(({ display, value }) => {
|
||||
it(`unexpected text value (${display}) in DB throw sanitized errors`, async () => {
|
||||
streamsRepoMock.enable()
|
||||
streamsRepoMock.mockFunction('getStreamsFactory', () => async () => [
|
||||
{
|
||||
id: stream.id,
|
||||
workspaceId: ''
|
||||
} as unknown as StreamRecord
|
||||
])
|
||||
const item = {
|
||||
id: '1',
|
||||
text: value
|
||||
text: value,
|
||||
streamId: stream.id,
|
||||
createdAt: new Date()
|
||||
} as unknown as LegacyCommentRecord
|
||||
|
||||
commentRepoMock.enable()
|
||||
@@ -1710,12 +1726,13 @@ describe('Comments @comments', () => {
|
||||
totalCount: 1
|
||||
}))
|
||||
|
||||
const { data, errors } = await readComments()
|
||||
const { errors } = await readComments()
|
||||
|
||||
expect(data?.comments).to.not.be.ok
|
||||
expect((errors || []).map((e) => e.message).join(';')).to.contain(
|
||||
'Unexpected comment schema format'
|
||||
)
|
||||
streamsRepoMock.disable()
|
||||
streamsRepoMock.resetMockedFunctions()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('Project Comments', () => {
|
||||
expect(res1).to.not.haveGraphQLErrors()
|
||||
expect(threadId).to.be.ok
|
||||
expect(res1.data?.commentMutations.create.rawText).to.equal(parentText)
|
||||
expect(res1.data?.commentMutations.create.text.doc).to.be.ok
|
||||
expect(res1.data?.commentMutations.create.text?.doc).to.be.ok
|
||||
expect(res1.data?.commentMutations.create.authorId).to.equal(me.id)
|
||||
expect(createEventFired).to.be.true
|
||||
})
|
||||
@@ -161,7 +161,7 @@ describe('Project Comments', () => {
|
||||
|
||||
expect(res2).to.not.haveGraphQLErrors()
|
||||
expect(res2.data?.commentMutations.reply.rawText).to.equal(replyText)
|
||||
expect(res2.data?.commentMutations.reply.text.doc).to.be.ok
|
||||
expect(res2.data?.commentMutations.reply.text?.doc).to.be.ok
|
||||
expect(res2.data?.commentMutations.reply.authorId).to.equal(me.id)
|
||||
expect(replyEventFired).to.be.true
|
||||
})
|
||||
@@ -190,7 +190,7 @@ describe('Project Comments', () => {
|
||||
|
||||
expect(res).to.not.haveGraphQLErrors()
|
||||
expect(res.data?.commentMutations.edit.rawText).to.equal(newText)
|
||||
expect(res.data?.commentMutations.edit.text.doc).to.be.ok
|
||||
expect(res.data?.commentMutations.edit.text?.doc).to.be.ok
|
||||
expect(res.data?.commentMutations.edit.authorId).to.equal(me.id)
|
||||
expect(editEventFired).to.be.true
|
||||
})
|
||||
|
||||
@@ -634,7 +634,7 @@ export type Comment = {
|
||||
parent?: Maybe<Comment>;
|
||||
permissions: CommentPermissionChecks;
|
||||
/** Plain-text version of the comment text, ideal for previews */
|
||||
rawText: Scalars['String']['output'];
|
||||
rawText?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated Not actually implemented */
|
||||
reactions?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
||||
/** Gets the replies to this comment. */
|
||||
@@ -644,7 +644,7 @@ export type Comment = {
|
||||
/** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
|
||||
resources: Array<ResourceIdentifier>;
|
||||
screenshot?: Maybe<Scalars['String']['output']>;
|
||||
text: SmartTextEditorValue;
|
||||
text?: Maybe<SmartTextEditorValue>;
|
||||
/** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
|
||||
@@ -6153,13 +6153,13 @@ export type CommentResolvers<ContextType = GraphQLContext, ParentType extends Re
|
||||
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
parent?: Resolver<Maybe<ResolversTypes['Comment']>, ParentType, ContextType>;
|
||||
permissions?: Resolver<ResolversTypes['CommentPermissionChecks'], ParentType, ContextType>;
|
||||
rawText?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
rawText?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
reactions?: Resolver<Maybe<Array<Maybe<ResolversTypes['String']>>>, ParentType, ContextType>;
|
||||
replies?: Resolver<ResolversTypes['CommentCollection'], ParentType, ContextType, RequireFields<CommentRepliesArgs, 'limit'>>;
|
||||
replyAuthors?: Resolver<ResolversTypes['CommentReplyAuthorCollection'], ParentType, ContextType, RequireFields<CommentReplyAuthorsArgs, 'limit'>>;
|
||||
resources?: Resolver<Array<ResolversTypes['ResourceIdentifier']>, ParentType, ContextType>;
|
||||
screenshot?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
text?: Resolver<ResolversTypes['SmartTextEditorValue'], ParentType, ContextType>;
|
||||
text?: Resolver<Maybe<ResolversTypes['SmartTextEditorValue']>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
viewedAt?: Resolver<Maybe<ResolversTypes['DateTime']>, ParentType, ContextType>;
|
||||
viewerResources?: Resolver<Array<ResolversTypes['ViewerResourceItem']>, ParentType, ContextType>;
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
batchDeleteCommitsFactory,
|
||||
batchMoveCommitsFactory
|
||||
} from '@/modules/core/services/commit/batchCommitActions'
|
||||
import { StreamInvalidAccessError } from '@/modules/core/errors/stream'
|
||||
import { isNonNullable, MaybeNullOrUndefined, Roles } from '@speckle/shared'
|
||||
import {
|
||||
StreamInvalidAccessError,
|
||||
StreamNotFoundError
|
||||
} from '@/modules/core/errors/stream'
|
||||
import {
|
||||
throwIfResourceAccessNotAllowed,
|
||||
toProjectIdWhitelist
|
||||
@@ -81,9 +83,18 @@ import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
|
||||
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
|
||||
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
|
||||
import { getDateFromLimitsFactory } from '@/modules/core/services/versions/limits'
|
||||
import {
|
||||
Authz,
|
||||
getProjectLimitDate,
|
||||
isNonNullable,
|
||||
MaybeNullOrUndefined,
|
||||
Roles
|
||||
} from '@speckle/shared'
|
||||
|
||||
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
|
||||
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
|
||||
? () => Promise.resolve(Authz.PersonalProjectsLimits)
|
||||
: () => Promise.resolve(null)
|
||||
|
||||
const getStreams = getStreamsFactory({ db })
|
||||
|
||||
@@ -209,12 +220,10 @@ export = {
|
||||
async commits(parent, args, ctx) {
|
||||
const projectDB = await getProjectDbClient({ projectId: parent.id })
|
||||
|
||||
const limitsDate = await getDateFromLimitsFactory({
|
||||
environment: {
|
||||
personalProjectsLimitEnabled: FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
|
||||
},
|
||||
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits
|
||||
})({ workspaceId: parent.workspaceId })
|
||||
const limitsDate = await getProjectLimitDate({
|
||||
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
|
||||
getPersonalProjectLimits
|
||||
})({ limitType: 'versionsHistory', project: parent })
|
||||
|
||||
const getCommitsByStreamId = legacyGetPaginatedStreamCommitsPageFactory({
|
||||
db: projectDB,
|
||||
@@ -330,13 +339,17 @@ export = {
|
||||
}
|
||||
}
|
||||
|
||||
const stream = await ctx.loaders.streams.getStream.load(parent.streamId)
|
||||
const limitsDate = await getDateFromLimitsFactory({
|
||||
environment: {
|
||||
personalProjectsLimitEnabled: FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
|
||||
},
|
||||
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits
|
||||
})({ workspaceId: stream?.workspaceId })
|
||||
const project = await ctx.loaders.streams.getStream.load(parent.streamId)
|
||||
if (!project) {
|
||||
throw new StreamNotFoundError('Project not found', {
|
||||
info: { streamId: parent.streamId }
|
||||
})
|
||||
}
|
||||
|
||||
const limitsDate = await getProjectLimitDate({
|
||||
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
|
||||
getPersonalProjectLimits
|
||||
})({ limitType: 'versionsHistory', project })
|
||||
|
||||
const getPaginatedBranchCommits = getPaginatedBranchCommitsFactory({
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDB }),
|
||||
|
||||
@@ -48,14 +48,21 @@ import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
|
||||
import coreModule from '@/modules/core'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { StreamNotFoundError } from '@/modules/core/errors/stream'
|
||||
import { getLimitedReferencedObjectFactory } from '@/modules/core/services/versions/limits'
|
||||
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
|
||||
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
|
||||
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
|
||||
import { Version } from '@/modules/core/domain/commits/types'
|
||||
import { GraphQLResolveInfo } from 'graphql'
|
||||
import {
|
||||
Authz,
|
||||
getProjectLimitDate,
|
||||
isCreatedBeyondHistoryLimitCutoff
|
||||
} from '@speckle/shared'
|
||||
|
||||
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
|
||||
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
|
||||
? () => Promise.resolve(Authz.PersonalProjectsLimits)
|
||||
: () => Promise.resolve(null)
|
||||
|
||||
/**
|
||||
* Simple utility to check if version is inside a Model or a Project
|
||||
@@ -126,12 +133,12 @@ export = {
|
||||
})
|
||||
}
|
||||
|
||||
const getLimitedReferencedObject = getLimitedReferencedObjectFactory({
|
||||
environment: {
|
||||
personalProjectsLimitEnabled: FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
|
||||
},
|
||||
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits
|
||||
})
|
||||
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
|
||||
getProjectLimitDate: getProjectLimitDate({
|
||||
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
|
||||
getPersonalProjectLimits
|
||||
})
|
||||
})({ entity: parent, limitType: 'versionsHistory', project })
|
||||
let lastVersion: Version | null
|
||||
if (getTypeFromPath(info) === 'Model') {
|
||||
lastVersion = await ctx.loaders
|
||||
@@ -143,10 +150,8 @@ export = {
|
||||
.streams.getLastVersion.load(parent.streamId)
|
||||
}
|
||||
if (lastVersion?.id === parent.id) return parent.referencedObject
|
||||
return await getLimitedReferencedObject({
|
||||
version: parent,
|
||||
workspaceId: project.workspaceId
|
||||
})
|
||||
if (isBeyondLimit) return null
|
||||
return parent.referencedObject
|
||||
}
|
||||
},
|
||||
Mutation: {
|
||||
|
||||
@@ -8,6 +8,9 @@ export function createRandomPassword(length?: number) {
|
||||
return crs({ length: length ?? 10 })
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use the one in shared
|
||||
*/
|
||||
export function createRandomString(length?: number) {
|
||||
return crs({ length: length ?? 10 })
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Version } from '@/modules/core/domain/commits/types'
|
||||
import { GetWorkspaceLimits } from '@speckle/shared/dist/commonjs/authz/domain/workspaces/operations'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const PersonalProjectsLimits: {
|
||||
versionHistory: { value: number; unit: 'week' }
|
||||
} = {
|
||||
versionHistory: {
|
||||
value: 1,
|
||||
unit: 'week'
|
||||
}
|
||||
}
|
||||
|
||||
export const getLimitedReferencedObjectFactory =
|
||||
({
|
||||
environment: { personalProjectsLimitEnabled },
|
||||
getWorkspaceLimits
|
||||
}: {
|
||||
environment: { personalProjectsLimitEnabled: boolean }
|
||||
getWorkspaceLimits: GetWorkspaceLimits
|
||||
}) =>
|
||||
async ({
|
||||
version,
|
||||
workspaceId
|
||||
}: {
|
||||
version: Pick<Version, 'referencedObject' | 'createdAt'>
|
||||
workspaceId?: string | null
|
||||
}) => {
|
||||
const limitDate = await getDateFromLimitsFactory({
|
||||
environment: { personalProjectsLimitEnabled },
|
||||
getWorkspaceLimits
|
||||
})({ workspaceId })
|
||||
|
||||
if (dayjs(limitDate).isAfter(version.createdAt)) return null
|
||||
return version.referencedObject
|
||||
}
|
||||
|
||||
export const getDateFromLimitsFactory =
|
||||
({
|
||||
getWorkspaceLimits,
|
||||
environment: { personalProjectsLimitEnabled }
|
||||
}: {
|
||||
getWorkspaceLimits: GetWorkspaceLimits
|
||||
environment: { personalProjectsLimitEnabled: boolean }
|
||||
}) =>
|
||||
async ({ workspaceId }: { workspaceId?: string | null }) => {
|
||||
if (workspaceId) {
|
||||
const limits = await getWorkspaceLimits({ workspaceId })
|
||||
if (!limits?.versionsHistory) {
|
||||
return null
|
||||
}
|
||||
|
||||
return dayjs()
|
||||
.subtract(limits.versionsHistory.value, limits.versionsHistory.unit)
|
||||
.toDate()
|
||||
}
|
||||
|
||||
if (!personalProjectsLimitEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return dayjs()
|
||||
.subtract(
|
||||
PersonalProjectsLimits.versionHistory.value,
|
||||
PersonalProjectsLimits.versionHistory.unit
|
||||
)
|
||||
.toDate()
|
||||
}
|
||||
@@ -83,7 +83,7 @@ const createUser = createUserFactory({
|
||||
emitEvent: getEventBus().emit
|
||||
})
|
||||
|
||||
const { FF_BILLING_INTEGRATION_ENABLED, FF_WORKSPACES_MODULE_ENABLED } =
|
||||
const { FF_BILLING_INTEGRATION_ENABLED, FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } =
|
||||
getFeatureFlags()
|
||||
|
||||
describe('Versions graphql @core', () => {
|
||||
@@ -136,11 +136,11 @@ describe('Versions graphql @core', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
;(FF_WORKSPACES_MODULE_ENABLED ? describe : describe.skip)(
|
||||
;(FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED ? describe : describe.skip)(
|
||||
'Version.referencedObject',
|
||||
() => {
|
||||
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
|
||||
it('should return version referencedObject if version is the last model version', async () => {
|
||||
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
|
||||
const user = await createTestUser({
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail()
|
||||
@@ -235,25 +235,14 @@ describe('Versions graphql @core', () => {
|
||||
})
|
||||
})
|
||||
it('should return version referencedObject if version is the last project version', async () => {
|
||||
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
|
||||
const user = await createTestUser({
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail()
|
||||
})
|
||||
const workspace = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
slug: createRandomString(),
|
||||
ownerId: user.id
|
||||
}
|
||||
await createTestWorkspace(workspace, user, {
|
||||
addPlan: { name: 'free', status: 'valid' }
|
||||
})
|
||||
|
||||
const project1 = {
|
||||
id: '',
|
||||
name: createRandomString(),
|
||||
workspaceId: workspace.id
|
||||
name: createRandomString()
|
||||
}
|
||||
await createTestStream(project1, user)
|
||||
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { createRandomString } from '@/modules/core/helpers/testHelpers'
|
||||
import {
|
||||
getDateFromLimitsFactory,
|
||||
getLimitedReferencedObjectFactory
|
||||
} from '@/modules/core/services/versions/limits'
|
||||
import { GetWorkspaceLimits } from '@speckle/shared/dist/commonjs/authz/domain/workspaces/operations'
|
||||
import { expect } from 'chai'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
describe('Module @core', () => {
|
||||
describe('Services versions', () => {
|
||||
describe('getDateFromLimits', () => {
|
||||
it('should return null if workspace has no versionHistory limits', async () => {
|
||||
const getWorkspaceLimits = async () => null
|
||||
const workspaceId = createRandomString()
|
||||
|
||||
expect(
|
||||
await getDateFromLimitsFactory({
|
||||
getWorkspaceLimits,
|
||||
environment: { personalProjectsLimitEnabled: false }
|
||||
})({ workspaceId })
|
||||
).to.eq(null)
|
||||
})
|
||||
it('should return date in workspace versionHistory limits', async () => {
|
||||
const getWorkspaceLimits = async () => ({
|
||||
projectCount: null,
|
||||
modelCount: null,
|
||||
versionsHistory: { value: 1, unit: 'month' as const }
|
||||
})
|
||||
const workspaceId = createRandomString()
|
||||
|
||||
expect(
|
||||
(
|
||||
await getDateFromLimitsFactory({
|
||||
getWorkspaceLimits,
|
||||
environment: { personalProjectsLimitEnabled: false }
|
||||
})({ workspaceId })
|
||||
)
|
||||
?.toISOString()
|
||||
.slice(0, -5)
|
||||
).to.eq(dayjs().subtract(1, 'month').toDate().toISOString().slice(0, -5))
|
||||
})
|
||||
})
|
||||
describe('getLimitedReferencedObjectFactory returns a function that, ', () => {
|
||||
it('should return the version referencedObject if project workspace has no limits', async () => {
|
||||
const getWorkspaceLimits = (() => null) as unknown as GetWorkspaceLimits
|
||||
const workspaceId = createRandomString()
|
||||
const version = {
|
||||
referencedObject: createRandomString(),
|
||||
createdAt: new Date()
|
||||
}
|
||||
expect(
|
||||
await getLimitedReferencedObjectFactory({
|
||||
environment: { personalProjectsLimitEnabled: false },
|
||||
getWorkspaceLimits
|
||||
})({ version, workspaceId })
|
||||
).to.eq(version.referencedObject)
|
||||
})
|
||||
it('should return null if version is outside of workspace limit', async () => {
|
||||
const getWorkspaceLimits = (() => ({
|
||||
versionsHistory: { value: 7, unit: 'day' }
|
||||
})) as unknown as GetWorkspaceLimits
|
||||
const workspaceId = createRandomString()
|
||||
const tenDaysAgo = new Date()
|
||||
tenDaysAgo.setDate(new Date().getDate() - 10)
|
||||
const version = {
|
||||
referencedObject: createRandomString(),
|
||||
createdAt: tenDaysAgo
|
||||
}
|
||||
expect(
|
||||
await getLimitedReferencedObjectFactory({
|
||||
environment: { personalProjectsLimitEnabled: false },
|
||||
getWorkspaceLimits
|
||||
})({ version, workspaceId })
|
||||
).to.eq(null)
|
||||
})
|
||||
it('should return version referencedObject if version is inside of workspace limit', async () => {
|
||||
const getWorkspaceLimits = (() => ({
|
||||
versionsHistory: { value: 7, unit: 'day' }
|
||||
})) as unknown as GetWorkspaceLimits
|
||||
const workspaceId = createRandomString()
|
||||
const twoDaysAgo = new Date()
|
||||
twoDaysAgo.setDate(new Date().getDate() - 2)
|
||||
const version = {
|
||||
referencedObject: createRandomString(),
|
||||
createdAt: twoDaysAgo
|
||||
}
|
||||
expect(
|
||||
await getLimitedReferencedObjectFactory({
|
||||
environment: { personalProjectsLimitEnabled: false },
|
||||
getWorkspaceLimits
|
||||
})({ version, workspaceId })
|
||||
).to.eq(version.referencedObject)
|
||||
})
|
||||
it('should return version referencedObject if project is not in a workspace and personalProjectsLimits is not enabled', async () => {
|
||||
const getWorkspaceLimits = (() =>
|
||||
expect.fail()) as unknown as GetWorkspaceLimits
|
||||
const workspaceId = null
|
||||
const tenDaysAgo = new Date()
|
||||
tenDaysAgo.setDate(new Date().getDate() - 10)
|
||||
const version = {
|
||||
referencedObject: createRandomString(),
|
||||
createdAt: tenDaysAgo
|
||||
}
|
||||
expect(
|
||||
await getLimitedReferencedObjectFactory({
|
||||
environment: { personalProjectsLimitEnabled: false },
|
||||
getWorkspaceLimits
|
||||
})({ version, workspaceId })
|
||||
).to.eq(version.referencedObject)
|
||||
})
|
||||
it('should return null if project is not in a workspace and personalProjectsLimits is enabled and version is outside of limits', async () => {
|
||||
const getWorkspaceLimits = (() =>
|
||||
expect.fail()) as unknown as GetWorkspaceLimits
|
||||
const workspaceId = null
|
||||
const tenDaysAgo = new Date()
|
||||
tenDaysAgo.setDate(new Date().getDate() - 10)
|
||||
const version = {
|
||||
referencedObject: createRandomString(),
|
||||
createdAt: tenDaysAgo
|
||||
}
|
||||
expect(
|
||||
await getLimitedReferencedObjectFactory({
|
||||
environment: { personalProjectsLimitEnabled: true },
|
||||
getWorkspaceLimits
|
||||
})({ version, workspaceId })
|
||||
).to.eq(null)
|
||||
})
|
||||
it('should return version referencedObject if project is not in a workspace and personalProjectsLimits is enabled and version is inside limits', async () => {
|
||||
const getWorkspaceLimits = (() =>
|
||||
expect.fail()) as unknown as GetWorkspaceLimits
|
||||
const workspaceId = null
|
||||
const version = {
|
||||
referencedObject: createRandomString(),
|
||||
createdAt: new Date()
|
||||
}
|
||||
expect(
|
||||
await getLimitedReferencedObjectFactory({
|
||||
environment: { personalProjectsLimitEnabled: true },
|
||||
getWorkspaceLimits
|
||||
})({ version, workspaceId })
|
||||
).to.eq(version.referencedObject)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -614,7 +614,7 @@ export type Comment = {
|
||||
parent?: Maybe<Comment>;
|
||||
permissions: CommentPermissionChecks;
|
||||
/** Plain-text version of the comment text, ideal for previews */
|
||||
rawText: Scalars['String']['output'];
|
||||
rawText?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated Not actually implemented */
|
||||
reactions?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
||||
/** Gets the replies to this comment. */
|
||||
@@ -624,7 +624,7 @@ export type Comment = {
|
||||
/** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
|
||||
resources: Array<ResourceIdentifier>;
|
||||
screenshot?: Maybe<Scalars['String']['output']>;
|
||||
text: SmartTextEditorValue;
|
||||
text?: Maybe<SmartTextEditorValue>;
|
||||
/** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
|
||||
@@ -5114,9 +5114,9 @@ export type CrossSyncDownloadableCommitViewerThreadsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type CrossSyncDownloadableCommitViewerThreadsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, commentThreads: { __typename?: 'ProjectCommentCollection', totalCount: number, totalArchivedCount: number, items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } }> }, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } }> } } };
|
||||
export type CrossSyncDownloadableCommitViewerThreadsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, commentThreads: { __typename?: 'ProjectCommentCollection', totalCount: number, totalArchivedCount: number, items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null }> }, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null }> } } };
|
||||
|
||||
export type DownloadbleCommentMetadataFragment = { __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } };
|
||||
export type DownloadbleCommentMetadataFragment = { __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null };
|
||||
|
||||
export type CrossSyncProjectMetadataQueryVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
|
||||
@@ -393,13 +393,13 @@ const saveNewThreadsFactory =
|
||||
|
||||
const threadInputs: { originalComment: ViewerThread; input: CreateCommentInput }[] =
|
||||
threads
|
||||
.filter((t) => !!t.text.doc)
|
||||
.filter((t) => !!t.text?.doc)
|
||||
.map((t) => ({
|
||||
originalComment: t,
|
||||
input: {
|
||||
projectId: targetStream.id,
|
||||
content: {
|
||||
doc: t.text.doc,
|
||||
doc: t.text?.doc,
|
||||
blobIds: [] // TODO: Currently not supported
|
||||
},
|
||||
viewerState: t.viewerState
|
||||
@@ -436,12 +436,12 @@ const saveNewThreadsFactory =
|
||||
)
|
||||
await Promise.all(
|
||||
replies.items
|
||||
.filter((i) => !!i.text.doc)
|
||||
.filter((i) => !!i.text?.doc)
|
||||
.map((r) =>
|
||||
deps.createCommentReplyAndNotify(
|
||||
{
|
||||
content: {
|
||||
doc: r.text.doc,
|
||||
doc: r.text?.doc,
|
||||
blobIds: []
|
||||
},
|
||||
threadId: newComment.id,
|
||||
|
||||
@@ -615,7 +615,7 @@ export type Comment = {
|
||||
parent?: Maybe<Comment>;
|
||||
permissions: CommentPermissionChecks;
|
||||
/** Plain-text version of the comment text, ideal for previews */
|
||||
rawText: Scalars['String']['output'];
|
||||
rawText?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated Not actually implemented */
|
||||
reactions?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
||||
/** Gets the replies to this comment. */
|
||||
@@ -625,7 +625,7 @@ export type Comment = {
|
||||
/** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
|
||||
resources: Array<ResourceIdentifier>;
|
||||
screenshot?: Maybe<Scalars['String']['output']>;
|
||||
text: SmartTextEditorValue;
|
||||
text?: Maybe<SmartTextEditorValue>;
|
||||
/** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
|
||||
@@ -5507,7 +5507,7 @@ export type AutomateValidateAuthCodeQueryVariables = Exact<{
|
||||
|
||||
export type AutomateValidateAuthCodeQuery = { __typename?: 'Query', automateValidateAuthCode: boolean };
|
||||
|
||||
export type CommentWithRepliesFragment = { __typename?: 'Comment', id: string, rawText: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } };
|
||||
export type CommentWithRepliesFragment = { __typename?: 'Comment', id: string, rawText?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null }> } };
|
||||
|
||||
export type CreateCommentMutationVariables = Exact<{
|
||||
input: CommentCreateInput;
|
||||
@@ -5529,7 +5529,7 @@ export type GetCommentQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetCommentQuery = { __typename?: 'Query', comment?: { __typename?: 'Comment', id: string, rawText: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } } | null };
|
||||
export type GetCommentQuery = { __typename?: 'Query', comment?: { __typename?: 'Comment', id: string, rawText?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null }> } } | null };
|
||||
|
||||
export type GetCommentsQueryVariables = Exact<{
|
||||
streamId: Scalars['String']['input'];
|
||||
@@ -5537,7 +5537,7 @@ export type GetCommentsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetCommentsQuery = { __typename?: 'Query', comments?: { __typename?: 'CommentCollection', totalCount: number, cursor?: string | null, items: Array<{ __typename?: 'Comment', id: string, rawText: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } }> } | null };
|
||||
export type GetCommentsQuery = { __typename?: 'Query', comments?: { __typename?: 'CommentCollection', totalCount: number, cursor?: string | null, items: Array<{ __typename?: 'Comment', id: string, rawText?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null }> } }> } | null };
|
||||
|
||||
export type BaseCommitFieldsFragment = { __typename?: 'Commit', id: string, authorName?: string | null, authorId?: string | null, authorAvatar?: string | null, streamId?: string | null, streamName?: string | null, sourceApplication?: string | null, message?: string | null, referencedObject: string, createdAt?: string | null, commentCount: number };
|
||||
|
||||
@@ -5732,28 +5732,28 @@ export type UseProjectAccessRequestMutationVariables = Exact<{
|
||||
|
||||
export type UseProjectAccessRequestMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', accessRequestMutations: { __typename?: 'ProjectAccessRequestMutations', use: { __typename?: 'Project', id: string } } } };
|
||||
|
||||
export type BasicProjectCommentFragment = { __typename?: 'Comment', id: string, rawText: string, authorId: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } };
|
||||
export type BasicProjectCommentFragment = { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null };
|
||||
|
||||
export type CreateProjectCommentMutationVariables = Exact<{
|
||||
input: CreateCommentInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', create: { __typename?: 'Comment', id: string, rawText: string, authorId: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } } } };
|
||||
export type CreateProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', create: { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null } } };
|
||||
|
||||
export type CreateProjectCommentReplyMutationVariables = Exact<{
|
||||
input: CreateCommentReplyInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateProjectCommentReplyMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', reply: { __typename?: 'Comment', id: string, rawText: string, authorId: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } } } };
|
||||
export type CreateProjectCommentReplyMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', reply: { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null } } };
|
||||
|
||||
export type EditProjectCommentMutationVariables = Exact<{
|
||||
input: EditCommentInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type EditProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', edit: { __typename?: 'Comment', id: string, rawText: string, authorId: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } } } };
|
||||
export type EditProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', edit: { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null } } };
|
||||
|
||||
export type BasicProjectFieldsFragment = { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: SimpleProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string };
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ export const EnvHelperMock = mockRequireModule<
|
||||
['@/modules/shared/index']
|
||||
)
|
||||
|
||||
export const StreamsRepositoryMock = mockRequireModule<
|
||||
typeof import('@/modules/core/repositories/streams')
|
||||
>(['@/modules/core/repositories/streams'])
|
||||
|
||||
export const mockAdminOverride = () => {
|
||||
const enable = (enabled: boolean) => {
|
||||
EnvHelperMock.mockFunction('adminOverrideEnabled', () => enabled)
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"3d"
|
||||
],
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.13",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^5.1.5",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HistoryLimits } from '../../../limit/domain.js'
|
||||
|
||||
export const PersonalProjectsLimits: HistoryLimits = {
|
||||
versionsHistory: {
|
||||
value: 1,
|
||||
unit: 'week'
|
||||
},
|
||||
commentHistory: {
|
||||
value: 1,
|
||||
unit: 'week'
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export {
|
||||
export * from './helpers/graphql.js'
|
||||
export * from './domain/authErrors.js'
|
||||
export { AuthPolicyResult } from './domain/policies.js'
|
||||
export { PersonalProjectsLimits } from './domain/projects/limits.js'
|
||||
|
||||
@@ -51,7 +51,8 @@ const buildCanMoveToWorkspace = (
|
||||
return {
|
||||
modelCount: 5,
|
||||
projectCount: 5,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
},
|
||||
getWorkspaceProjectCount: async () => {
|
||||
@@ -131,7 +132,8 @@ describe('canMoveToWorkspacePolicy returns a function, that', () => {
|
||||
return {
|
||||
projectCount: 1,
|
||||
modelCount: 5,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
},
|
||||
getWorkspaceProjectCount: async () => {
|
||||
|
||||
@@ -54,7 +54,8 @@ const buildCanCreateModelPolicy = (
|
||||
return {
|
||||
modelCount: 5,
|
||||
projectCount: 1,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
},
|
||||
getWorkspaceModelCount: async () => {
|
||||
@@ -143,7 +144,8 @@ describe('canCreateModelPolicy returns a function, that', () => {
|
||||
return {
|
||||
projectCount: 1,
|
||||
modelCount: 5,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
},
|
||||
getWorkspaceModelCount: async () => {
|
||||
|
||||
@@ -477,7 +477,8 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
|
||||
return {
|
||||
projectCount: null,
|
||||
modelCount: null,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
},
|
||||
getWorkspaceProjectCount: async () => {
|
||||
@@ -517,7 +518,8 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
|
||||
return {
|
||||
projectCount: 10,
|
||||
modelCount: 50,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
},
|
||||
getWorkspaceProjectCount: async () => {
|
||||
@@ -559,7 +561,8 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
|
||||
return {
|
||||
projectCount: 10,
|
||||
modelCount: 50,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
},
|
||||
getWorkspaceProjectCount: async () => {
|
||||
@@ -599,7 +602,8 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
|
||||
return {
|
||||
projectCount: 10,
|
||||
modelCount: 50,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
},
|
||||
getWorkspaceProjectCount: async () => {
|
||||
|
||||
@@ -7,3 +7,4 @@ export * as Authz from './authz/index.js'
|
||||
export * from './core/index.js'
|
||||
export * from './workspaces/index.js'
|
||||
export * from './onboarding/index.js'
|
||||
export * from './limit/utils.js'
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export type HistoryLimit = { value: number; unit: 'day' | 'week' | 'month' }
|
||||
|
||||
export type HistoryLimits = Record<HistoryLimitTypes, HistoryLimit | null>
|
||||
|
||||
export const HistoryLimitTypes = {
|
||||
versionsHistory: 'versionsHistory',
|
||||
commentHistory: 'commentHistory'
|
||||
} as const
|
||||
|
||||
export type HistoryLimitTypes =
|
||||
(typeof HistoryLimitTypes)[keyof typeof HistoryLimitTypes]
|
||||
|
||||
export type GetHistoryLimits = () => Promise<HistoryLimits | null>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
calculateLimitCutoffDate,
|
||||
getProjectLimitDate,
|
||||
isCreatedBeyondHistoryLimitCutoff
|
||||
} from './utils.js'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
describe('Limits utils', () => {
|
||||
describe('calculateLimitCutoffDate', () => {
|
||||
it('returns null if historyLimits is null', () => {
|
||||
const cutoffDate = calculateLimitCutoffDate(null, 'commentHistory')
|
||||
expect(cutoffDate).toBeNull()
|
||||
})
|
||||
it('returns null if limitType is null on the historyLimit', () => {
|
||||
const cutoffDate = calculateLimitCutoffDate(
|
||||
{ commentHistory: { value: 1, unit: 'day' }, versionsHistory: null },
|
||||
'versionsHistory'
|
||||
)
|
||||
expect(cutoffDate).toBeNull()
|
||||
})
|
||||
it('returns cutoff date from limits', () => {
|
||||
const cutoffDate = calculateLimitCutoffDate(
|
||||
{ commentHistory: { value: 1, unit: 'day' }, versionsHistory: null },
|
||||
'commentHistory'
|
||||
)
|
||||
const now = dayjs()
|
||||
expect(now.diff(cutoffDate, 'days')).to.equal(1)
|
||||
})
|
||||
})
|
||||
describe('getProjectLimitDate', () => {
|
||||
it('returns workspaceLimits for workspace projects', async () => {
|
||||
const cutoffDate = await getProjectLimitDate({
|
||||
getPersonalProjectLimits: () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspaceLimits: async () => null
|
||||
})({ limitType: 'commentHistory', project: { workspaceId: 'asdfg12345' } })
|
||||
expect(cutoffDate).toBeNull()
|
||||
})
|
||||
it('returns projectLimits for non workspaceProjects', async () => {
|
||||
const cutoffDate = await getProjectLimitDate({
|
||||
getPersonalProjectLimits: async () => null,
|
||||
getWorkspaceLimits: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})({ limitType: 'commentHistory', project: { workspaceId: null } })
|
||||
expect(cutoffDate).toBeNull()
|
||||
})
|
||||
})
|
||||
describe('isCreatedBeyondHistoryLimitCutoff', () => {
|
||||
it('returns false if there are no limits', async () => {
|
||||
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
|
||||
getProjectLimitDate: async () => null
|
||||
})({
|
||||
entity: { createdAt: new Date() },
|
||||
limitType: 'commentHistory',
|
||||
project: { workspaceId: null }
|
||||
})
|
||||
expect(isCreatedBeyondHistoryLimit).to.be.toBeFalsy()
|
||||
})
|
||||
it('returns false if entity is newer than the limit cutoff date', async () => {
|
||||
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
|
||||
getProjectLimitDate: async () => new Date(1999)
|
||||
})({
|
||||
entity: { createdAt: new Date() },
|
||||
limitType: 'commentHistory',
|
||||
project: { workspaceId: null }
|
||||
})
|
||||
expect(isCreatedBeyondHistoryLimit).to.be.toBeFalsy()
|
||||
})
|
||||
it('returns false if entity is right on the limit cutoff date', async () => {
|
||||
const date = new Date()
|
||||
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
|
||||
getProjectLimitDate: async () => date
|
||||
})({
|
||||
entity: { createdAt: date },
|
||||
limitType: 'commentHistory',
|
||||
project: { workspaceId: null }
|
||||
})
|
||||
expect(isCreatedBeyondHistoryLimit).to.be.toBeFalsy()
|
||||
})
|
||||
})
|
||||
it('returns true of entity is older than the limit cutoff date', async () => {
|
||||
const date = new Date()
|
||||
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
|
||||
getProjectLimitDate: async () => date
|
||||
})({
|
||||
entity: { createdAt: new Date(1999) },
|
||||
limitType: 'commentHistory',
|
||||
project: { workspaceId: null }
|
||||
})
|
||||
expect(isCreatedBeyondHistoryLimit).to.be.toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { GetWorkspaceLimits } from '../authz/domain/workspaces/operations.js'
|
||||
import { GetHistoryLimits, HistoryLimitTypes, HistoryLimits } from './domain.js'
|
||||
import { Project } from '../authz/domain/projects/types.js'
|
||||
|
||||
export const isCreatedBeyondHistoryLimitCutoff =
|
||||
({ getProjectLimitDate }: { getProjectLimitDate: GetProjectLimitDate }) =>
|
||||
async ({
|
||||
entity,
|
||||
project,
|
||||
limitType
|
||||
}: {
|
||||
entity: { createdAt: Date }
|
||||
project: Pick<Project, 'workspaceId'>
|
||||
limitType: HistoryLimitTypes
|
||||
}): Promise<boolean> => {
|
||||
const limitDate = await getProjectLimitDate({
|
||||
project,
|
||||
limitType
|
||||
})
|
||||
return limitDate ? dayjs(limitDate).isAfter(entity.createdAt) : false
|
||||
}
|
||||
|
||||
export const calculateLimitCutoffDate = (
|
||||
historyLimits: HistoryLimits | null,
|
||||
limitType: HistoryLimitTypes
|
||||
): Date | null => {
|
||||
if (!historyLimits) return null
|
||||
if (!historyLimits[limitType]) return null
|
||||
return dayjs()
|
||||
.subtract(historyLimits[limitType].value, historyLimits[limitType].unit)
|
||||
.toDate()
|
||||
}
|
||||
|
||||
type GetProjectLimitDate = (args: {
|
||||
project: Pick<Project, 'workspaceId'>
|
||||
limitType: HistoryLimitTypes
|
||||
}) => Promise<Date | null>
|
||||
|
||||
export const getProjectLimitDate =
|
||||
({
|
||||
getWorkspaceLimits,
|
||||
getPersonalProjectLimits
|
||||
}: {
|
||||
getWorkspaceLimits: GetWorkspaceLimits
|
||||
getPersonalProjectLimits: GetHistoryLimits
|
||||
}): GetProjectLimitDate =>
|
||||
async ({ project, limitType }) => {
|
||||
const limits = project.workspaceId
|
||||
? await getWorkspaceLimits({ workspaceId: project.workspaceId })
|
||||
: await getPersonalProjectLimits()
|
||||
return calculateLimitCutoffDate(limits, limitType)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
|
||||
export function createRandomString(length?: number) {
|
||||
return cryptoRandomString({ length: length ?? 10 })
|
||||
}
|
||||
@@ -66,7 +66,8 @@ export type WorkspacePlanPriceStructure = {
|
||||
const unlimited: WorkspaceLimits = {
|
||||
projectCount: null,
|
||||
modelCount: null,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
|
||||
export type WorkspacePlanConfig<Plan extends WorkspacePlans = WorkspacePlans> = {
|
||||
@@ -109,7 +110,8 @@ export const WorkspacePaidPlanConfigs: {
|
||||
limits: {
|
||||
projectCount: 5,
|
||||
modelCount: 25,
|
||||
versionsHistory: { value: 30, unit: 'day' }
|
||||
versionsHistory: { value: 30, unit: 'day' },
|
||||
commentHistory: { value: 30, unit: 'day' }
|
||||
}
|
||||
},
|
||||
// New
|
||||
@@ -119,7 +121,8 @@ export const WorkspacePaidPlanConfigs: {
|
||||
limits: {
|
||||
projectCount: null,
|
||||
modelCount: null,
|
||||
versionsHistory: { value: 30, unit: 'day' }
|
||||
versionsHistory: { value: 30, unit: 'day' },
|
||||
commentHistory: { value: 30, unit: 'day' }
|
||||
}
|
||||
},
|
||||
[PaidWorkspacePlans.Pro]: {
|
||||
@@ -133,7 +136,8 @@ export const WorkspacePaidPlanConfigs: {
|
||||
limits: {
|
||||
projectCount: 10,
|
||||
modelCount: 50,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
},
|
||||
[PaidWorkspacePlans.ProUnlimited]: {
|
||||
@@ -147,7 +151,8 @@ export const WorkspacePaidPlanConfigs: {
|
||||
limits: {
|
||||
projectCount: null,
|
||||
modelCount: null,
|
||||
versionsHistory: null
|
||||
versionsHistory: null,
|
||||
commentHistory: null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +208,8 @@ export const WorkspaceUnpaidPlanConfigs: {
|
||||
limits: {
|
||||
projectCount: 1,
|
||||
modelCount: 5,
|
||||
versionsHistory: { value: 7, unit: 'day' }
|
||||
versionsHistory: { value: 7, unit: 'day' },
|
||||
commentHistory: { value: 7, unit: 'day' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HistoryLimits } from '../../limit/domain.js'
|
||||
|
||||
export type WorkspaceLimits = {
|
||||
projectCount: number | null
|
||||
modelCount: number | null
|
||||
versionsHistory: { value: number; unit: 'day' | 'week' | 'month' } | null
|
||||
}
|
||||
} & HistoryLimits
|
||||
|
||||
@@ -16986,6 +16986,7 @@ __metadata:
|
||||
"@vitest/coverage-v8": "npm:^3.0.9"
|
||||
"@vitest/ui": "npm:^3.0.9"
|
||||
crypto-random-string: "npm:^5.0.0"
|
||||
dayjs: "npm:^1.11.13"
|
||||
eslint: "npm:^9.4.0"
|
||||
eslint-config-prettier: "npm:^9.1.0"
|
||||
knex: "npm:^2.5.1"
|
||||
@@ -27572,6 +27573,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dayjs@npm:^1.11.13":
|
||||
version: 1.11.13
|
||||
resolution: "dayjs@npm:1.11.13"
|
||||
checksum: 10/7374d63ab179b8d909a95e74790def25c8986e329ae989840bacb8b1888be116d20e1c4eee75a69ea0dfbae13172efc50ef85619d304ee7ca3c01d5878b704f5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dayjs@npm:^1.11.5":
|
||||
version: 1.11.5
|
||||
resolution: "dayjs@npm:1.11.5"
|
||||
|
||||
Reference in New Issue
Block a user