diff --git a/cypress/e2e/dashboard.cy.ts b/cypress/e2e/dashboard.cy.ts index aa04a1c85..bd0eaab68 100644 --- a/cypress/e2e/dashboard.cy.ts +++ b/cypress/e2e/dashboard.cy.ts @@ -33,7 +33,9 @@ describe('Dashboard', () => { }); beforeEach(() => { + cy.interceptWhatsNew(); cy.signInViaEmail(); + cy.wait('@whatsnew.check'); }); it('filters are active by default', () => { @@ -72,7 +74,9 @@ describe('User dashboard', () => { }); beforeEach(() => { + cy.interceptWhatsNew(); cy.signInViaEmail(testUser); + cy.wait('@whatsnew.check'); }); describe('User have own project', () => { @@ -142,6 +146,7 @@ describe('User dashboard', () => { }); it('User cannot see self goal which assigned in not own project if filter contains next quarter', () => { + cy.hideEmptyProjectOnGoalLists(); cy.get(appliedFiltersPanelEstimate.query).click(); cy.get(estimateQuarterTrigger.query).children().find(':button:contains(@next)').click(); cy.get(appliedFiltersPanelEstimate.query).focus().realPress('{esc}'); diff --git a/cypress/index.d.ts b/cypress/index.d.ts index 7d55cac0f..eb92799bc 100644 --- a/cypress/index.d.ts +++ b/cypress/index.d.ts @@ -53,6 +53,8 @@ declare global { interface Chainable { logout(): Chainable; signInViaEmail(fields?: SignInFields): Chainable; + interceptWhatsNew(): Chainable; + hideEmptyProjectOnGoalLists(): Chainable; createProject(fields: ProjectCreate): Chainable; createGoal(projectTitle: string, fields: GoalCommon): Chainable; updateGoal(shortId: string, filelds: GoalUpdate): Chainable; diff --git a/cypress/support/index.ts b/cypress/support/index.ts index fc1d106d5..eb24191a0 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -30,6 +30,9 @@ import { userSettingsLogoutButton, commentFormDescription, pageContent, + sortPanelDropdownTrigger, + sortPanel, + sortPanelEmptyProjectsCheckbox, } from '../../src/utils/domObjects'; import { keyPredictor } from '../../src/utils/keyPredictor'; import { SignInFields } from '..'; @@ -54,6 +57,18 @@ Cypress.Commands.addAll({ cy.reload(true); }, + interceptWhatsNew: () => { + cy.intercept('api/trpc/whatsnew.check*', (req) => + req.on('response', (res) => { + if (res.body.result?.data != null && 'version' in res.body.result.data) { + res.body.result.data = null; + } + + res.send(); + }), + ).as('whatsnew.check'); + }, + signInViaEmail: (fields?: SignInFields) => { cy.intercept('/api/auth/session', (req) => { req.on('after:response', (res) => { @@ -268,6 +283,16 @@ Cypress.Commands.addAll({ return cy.wrap(values); }, + hideEmptyProjectOnGoalLists: () => { + cy.get(sortPanelDropdownTrigger.query).should('exist').click(); + cy.get(sortPanel.query).should('exist').and('be.visible'); + + cy.get(sortPanel.query) + .get(sortPanelEmptyProjectsCheckbox.query) + .should('be.checked') + .click() + .should('not.be.checked'); + }, }); /** diff --git a/trpc/queries/goalV2.ts b/trpc/queries/goalV2.ts index 3965ea424..0d9f8c9db 100644 --- a/trpc/queries/goalV2.ts +++ b/trpc/queries/goalV2.ts @@ -88,10 +88,15 @@ export const getGoalsQuery = (params: GetGoalsQueryParams) => qb.selectFrom('_goalParticipants').select('A').whereRef('B', '=', 'Goal.id'), ), ) - .leftJoin('Tag as tag', (join) => - join.onRef('tag.id', 'in', ({ selectFrom }) => - selectFrom('_GoalToTag').select('B').whereRef('A', '=', 'Goal.id'), - ), + .leftJoinLateral( + ({ selectFrom }) => + selectFrom('Tag') + .selectAll('Tag') + .whereRef('Tag.id', 'in', ({ selectFrom }) => + selectFrom('_GoalToTag').select('B').whereRef('A', '=', 'Goal.id'), + ) + .as('tag'), + (join) => join.onTrue(), ) .leftJoinLateral( ({ selectFrom }) => @@ -105,10 +110,15 @@ export const getGoalsQuery = (params: GetGoalsQueryParams) => .as('criteria'), (join) => join.onTrue(), ) - .leftJoin('Project as partnershipProject', (join) => - join.onRef('partnershipProject.id', 'in', ({ selectFrom }) => - selectFrom('_partnershipProjects').select('B').whereRef('A', '=', 'Goal.id'), - ), + .leftJoinLateral( + ({ selectFrom }) => + selectFrom('Project') + .selectAll('Project') + .whereRef('Project.id', 'in', ({ selectFrom }) => + selectFrom('_partnershipProjects').select('B').whereRef('A', '=', 'Goal.id'), + ) + .as('partnershipProjects'), + (join) => join.onTrue(), ) .select(({ case: caseFn, exists, selectFrom, val, fn }) => [ sql`("Goal"."ownerId" = ${val(params.activityId)})`.as('_isOwner'), @@ -173,42 +183,57 @@ export const getGoalsQuery = (params: GetGoalsQueryParams) => .end() .as('participants'), caseFn() - .when(fn.count('partnershipProject.id'), '>', 0) - .then(fn.agg('array_agg', [sql`"partnershipProject"`]).distinct()) + .when(fn.count('partnershipProjects.id'), '>', 0) + .then(fn.agg('array_agg', [sql`"partnershipProjects"`]).distinct()) .else(null) .end() .as('partnershipProjects'), ]) - .where(({ or, eb, and }) => { - const baseOr = or([ - eb('Goal.projectId', '=', params.projectId), - eb('Goal.id', 'in', ({ selectFrom }) => - selectFrom('_partnershipProjects').where('B', '=', params.projectId).select('A'), - ), - ]); - - if (params.isOnlySubsGoals) { - return and([ - baseOr, - eb('Goal.id', 'in', ({ selectFrom }) => - selectFrom('_goalStargizers') - .select('B as id') - .where('A', '=', params.activityId) - .union( - selectFrom('_goalWatchers') - .select('B as id') - .where('A', '=', params.activityId), - ) - .union( - selectFrom('_partnershipProjects') - .where('B', '=', params.projectId) - .select('A as id'), - ), + .where('Goal.archived', 'is not', true) + .where('Goal.projectId', '=', params.projectId) + .where(({ and, or, eb, val, cast, selectFrom }) => + or([ + and([ + eb(cast(val(!!params.isOnlySubsGoals), 'boolean'), 'is', false), + eb( + 'Goal.id', + 'in', + selectFrom('_partnershipProjects').select('A').where('B', '=', params.projectId), ), - ]); - } - return baseOr; - }) + ]), + and([ + eb(cast(val(!!params.isOnlySubsGoals), 'boolean'), 'is', true), + or([ + eb('Goal.ownerId', '=', params.activityId), + eb('Goal.activityId', '=', params.activityId), + eb('Goal.id', 'in', ({ selectFrom }) => + selectFrom('_goalParticipants') + .select('B') + .where('A', '=', params.activityId) + .union( + selectFrom('_goalWatchers').select('B').where('A', '=', params.activityId), + ) + .union( + selectFrom('_goalStargizers') + .select('B') + .where('A', '=', params.activityId), + ) + .union( + selectFrom('_partnershipProjects') + .select('A as B') + .where( + 'B', + 'in', + selectFrom('Project') + .select('Project.id') + .where('Project.activityId', '=', params.activityId), + ), + ), + ), + ]), + ]), + ]), + ) .where(({ or, and, eb, selectFrom, cast, val }) => { const { goalsQuery } = params; const estimate: Array = []; @@ -232,13 +257,7 @@ export const getGoalsQuery = (params: GetGoalsQueryParams) => const filters: Record, null | ReturnType> = { project: null, - partnershipProject: goalsQuery?.partnershipProject?.length - ? eb('Goal.id', 'in', ({ selectFrom }) => - selectFrom('_partnershipProjects') - .where('B', 'in', goalsQuery.partnershipProject || []) - .select('A'), - ) - : null, + partnershipProject: null, owner: eb('Goal.ownerId', 'in', goalsQuery?.owner || []), issuer: eb('Goal.activityId', 'in', goalsQuery?.issuer || []), participant: eb('participant.id', 'in', goalsQuery?.participant || []), @@ -301,7 +320,6 @@ export const getGoalsQuery = (params: GetGoalsQueryParams) => return and(filterToApply); }) - .where('Goal.archived', 'is not', true) .groupBy(['Goal.id']), ) .selectFrom('proj_goals') diff --git a/trpc/queries/projectV2.ts b/trpc/queries/projectV2.ts index 3da860049..68650deed 100644 --- a/trpc/queries/projectV2.ts +++ b/trpc/queries/projectV2.ts @@ -1,11 +1,13 @@ -import { AnyColumnWithTable, Expression, ExpressionOrFactory, Nullable, OrderByExpression, sql, SqlBool } from 'kysely'; +import { AnyColumnWithTable, Expression, Nullable, OrderByExpression, sql, SqlBool } from 'kysely'; import { jsonBuildObject } from 'kysely/helpers/postgres'; import { OrderByDirection } from 'kysely/dist/cjs/parser/order-by-parser'; import { decodeUrlDateRange, getDateString } from '@taskany/bricks'; +import { ExpressionFactory } from 'kysely/dist/cjs/parser/expression-parser'; import { db } from '../connection/kysely'; import { Activity, DB, Role } from '../../generated/kysely/types'; import { QueryWithFilters, SortableProjectsPropertiesArray } from '../../src/schema/common'; +import { ProjectRoles, ProjectRules } from '../utils'; import { getUserActivity } from './activity'; @@ -75,6 +77,14 @@ export const getProjectsByIds = (params: { in: Array<{ id: string }>; activityId selectFrom('project_goals').select('id').whereRef('projectId', '=', 'Project.id'), ), ).as('_isGoalStarred'), + exists( + selectFrom('_goalParticipants') + .select('B') + .where('A', '=', params.activityId) + .whereRef('B', 'in', ({ selectFrom }) => + selectFrom('project_goals').select('id').whereRef('projectId', '=', 'Project.id'), + ), + ).as('_isGoalParticipant'), jsonBuildObject({ activityId: ref('user.activityId'), user: fn.toJson('user'), @@ -306,21 +316,10 @@ const mapProjectsSortParamsToTableColumns = ( }); }; -interface GetUserDashboardProjectsParams extends GetUserProjectsQueryParams { - in?: Array<{ id: string }>; - goalsQuery?: QueryWithFilters; - projectsSort?: SortableProjectsPropertiesArray; - limit?: number; - offset?: number; -} - -/** Limit for subquery goals by project */ -const dashboardGoalByProjectLimit = 30; - const getGoalsFiltersWhereExpressionBuilder = ( goalsQuery?: QueryWithFilters, - ): ExpressionOrFactory< + ): ExpressionFactory< DB & { participant: Nullable; tag: Nullable<{ @@ -331,8 +330,9 @@ const getGoalsFiltersWhereExpressionBuilder = createdAt: Date; updatedAt: Date; }>; + cte_projects: any; }, - 'Goal' | 'tag' | 'participant', + 'Goal' | 'tag' | 'participant' | 'cte_projects', SqlBool > => ({ or, and, eb, selectFrom, cast, val }) => { @@ -425,188 +425,6 @@ const getGoalsFiltersWhereExpressionBuilder = return and(filterToApply); }; -export const getUserDashboardProjects = (params: GetUserDashboardProjectsParams) => { - return db - .with('subs_projects', (db) => - db - .selectFrom('_projectParticipants') - .select('B') - .where('A', '=', params.activityId) - .union(db.selectFrom('_projectWatchers').select('B').where('A', '=', params.activityId)) - .union(db.selectFrom('_projectStargizers').select('B').where('A', '=', params.activityId)), - ) - .with('subs_goals', (db) => - db - .selectFrom('_goalParticipants') - .select('B') - .where('A', '=', params.activityId) - .union(db.selectFrom('_goalWatchers').select('B').where('A', '=', params.activityId)) - .union(db.selectFrom('_goalStargizers').select('B').where('A', '=', params.activityId)), - ) - .with('project_ids', (db) => - db - .selectFrom('Project') - .select('Project.id as pid') - .where(({ or, eb }) => - or([ - eb('Project.id', 'in', ({ selectFrom }) => selectFrom('subs_projects').select('B')), - eb('Project.activityId', '=', params.activityId), - ]), - ) - .union( - db - .selectFrom('_parentChildren') - .select('B as pid') - .where('A', 'in', ({ selectFrom }) => selectFrom('subs_projects').select('B')), - ), - ) - .with('partnership_goals', (db) => - db - .selectFrom('_partnershipProjects') - .where('B', 'in', ({ selectFrom }) => selectFrom('project_ids').select('pid')) - .select('A'), - ) - .with('goals', (db) => - db - .selectFrom('Goal') - .select(['Goal.id', 'Goal.projectId']) - .where(({ or, eb }) => - or([ - eb('Goal.id', 'in', ({ selectFrom }) => selectFrom('subs_goals').select('B')), - eb('Goal.projectId', 'in', ({ selectFrom }) => selectFrom('project_ids').select('pid')), - eb('Goal.id', 'in', ({ selectFrom }) => selectFrom('partnership_goals').select('A')), - eb('Goal.ownerId', '=', params.activityId), - eb('Goal.activityId', '=', params.activityId), - ]), - ) - .where(getGoalsFiltersWhereExpressionBuilder(params.goalsQuery)) - .where('Goal.archived', 'is not', true), - ) - .selectFrom('Project') - .leftJoinLateral( - ({ selectFrom }) => - selectFrom('goals') - .selectAll('goals') - .where((eb) => - eb.or([ - eb('goals.id', 'in', () => - selectFrom('_partnershipProjects').whereRef('B', '=', 'Project.id').select('A'), - ), - eb('goals.projectId', '=', eb.ref('Project.id')), - ]), - ) - .limit(params.goalsQuery?.limit ?? dashboardGoalByProjectLimit) - .as('goal'), - (join) => join.onTrue(), - ) - .leftJoinLateral( - ({ selectFrom }) => - selectFrom('_partnershipProjects') - .select('B') - .whereRef('A', 'in', ({ selectFrom }) => - selectFrom('Goal').select('Goal.id').whereRef('Goal.projectId', '=', 'Project.id'), - ) - .as('partnerProjectIds'), - (join) => join.on('Project.id', 'not in', ({ selectFrom }) => selectFrom('project_ids').select('pid')), - ) - .select(({ fn, eb }) => [ - 'Project.id', - eb - .case() - .when(fn.count('partnerProjectIds.B'), '>', 0) - .then(fn.agg('array_agg', ['partnerProjectIds.B']).distinct()) - .else(null) - .end() - .as('partnerProjectIds'), - - jsonBuildObject({ - stargizers: sql`(select count("A") from "_projectStargizers" where "B" = "Project".id)`, - watchers: sql`(select count("A") from "_projectWatchers" where "B" = "Project".id)`, - children: sql`(select count("B") from "_parentChildren" where "A" = "Project".id)`, - participants: sql`(select count("A") from "_projectParticipants" where "B" = "Project".id)`, - goals: fn.count('goal.id'), - }).as('_count'), - ]) - .where('Project.archived', 'is not', true) - .where(({ or, eb }) => - or([ - eb('Project.id', 'in', ({ selectFrom }) => selectFrom('goals').select('goals.projectId')), - eb('Project.id', 'in', ({ selectFrom }) => selectFrom('project_ids').select('pid')), - ]), - ) - .groupBy('Project.id') - .orderBy(mapProjectsSortParamsToTableColumns(params.projectsSort)) - .$if(!!params.goalsQuery?.hideEmptyProjects, (qb) => qb.having(({ fn }) => fn.count('goal.id'), '>', 0)) - .limit(params.limit || 5) - .offset(params.offset || 0); -}; - -/** - * Если мы получаем пользовательские проекты по следующим условиям - * 1. Пользователь - владелец или участник проекта V - * 2. Пользователь - поставил звездочку или следит за проектом V - * 3. Проект является партенским по отношению к проектам пользователя владельцем которых он является - * 4. Пользователь является краним за цель в любом проекте - * 5. Пользователь поставил звездочку или подписался на цель внутри любого проекта - * 6. Пользователь является участником цели любого проекта - */ - -export const getUserDashBoardProjectsAgain = (params: GetUserDashboardProjectsParams) => { - return db - .selectFrom('Project') - .selectAll('Project') - .where('Project.archived', 'is not', true) - .where(({ or, eb }) => - or([ - eb('Project.activityId', '=', params.activityId), - eb('Project.id', 'in', ({ selectFrom }) => - selectFrom('_projectParticipants') - .select('B as id') - .where('A', '=', params.activityId) - .union(selectFrom('_projectStargizers').select('B as id').where('A', '=', params.activityId)) - .union(selectFrom('_projectWatchers').select('B as id').where('A', '=', params.activityId)) - .union( - selectFrom('_partnershipProjects') - .select('A as id') - .where( - 'A', - 'in', - selectFrom('Project') - .select('Project.id') - .where('Project.activityId', '=', params.activityId), - ), - ), - ), - eb('Project.id', 'in', ({ selectFrom }) => - selectFrom('Goal') - .select('Goal.projectId as id') - .where('Goal.archived', 'is not', true) - .where(({ or, eb }) => - or([ - eb('Goal.activityId', '=', params.activityId), - eb('Goal.ownerId', '=', params.activityId), - eb('Goal.id', 'in', ({ selectFrom }) => - selectFrom('_goalParticipants') - .select('B as id') - .where('A', '=', params.activityId) - .union( - selectFrom('_goalStargizers') - .select('B as id') - .where('A', '=', params.activityId), - ) - .union( - selectFrom('_goalWatchers') - .select('B as id') - .where('A', '=', params.activityId), - ), - ), - ]), - ), - ), - ]), - ); -}; - interface GetWholeGoalCountByProjectIds { in: Array<{ id: string }>; } @@ -950,3 +768,243 @@ export const getChildrenProjectByParentProjectId = ({ id }: { id: string }) => { selectFrom('_parentChildren').select('_parentChildren.B').where('_parentChildren.A', '=', id), ); }; + +export const getUserProjects = ({ activityId }: Pick) => { + return db + .with('subs_projects', (db) => + db + .selectFrom('Project') + .select(({ val, cast }) => [ + 'Project.id as pid', + cast(val(ProjectRoles.project_owner), 'integer').as('role'), + ]) + .where('Project.activityId', '=', activityId) + .union( + db + .selectFrom('_projectParticipants') + .select(({ cast, val }) => [ + 'B as pid', + cast(val(ProjectRoles.project_participant), 'integer').as('role'), + ]) + .where('A', '=', activityId), + ) + .union( + db + .selectFrom('_projectWatchers') + .select(({ cast, val }) => [ + 'B as pid', + cast(val(ProjectRoles.project_watcher), 'integer').as('role'), + ]) + .where('A', '=', activityId), + ) + .union( + db + .selectFrom('_projectStargizers') + .select(({ cast, val }) => [ + 'B as pid', + cast(val(ProjectRoles.project_stargizer), 'integer').as('role'), + ]) + .where('A', '=', activityId), + ), + ) + .selectFrom('Project') + .innerJoin('subs_projects', 'subs_projects.pid', 'Project.id') + .select(['Project.id as pid', 'subs_projects.role']) + .where(({ or, eb }) => + or([ + eb('Project.id', 'in', ({ selectFrom }) => selectFrom('subs_projects').select('pid')), + eb('Project.activityId', '=', activityId), + ]), + ) + .union((eb) => + eb.parens( + eb + .selectFrom('_parentChildren') + .innerJoin('subs_projects', 'subs_projects.pid', 'A') + .select(['B as pid', 'subs_projects.role']), + ), + ) + .$castTo<{ pid: string; role: number }>(); +}; + +export const getUserProjectsByGoals = ({ activityId }: Pick) => { + return db + .with('cte_user_goals', () => + db + .selectFrom('Goal') + .select(({ cast, val }) => [ + 'Goal.id as gid', + cast(val(ProjectRoles.goal_owner), 'integer').as('role'), + ]) + .where('Goal.ownerId', '=', activityId) + .union( + db + .selectFrom('Goal') + .select(({ cast, val }) => [ + 'Goal.id as gid', + cast(val(ProjectRoles.goal_issuer), 'integer').as('role'), + ]) + .where('Goal.activityId', '=', activityId), + ) + .union( + db + .selectFrom('_goalParticipants') + .select(({ cast, val }) => [ + 'B as gid', + cast(val(ProjectRoles.goal_participant), 'integer').as('role'), + ]) + .where('A', '=', activityId), + ) + .union( + db + .selectFrom('_goalStargizers') + .select(({ cast, val }) => [ + 'B as gid', + cast(val(ProjectRoles.goal_stargizer), 'integer').as('role'), + ]) + .where('A', '=', activityId), + ) + .union( + db + .selectFrom('_goalWatchers') + .select(({ cast, val }) => [ + 'B as gid', + cast(val(ProjectRoles.goal_watcher), 'integer').as('role'), + ]) + .where('A', '=', activityId), + ) + .union( + db + .selectFrom('_partnershipProjects') + .innerJoinLateral( + () => getUserProjects({ activityId }).as('cte_user_projects'), + (join) => + join + .on('role', 'in', [ProjectRoles.project_owner, ProjectRoles.project_participant]) + .onRef('_partnershipProjects.B', '=', 'cte_user_projects.pid'), + ) + .select(({ cast, val }) => [ + 'A as gid', + cast(val(ProjectRoles.goal_partner), 'integer').as('role'), + ]), + ), + ) + .selectFrom('Goal') + .innerJoin('cte_user_goals', 'Goal.id', 'cte_user_goals.gid') + .select(['Goal.projectId as pid', 'cte_user_goals.role as role']) + .$castTo<{ pid: string; role: number }>(); +}; + +export const getRealDashboardQueryByProjectIds = ({ + activityId, + rules, + goalsQuery, + limit, + offset, +}: { + activityId: string; + rules: Map; + goalsQuery?: QueryWithFilters; + limit: number; + offset: number; +}) => { + const toJoinValues = Array.from( + rules, + ([pid, rules]) => sql`(${sql.join([pid, rules.projectFullAccess, rules.projectOnlySubsGoals])})`, + ); + const values = sql<{ pid: string; full_access: boolean; only_subs: boolean }>`(values ${sql.join(toJoinValues)})`; + const aliasedValues = values.as<'proj_rules'>(sql`proj_rules(pid, full_access, only_subs)`); + + const query = db + .with('cte_projects', () => db.selectFrom(aliasedValues).selectAll('proj_rules')) + .selectFrom('Project') + .innerJoin('cte_projects as projectRights', 'projectRights.pid', 'Project.id') + .leftJoinLateral( + ({ selectFrom }) => + selectFrom('_partnershipProjects') + .innerJoin('Goal', 'Goal.id', '_partnershipProjects.A') + .distinctOn('Goal.projectId') + .select('Goal.projectId as pid') + .whereRef('_partnershipProjects.B', '=', 'Project.id') + .as('partnershipProjectIds'), + (join) => join.onTrue(), + ) + .leftJoinLateral( + ({ selectFrom }) => + selectFrom('Goal') + .select('Goal.id') + .where('Goal.archived', 'is not', true) + .whereRef('Goal.projectId', '=', 'Project.id') + .where(({ and, or, eb, ref, cast }) => + or([ + and([ + eb(cast(ref('projectRights.full_access'), 'boolean'), 'is', true), + eb( + 'Goal.id', + 'in', + selectFrom('_partnershipProjects').select('A').whereRef('B', '=', 'Project.id'), + ), + ]), + and([ + eb(cast(ref('projectRights.only_subs'), 'boolean'), 'is', true), + eb('Goal.projectId', '=', ref('Project.id')), + or([ + eb('Goal.ownerId', '=', activityId), + eb('Goal.activityId', '=', activityId), + eb('Goal.id', 'in', ({ selectFrom }) => + selectFrom('_goalParticipants') + .select('B') + .where('A', '=', activityId) + .union(selectFrom('_goalWatchers').select('B').where('A', '=', activityId)) + .union( + selectFrom('_goalStargizers').select('B').where('A', '=', activityId), + ) + .union( + selectFrom('_partnershipProjects') + .select('A as B') + .where( + 'B', + 'in', + selectFrom('Project') + .select('Project.id') + .where('Project.activityId', '=', activityId), + ), + ), + ), + ]), + ]), + ]), + ) + .where(getGoalsFiltersWhereExpressionBuilder(goalsQuery)) + .as('goals'), + (join) => join.onTrue(), + ) + .select(({ fn, ref, cast, eb }) => [ + 'Project.id', + jsonBuildObject({ + fullProject: cast(ref('projectRights.full_access'), 'boolean'), + onlySubs: cast(ref('projectRights.only_subs'), 'boolean'), + }).as('readRights'), + eb + .case() + .when(fn.count('partnershipProjectIds.pid'), '>', 0) + .then(fn.agg('array_agg', ['partnershipProjectIds.pid']).distinct()) + .else(null) + .end() + .as('partnerProjectIds'), + jsonBuildObject({ + stargizers: sql`(select count("A") from "_projectStargizers" where "B" = "Project".id)`, + watchers: sql`(select count("A") from "_projectWatchers" where "B" = "Project".id)`, + children: sql`(select count("B") from "_parentChildren" where "A" = "Project".id)`, + participants: sql`(select count("A") from "_projectParticipants" where "B" = "Project".id)`, + goals: fn.count('goals.id').distinct(), + }).as('_count'), + ]) + .$if(!!goalsQuery?.hideEmptyProjects, (qb) => qb.having(({ fn }) => fn.count('goals.id').distinct(), '>', 0)) + .groupBy(['Project.id', 'projectRights.full_access', 'projectRights.only_subs']) + .orderBy('Project.updatedAt desc') + .limit(limit) + .offset(offset); + + return query; +}; diff --git a/trpc/router/projectV2.ts b/trpc/router/projectV2.ts index b188510c5..9ba9b668b 100644 --- a/trpc/router/projectV2.ts +++ b/trpc/router/projectV2.ts @@ -9,13 +9,15 @@ import { getStarredProjectsIds, getProjectSuggestions, getUserProjectsQuery, - getUserDashboardProjects, getWholeGoalCountByProjectIds, getDeepChildrenProjectsId, getAllProjectsQuery, getProjectChildrenTreeQuery, getProjectById, getChildrenProjectByParentProjectId, + getUserProjects, + getUserProjectsByGoals, + getRealDashboardQueryByProjectIds, } from '../queries/projectV2'; import { queryWithFiltersSchema, sortableProjectsPropertiesArraySchema } from '../../src/schema/common'; import { @@ -30,7 +32,7 @@ import { Priority, Team, } from '../../generated/kysely/types'; -import { ExtractTypeFromGenerated, pickUniqueValues } from '../utils'; +import { calculateProjectRules, ExtractTypeFromGenerated, pickUniqueValues } from '../utils'; import { baseCalcCriteriaWeight } from '../../src/utils/recalculateCriteriaScore'; import { getGoalsQuery } from '../queries/goalV2'; import { projectAccessMiddleware } from '../access/accessMiddlewares'; @@ -49,6 +51,7 @@ type ProjectResponse = ExtractTypeFromGenerated & { _isEditable: boolean; _isGoalWatching: boolean; _isGoalStarred: boolean; + _isGoalParticipant: boolean; _onlySubsGoals: boolean; activity: ProjectActivity; participants: ProjectActivity[] | null; @@ -58,6 +61,7 @@ type ProjectResponse = ExtractTypeFromGenerated & { interface DashboardProject extends Pick { partnerProjectIds?: string[]; + readRights: { fullProject: true; onlySubs: false } | { fullProject: false; onlySubs: true }; _count: { children: number; stargizers: number; @@ -200,15 +204,50 @@ export const project = router({ }), ) .query(async ({ ctx, input }) => { - const { limit = 5, cursor: offset = 0, goalsQuery, projectsSort } = input; + const { limit = 5, cursor: offset = 0, goalsQuery, projectsSort: _ } = input; const { session: { user }, } = ctx; - const dashboardProjects = await getUserDashboardProjects({ - ...user, + const allDashboardProjectsRules = await Promise.all([ + getUserProjects(user).execute(), + getUserProjectsByGoals(user).execute(), + ]).then(([p1, p2]) => { + const projectMap: { [key: string]: number[] } = {}; + + for (const record of p1.concat(p2)) { + const { pid, role } = record; + if (!(pid in projectMap)) { + projectMap[pid] = []; + } + + projectMap[pid].push(role); + } + + const projectsRules: Map> = new Map( + Object.entries(projectMap).map(([pid, roles]) => { + return [pid, calculateProjectRules(Array.from(new Set(roles)))]; + }), + ); + + return projectsRules; + }); + + if (allDashboardProjectsRules.size === 0) { + return { + groups: [], + pagination: { + limit, + offset: undefined, + }, + totalGoalsCount: 0, + }; + } + + const dashboardProjects = await getRealDashboardQueryByProjectIds({ + rules: allDashboardProjectsRules, goalsQuery, - projectsSort, + ...user, limit: limit + 1, offset, }) @@ -231,7 +270,7 @@ export const project = router({ const resultProjects: (ProjectResponse & Pick)[] = []; - for (const { id, _count, partnerProjectIds } of dashboardProjects.slice(0, limit)) { + for (const { id, _count, partnerProjectIds, readRights } of dashboardProjects.slice(0, limit)) { const currentProject = projectsExtendedDataMap.get(id) as | (ProjectResponse & Pick) | undefined; @@ -240,8 +279,16 @@ export const project = router({ throw new Error(`Missing project by id: ${id}`); } - const { _isEditable, _isGoalStarred, _isGoalWatching, _isOwner, _isStarred, _isWatching, ...project } = - currentProject; + const { + _isEditable, + _isGoalStarred, + _isGoalWatching, + _isGoalParticipant, + _isOwner, + _isStarred, + _isWatching, + ...project + } = currentProject; const flags = { _isEditable, @@ -250,8 +297,8 @@ export const project = router({ _isWatching, _isGoalStarred, _isGoalWatching, - _onlySubsGoals: - !(_isEditable || _isOwner || _isStarred || _isWatching) && (_isGoalStarred || _isGoalWatching), + _isGoalParticipant, + _onlySubsGoals: readRights.onlySubs, }; resultProjects.push({ ...project, ...flags, _count, partnerProjectIds }); diff --git a/trpc/utils.ts b/trpc/utils.ts index ae1747683..cb141f288 100644 --- a/trpc/utils.ts +++ b/trpc/utils.ts @@ -91,3 +91,36 @@ export const applyLastStateUpdateComment = (goal: any) => { return null; }; + +export enum ProjectRoles { + 'project_owner', + 'project_participant', + 'project_stargizer', + 'project_watcher', + 'goal_owner', + 'goal_issuer', + 'goal_participant', + 'goal_stargizer', + 'goal_watcher', + 'goal_partner', +} + +export interface ProjectRules { + projectFullAccess: boolean; + projectOnlySubsGoals: boolean; +} + +export const calculateProjectRules = (roles: ProjectRoles[]): ProjectRules => { + const rules: ProjectRules = { + projectFullAccess: false, + projectOnlySubsGoals: false, + }; + + if (roles.some((role) => ProjectRoles[role].startsWith('project_'))) { + rules.projectFullAccess = true; + } else { + rules.projectOnlySubsGoals = true; + } + + return rules; +};