11using AutoMapper ;
22using Bit . Core . Dirt . Reports . Models . Data ;
3+ using Bit . Core . Dirt . Reports . ReportFeatures ;
34using Bit . Core . Dirt . Reports . Repositories ;
45using Bit . Core . Enums ;
56using Bit . Core . Utilities ;
@@ -28,37 +29,11 @@ public async Task<IReadOnlyList<MemberAdoptionReportDetail>> GetMemberAdoptionDe
2829 var dbContext = GetDatabaseContext ( scope ) ;
2930 dbContext . Database . SetCommandTimeout ( ReportCommandTimeoutSeconds ) ;
3031
31- var sharedItemCounts = await GetSharedItemCountsByOrganizationUserIdAsync ( dbContext , organizationId ) ;
32+ var access = await GetMemberCollectionAccessAsync ( dbContext , organizationId ) ;
33+ var collectionCiphers = await GetCollectionCipherLinksAsync ( dbContext , organizationId ) ;
34+ var details = await GetConfirmedMemberDetailsAsync ( dbContext , organizationId ) ;
3235
33- var details = await (
34- from organizationUser in dbContext . OrganizationUsers
35- where organizationUser . OrganizationId == organizationId
36- && organizationUser . Status == OrganizationUserStatusType . Confirmed
37- join user in dbContext . Users
38- on organizationUser . UserId equals ( Guid ? ) user . Id into userJoin
39- from user in userJoin . DefaultIfEmpty ( )
40- orderby user . Email ?? organizationUser . Email , organizationUser . Id
41- select new MemberAdoptionReportDetail
42- {
43- OrganizationUserId = organizationUser . Id ,
44- UserId = organizationUser . UserId ,
45- Name = user . Name ,
46- Email = user . Email ?? organizationUser . Email ?? string . Empty ,
47- LastActivityDate = dbContext . Devices
48- . Where ( device => device . UserId == organizationUser . UserId )
49- . Max ( device => device . LastActivityDate ) ,
50- HasExtensionInstalled = dbContext . Devices
51- . Any ( device => device . UserId == organizationUser . UserId
52- && DeviceTypes . BrowserExtensionTypes . Contains ( device . Type ) ) ,
53- VaultItemCount = dbContext . Ciphers
54- . Count ( cipher => cipher . UserId == organizationUser . UserId
55- && cipher . OrganizationId == null
56- && cipher . DeletedDate == null ) ,
57- HasRedeemedSponsorship = dbContext . OrganizationSponsorships
58- . Any ( sponsorship => sponsorship . SponsoringOrganizationUserId == organizationUser . Id
59- && sponsorship . SponsoredOrganizationId != null )
60- } )
61- . ToListAsync ( ) ;
36+ var sharedItemCounts = SharedItemCountCalculator . Calculate ( access , collectionCiphers ) ;
6237
6338 foreach ( var detail in details )
6439 {
@@ -69,20 +44,21 @@ from user in userJoin.DefaultIfEmpty()
6944 }
7045
7146 /// <summary>
72- /// Counts the distinct organization-owned ciphers each member can reach, directly or through their groups.
47+ /// Reads every member-to-collection edge in the organization, whether the grant is direct or
48+ /// inherited from a group.
7349 /// </summary>
74- private static async Task < Dictionary < Guid , int > > GetSharedItemCountsByOrganizationUserIdAsync (
50+ internal static Task < List < MemberCollectionAccess > > GetMemberCollectionAccessAsync (
7551 DatabaseContext dbContext ,
7652 Guid organizationId )
7753 {
78- var directCollectionAccess =
54+ var directAccess =
7955 from collectionUser in dbContext . CollectionUsers
8056 join collection in dbContext . Collections
8157 on collectionUser . CollectionId equals collection . Id
8258 where collection . OrganizationId == organizationId
8359 select new { collectionUser . OrganizationUserId , collectionUser . CollectionId } ;
8460
85- var groupCollectionAccess =
61+ var groupAccess =
8662 from groupUser in dbContext . GroupUsers
8763 join collectionGroup in dbContext . CollectionGroups
8864 on groupUser . GroupId equals collectionGroup . GroupId
@@ -91,17 +67,78 @@ on collectionGroup.CollectionId equals collection.Id
9167 where collection . OrganizationId == organizationId
9268 select new { groupUser . OrganizationUserId , collectionGroup . CollectionId } ;
9369
94- return await (
95- from access in directCollectionAccess . Union ( groupCollectionAccess )
96- join collectionCipher in dbContext . CollectionCiphers
97- on access . CollectionId equals collectionCipher . CollectionId
70+ // Union, not Concat: a member who reaches one collection both directly and through one or more
71+ // groups is a single edge, and the database is the cheap place to collapse that. The projection
72+ // has to stay anonymous until after the set operation, because EF cannot translate a union whose
73+ // sides already project into MemberCollectionAccess.
74+ return directAccess
75+ . Union ( groupAccess )
76+ . Select ( edge => new MemberCollectionAccess ( edge . OrganizationUserId , edge . CollectionId ) )
77+ . ToListAsync ( ) ;
78+ }
79+
80+ /// <summary>
81+ /// Reads every collection-to-cipher edge in the organization, restricted to organization-owned
82+ /// ciphers that are not in the trash.
83+ /// </summary>
84+ internal static Task < List < CollectionCipherLink > > GetCollectionCipherLinksAsync (
85+ DatabaseContext dbContext ,
86+ Guid organizationId )
87+ {
88+ // CollectionCipher is keyed on (CollectionId, CipherId), so these edges are already distinct.
89+ return (
90+ from collectionCipher in dbContext . CollectionCiphers
91+ join collection in dbContext . Collections
92+ on collectionCipher . CollectionId equals collection . Id
9893 join cipher in dbContext . Ciphers
9994 on collectionCipher . CipherId equals cipher . Id
100- where cipher . OrganizationId == organizationId && cipher . DeletedDate == null
101- select new { access . OrganizationUserId , cipher . Id } )
102- . Distinct ( )
103- . GroupBy ( reachableCipher => reachableCipher . OrganizationUserId )
104- . Select ( grouping => new { OrganizationUserId = grouping . Key , Count = grouping . Count ( ) } )
105- . ToDictionaryAsync ( result => result . OrganizationUserId , result => result . Count ) ;
95+ where collection . OrganizationId == organizationId
96+ && cipher . OrganizationId == organizationId
97+ && cipher . DeletedDate == null
98+ select new CollectionCipherLink ( collectionCipher . CollectionId , collectionCipher . CipherId ) )
99+ . ToListAsync ( ) ;
100+ }
101+
102+ /// <summary>
103+ /// Reads one row per confirmed member with everything the report needs except the shared item count.
104+ /// </summary>
105+ internal static Task < List < MemberAdoptionReportDetail > > GetConfirmedMemberDetailsAsync (
106+ DatabaseContext dbContext ,
107+ Guid organizationId )
108+ {
109+ return (
110+ from organizationUser in dbContext . OrganizationUsers
111+ where organizationUser . OrganizationId == organizationId
112+ && organizationUser . Status == OrganizationUserStatusType . Confirmed
113+ join user in dbContext . Users
114+ on organizationUser . UserId equals ( Guid ? ) user . Id into userJoin
115+ from user in userJoin . DefaultIfEmpty ( )
116+ // Sort on the coalesced email the report displays, so a member with no email either side
117+ // sorts as the empty string rather than as a null each provider places differently.
118+ orderby user . Email ?? organizationUser . Email ?? string . Empty , organizationUser . Id
119+ select new MemberAdoptionReportDetail
120+ {
121+ OrganizationUserId = organizationUser . Id ,
122+ UserId = organizationUser . UserId ,
123+ Name = user . Name ,
124+ Email = user . Email ?? organizationUser . Email ?? string . Empty ,
125+ LastActivityDate = dbContext . Devices
126+ . Where ( device => device . UserId == organizationUser . UserId )
127+ . Max ( device => device . LastActivityDate ) ,
128+ HasExtensionInstalled = dbContext . Devices
129+ . Any ( device => device . UserId == organizationUser . UserId
130+ && DeviceTypes . BrowserExtensionTypes . Contains ( device . Type ) ) ,
131+ // A confirmed member can still have no linked user. The null check keeps that member
132+ // from matching every unowned cipher, which EF's null semantics would otherwise do.
133+ VaultItemCount = dbContext . Ciphers
134+ . Count ( cipher => cipher . UserId != null
135+ && cipher . UserId == organizationUser . UserId
136+ && cipher . OrganizationId == null
137+ && cipher . DeletedDate == null ) ,
138+ HasRedeemedSponsorship = dbContext . OrganizationSponsorships
139+ . Any ( sponsorship => sponsorship . SponsoringOrganizationUserId == organizationUser . Id
140+ && sponsorship . SponsoredOrganizationId != null )
141+ } )
142+ . ToListAsync ( ) ;
106143 }
107144}
0 commit comments