diff --git a/packages/server/assets/comments/typedefs/comments.gql b/packages/server/assets/comments/typedefs/comments.gql index 30546a932..dbfe6c7e8 100644 --- a/packages/server/assets/comments/typedefs/comments.gql +++ b/packages/server/assets/comments/typedefs/comments.gql @@ -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. """ diff --git a/packages/server/modules/comments/graph/resolvers/comments.ts b/packages/server/modules/comments/graph/resolvers/comments.ts index ac114092f..b1c780efd 100644 --- a/packages/server/modules/comments/graph/resolvers/comments.ts +++ b/packages/server/modules/comments/graph/resolvers/comments.ts @@ -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) }, diff --git a/packages/server/modules/comments/tests/comments.spec.ts b/packages/server/modules/comments/tests/comments.spec.ts index 199aa4763..eaa30649e 100644 --- a/packages/server/modules/comments/tests/comments.spec.ts +++ b/packages/server/modules/comments/tests/comments.spec.ts @@ -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() }) }) }) diff --git a/packages/server/modules/comments/tests/projectComments.spec.ts b/packages/server/modules/comments/tests/projectComments.spec.ts index a7a8dfff2..d2047bf91 100644 --- a/packages/server/modules/comments/tests/projectComments.spec.ts +++ b/packages/server/modules/comments/tests/projectComments.spec.ts @@ -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 }) diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 9cb817cf3..01e4df22c 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -634,7 +634,7 @@ export type Comment = { parent?: Maybe; permissions: CommentPermissionChecks; /** Plain-text version of the comment text, ideal for previews */ - rawText: Scalars['String']['output']; + rawText?: Maybe; /** @deprecated Not actually implemented */ reactions?: Maybe>>; /** 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; screenshot?: Maybe; - text: SmartTextEditorValue; + text?: Maybe; /** 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; parent?: Resolver, ParentType, ContextType>; permissions?: Resolver; - rawText?: Resolver; + rawText?: Resolver, ParentType, ContextType>; reactions?: Resolver>>, ParentType, ContextType>; replies?: Resolver>; replyAuthors?: Resolver>; resources?: Resolver, ParentType, ContextType>; screenshot?: Resolver, ParentType, ContextType>; - text?: Resolver; + text?: Resolver, ParentType, ContextType>; updatedAt?: Resolver; viewedAt?: Resolver, ParentType, ContextType>; viewerResources?: Resolver, ParentType, ContextType>; diff --git a/packages/server/modules/core/graph/resolvers/commits.ts b/packages/server/modules/core/graph/resolvers/commits.ts index e35f6da92..2aaed6f48 100644 --- a/packages/server/modules/core/graph/resolvers/commits.ts +++ b/packages/server/modules/core/graph/resolvers/commits.ts @@ -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 }), diff --git a/packages/server/modules/core/graph/resolvers/versions.ts b/packages/server/modules/core/graph/resolvers/versions.ts index 51f4364b7..049d18c83 100644 --- a/packages/server/modules/core/graph/resolvers/versions.ts +++ b/packages/server/modules/core/graph/resolvers/versions.ts @@ -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: { diff --git a/packages/server/modules/core/helpers/testHelpers.ts b/packages/server/modules/core/helpers/testHelpers.ts index 1b771176b..0404b06c4 100644 --- a/packages/server/modules/core/helpers/testHelpers.ts +++ b/packages/server/modules/core/helpers/testHelpers.ts @@ -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 }) } diff --git a/packages/server/modules/core/services/versions/limits.ts b/packages/server/modules/core/services/versions/limits.ts deleted file mode 100644 index 7f79958e6..000000000 --- a/packages/server/modules/core/services/versions/limits.ts +++ /dev/null @@ -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 - 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() - } diff --git a/packages/server/modules/core/tests/integration/versions.graph.spec.ts b/packages/server/modules/core/tests/integration/versions.graph.spec.ts index 4c49129df..3bd5a1803 100644 --- a/packages/server/modules/core/tests/integration/versions.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/versions.graph.spec.ts @@ -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) diff --git a/packages/server/modules/core/tests/unit/services/versions.spec.ts b/packages/server/modules/core/tests/unit/services/versions.spec.ts deleted file mode 100644 index b9d3fe21f..000000000 --- a/packages/server/modules/core/tests/unit/services/versions.spec.ts +++ /dev/null @@ -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) - }) - }) - }) -}) diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 6722d303a..1ec3c8884 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -614,7 +614,7 @@ export type Comment = { parent?: Maybe; permissions: CommentPermissionChecks; /** Plain-text version of the comment text, ideal for previews */ - rawText: Scalars['String']['output']; + rawText?: Maybe; /** @deprecated Not actually implemented */ reactions?: Maybe>>; /** 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; screenshot?: Maybe; - text: SmartTextEditorValue; + text?: Maybe; /** 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 | null, screenshot?: string | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record | null, screenshot?: string | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null } }> }, text: { __typename?: 'SmartTextEditorValue', doc?: Record | 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 | null, screenshot?: string | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record | null, screenshot?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record | null } | null }> }, text?: { __typename?: 'SmartTextEditorValue', doc?: Record | null } | null }> } } }; -export type DownloadbleCommentMetadataFragment = { __typename?: 'Comment', id: string, viewerState?: Record | null, screenshot?: string | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record | null } }; +export type DownloadbleCommentMetadataFragment = { __typename?: 'Comment', id: string, viewerState?: Record | null, screenshot?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record | null } | null }; export type CrossSyncProjectMetadataQueryVariables = Exact<{ id: Scalars['String']['input']; diff --git a/packages/server/modules/cross-server-sync/services/commit.ts b/packages/server/modules/cross-server-sync/services/commit.ts index 02710b2b2..0d48a226c 100644 --- a/packages/server/modules/cross-server-sync/services/commit.ts +++ b/packages/server/modules/cross-server-sync/services/commit.ts @@ -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, diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 584761421..8713b5f7e 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -615,7 +615,7 @@ export type Comment = { parent?: Maybe; permissions: CommentPermissionChecks; /** Plain-text version of the comment text, ideal for previews */ - rawText: Scalars['String']['output']; + rawText?: Maybe; /** @deprecated Not actually implemented */ reactions?: Maybe>>; /** 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; screenshot?: Maybe; - text: SmartTextEditorValue; + text?: Maybe; /** 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | null } }; +export type BasicProjectCommentFragment = { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record | 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 | null } } } }; +export type CreateProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', create: { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record | 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 | null } } } }; +export type CreateProjectCommentReplyMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', reply: { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record | 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 | null } } } }; +export type EditProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', edit: { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record | 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 }; diff --git a/packages/server/test/mocks/global.ts b/packages/server/test/mocks/global.ts index 2bb09e5f5..5f32df19c 100644 --- a/packages/server/test/mocks/global.ts +++ b/packages/server/test/mocks/global.ts @@ -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) diff --git a/packages/shared/package.json b/packages/shared/package.json index 264f1b06d..1b0890c6d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,6 +36,7 @@ "3d" ], "dependencies": { + "dayjs": "^1.11.13", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "nanoid": "^5.1.5", diff --git a/packages/shared/src/authz/domain/projects/limits.ts b/packages/shared/src/authz/domain/projects/limits.ts new file mode 100644 index 000000000..357720396 --- /dev/null +++ b/packages/shared/src/authz/domain/projects/limits.ts @@ -0,0 +1,12 @@ +import { HistoryLimits } from '../../../limit/domain.js' + +export const PersonalProjectsLimits: HistoryLimits = { + versionsHistory: { + value: 1, + unit: 'week' + }, + commentHistory: { + value: 1, + unit: 'week' + } +} diff --git a/packages/shared/src/authz/index.ts b/packages/shared/src/authz/index.ts index 4fbbd743e..ed7c4aa17 100644 --- a/packages/shared/src/authz/index.ts +++ b/packages/shared/src/authz/index.ts @@ -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' diff --git a/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts b/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts index 82d7fbcdc..5a305f9d9 100644 --- a/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts +++ b/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts @@ -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 () => { diff --git a/packages/shared/src/authz/policies/project/model/canCreate.spec.ts b/packages/shared/src/authz/policies/project/model/canCreate.spec.ts index eb11989f2..3727f436a 100644 --- a/packages/shared/src/authz/policies/project/model/canCreate.spec.ts +++ b/packages/shared/src/authz/policies/project/model/canCreate.spec.ts @@ -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 () => { diff --git a/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.spec.ts b/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.spec.ts index ee3c94c86..f8cb27bd0 100644 --- a/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.spec.ts +++ b/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.spec.ts @@ -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 () => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 30a862992..6d42eb36e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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' diff --git a/packages/shared/src/limit/domain.ts b/packages/shared/src/limit/domain.ts new file mode 100644 index 000000000..b6dcffa1f --- /dev/null +++ b/packages/shared/src/limit/domain.ts @@ -0,0 +1,13 @@ +export type HistoryLimit = { value: number; unit: 'day' | 'week' | 'month' } + +export type HistoryLimits = Record + +export const HistoryLimitTypes = { + versionsHistory: 'versionsHistory', + commentHistory: 'commentHistory' +} as const + +export type HistoryLimitTypes = + (typeof HistoryLimitTypes)[keyof typeof HistoryLimitTypes] + +export type GetHistoryLimits = () => Promise diff --git a/packages/shared/src/limit/utils.spec.ts b/packages/shared/src/limit/utils.spec.ts new file mode 100644 index 000000000..dde83da87 --- /dev/null +++ b/packages/shared/src/limit/utils.spec.ts @@ -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() + }) +}) diff --git a/packages/shared/src/limit/utils.ts b/packages/shared/src/limit/utils.ts new file mode 100644 index 000000000..9c8074c8c --- /dev/null +++ b/packages/shared/src/limit/utils.ts @@ -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 + limitType: HistoryLimitTypes + }): Promise => { + 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 + limitType: HistoryLimitTypes +}) => Promise + +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) + } diff --git a/packages/shared/src/tests/helpers/utils.ts b/packages/shared/src/tests/helpers/utils.ts new file mode 100644 index 000000000..856771ab6 --- /dev/null +++ b/packages/shared/src/tests/helpers/utils.ts @@ -0,0 +1,5 @@ +import cryptoRandomString from 'crypto-random-string' + +export function createRandomString(length?: number) { + return cryptoRandomString({ length: length ?? 10 }) +} diff --git a/packages/shared/src/workspaces/helpers/features.ts b/packages/shared/src/workspaces/helpers/features.ts index f61799b6a..995e4ae35 100644 --- a/packages/shared/src/workspaces/helpers/features.ts +++ b/packages/shared/src/workspaces/helpers/features.ts @@ -66,7 +66,8 @@ export type WorkspacePlanPriceStructure = { const unlimited: WorkspaceLimits = { projectCount: null, modelCount: null, - versionsHistory: null + versionsHistory: null, + commentHistory: null } export type WorkspacePlanConfig = { @@ -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' } } } } diff --git a/packages/shared/src/workspaces/helpers/limits.ts b/packages/shared/src/workspaces/helpers/limits.ts index ea38e75f7..d2647af7d 100644 --- a/packages/shared/src/workspaces/helpers/limits.ts +++ b/packages/shared/src/workspaces/helpers/limits.ts @@ -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 diff --git a/yarn.lock b/yarn.lock index 26ff24cc5..7f96b9b2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"