Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion charts/lfx-v2-committee-service/templates/ruleset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,34 @@ spec:
- authorizer: openfga_check
config:
values:
relation: viewer
relation: roster_viewer
object: "committee:{{ "{{- .Request.URL.Captures.uid -}}" }}"
{{- else }}
- authorizer: allow_all
{{- end }}
- finalizer: create_jwt
config:
values:
aud: {{ .Values.app.audience }}

- id: "rule:lfx:lfx-v2-committee-service:committee_members:get_contact"
allow_encoded_slashes: 'off'
match:
methods:
- GET
routes:
- path: /committees/:uid/members/:member_uid/contact
execute:
- authenticator: oidc
- authenticator: anonymous_authenticator
{{- if .Values.app.use_oidc_contextualizer }}
- contextualizer: oidc_contextualizer
{{- end }}
{{- if .Values.openfga.enabled }}
- authorizer: openfga_check
config:
values:
relation: email_viewer
object: "committee:{{ "{{- .Request.URL.Captures.uid -}}" }}"
{{- else }}
- authorizer: allow_all
Expand Down
37 changes: 37 additions & 0 deletions cmd/committee-api/design/committee.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,43 @@ var _ = dsl.Service("committee-service", func() {
})
})

// GET - Get committee member contact information (email)
// This endpoint is gated by the email_viewer relation and returns only the member's email address.
dsl.Method("get-committee-member-contact", func() {
dsl.Description("Get contact information for a specific committee member")

dsl.Security(JWTAuth)

dsl.Payload(func() {
BearerTokenAttribute()
VersionAttribute()
CommitteeUIDAttribute()
MemberUIDAttribute()

dsl.Required("version", "uid", "member_uid")
})

dsl.Result(CommitteeMemberContactWithReadonlyAttributes)

dsl.Error("BadRequest", BadRequestError, "Bad request")
dsl.Error("NotFound", NotFoundError, "Member not found")
dsl.Error("InternalServerError", InternalServerError, "Internal server error")
dsl.Error("ServiceUnavailable", ServiceUnavailableError, "Service unavailable")

dsl.HTTP(func() {
dsl.GET("/committees/{uid}/members/{member_uid}/contact")
dsl.Param("version:v")
dsl.Param("uid")
dsl.Param("member_uid")
dsl.Header("bearer_token:Authorization")
dsl.Response(dsl.StatusOK)
dsl.Response("BadRequest", dsl.StatusBadRequest)
dsl.Response("NotFound", dsl.StatusNotFound)
dsl.Response("InternalServerError", dsl.StatusInternalServerError)
dsl.Response("ServiceUnavailable", dsl.StatusServiceUnavailable)
})
})

// PUT - Replace committee member (complete resource replacement)
// This endpoint follows PUT semantics: it replaces the entire member resource.
// All required fields must be provided, even if unchanged.
Expand Down
35 changes: 31 additions & 4 deletions cmd/committee-api/design/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ var CommitteeMemberBase = dsl.Type("committee-member-base", func() {
})

