feat: add flag-gated membersPreview to /api/admin/projects#12107
feat: add flag-gated membersPreview to /api/admin/projects#12107dharmadeveloper108 wants to merge 4 commits intomainUnleash/unleash:mainfrom dx-4614-include-members-preview-backendUnleash/unleash:dx-4614-include-members-preview-backendCopy head branch name to clipboard
membersPreview to /api/admin/projects#12107Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
API Changelog 7.6.5-beta.0 vs. 7.6.5-beta.0No changes detected |
| this.db = db; | ||
| } | ||
|
|
||
| static membersUnion(db: Db) { |
There was a problem hiding this comment.
Broke this out of project-read-model.ts for two reasons:
- The same exact operation (determine what users count as members of a project) is needed by
getMembersCountthere too. - Conceptually it belongs better in a project members model than a project read model (better separation of concerns).
There was a problem hiding this comment.
What does it actually do? If I understand correctly, it gives you all the members who are either direct members or members via a group? maybe there's a better name for the method?
There was a problem hiding this comment.
Yes exactly, it's users who have a project scoped role (either individual or coming from a group they're a part of), excluding root roles (which are instance wide).
I agree that renaming this would definitely help, thank you 🙌
| const rows = await this.db | ||
| .select('*') | ||
| .from(selectedMembers) | ||
| .where('rn', '<=', MEMBERS_PREVIEW_SIZE); |
There was a problem hiding this comment.
The project list card only renders 4 avatars (see figma in the ticket), and capping avoids transferring potentially hundreds of rows per project
There was a problem hiding this comment.
Right, so is it only .. the 4 first members of the project? Or how does it work?
There was a problem hiding this comment.
"First" 4 in the sense that they have the "oldest" user IDs (in quotes because I'm not sure that's a super reliable way to determine whether they're actually the oldest users). So not the 4 oldest members, the 4 oldest users.
But it's completely arbitrary (it's a way to deterministically return the same users every time).
There was a problem hiding this comment.
Oh, interesting. Is that what we want? As a user, I'd probably expect to get the last 4 people who have done something in the project (e.g. the last 4 unique user ids from the event log) who are also members somehow. But if that's not something we're doing, this is gonna be cheaper.
There was a problem hiding this comment.
If we want to have an accurate representation of who were the last 4 users who did something in a project, then I don't think we can avoid looking into the events table. I can see that being considerably more expensive 😅 so I'd like to avoid that. Also, depending on the criteria we'd set, we could then have projects with people who were added as members but never did anything actively, so they wouldn't show up.
But we can at least reverse the sorting and get the 4 "newest" users (most recently registered)?
| for (const project of projects) { | ||
| project.onboardingStatus = onboardingStatuses.get(project.id); | ||
| project.membersPreview = | ||
| membersPreviewByProject[project.id] ?? []; |
There was a problem hiding this comment.
We already have owners: so I'm thinking of using those for the avatars as fallback if the membersPreview is empty (otherwise if both are missing, we're still rendering the "Updated/Created" so at least there is something in the card footer
There was a problem hiding this comment.
How can members be empty? If there are no members and no owners, I guess. So ... that might be the case for the default project?
There was a problem hiding this comment.
Yes exactly. Not sure if that can be the case for a non-default enterprise project 🤔 maybe if you create a project and then remove your user access to it?
| test('caps at 4 members per project', async () => { | ||
| const projectId = randomId(); | ||
| await db.stores.projectStore.create({ id: projectId, name: projectId }); | ||
|
|
||
| for (const user of [userA, userB, userC, userD, userE]) { | ||
| await db.stores.accessStore.addUserToRole( | ||
| user.id, | ||
| memberRoleId, | ||
| projectId, | ||
| ); | ||
| } | ||
|
|
||
| const members = await readModel.getMembersPreviewByProject(); | ||
|
|
||
| expect(members[projectId]).toHaveLength(4); | ||
| }); |
There was a problem hiding this comment.
Should there be a test about which 4 are kept? And how is that determined anyway? I might find out later, but the description made it sound like it'd be the 4 with the highest user ids?
| this.db = db; | ||
| } | ||
|
|
||
| static membersUnion(db: Db) { |
There was a problem hiding this comment.
What does it actually do? If I understand correctly, it gives you all the members who are either direct members or members via a group? maybe there's a better name for the method?
| 'users.email', | ||
| 'users.image_url', | ||
| ) | ||
| .rowNumber('rn', 'users.id', 'query.project') |
There was a problem hiding this comment.
I'm not familiar with rowNumber, but the docs seem to say that it just adds an extra counter to the results that you can reference?
There was a problem hiding this comment.
the signature is rowNumber(alias, orderByClause, [partitionByClause]) so order by user ID (defaulted to ASC), and partition by project (restart counting after each project).
| const rows = await this.db | ||
| .select('*') | ||
| .from(selectedMembers) |
There was a problem hiding this comment.
does this come out as a subquery? Or .. how does it work?
There was a problem hiding this comment.
yes a nested query (but it's one big query under the hood):
select * from (select "query"."project", "users"."name", "users"."username", "users"."email", "users"."image_url", row_number() over (partition by "query"."project" order by "users"."id") as "rn"
from (select "user_id", "project" from "role_user" left join "roles" on "role_user"."role_id" = "roles"."id" where (not "type" = 'root') union select "user_id", "project" from "group_role" left join "group_user" on "group_user"."group_id" = "group_role"."group_id") as "query" left join "users" on "users"."id" = "query"."user_id" where "users"."id" is not null) as "selected_members" where "rn" <= 4There was a problem hiding this comment.
Yep, yep. It's probably fine. Can't remember how we deal with subqueries / nested queries in other places. I think there may be some perf implications, but I'm a little fuzzy on the details there. Maybe it's fine (and indeed, maybe this is the right way to do it) 🤷🏼
There was a problem hiding this comment.
I'll try to pull in a query expert for a quick look, I'm also not 1000% confident :D
| const rows = await this.db | ||
| .select('*') | ||
| .from(selectedMembers) | ||
| .where('rn', '<=', MEMBERS_PREVIEW_SIZE); |
There was a problem hiding this comment.
Right, so is it only .. the 4 first members of the project? Or how does it work?
| export type ProjectMember = { | ||
| name: string; | ||
| email?: string; | ||
| imageUrl?: string; | ||
| }; |
There was a problem hiding this comment.
Don't we have this type or a similar one somewhere else? Feels like it's pretty close to IUser? Maybe they should be related? 🤷🏼
There was a problem hiding this comment.
You're probably right actually 👀
| for (const project of projects) { | ||
| project.onboardingStatus = onboardingStatuses.get(project.id); | ||
| project.membersPreview = | ||
| membersPreviewByProject[project.id] ?? []; |
There was a problem hiding this comment.
How can members be empty? If there are no members and no owners, I guess. So ... that might be the case for the default project?
82c23a0 to
99eda54
Compare
| this.db = db; | ||
| } | ||
|
|
||
| static usersWithProjectRoles(db: Db) { |
There was a problem hiding this comment.
Broke this out of project-read-model.ts for two reasons:
- The same exact operation (determine what users count as members of a project) is needed by
getMembersCountthere too. - Conceptually it belongs better in a project members model than a project read model (better separation of concerns).
There was a problem hiding this comment.
(put this comment back here as it disappeared after I renamed the method but I think it's useful to keep it)
As part of the project card redesign, we want to render avatars for 4 project members (see MUI component).
This adds a flag-gated
membersPreviewfield to each project on /api/admin/projects, which represents the "last" 4 members of a project (sorted by ID).ProjectMembersReadModelwithgetMembersPreviewByProject()and a staticmembersUnion()for the "who counts as a project member" subquery.getProjectsin the project service mergesmembersPreviewonto each project (gated bynewProjectList)getMembersCountin the new project members read model reusesmembersUnioninstead of duplicating the UNION inline.