diff --git a/internal/api/assignment_handler.go b/internal/api/assignment_handler.go index a137e9f6..7482e0ed 100644 --- a/internal/api/assignment_handler.go +++ b/internal/api/assignment_handler.go @@ -13,7 +13,6 @@ import ( "github.com/manchtools/power-manage/server/internal/eventtypes" "github.com/manchtools/power-manage/server/internal/eventtypes/payloads" "github.com/manchtools/power-manage/server/internal/store" - db "github.com/manchtools/power-manage/server/internal/store/generated" ) // AssignmentHandler handles assignment RPCs. @@ -123,12 +122,7 @@ func (h *AssignmentHandler) CreateAssignment(ctx context.Context, req *connect.R targetTypeStr := assignmentTargetTypeToString(req.Msg.TargetType) // Check if an active assignment already exists - existingAssignment, err := h.store.Queries().GetAssignment(ctx, db.GetAssignmentParams{ - SourceType: sourceTypeStr, - SourceID: req.Msg.SourceId, - TargetType: targetTypeStr, - TargetID: req.Msg.TargetId, - }) + existingAssignment, err := h.store.Repos().Assignment.Get(ctx, store.AssignmentKey{SourceType: sourceTypeStr, SourceID: req.Msg.SourceId, TargetType: targetTypeStr, TargetID: req.Msg.TargetId}) if err == nil { // Assignment already exists, return it return connect.NewResponse(&pm.CreateAssignmentResponse{ @@ -159,12 +153,7 @@ func (h *AssignmentHandler) CreateAssignment(ctx context.Context, req *connect.R // Use GetAssignment instead of GetAssignmentByID because the upsert // may have updated an existing soft-deleted record with a different ID - assignment, err := h.store.Queries().GetAssignment(ctx, db.GetAssignmentParams{ - SourceType: sourceTypeStr, - SourceID: req.Msg.SourceId, - TargetType: targetTypeStr, - TargetID: req.Msg.TargetId, - }) + assignment, err := h.store.Repos().Assignment.Get(ctx, store.AssignmentKey{SourceType: sourceTypeStr, SourceID: req.Msg.SourceId, TargetType: targetTypeStr, TargetID: req.Msg.TargetId}) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to get assignment") } @@ -186,7 +175,7 @@ func (h *AssignmentHandler) DeleteAssignment(ctx context.Context, req *connect.R } // Verify assignment exists before emitting delete event - _, err = h.store.Queries().GetAssignmentByID(ctx, req.Msg.Id) + _, err = h.store.Repos().Assignment.GetByID(ctx, req.Msg.Id) if err != nil { return nil, handleGetError(ctx, err, ErrAssignmentNotFound, "assignment not found") } @@ -218,24 +207,12 @@ func (h *AssignmentHandler) ListAssignments(ctx context.Context, req *connect.Re sourceTypeStr := assignmentSourceTypeToString(req.Msg.SourceType) targetTypeStr := assignmentTargetTypeToString(req.Msg.TargetType) - assignments, err := h.store.Queries().ListAssignments(ctx, db.ListAssignmentsParams{ - Column1: sourceTypeStr, - Column2: req.Msg.SourceId, - Column3: targetTypeStr, - Column4: req.Msg.TargetId, - Limit: pageSize, - Offset: offset, - }) + assignments, err := h.store.Repos().Assignment.List(ctx, store.ListAssignmentsFilter{SourceType: sourceTypeStr, SourceID: req.Msg.SourceId, TargetType: targetTypeStr, TargetID: req.Msg.TargetId, Limit: pageSize, Offset: offset}) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to list assignments") } - count, err := h.store.Queries().CountAssignments(ctx, db.CountAssignmentsParams{ - Column1: sourceTypeStr, - Column2: req.Msg.SourceId, - Column3: targetTypeStr, - Column4: req.Msg.TargetId, - }) + count, err := h.store.Repos().Assignment.Count(ctx, store.CountAssignmentsFilter{SourceType: sourceTypeStr, SourceID: req.Msg.SourceId, TargetType: targetTypeStr, TargetID: req.Msg.TargetId}) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to count assignments") } @@ -459,7 +436,20 @@ func (h *AssignmentHandler) GetUserAssignments(ctx context.Context, req *connect protoAssignments := make([]*pm.Assignment, len(assignments)) for i, a := range assignments { - protoAssignments[i] = h.assignmentToProto(a) + // Transitional: ListAssignmentsForUser hasn't migrated yet + // (different row shape), so convert to store.Assignment for + // assignmentToProto. Falls away when that query moves. + protoAssignments[i] = h.assignmentToProto(store.Assignment{ + ID: a.ID, + SourceType: a.SourceType, + SourceID: a.SourceID, + TargetType: a.TargetType, + TargetID: a.TargetID, + SortOrder: a.SortOrder, + Mode: a.Mode, + CreatedAt: a.CreatedAt, + CreatedBy: a.CreatedBy, + }) } return connect.NewResponse(&pm.GetUserAssignmentsResponse{ @@ -467,7 +457,7 @@ func (h *AssignmentHandler) GetUserAssignments(ctx context.Context, req *connect }), nil } -func (h *AssignmentHandler) assignmentToProto(a db.AssignmentsProjection) *pm.Assignment { +func (h *AssignmentHandler) assignmentToProto(a store.Assignment) *pm.Assignment { assignment := &pm.Assignment{ Id: a.ID, SourceType: assignmentSourceTypeFromString(a.SourceType), diff --git a/internal/api/device_handler.go b/internal/api/device_handler.go index c820c1d4..ad5c0880 100644 --- a/internal/api/device_handler.go +++ b/internal/api/device_handler.go @@ -819,7 +819,7 @@ func (h *DeviceHandler) CreateLuksToken(ctx context.Context, req *connect.Reques } // Only an assigned owner can create a LUKS token - assignedUserIDs, err := h.store.Queries().ListDeviceAssignedUserIDs(ctx, req.Msg.DeviceId) + assignedUserIDs, err := h.store.Repos().Assignment.ListAssignedUserIDsForDevice(ctx, req.Msg.DeviceId) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to check device assignments") } diff --git a/internal/api/user_selection_handler.go b/internal/api/user_selection_handler.go index 92d76536..eb0b9819 100644 --- a/internal/api/user_selection_handler.go +++ b/internal/api/user_selection_handler.go @@ -54,7 +54,7 @@ func (h *UserSelectionHandler) SetUserSelection(ctx context.Context, req *connec sourceTypeStr := assignmentSourceTypeToString(req.Msg.SourceType) // Verify an available-mode assignment exists for this source targeting this device - availableAssignments, err := h.store.Queries().ListAvailableAssignmentsForDevice(ctx, req.Msg.DeviceId) + availableAssignments, err := h.store.Repos().Assignment.ListAvailableForDevice(ctx, req.Msg.DeviceId) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to check available assignments") } @@ -118,7 +118,7 @@ func (h *UserSelectionHandler) ListAvailableActions(ctx context.Context, req *co } // Get available assignments for this device - assignments, err := h.store.Queries().ListAvailableAssignmentsForDevice(ctx, req.Msg.DeviceId) + assignments, err := h.store.Repos().Assignment.ListAvailableForDevice(ctx, req.Msg.DeviceId) if err != nil { return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to list available assignments") } diff --git a/internal/store/assignment.go b/internal/store/assignment.go new file mode 100644 index 00000000..b66ac7b2 --- /dev/null +++ b/internal/store/assignment.go @@ -0,0 +1,99 @@ +package store + +import ( + "context" + "time" +) + +// Assignment is the basic assignment-projection row. Mode is the +// pm.AssignmentMode enum value (Required / Available); SortOrder +// drives the dispatch ordering on the agent. +type Assignment struct { + ID string + SourceType string + SourceID string + TargetType string + TargetID string + SortOrder int32 + Mode int32 + CreatedAt *time.Time + CreatedBy string +} + +// AssignmentWithNames is the List-side shape — wraps Assignment with +// the joined SourceName + TargetName so the list UI can render without +// per-row lookups for display. +type AssignmentWithNames struct { + Assignment + SourceName string + TargetName string +} + +// AssignmentKey is the composite (source, target) lookup used by +// GetAssignment and the duplicate-pre-check path. The projection's +// unique index on this tuple guarantees at most one active row. +type AssignmentKey struct { + SourceType string + SourceID string + TargetType string + TargetID string +} + +// ListAssignmentsFilter pairs pagination with the four optional +// filter axes the UI exposes. Empty strings disable each axis +// independently — the projection treats "" as "no filter". +type ListAssignmentsFilter struct { + SourceType string + SourceID string + TargetType string + TargetID string + Limit int32 + Offset int32 +} + +// CountAssignmentsFilter mirrors ListAssignmentsFilter's filter +// fields for the matching count side. +type CountAssignmentsFilter struct { + SourceType string + SourceID string + TargetType string + TargetID string +} + +// AssignmentRepo reads assignment state. Writes flow through events +// (AssignmentCreated / AssignmentDeleted / AssignmentModeChanged / +// AssignmentSortOrderChanged) and the projector listener. +// +// Per-device join queries that hydrate action / source-type details +// (ListAssignedActionsForDevice, ListDirectAssignmentsForDevice, +// ListGroupAssignmentsForDevice, ListAssignmentsForUser) stay on +// Store.Queries() — they return join-shaped rows that migrate with +// their respective domain consumers. +type AssignmentRepo interface { + // Get returns the assignment for a (source, target) tuple. + // Returns ErrNotFound when no active assignment matches. + Get(ctx context.Context, key AssignmentKey) (Assignment, error) + + // GetByID returns the assignment by its ID. + GetByID(ctx context.Context, id string) (Assignment, error) + + // List returns a page of assignments hydrated with source + + // target display names, ordered by created_at descending. + List(ctx context.Context, filter ListAssignmentsFilter) ([]AssignmentWithNames, error) + + // Count returns the total matching the filter. + Count(ctx context.Context, filter CountAssignmentsFilter) (int64, error) + + // ListAvailableForDevice returns every "Available"-mode + // assignment whose effective target reaches the given device + // (directly, via device-group, via user assigned to the device, + // or via that user's user-groups). Used to build the per-device + // "what can the user opt into" catalog. + ListAvailableForDevice(ctx context.Context, deviceID string) ([]Assignment, error) + + // ListAssignedUserIDsForDevice returns the user IDs with at + // least one assignment that resolves to the given device. + // Returns an empty slice when the device has no user + // assignments. + ListAssignedUserIDsForDevice(ctx context.Context, deviceID string) ([]string, error) +} diff --git a/internal/store/postgres/assignment.go b/internal/store/postgres/assignment.go new file mode 100644 index 00000000..f0f6881c --- /dev/null +++ b/internal/store/postgres/assignment.go @@ -0,0 +1,122 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/manchtools/power-manage/server/internal/store" + "github.com/manchtools/power-manage/server/internal/store/generated" +) + +// Assignment implements store.AssignmentRepo against +// assignments_projection. +type Assignment struct { + q *generated.Queries +} + +// NewAssignment returns an Assignment repo bound to the given sqlc +// handle. +func NewAssignment(q *generated.Queries) *Assignment { + return &Assignment{q: q} +} + +func (a *Assignment) Get(ctx context.Context, key store.AssignmentKey) (store.Assignment, error) { + row, err := a.q.GetAssignment(ctx, generated.GetAssignmentParams{ + SourceType: key.SourceType, + SourceID: key.SourceID, + TargetType: key.TargetType, + TargetID: key.TargetID, + }) + if err != nil { + return store.Assignment{}, fmt.Errorf("assignment: get: %w", translateNotFound(err)) + } + return assignmentFromRow(row), nil +} + +func (a *Assignment) GetByID(ctx context.Context, id string) (store.Assignment, error) { + row, err := a.q.GetAssignmentByID(ctx, id) + if err != nil { + return store.Assignment{}, fmt.Errorf("assignment: get by id: %w", translateNotFound(err)) + } + return assignmentFromRow(row), nil +} + +func (a *Assignment) List(ctx context.Context, filter store.ListAssignmentsFilter) ([]store.AssignmentWithNames, error) { + rows, err := a.q.ListAssignments(ctx, generated.ListAssignmentsParams{ + Column1: filter.SourceType, + Column2: filter.SourceID, + Column3: filter.TargetType, + Column4: filter.TargetID, + Limit: filter.Limit, + Offset: filter.Offset, + }) + if err != nil { + return nil, fmt.Errorf("assignment: list: %w", err) + } + out := make([]store.AssignmentWithNames, len(rows)) + for i, r := range rows { + out[i] = store.AssignmentWithNames{ + Assignment: store.Assignment{ + ID: r.ID, + SourceType: r.SourceType, + SourceID: r.SourceID, + TargetType: r.TargetType, + TargetID: r.TargetID, + SortOrder: r.SortOrder, + Mode: r.Mode, + CreatedAt: r.CreatedAt, + CreatedBy: r.CreatedBy, + }, + SourceName: r.SourceName, + TargetName: r.TargetName, + } + } + return out, nil +} + +func (a *Assignment) Count(ctx context.Context, filter store.CountAssignmentsFilter) (int64, error) { + n, err := a.q.CountAssignments(ctx, generated.CountAssignmentsParams{ + Column1: filter.SourceType, + Column2: filter.SourceID, + Column3: filter.TargetType, + Column4: filter.TargetID, + }) + if err != nil { + return 0, fmt.Errorf("assignment: count: %w", translateNotFound(err)) + } + return n, nil +} + +func (a *Assignment) ListAvailableForDevice(ctx context.Context, deviceID string) ([]store.Assignment, error) { + rows, err := a.q.ListAvailableAssignmentsForDevice(ctx, deviceID) + if err != nil { + return nil, fmt.Errorf("assignment: list available for device: %w", err) + } + out := make([]store.Assignment, len(rows)) + for i, r := range rows { + out[i] = assignmentFromRow(r) + } + return out, nil +} + +func (a *Assignment) ListAssignedUserIDsForDevice(ctx context.Context, deviceID string) ([]string, error) { + ids, err := a.q.ListDeviceAssignedUserIDs(ctx, deviceID) + if err != nil { + return nil, fmt.Errorf("assignment: list assigned user ids for device: %w", err) + } + return ids, nil +} + +func assignmentFromRow(r generated.AssignmentsProjection) store.Assignment { + return store.Assignment{ + ID: r.ID, + SourceType: r.SourceType, + SourceID: r.SourceID, + TargetType: r.TargetType, + TargetID: r.TargetID, + SortOrder: r.SortOrder, + Mode: r.Mode, + CreatedAt: r.CreatedAt, + CreatedBy: r.CreatedBy, + } +} diff --git a/internal/store/postgres/wire.go b/internal/store/postgres/wire.go index 55965b7e..7645a621 100644 --- a/internal/store/postgres/wire.go +++ b/internal/store/postgres/wire.go @@ -16,6 +16,7 @@ func NewRepos(q *generated.Queries) *store.Repos { return &store.Repos{ Action: NewAction(q), ActionSet: NewActionSet(q), + Assignment: NewAssignment(q), AuthState: NewAuthState(q), Compliance: NewCompliance(q), Definition: NewDefinition(q), diff --git a/internal/store/repos.go b/internal/store/repos.go index cbd2cda5..d66c76dc 100644 --- a/internal/store/repos.go +++ b/internal/store/repos.go @@ -15,6 +15,7 @@ package store type Repos struct { Action ActionRepo ActionSet ActionSetRepo + Assignment AssignmentRepo AuthState AuthStateRepo Compliance ComplianceRepo Definition DefinitionRepo