Skip to content

Commit ad30608

Browse files
committed
refactor(store): wave B.10 — user_group repo (#263)
UserGroupRepo covers the read side of user_groups_projection and user_group_members_projection: Get / GetByName / GetWithMembers / List / Count / ListForUser / ListMemberIDs / ListMembers UserGroupWithMembers pairs the projection row with the JOIN-computed ActualMemberCount so SCIM handlers can reconcile against projection lag. MaintenanceWindow stays as json.RawMessage at the boundary per the JSONB normalize plan in #242. Call sites migrated: - internal/api/user_group_handler.go — ~20 sites + userGroupToProto signature shifted to store.UserGroup - internal/scim/groups_helpers.go — 2 sites - internal/search/index.go — 1 site (group reindex sweep) Non-goals: - GetUserGroupRoles / UserGroupHasRole — Role-domain queries by group ID, will be added to RoleRepo in a follow-up wave. - EvaluateDynamicUserGroup / EvaluateQueuedDynamicUserGroups / ValidateUserGroupQuery — call PL/pgSQL, move with Wave C (dynamic-query interpreter port). - Write-side projector queries — stay on Queries() per pattern. Closes #263.
1 parent af98a82 commit ad30608

9 files changed

Lines changed: 251 additions & 31 deletions

File tree

internal/api/assignment_handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func (h *AssignmentHandler) CreateAssignment(ctx context.Context, req *connect.R
105105
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get user")
106106
}
107107
case pm.AssignmentTargetType_ASSIGNMENT_TARGET_TYPE_USER_GROUP:
108-
_, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.TargetId)
108+
_, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.TargetId)
109109
if err != nil {
110110
if store.IsNotFound(err) {
111111
return nil, apiErrorCtx(ctx, ErrUserGroupNotFound, connect.CodeNotFound, "user group not found")

internal/api/user_group_handler.go

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func (h *UserGroupHandler) CreateUserGroup(ctx context.Context, req *connect.Req
4343
// Check name uniqueness — distinguishing NotFound from a transient
4444
// DB error matters: silently treating any error as "name available"
4545
// would let a concurrent CreateUserGroup succeed twice on a flaky DB.
46-
_, err := h.store.Queries().GetUserGroupByName(ctx, req.Msg.Name)
46+
_, err := h.store.Repos().UserGroup.GetByName(ctx, req.Msg.Name)
4747
if err == nil {
4848
return nil, apiErrorCtx(ctx, ErrUserGroupNameExists, connect.CodeAlreadyExists, "user group name already exists")
4949
}
@@ -88,7 +88,7 @@ func (h *UserGroupHandler) CreateUserGroup(ctx context.Context, req *connect.Req
8888
return nil, err
8989
}
9090

91-
group, err := h.store.Queries().GetUserGroupByID(ctx, id)
91+
group, err := h.store.Repos().UserGroup.Get(ctx, id)
9292
if err != nil {
9393
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to read user group")
9494
}
@@ -104,7 +104,7 @@ func (h *UserGroupHandler) GetUserGroup(ctx context.Context, req *connect.Reques
104104
return nil, err
105105
}
106106

107-
group, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.Id)
107+
group, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.Id)
108108
if err != nil {
109109
return nil, handleGetError(ctx, err, ErrUserGroupNotFound, "user group not found")
110110
}
@@ -114,7 +114,7 @@ func (h *UserGroupHandler) GetUserGroup(ctx context.Context, req *connect.Reques
114114
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get group roles")
115115
}
116116

117-
members, err := h.store.Queries().ListUserGroupMembers(ctx, req.Msg.Id)
117+
members, err := h.store.Repos().UserGroup.ListMembers(ctx, req.Msg.Id)
118118
if err != nil {
119119
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get group members")
120120
}
@@ -143,15 +143,12 @@ func (h *UserGroupHandler) ListUserGroups(ctx context.Context, req *connect.Requ
143143
return nil, err
144144
}
145145

146-
groups, err := h.store.Queries().ListUserGroups(ctx, db.ListUserGroupsParams{
147-
Limit: pageSize,
148-
Offset: offset,
149-
})
146+
groups, err := h.store.Repos().UserGroup.List(ctx, store.ListUserGroupsFilter{Limit: pageSize, Offset: offset})
150147
if err != nil {
151148
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to list user groups")
152149
}
153150

154-
count, err := h.store.Queries().CountUserGroups(ctx)
151+
count, err := h.store.Repos().UserGroup.Count(ctx)
155152
if err != nil {
156153
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to count user groups")
157154
}
@@ -178,7 +175,7 @@ func (h *UserGroupHandler) UpdateUserGroup(ctx context.Context, req *connect.Req
178175
return nil, err
179176
}
180177

181-
_, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.GroupId)
178+
_, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.GroupId)
182179
if err != nil {
183180
return nil, handleGetError(ctx, err, ErrUserGroupNotFound, "user group not found")
184181
}
@@ -202,7 +199,7 @@ func (h *UserGroupHandler) UpdateUserGroup(ctx context.Context, req *connect.Req
202199
return nil, err
203200
}
204201