// CommitteeMemberBaseAttributes defines the base attributes for a committee member.
// Used for create/update payloads where email is a required input field.
func CommitteeMemberBaseAttributes() {
UsernameAttribute()
EmailAttribute()
Expand All @@ -437,26 +438,52 @@ func CommitteeMemberBaseAttributes() {
OrganizationInfoAttributes()
}
Comment on lines 425 to 439

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CommitteeMemberBaseAttributes() is still used for update payloads and includes EmailAttribute() (and the update method requires email). Since the roster-view member endpoints no longer return email, clients that can update a member but don’t have email_viewer may have no way to obtain the current email to satisfy PUT “complete replacement” semantics. Consider making email optional for updates (use stored email when omitted), switching to PATCH semantics for non-email updates, or explicitly ensuring the update permission implies email_viewer so callers can fetch /contact first.

Copilot uses AI. Check for mistakes.

// CommitteeMemberBasicBaseAttributes defines the core display attributes for a committee member.
// Used for response types that show member identity and role information.
func CommitteeMemberBasicBaseAttributes() {
UsernameAttribute()
FirstNameAttribute()
LastNameAttribute()
JobTitleAttribute()
LinkedInProfileAttribute()
RoleInfoAttributes()
AppointedByAttribute()
StatusAttribute()
VotingInfoAttributes()
OrganizationInfoAttributes()
}

// CommitteeMemberFull is the DSL type for a complete committee member.
var CommitteeMemberFull = dsl.Type("committee-member-full", func() {
dsl.Description("A complete representation of committee members with all attributes.")

CommitteeMemberBaseAttributes()
})

// CommitteeMemberFullWithReadonlyAttributes is the DSL type for a complete committee member with readonly attributes.
// CommitteeMemberFullWithReadonlyAttributes is the DSL type for a committee member response
// with readonly attributes. Shows member identity and role information.
var CommitteeMemberFullWithReadonlyAttributes = dsl.Type("committee-member-full-with-readonly-attributes", func() {
dsl.Description("A complete representation of committee members with readonly attributes.")
dsl.Description("A committee member response with readonly attributes.")

CommitteeMemberUIDAttribute()
CommitteeUIDMemberAttribute()
CommitteeNameMemberAttribute()
CommitteeCategoryMemberAttribute()
CommitteeMemberBaseAttributes()
CommitteeMemberBasicBaseAttributes()
CreatedAtAttribute()
UpdatedAtAttribute()
})
Comment thread
andrest50 marked this conversation as resolved.

// CommitteeMemberContactWithReadonlyAttributes is the DSL type for the contact
// information of a committee member. Contains the member UID, committee UID, and email.
var CommitteeMemberContactWithReadonlyAttributes = dsl.Type("committee-member-contact-with-readonly-attributes", func() {
dsl.Description("Contact information for a committee member.")

CommitteeMemberUIDAttribute()
CommitteeUIDMemberAttribute()
EmailAttribute()
})

// CommitteeMemberCreateAttributes defines attributes for creating a committee member.
func CommitteeMemberCreateAttributes() {
CommitteeMemberBaseAttributes()
Expand Down Expand Up @@ -719,7 +746,7 @@ func OrganizationIDAttribute() {
// MemberVisibilityAttribute is the DSL attribute for the member visibility setting
func MemberVisibilityAttribute() {
dsl.Attribute("member_visibility", dsl.String, "Dertermines the visibility level of members profiles to other members of the same committee", func() {
Comment thread
andrest50 marked this conversation as resolved.
Outdated
dsl.Enum("hidden", "basic_profile")
dsl.Enum("hidden", "basic_profile", "full_profile")
dsl.Default("hidden")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
dsl.Example("hidden")
})
Expand Down
30 changes: 27 additions & 3 deletions cmd/committee-api/service/committee_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,24 @@ func (s *committeeServicesrvc) GetCommitteeMember(ctx context.Context, p *commit
return res, nil
}

// GetCommitteeMemberContact retrieves contact information (email) for a specific committee member.
// This endpoint is gated by the email_viewer relation.
func (s *committeeServicesrvc) GetCommitteeMemberContact(ctx context.Context, p *committeeservice.GetCommitteeMemberContactPayload) (res *committeeservice.CommitteeMemberContactWithReadonlyAttributes, err error) {

slog.DebugContext(ctx, "committeeMemberService.get-committee-member-contact",
"committee_uid", p.UID,
"member_uid", p.MemberUID,
)

// Execute use case — reuse GetMember; we only expose the email from the result
committeeMember, _, err := s.committeeReaderOrchestrator.GetMember(ctx, p.UID, p.MemberUID)
if err != nil {
return nil, wrapError(ctx, err)
}

return s.convertMemberDomainToContactResponse(committeeMember), nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// UpdateCommitteeMember updates an existing committee member
func (s *committeeServicesrvc) UpdateCommitteeMember(ctx context.Context, p *committeeservice.UpdateCommitteeMemberPayload) (res *committeeservice.CommitteeMemberFullWithReadonlyAttributes, err error) {

Expand Down Expand Up @@ -510,10 +528,12 @@ func (s *committeeServicesrvc) AcceptInvite(ctx context.Context, p *committeeser
CommitteeMemberBase: model.CommitteeMemberBase{
CommitteeUID: invite.CommitteeUID,
Username: username,
Email: invite.InviteeEmail,
Role: model.CommitteeMemberRole{Name: invite.Role},
Status: "Active",
},
CommitteeMemberSensitive: model.CommitteeMemberSensitive{
Email: invite.InviteeEmail,
},
}

response, err := s.committeeWriterOrchestrator.CreateMember(ctx, member, false)
Expand Down Expand Up @@ -705,9 +725,11 @@ func (s *committeeServicesrvc) ApproveApplication(ctx context.Context, p *commit
member := &model.CommitteeMember{
CommitteeMemberBase: model.CommitteeMemberBase{
CommitteeUID: application.CommitteeUID,
Email: application.ApplicantEmail,
Status: "Active",
},
CommitteeMemberSensitive: model.CommitteeMemberSensitive{
Email: application.ApplicantEmail,
},
}

response, err := s.committeeWriterOrchestrator.CreateMember(ctx, member, false)
Expand Down Expand Up @@ -795,9 +817,11 @@ func (s *committeeServicesrvc) JoinCommittee(ctx context.Context, p *committeese
CommitteeMemberBase: model.CommitteeMemberBase{
CommitteeUID: p.UID,
Username: username,
Email: email,
Status: "Active",
},
CommitteeMemberSensitive: model.CommitteeMemberSensitive{
Email: email,
},
}

response, err := s.committeeWriterOrchestrator.CreateMember(ctx, member, p.XSync)
Expand Down
24 changes: 21 additions & 3 deletions cmd/committee-api/service/committee_service_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,12 @@ func (s *committeeServicesrvc) convertMemberPayloadToDomain(p *committeeservice.
member := &model.CommitteeMember{
CommitteeMemberBase: model.CommitteeMemberBase{
CommitteeUID: p.UID,
Email: p.Email,
AppointedBy: p.AppointedBy,
Status: p.Status,
},
CommitteeMemberSensitive: model.CommitteeMemberSensitive{
Email: p.Email,
},
}

// Handle Username with nil check
Expand Down Expand Up @@ -434,10 +436,12 @@ func (s *committeeServicesrvc) convertPayloadToUpdateMember(p *committeeservice.
CommitteeMemberBase: model.CommitteeMemberBase{
UID: p.MemberUID, // Member UID is required for updates
CommitteeUID: p.UID, // Committee UID from path parameter
Email: p.Email,
AppointedBy: p.AppointedBy,
Status: p.Status,
},
CommitteeMemberSensitive: model.CommitteeMemberSensitive{
Email: p.Email,
},
}

// Handle Username with nil check
Expand Down Expand Up @@ -516,7 +520,6 @@ func (s *committeeServicesrvc) convertMemberDomainToFullResponse(member *model.C
result := &committeeservice.CommitteeMemberFullWithReadonlyAttributes{
CommitteeUID: &member.CommitteeUID,
UID: &member.UID,
Email: &member.Email,
AppointedBy: member.AppointedBy,
Status: member.Status,
}
Expand Down Expand Up @@ -613,6 +616,21 @@ func (s *committeeServicesrvc) convertMemberDomainToFullResponse(member *model.C
return result
}

// convertMemberDomainToContactResponse converts domain CommitteeMember to a contact-only GOA response.
// Returns only the member UID, committee UID, and email address.
// This response is gated by the email_viewer relation.
func (s *committeeServicesrvc) convertMemberDomainToContactResponse(member *model.CommitteeMember) *committeeservice.CommitteeMemberContactWithReadonlyAttributes {
if member == nil {
return nil
}

return &committeeservice.CommitteeMemberContactWithReadonlyAttributes{
UID: &member.UID,
CommitteeUID: &member.CommitteeUID,
Email: &member.Email,
}
}

func (s *committeeServicesrvc) convertInviteDomainToResponse(invite *model.CommitteeInvite) *committeeservice.CommitteeInviteWithReadonlyAttributes {
if invite == nil {
return nil
Expand Down
Loading
Loading