Skip to content

Commit 9663937

Browse files
committed
**feat(membership): Add MemberVisibilityService and ProjectStrComparisonService with tests**
- Implemented `MemberVisibilityService` for managing group project member visibility, filtering logic, and effective visibility calculations. Includes: - Methods like `updateVisibility`, `getEffectiveVisibility`, and `getFilteredMembersForProject`. - Integration with `GroupProject` and `GroupProjectMember` repositories. - Validation of visibility settings and error handling. - Comprehensive unit tests (`MemberVisibilityServiceSpec`) to ensure correctness. - Added `ProjectStrComparisonService` for STR-based genetic distance calculations and modal comparisons. Includes: - Methods like `getProjectModalHaplotype`, `getMemberDistanceFromModal`, and `getDistanceMatrix`. - STR data computation with validation and permissions handling. - Thorough unit test coverage for STR handling and distance calculations.
1 parent 88f6219 commit 9663937

7 files changed

Lines changed: 2214 additions & 0 deletions

app/models/domain/GroupProject.scala

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,112 @@ case class MemberVisibility(
7777

7878
object MemberVisibility {
7979
implicit val format: OFormat[MemberVisibility] = Json.format[MemberVisibility]
80+
81+
val ValidAncestorVisibility: Set[String] = Set("NONE", "CENTURY_ONLY", "REGION_ONLY", "COUNTRY_ONLY", "SURNAME_ONLY", "FULL")
82+
val ValidStrVisibility: Set[String] = Set("NONE", "DISTANCE_CALCULATION_ONLY", "MODAL_COMPARISON_ONLY", "FULL_TO_MEMBERS", "FULL_PUBLIC")
83+
84+
private val ancestorRank: Map[String, Int] = Map(
85+
"NONE" -> 0, "CENTURY_ONLY" -> 1, "REGION_ONLY" -> 2,
86+
"COUNTRY_ONLY" -> 3, "SURNAME_ONLY" -> 4, "FULL" -> 5
87+
)
88+
private val strRank: Map[String, Int] = Map(
89+
"NONE" -> 0, "DISTANCE_CALCULATION_ONLY" -> 1, "MODAL_COMPARISON_ONLY" -> 2,
90+
"FULL_TO_MEMBERS" -> 3, "FULL_PUBLIC" -> 4
91+
)
92+
93+
def moreRestrictiveAncestor(a: String, b: String): String =
94+
if (ancestorRank.getOrElse(a, 0) <= ancestorRank.getOrElse(b, 0)) a else b
95+
96+
def moreRestrictiveStr(a: String, b: String): String =
97+
if (strRank.getOrElse(a, 0) <= strRank.getOrElse(b, 0)) a else b
98+
}
99+
100+
case class EffectiveVisibility(
101+
showInMemberList: Boolean,
102+
showInTree: Boolean,
103+
shareTerminalHaplogroup: Boolean,
104+
shareFullLineagePath: Boolean,
105+
sharePrivateVariants: Boolean,
106+
ancestorVisibility: String,
107+
strVisibility: String,
108+
allowDirectContact: Boolean,
109+
showDisplayName: Boolean
110+
)
111+
112+
object EffectiveVisibility {
113+
implicit val format: OFormat[EffectiveVisibility] = Json.format[EffectiveVisibility]
114+
115+
def compute(project: GroupProject, member: MemberVisibility): EffectiveVisibility = {
116+
val projectSnpAllowsFullPath = project.snpPolicy == "FULL_PATH" || project.snpPolicy == "WITH_PRIVATE_VARIANTS"
117+
val projectSnpAllowsPrivate = project.snpPolicy == "WITH_PRIVATE_VARIANTS"
118+
119+
val projectStrLevel = project.strPolicy match {
120+
case "HIDDEN" => "NONE"
121+
case "DISTANCE_ONLY" => "DISTANCE_CALCULATION_ONLY"
122+
case "MODAL_COMPARISON" => "MODAL_COMPARISON_ONLY"
123+
case "MEMBERS_ONLY_RAW" => "FULL_TO_MEMBERS"
124+
case "PUBLIC_RAW" => "FULL_PUBLIC"
125+
case _ => "NONE"
126+
}
127+
128+
val projectAncestorLevel = "FULL" // project doesn't restrict ancestor granularity directly; member controls
129+
130+
EffectiveVisibility(
131+
showInMemberList = member.showInMemberList && project.memberListVisibility != "HIDDEN",
132+
showInTree = member.showInTree && project.publicTreeView || member.showInTree,
133+
shareTerminalHaplogroup = member.shareTerminalHaplogroup && project.snpPolicy != "HIDDEN",
134+
shareFullLineagePath = member.shareFullLineagePath && projectSnpAllowsFullPath,
135+
sharePrivateVariants = member.sharePrivateVariants && projectSnpAllowsPrivate,
136+
ancestorVisibility = MemberVisibility.moreRestrictiveAncestor(member.ancestorVisibility, projectAncestorLevel),
137+
strVisibility = MemberVisibility.moreRestrictiveStr(member.strVisibility, projectStrLevel),
138+
allowDirectContact = member.allowDirectContact,
139+
showDisplayName = member.showDisplayName
140+
)
141+
}
142+
}
143+
144+
case class AncestorData(
145+
name: Option[String] = None,
146+
surname: Option[String] = None,
147+
birthYear: Option[Int] = None,
148+
birthCentury: Option[String] = None,
149+
birthDecade: Option[String] = None,
150+
birthCountry: Option[String] = None,
151+
birthRegion: Option[String] = None,
152+
birthPlace: Option[String] = None,
153+
additionalInfo: Option[String] = None
154+
)
155+
156+
object AncestorData {
157+
implicit val format: OFormat[AncestorData] = Json.format[AncestorData]
158+
159+
def filter(data: AncestorData, level: String): AncestorData = level match {
160+
case "NONE" => AncestorData()
161+
case "CENTURY_ONLY" => AncestorData(birthCentury = data.birthCentury)
162+
case "REGION_ONLY" => AncestorData(birthCountry = data.birthCountry, birthRegion = data.birthRegion)
163+
case "COUNTRY_ONLY" => AncestorData(birthCountry = data.birthCountry)
164+
case "SURNAME_ONLY" => AncestorData(surname = data.surname, birthCentury = data.birthCentury)
165+
case "FULL" => data
166+
case _ => AncestorData()
167+
}
168+
}
169+
170+
case class FilteredMemberView(
171+
memberId: Int,
172+
kitId: Option[String],
173+
displayName: Option[String],
174+
role: String,
175+
contributionLevel: Option[String],
176+
terminalHaplogroup: Option[String],
177+
lineagePath: Option[Seq[String]],
178+
privateVariantCount: Option[Int],
179+
ancestor: AncestorData,
180+
strVisibility: String,
181+
allowDirectContact: Boolean,
182+
subgroupIds: List[String],
183+
joinedAt: Option[LocalDateTime]
184+
)
185+
186+
object FilteredMemberView {
187+
implicit val format: OFormat[FilteredMemberView] = Json.format[FilteredMemberView]
80188
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package services
2+
3+
import jakarta.inject.{Inject, Singleton}
4+
import models.domain.*
5+
import play.api.Logging
6+
import repositories.{GroupProjectMemberRepository, GroupProjectRepository}
7+
8+
import java.time.LocalDateTime
9+
import scala.concurrent.{ExecutionContext, Future}
10+
11+
@Singleton
12+
class MemberVisibilityService @Inject()(
13+
projectRepo: GroupProjectRepository,
14+
memberRepo: GroupProjectMemberRepository
15+
)(implicit ec: ExecutionContext) extends Logging {
16+
17+
def updateVisibility(
18+
memberId: Int,
19+
requesterDid: String,
20+
newVisibility: MemberVisibility
21+
): Future[Either[String, GroupProjectMember]] = {
22+
memberRepo.findById(memberId).flatMap {
23+
case None => Future.successful(Left("Membership not found"))
24+
case Some(member) if member.citizenDid != requesterDid =>
25+
Future.successful(Left("Only the member can update their own visibility"))
26+
case Some(member) if member.status != "ACTIVE" =>
27+
Future.successful(Left("Can only update visibility for active memberships"))
28+
case Some(member) =>
29+
validateVisibility(newVisibility) match {
30+
case Some(error) => Future.successful(Left(error))
31+
case None =>
32+
val updated = member.copy(visibility = newVisibility, updatedAt = LocalDateTime.now())
33+
memberRepo.update(updated).map {
34+
case true => Right(updated)
35+
case false => Left("Failed to update visibility")
36+
}
37+
}
38+
}
39+
}
40+
41+
def getEffectiveVisibility(memberId: Int): Future[Option[EffectiveVisibility]] = {
42+
memberRepo.findById(memberId).flatMap {
43+
case None => Future.successful(None)
44+
case Some(member) =>
45+
projectRepo.findById(member.groupProjectId).map {
46+
case None => None
47+
case Some(project) => Some(EffectiveVisibility.compute(project, member.visibility))
48+
}
49+
}
50+
}
51+
52+
def getFilteredMemberView(
53+
memberId: Int,
54+
viewerDid: String,
55+
haplogroup: Option[String] = None,
56+
lineagePath: Option[Seq[String]] = None,
57+
privateVariantCount: Option[Int] = None,
58+
ancestor: AncestorData = AncestorData()
59+
): Future[Option[FilteredMemberView]] = {
60+
memberRepo.findById(memberId).flatMap {
61+
case None => Future.successful(None)
62+
case Some(member) if member.status != "ACTIVE" => Future.successful(None)
63+
case Some(member) =>
64+
projectRepo.findById(member.groupProjectId).flatMap {
65+
case None => Future.successful(None)
66+
case Some(project) =>
67+
val effective = EffectiveVisibility.compute(project, member.visibility)
68+
resolveViewerContext(project, member.groupProjectId, viewerDid).map { context =>
69+
Some(buildFilteredView(member, effective, context, haplogroup, lineagePath, privateVariantCount, ancestor))
70+
}
71+
}
72+
}
73+
}
74+
75+
def getFilteredMembersForProject(
76+
projectId: Int,
77+
viewerDid: String,
78+
memberData: Map[Int, MemberSupplementalData]
79+
): Future[Either[String, Seq[FilteredMemberView]]] = {
80+
projectRepo.findById(projectId).flatMap {
81+
case None => Future.successful(Left("Project not found"))
82+
case Some(project) =>
83+
for {
84+
viewerContext <- resolveViewerContext(project, projectId, viewerDid)
85+
members <- memberRepo.findByProjectAndStatus(projectId, "ACTIVE")
86+
} yield {
87+
if (!canViewMemberList(project, viewerContext))
88+
Left("Insufficient permissions to view member list")
89+
else {
90+
val views = members.flatMap { member =>
91+
val effective = EffectiveVisibility.compute(project, member.visibility)
92+
if (!effective.showInMemberList && !viewerContext.isAdmin) None
93+
else {
94+
val data = memberData.getOrElse(member.id.getOrElse(-1), MemberSupplementalData())
95+
Some(buildFilteredView(member, effective, viewerContext,
96+
data.haplogroup, data.lineagePath, data.privateVariantCount, data.ancestor))
97+
}
98+
}
99+
Right(views)
100+
}
101+
}
102+
}
103+
}
104+
105+
private def buildFilteredView(
106+
member: GroupProjectMember,
107+
effective: EffectiveVisibility,
108+
context: ViewerContext,
109+
haplogroup: Option[String],
110+
lineagePath: Option[Seq[String]],
111+
privateVariantCount: Option[Int],
112+
ancestor: AncestorData
113+
): FilteredMemberView = {
114+
FilteredMemberView(
115+
memberId = member.id.getOrElse(0),
116+
kitId = member.kitId.orElse(Some(s"KIT-${member.id.getOrElse(0)}")),
117+
displayName = if (effective.showDisplayName) member.displayName else None,
118+
role = member.role,
119+
contributionLevel = member.contributionLevel,
120+
terminalHaplogroup = if (effective.shareTerminalHaplogroup) haplogroup else None,
121+
lineagePath = if (effective.shareFullLineagePath) lineagePath else None,
122+
privateVariantCount = if (effective.sharePrivateVariants) privateVariantCount else None,
123+
ancestor = AncestorData.filter(ancestor, effective.ancestorVisibility),
124+
strVisibility = effective.strVisibility,
125+
allowDirectContact = effective.allowDirectContact && context.isMember,
126+
subgroupIds = member.subgroupIds,
127+
joinedAt = member.joinedAt
128+
)
129+
}
130+
131+
private def canViewMemberList(project: GroupProject, context: ViewerContext): Boolean = {
132+
project.memberListVisibility match {
133+
case "PUBLIC" => true
134+
case "MEMBERS_ONLY" => context.isMember
135+
case "ADMINS_ONLY" => context.isAdmin
136+
case "HIDDEN" => context.isAdmin && context.role == "ADMIN"
137+
case _ => false
138+
}
139+
}
140+
141+
private[services] def resolveViewerContext(project: GroupProject, projectId: Int, viewerDid: String): Future[ViewerContext] = {
142+
memberRepo.findByProjectAndCitizen(projectId, viewerDid).map {
143+
case Some(m) if m.status == "ACTIVE" =>
144+
ViewerContext(
145+
isMember = true,
146+
isAdmin = Set("ADMIN", "CO_ADMIN").contains(m.role),
147+
role = m.role,
148+
viewerDid = viewerDid
149+
)
150+
case _ =>
151+
ViewerContext(isMember = false, isAdmin = false, role = "NONE", viewerDid = viewerDid)
152+
}
153+
}
154+
155+
private def validateVisibility(v: MemberVisibility): Option[String] = {
156+
if (!MemberVisibility.ValidAncestorVisibility.contains(v.ancestorVisibility))
157+
Some(s"Invalid ancestor visibility: ${v.ancestorVisibility}")
158+
else if (!MemberVisibility.ValidStrVisibility.contains(v.strVisibility))
159+
Some(s"Invalid STR visibility: ${v.strVisibility}")
160+
else
161+
None
162+
}
163+
}
164+
165+
case class ViewerContext(
166+
isMember: Boolean,
167+
isAdmin: Boolean,
168+
role: String,
169+
viewerDid: String
170+
)
171+
172+
case class MemberSupplementalData(
173+
haplogroup: Option[String] = None,
174+
lineagePath: Option[Seq[String]] = None,
175+
privateVariantCount: Option[Int] = None,
176+
ancestor: AncestorData = AncestorData()
177+
)

0 commit comments

Comments
 (0)