205-
updated, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.GroupId)
202+
updated, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.GroupId)
206203
if err != nil {
207204
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to read user group")
208205
}
@@ -235,7 +232,7 @@ func (h *UserGroupHandler) SetUserGroupMaintenanceWindow(ctx context.Context, re
235232
return nil, apiErrorCtx(ctx, ErrValidationFailed, connect.CodeInvalidArgument, err.Error())
236233
}
237234

238-
if _, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.Id); err != nil {
235+
if _, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.Id); err != nil {
239236
return nil, handleGetError(ctx, err, ErrUserGroupNotFound, "user group not found")
240237
}
241238

@@ -252,7 +249,7 @@ func (h *UserGroupHandler) SetUserGroupMaintenanceWindow(ctx context.Context, re
252249
return nil, err
253250
}
254251

255-
updated, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.Id)
252+
updated, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.Id)
256253
if err != nil {
257254
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to read user group")
258255
}
@@ -270,7 +267,7 @@ func (h *UserGroupHandler) DeleteUserGroup(ctx context.Context, req *connect.Req
270267
return nil, err
271268
}
272269

273-
_, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.Id)
270+
_, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.Id)
274271
if err != nil {
275272
return nil, handleGetError(ctx, err, ErrUserGroupNotFound, "user group not found")
276273
}
@@ -399,7 +396,7 @@ func (h *UserGroupHandler) RemoveUserFromGroup(ctx context.Context, req *connect
399396
}
400397

401398
// Verify group exists and is not dynamic
402-
group, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.GroupId)
399+
group, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.GroupId)
403400
if err != nil {
404401
return nil, handleGetError(ctx, err, ErrUserGroupNotFound, "user group not found")
405402
}
@@ -583,7 +580,7 @@ func (h *UserGroupHandler) ListUserGroupsForUser(ctx context.Context, req *conne
583580
return nil, handleGetError(ctx, err, ErrUserNotFound, "user not found")
584581
}
585582

586-
groups, err := h.store.Queries().ListUserGroupsForUser(ctx, req.Msg.UserId)
583+
groups, err := h.store.Repos().UserGroup.ListForUser(ctx, req.Msg.UserId)
587584
if err != nil {
588585
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to list user groups")
589586
}
@@ -639,7 +636,7 @@ func (h *UserGroupHandler) UpdateUserGroupQuery(ctx context.Context, req *connec
639636
return nil, err
640637
}
641638

642-
group, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.Id)
639+
group, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.Id)
643640
if err != nil {
644641
return nil, handleGetError(ctx, err, ErrUserGroupNotFound, "user group not found")
645642
}
@@ -700,7 +697,7 @@ func (h *UserGroupHandler) EvaluateDynamicUserGroup(ctx context.Context, req *co
700697
}
701698

702699
// Verify group exists and is dynamic
703-
group, err := h.store.Queries().GetUserGroupByID(ctx, req.Msg.Id)
700+
group, err := h.store.Repos().UserGroup.Get(ctx, req.Msg.Id)
704701
if err != nil {
705702
return nil, handleGetError(ctx, err, ErrUserGroupNotFound, "user group not found")
706703
}
@@ -719,7 +716,7 @@ func (h *UserGroupHandler) EvaluateDynamicUserGroup(ctx context.Context, req *co
719716
}
720717

721718
// Get updated group
722-
group, err = h.store.Queries().GetUserGroupByID(ctx, req.Msg.Id)
719+
group, err = h.store.Repos().UserGroup.Get(ctx, req.Msg.Id)
723720
if err != nil {
724721
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get user group")
725722
}
@@ -786,7 +783,7 @@ func (h *UserGroupHandler) bumpUserSessionVersion(ctx context.Context, userID, a
786783
// failing member does not prevent the remaining members from being
787784
// invalidated. A partial bump is better than stopping early.
788785
func (h *UserGroupHandler) bumpSessionVersionForGroupMembers(ctx context.Context, groupID, actorID string) error {
789-
memberIDs, err := h.store.Queries().ListUserGroupMemberIDs(ctx, groupID)
786+
memberIDs, err := h.store.Repos().UserGroup.ListMemberIDs(ctx, groupID)
790787
if err != nil {
791788
return err
792789
}
@@ -802,7 +799,7 @@ func (h *UserGroupHandler) bumpSessionVersionForGroupMembers(ctx context.Context
802799
}
803800

804801
// userGroupToProto converts a database user group projection to a protobuf UserGroup.
805-
func userGroupToProto(g db.UserGroupsProjection, roles []db.RolesProjection, isScimManaged bool) *pm.UserGroup {
802+
func userGroupToProto(g store.UserGroup, roles []db.RolesProjection, isScimManaged bool) *pm.UserGroup {
806803
group := &pm.UserGroup{
807804
Id: g.ID,
808805
Name: g.Name,

internal/scim/groups_helpers.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
// member set twice produces no second-round events.
2121
func (h *Handler) reconcileGroupMembers(ctx context.Context, provider store.IdentityProvider, groupID string, requestedMembers []SCIMMember) {
2222
h.logger.Debug("SCIM reconcileGroupMembers", "group_id", groupID, "requested_count", len(requestedMembers))
23-
currentMemberIDs, err := h.store.Queries().ListUserGroupMemberIDs(ctx, groupID)
23+
currentMemberIDs, err := h.store.Repos().UserGroup.ListMemberIDs(ctx, groupID)
2424
if err != nil {
2525
h.logger.Error("failed to list current group members for reconciliation", "error", err)
2626
return
@@ -82,20 +82,20 @@ func (h *Handler) reconcileGroupMembers(ctx context.Context, provider store.Iden
8282
// If the user group was deleted but the mapping still exists (orphaned), it re-creates
8383
// the user group and updates the mapping to restore consistency.
8484
func (h *Handler) buildGroupResource(ctx context.Context, providerID string, mapping store.SCIMGroupMapping, baseURL string) (SCIMGroup, error) {
85-
group, err := h.store.Queries().GetUserGroupWithMembers(ctx, mapping.UserGroupID)
85+
group, err := h.store.Repos().UserGroup.GetWithMembers(ctx, mapping.UserGroupID)
8686
if store.IsNotFound(err) {
8787
// User group was deleted but SCIM mapping still exists — restore it.
8888
mapping, err = h.restoreOrphanedGroup(ctx, providerID, mapping)
8989
if err != nil {
9090
return SCIMGroup{}, fmt.Errorf("restore orphaned group: %w", err)
9191
}
92-
group, err = h.store.Queries().GetUserGroupWithMembers(ctx, mapping.UserGroupID)
92+
group, err = h.store.Repos().UserGroup.GetWithMembers(ctx, mapping.UserGroupID)
9393
}
9494
if err != nil {
9595
return SCIMGroup{}, fmt.Errorf("get user group: %w", err)
9696
}
9797

98-
memberIDs, err := h.store.Queries().ListUserGroupMemberIDs(ctx, mapping.UserGroupID)
98+
memberIDs, err := h.store.Repos().UserGroup.ListMemberIDs(ctx, mapping.UserGroupID)
9999
if err != nil {
100100
return SCIMGroup{}, fmt.Errorf("list group members: %w", err)
101101
}

internal/scim/groups_mutate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ func (h *Handler) handleGroupPatchReplace(ctx context.Context, provider store.Id
290290
requestedSet[id] = true
291291
}
292292

293-
currentMemberIDs, err := h.store.Queries().ListUserGroupMemberIDs(ctx, groupID)
293+
currentMemberIDs, err := h.store.Repos().UserGroup.ListMemberIDs(ctx, groupID)
294294
if err != nil {
295295
h.logger.Error("failed to list group members for patch replace", "group_id", groupID, "error", err)
296296
return

internal/search/index.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -835,10 +835,7 @@ func (idx *Index) warmUserGroups(ctx context.Context) (int, error) {
835835
var total int
836836

837837
for {
838-
groups, err := idx.store.Queries().ListUserGroups(ctx, db.ListUserGroupsParams{
839-
Limit: pageSize,
840-
Offset: offset,
841-
})
838+
groups, err := idx.store.Repos().UserGroup.List(ctx, store.ListUserGroupsFilter{Limit: pageSize, Offset: offset})
842839
if err != nil {
843840
return total, err
844841
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package postgres
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/manchtools/power-manage/server/internal/store"
9+
"github.com/manchtools/power-manage/server/internal/store/generated"
10+
)
11+
12+
// UserGroup implements store.UserGroupRepo against
13+
// user_groups_projection + user_group_members_projection.
14+
type UserGroup struct {
15+
q *generated.Queries
16+
}
17+
18+
// NewUserGroup returns a UserGroup repo bound to the given sqlc handle.
19+
func NewUserGroup(q *generated.Queries) *UserGroup {
20+
return &UserGroup{q: q}
21+
}
22+
23+
func (g *UserGroup) Get(ctx context.Context, id string) (store.UserGroup, error) {
24+
row, err := g.q.GetUserGroupByID(ctx, id)
25+
if err != nil {
26+
return store.UserGroup{}, fmt.Errorf("user_group: get: %w", translateNotFound(err))
27+
}
28+
return userGroupFromRow(row), nil
29+
}
30+
31+
func (g *UserGroup) GetByName(ctx context.Context, name string) (store.UserGroup, error) {
32+
row, err := g.q.GetUserGroupByName(ctx, name)
33+
if err != nil {
34+
return store.UserGroup{}, fmt.Errorf("user_group: get by name: %w", translateNotFound(err))
35+
}
36+
return userGroupFromRow(row), nil
37+
}
38+
39+
func (g *UserGroup) GetWithMembers(ctx context.Context, id string) (store.UserGroupWithMembers, error) {
40+
row, err := g.q.GetUserGroupWithMembers(ctx, id)
41+
if err != nil {
42+
return store.UserGroupWithMembers{}, fmt.Errorf("user_group: get with members: %w", translateNotFound(err))
43+
}
44+
return store.UserGroupWithMembers{
45+
UserGroup: store.UserGroup{
46+
ID: row.ID,
47+
Name: row.Name,
48+
Description: row.Description,
49+
MemberCount: row.MemberCount,
50+
CreatedAt: row.CreatedAt,
51+
CreatedBy: row.CreatedBy,
52+
UpdatedAt: row.UpdatedAt,
53+
IsDynamic: row.IsDynamic,
54+
DynamicQuery: row.DynamicQuery,
55+
MaintenanceWindow: json.RawMessage(row.MaintenanceWindow),
56+
},
57+
ActualMemberCount: row.ActualMemberCount,
58+
}, nil
59+
}
60+
61+
func (g *UserGroup) List(ctx context.Context, filter store.ListUserGroupsFilter) ([]store.UserGroup, error) {
62+
rows, err := g.q.ListUserGroups(ctx, generated.ListUserGroupsParams{
63+
Limit: filter.Limit,
64+
Offset: filter.Offset,
65+
})
66+
if err != nil {
67+
return nil, fmt.Errorf("user_group: list: %w", err)
68+
}
69+
out := make([]store.UserGroup, len(rows))
70+
for i, r := range rows {
71+
out[i] = userGroupFromRow(r)
72+
}
73+
return out, nil
74+
}
75+
76+
func (g *UserGroup) Count(ctx context.Context) (int64, error) {
77+
n, err := g.q.CountUserGroups(ctx)
78+
if err != nil {
79+
return 0, fmt.Errorf("user_group: count: %w", translateNotFound(err))
80+
}
81+
return n, nil
82+
}
83+
84+
func (g *UserGroup) ListForUser(ctx context.Context, userID string) ([]store.UserGroup, error) {
85+
rows, err := g.q.ListUserGroupsForUser(ctx, userID)
86+
if err != nil {
87+
return nil, fmt.Errorf("user_group: list for user: %w", err)
88+
}
89+
out := make([]store.UserGroup, len(rows))
90+
for i, r := range rows {
91+
out[i] = userGroupFromRow(r)
92+
}
93+
return out, nil
94+
}
95+
96+
func (g *UserGroup) ListMemberIDs(ctx context.Context, groupID string) ([]string, error) {
97+
ids, err := g.q.ListUserGroupMemberIDs(ctx, groupID)
98+
if err != nil {
99+
return nil, fmt.Errorf("user_group: list member ids: %w", err)
100+
}
101+
return ids, nil
102+
}
103+
104+
func (g *UserGroup) ListMembers(ctx context.Context, groupID string) ([]store.UserGroupMember, error) {
105+
rows, err := g.q.ListUserGroupMembers(ctx, groupID)
106+
if err != nil {
107+
return nil, fmt.Errorf("user_group: list members: %w", err)
108+
}
109+
out := make([]store.UserGroupMember, len(rows))
110+
for i, r := range rows {
111+
out[i] = store.UserGroupMember{
112+
UserID: r.UserID,
113+
Email: r.Email,
114+
AddedAt: r.AddedAt,
115+
}
116+
}
117+
return out, nil
118+
}
119+
120+
func userGroupFromRow(r generated.UserGroupsProjection) store.UserGroup {
121+
return store.UserGroup{
122+
ID: r.ID,
123+
Name: r.Name,
124+
Description: r.Description,
125+
MemberCount: r.MemberCount,
126+
CreatedAt: r.CreatedAt,
127+
CreatedBy: r.CreatedBy,
128+
UpdatedAt: r.UpdatedAt,
129+
IsDynamic: r.IsDynamic,
130+
DynamicQuery: r.DynamicQuery,
131+
MaintenanceWindow: json.RawMessage(r.MaintenanceWindow),
132+
}
133+
}

internal/store/postgres/wire.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ func NewRepos(q *generated.Queries) *store.Repos {
2828
TerminalSession: NewTerminalSession(q),
2929
Token: NewToken(q),
3030
Totp: NewTotp(q),
31+
UserGroup: NewUserGroup(q),
3132
UserSelection: NewUserSelection(q),
3233
}
3334
}

internal/store/repos.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ type Repos struct {
2727
TerminalSession TerminalSessionRepo
2828
Token TokenRepo
2929
Totp TotpRepo
30+
UserGroup UserGroupRepo
3031
UserSelection UserSelectionRepo
3132
}

0 commit comments

Comments
 (0)