This is an automated email from the ASF dual-hosted git repository.

DImuthuUpe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 9d6333262129e7c01cc67ea451341ab62c5842db
Author: DImuthuUpe <[email protected]>
AuthorDate: Sat May 16 21:32:55 2026 -0400

    Implemented the service layer for Compute Allocation Memberships
---
 docs/API-Docs.md                                   |  92 +++++++++
 .../000008_compute_allocation_memberships.down.sql |  18 ++
 .../000008_compute_allocation_memberships.up.sql   |  34 ++++
 internal/server/server.go                          | 105 ++++++++++
 .../store/compute_allocation_membership_store.go   | 122 ++++++++++++
 internal/store/store.go                            |  22 +++
 pkg/models/allocation.go                           |  14 +-
 pkg/service/compute_allocation_membership.go       | 220 +++++++++++++++++++++
 pkg/service/service.go                             |   4 +
 9 files changed, 624 insertions(+), 7 deletions(-)

diff --git a/docs/API-Docs.md b/docs/API-Docs.md
index 7854758d0..2a308db05 100644
--- a/docs/API-Docs.md
+++ b/docs/API-Docs.md
@@ -953,6 +953,98 @@ Return the most recent event for the given change request.
 
 ---
 
+## Compute Allocation Memberships
+
+A `ComputeAllocationMembership` records a user's sub-allocation against a
+parent `ComputeAllocation` — i.e. how many SUs of the parent allocation the
+user is entitled to consume, and the time window plus lifecycle status of
+that grant. At most one membership can exist per `(compute_allocation_id,
+user_id)` pair (enforced by a unique key). Memberships are cascade-deleted
+when their parent allocation is removed.
+
+### POST `/compute-allocation-memberships`
+
+Create a new membership.
+
+**Request body**
+
+```json
+{
+  "compute_allocation_id": "alloc-123",
+  "user_id":               "user-456",
+  "allocation_amount":     50000,
+  "start_time":            "2026-01-01T00:00:00Z",
+  "end_time":              "2026-12-31T23:59:59Z",
+  "membership_status":     "ACTIVE"
+}
+```
+
+- `compute_allocation_id` and `user_id` are required and must reference
+  existing rows.
+- `membership_status` defaults to `ACTIVE` when omitted.
+- `id` is generated server-side when omitted.
+
+**Errors**
+
+- `400` — missing required fields, or referenced allocation/user not found.
+- `409` — a membership already exists for this `(allocation, user)` pair.
+
+### GET `/compute-allocation-memberships/{id}`
+
+Retrieve a membership by ID.
+
+### PUT `/compute-allocation-memberships/{id}`
+
+Replace mutable fields of a membership. Fields left blank/zero in the request
+body fall back to the stored value (partial updates).
+
+### PUT `/compute-allocation-memberships/{id}/allocation-amount`
+
+Update only the SU sub-allocation granted to the user.
+
+**Request body**
+
+```json
+{ "allocation_amount": 75000 }
+```
+
+**Errors**
+
+- `400` — negative `allocation_amount`.
+- `404` — no membership with the given ID.
+
+### PUT `/compute-allocation-memberships/{id}/status`
+
+Update only the lifecycle status of the membership (`ACTIVE`, `INACTIVE`,
+`DELETED`, etc.).
+
+**Request body**
+
+```json
+{ "membership_status": "INACTIVE" }
+```
+
+**Errors**
+
+- `400` — empty `membership_status`.
+- `404` — no membership with the given ID.
+
+### DELETE `/compute-allocation-memberships/{id}`
+
+Remove a membership.
+
+### GET `/compute-allocations/{id}/memberships`
+
+List every membership recorded against the given allocation, ordered by
+`start_time` ascending.
+
+### GET `/users/{id}/compute-allocation-memberships`
+
+List every allocation membership held by the given user, ordered by
+`start_time` ascending.
+
+---
+
 ## End-to-end example
 
 ```bash
diff --git 
a/internal/db/migrations/000008_compute_allocation_memberships.down.sql 
b/internal/db/migrations/000008_compute_allocation_memberships.down.sql
new file mode 100644
index 000000000..731073898
--- /dev/null
+++ b/internal/db/migrations/000008_compute_allocation_memberships.down.sql
@@ -0,0 +1,18 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+DROP TABLE IF EXISTS compute_allocation_memberships;
diff --git 
a/internal/db/migrations/000008_compute_allocation_memberships.up.sql 
b/internal/db/migrations/000008_compute_allocation_memberships.up.sql
new file mode 100644
index 000000000..17f753566
--- /dev/null
+++ b/internal/db/migrations/000008_compute_allocation_memberships.up.sql
@@ -0,0 +1,34 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+CREATE TABLE IF NOT EXISTS compute_allocation_memberships
+(
+    id                    VARCHAR(255) NOT NULL,
+    compute_allocation_id VARCHAR(255) NOT NULL,
+    user_id               VARCHAR(255) NOT NULL,
+    allocation_amount     BIGINT       NOT NULL DEFAULT 0,
+    start_time            TIMESTAMP(6) NOT NULL,
+    end_time              TIMESTAMP(6) NOT NULL,
+    membership_status     VARCHAR(64)  NOT NULL,
+    created_at            TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    PRIMARY KEY (id),
+    UNIQUE KEY uq_compute_allocation_memberships_allocation_user 
(compute_allocation_id, user_id),
+    KEY idx_compute_allocation_memberships_user (user_id),
+    KEY idx_compute_allocation_memberships_status (membership_status),
+    CONSTRAINT fk_compute_allocation_memberships_allocation FOREIGN KEY 
(compute_allocation_id)
+        REFERENCES compute_allocations (id) ON DELETE CASCADE
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
diff --git a/internal/server/server.go b/internal/server/server.go
index 70f1e5948..bcf633a8c 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -99,6 +99,15 @@ func (s *Server) routes() {
        s.mux.HandleFunc("DELETE 
/compute-allocation-change-request-events/{id}", 
s.deleteComputeAllocationChangeRequestEvent)
        s.mux.HandleFunc("GET /compute-allocation-change-requests/{id}/events", 
s.listEventsForChangeRequest)
        s.mux.HandleFunc("GET 
/compute-allocation-change-requests/{id}/events/latest", 
s.getLatestEventForChangeRequest)
+
+       s.mux.HandleFunc("POST /compute-allocation-memberships", 
s.createComputeAllocationMembership)
+       s.mux.HandleFunc("GET /compute-allocation-memberships/{id}", 
s.getComputeAllocationMembership)
+       s.mux.HandleFunc("PUT /compute-allocation-memberships/{id}", 
s.updateComputeAllocationMembership)
+       s.mux.HandleFunc("PUT 
/compute-allocation-memberships/{id}/allocation-amount", 
s.updateMembershipAllocationAmount)
+       s.mux.HandleFunc("PUT /compute-allocation-memberships/{id}/status", 
s.updateMembershipStatus)
+       s.mux.HandleFunc("DELETE /compute-allocation-memberships/{id}", 
s.deleteComputeAllocationMembership)
+       s.mux.HandleFunc("GET /compute-allocations/{id}/memberships", 
s.listMembersForAllocation)
+       s.mux.HandleFunc("GET /users/{id}/compute-allocation-memberships", 
s.listAllocationsForUser)
 }
 
 func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
@@ -517,6 +526,102 @@ func (s *Server) getLatestEventForChangeRequest(w 
http.ResponseWriter, r *http.R
        writeJSON(w, http.StatusOK, evt)
 }
 
+func (s *Server) createComputeAllocationMembership(w http.ResponseWriter, r 
*http.Request) {
+       var m models.ComputeAllocationMembership
+       if err := decodeJSON(r, &m); err != nil {
+               writeError(w, http.StatusBadRequest, err)
+               return
+       }
+       created, err := s.svc.CreateComputeAllocationMembership(r.Context(), &m)
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusCreated, created)
+}
+
+func (s *Server) getComputeAllocationMembership(w http.ResponseWriter, r 
*http.Request) {
+       m, err := s.svc.GetComputeAllocationMembership(r.Context(), 
r.PathValue("id"))
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusOK, m)
+}
+
+func (s *Server) updateComputeAllocationMembership(w http.ResponseWriter, r 
*http.Request) {
+       var m models.ComputeAllocationMembership
+       if err := decodeJSON(r, &m); err != nil {
+               writeError(w, http.StatusBadRequest, err)
+               return
+       }
+       m.ID = r.PathValue("id")
+       updated, err := s.svc.UpdateComputeAllocationMembership(r.Context(), &m)
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusOK, updated)
+}
+
+func (s *Server) updateMembershipAllocationAmount(w http.ResponseWriter, r 
*http.Request) {
+       var body struct {
+               AllocationAmount int64 `json:"allocation_amount"`
+       }
+       if err := decodeJSON(r, &body); err != nil {
+               writeError(w, http.StatusBadRequest, err)
+               return
+       }
+       updated, err := s.svc.UpdateMembershipAllocationAmount(r.Context(), 
r.PathValue("id"), body.AllocationAmount)
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusOK, updated)
+}
+
+func (s *Server) updateMembershipStatus(w http.ResponseWriter, r 
*http.Request) {
+       var body struct {
+               MembershipStatus models.AllocationStatus 
`json:"membership_status"`
+       }
+       if err := decodeJSON(r, &body); err != nil {
+               writeError(w, http.StatusBadRequest, err)
+               return
+       }
+       updated, err := s.svc.UpdateMembershipStatus(r.Context(), 
r.PathValue("id"), body.MembershipStatus)
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusOK, updated)
+}
+
+func (s *Server) deleteComputeAllocationMembership(w http.ResponseWriter, r 
*http.Request) {
+       if err := s.svc.DeleteComputeAllocationMembership(r.Context(), 
r.PathValue("id")); err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) listMembersForAllocation(w http.ResponseWriter, r 
*http.Request) {
+       rows, err := s.svc.ListMembersForAllocation(r.Context(), 
r.PathValue("id"))
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusOK, rows)
+}
+
+func (s *Server) listAllocationsForUser(w http.ResponseWriter, r 
*http.Request) {
+       rows, err := s.svc.ListAllocationsForUser(r.Context(), 
r.PathValue("id"))
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusOK, rows)
+}
+
 // LoggingMiddleware logs every request once it completes.
 func LoggingMiddleware(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/store/compute_allocation_membership_store.go 
b/internal/store/compute_allocation_membership_store.go
new file mode 100644
index 000000000..aae350ac9
--- /dev/null
+++ b/internal/store/compute_allocation_membership_store.go
@@ -0,0 +1,122 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package store
+
+import (
+       "context"
+       "database/sql"
+       "errors"
+
+       "github.com/jmoiron/sqlx"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+const computeAllocationMembershipColumns = "id, compute_allocation_id, 
user_id, allocation_amount, start_time, end_time, membership_status"
+
+type mysqlComputeAllocationMembershipStore struct {
+       db *sqlx.DB
+}
+
+// NewComputeAllocationMembershipStore returns a MySQL-backed
+// ComputeAllocationMembershipStore.
+func NewComputeAllocationMembershipStore(db *sqlx.DB) 
ComputeAllocationMembershipStore {
+       return &mysqlComputeAllocationMembershipStore{db: db}
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByID(ctx context.Context, 
id string) (*models.ComputeAllocationMembership, error) {
+       var m models.ComputeAllocationMembership
+       err := s.db.GetContext(ctx, &m,
+               `SELECT `+computeAllocationMembershipColumns+` FROM 
compute_allocation_memberships WHERE id = ?`, id)
+       if err != nil {
+               if errors.Is(err, sql.ErrNoRows) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return &m, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByPair(ctx 
context.Context, allocationID, userID string) 
(*models.ComputeAllocationMembership, error) {
+       var m models.ComputeAllocationMembership
+       err := s.db.GetContext(ctx, &m,
+               `SELECT `+computeAllocationMembershipColumns+`
+                FROM compute_allocation_memberships
+                WHERE compute_allocation_id = ? AND user_id = ?`, 
allocationID, userID)
+       if err != nil {
+               if errors.Is(err, sql.ErrNoRows) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return &m, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByAllocation(ctx 
context.Context, allocationID string) ([]models.ComputeAllocationMembership, 
error) {
+       var rows []models.ComputeAllocationMembership
+       err := s.db.SelectContext(ctx, &rows,
+               `SELECT `+computeAllocationMembershipColumns+`
+                FROM compute_allocation_memberships
+                WHERE compute_allocation_id = ?
+                ORDER BY start_time`, allocationID)
+       if err != nil {
+               return nil, err
+       }
+       return rows, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByUser(ctx 
context.Context, userID string) ([]models.ComputeAllocationMembership, error) {
+       var rows []models.ComputeAllocationMembership
+       err := s.db.SelectContext(ctx, &rows,
+               `SELECT `+computeAllocationMembershipColumns+`
+                FROM compute_allocation_memberships
+                WHERE user_id = ?
+                ORDER BY start_time`, userID)
+       if err != nil {
+               return nil, err
+       }
+       return rows, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) Create(ctx context.Context, tx 
*sql.Tx, m *models.ComputeAllocationMembership) error {
+       _, err := tx.ExecContext(ctx,
+               `INSERT INTO compute_allocation_memberships
+                    (id, compute_allocation_id, user_id, allocation_amount, 
start_time, end_time, membership_status)
+                VALUES (?, ?, ?, ?, ?, ?, ?)`,
+               m.ID, m.ComputeAllocationID, m.UserID, m.AllocationAmount, 
m.StartTime, m.EndTime, string(m.MembershipStatus))
+       return err
+}
+
+func (s *mysqlComputeAllocationMembershipStore) Update(ctx context.Context, tx 
*sql.Tx, m *models.ComputeAllocationMembership) error {
+       _, err := tx.ExecContext(ctx,
+               `UPDATE compute_allocation_memberships
+                   SET compute_allocation_id = ?,
+                       user_id               = ?,
+                       allocation_amount     = ?,
+                       start_time            = ?,
+                       end_time              = ?,
+                       membership_status     = ?
+                 WHERE id = ?`,
+               m.ComputeAllocationID, m.UserID, m.AllocationAmount, 
m.StartTime, m.EndTime, string(m.MembershipStatus), m.ID)
+       return err
+}
+
+func (s *mysqlComputeAllocationMembershipStore) Delete(ctx context.Context, tx 
*sql.Tx, id string) error {
+       _, err := tx.ExecContext(ctx, `DELETE FROM 
compute_allocation_memberships WHERE id = ?`, id)
+       return err
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index 54a367c49..3498072a3 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -208,3 +208,25 @@ type ComputeAllocationChangeRequestEventStore interface {
        // Delete removes an event by ID within the provided transaction.
        Delete(ctx context.Context, tx *sql.Tx, id string) error
 }
+
+// ComputeAllocationMembershipStore defines persistence operations for the
+// per-user membership of a compute allocation, including the SU sub-allocation
+// granted to that user and the membership lifecycle.
+type ComputeAllocationMembershipStore interface {
+       // FindByID returns the membership with the given ID, or nil if it does 
not exist.
+       FindByID(ctx context.Context, id string) 
(*models.ComputeAllocationMembership, error)
+       // FindByPair returns the membership for a (allocation, user) pair, or 
nil if absent.
+       FindByPair(ctx context.Context, allocationID, userID string) 
(*models.ComputeAllocationMembership, error)
+       // FindByAllocation returns every membership recorded against the given
+       // allocation, ordered by start_time ascending.
+       FindByAllocation(ctx context.Context, allocationID string) 
([]models.ComputeAllocationMembership, error)
+       // FindByUser returns every membership held by the given user, ordered 
by
+       // start_time ascending.
+       FindByUser(ctx context.Context, userID string) 
([]models.ComputeAllocationMembership, error)
+       // Create inserts a new membership within the provided transaction.
+       Create(ctx context.Context, tx *sql.Tx, m 
*models.ComputeAllocationMembership) error
+       // Update replaces mutable fields of an existing membership within the 
provided transaction.
+       Update(ctx context.Context, tx *sql.Tx, m 
*models.ComputeAllocationMembership) error
+       // Delete removes a membership by ID within the provided transaction.
+       Delete(ctx context.Context, tx *sql.Tx, id string) error
+}
diff --git a/pkg/models/allocation.go b/pkg/models/allocation.go
index a6003f987..04bd11b4b 100644
--- a/pkg/models/allocation.go
+++ b/pkg/models/allocation.go
@@ -89,11 +89,11 @@ type ComputeAllocationUsage struct { // Represents the 
usage of a compute alloca
 }
 
 type ComputeAllocationMembership struct {
-       ID                  string           `json:"id"`
-       ComputeAllocationID string           `json:"compute_allocation_id"`
-       UserID              string           `json:"user_id"`
-       AllocationAmount    int64            `json:"allocation_amount"` // SUs 
allocated to the user, e.g., 100 CPU hours, 50 GPU hours, etc.
-       StartTime           time.Time        `json:"start_time"`
-       EndTime             time.Time        `json:"end_time"`
-       MembershipStatus    AllocationStatus `json:"membership_status"` // 
ACTIVE, INACTIVE, etc.
+       ID                  string           `json:"id"                    
db:"id"`
+       ComputeAllocationID string           `json:"compute_allocation_id" 
db:"compute_allocation_id"`
+       UserID              string           `json:"user_id"               
db:"user_id"`
+       AllocationAmount    int64            `json:"allocation_amount"     
db:"allocation_amount"` // SUs allocated to the user, e.g., 100 CPU hours, 50 
GPU hours, etc.
+       StartTime           time.Time        `json:"start_time"            
db:"start_time"`
+       EndTime             time.Time        `json:"end_time"              
db:"end_time"`
+       MembershipStatus    AllocationStatus `json:"membership_status"     
db:"membership_status"` // ACTIVE, INACTIVE, etc.
 }
diff --git a/pkg/service/compute_allocation_membership.go 
b/pkg/service/compute_allocation_membership.go
new file mode 100644
index 000000000..b010224c5
--- /dev/null
+++ b/pkg/service/compute_allocation_membership.go
@@ -0,0 +1,220 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+       "context"
+       "database/sql"
+       "fmt"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+// CreateComputeAllocationMembership grants a user a sub-allocation against a
+// compute allocation. Both the referenced allocation and user must exist, and
+// the (allocation, user) pair must not already have a membership.
+// MembershipStatus defaults to ACTIVE when unset.
+func (s *Service) CreateComputeAllocationMembership(ctx context.Context, m 
*models.ComputeAllocationMembership) (*models.ComputeAllocationMembership, 
error) {
+       if m == nil {
+               return nil, fmt.Errorf("%w: compute allocation membership is 
nil", ErrInvalidInput)
+       }
+       if m.ComputeAllocationID == "" {
+               return nil, fmt.Errorf("%w: compute_allocation_id is required", 
ErrInvalidInput)
+       }
+       if m.UserID == "" {
+               return nil, fmt.Errorf("%w: user_id is required", 
ErrInvalidInput)
+       }
+
+       if alloc, err := s.allocs.FindByID(ctx, m.ComputeAllocationID); err != 
nil {
+               return nil, fmt.Errorf("lookup compute allocation: %w", err)
+       } else if alloc == nil {
+               return nil, fmt.Errorf("%w: compute allocation %q not found", 
ErrInvalidInput, m.ComputeAllocationID)
+       }
+       if u, err := s.users.FindByID(ctx, m.UserID); err != nil {
+               return nil, fmt.Errorf("lookup user: %w", err)
+       } else if u == nil {
+               return nil, fmt.Errorf("%w: user %q not found", 
ErrInvalidInput, m.UserID)
+       }
+
+       if existing, err := s.memberships.FindByPair(ctx, 
m.ComputeAllocationID, m.UserID); err != nil {
+               return nil, fmt.Errorf("lookup compute allocation membership: 
%w", err)
+       } else if existing != nil {
+               return nil, fmt.Errorf("%w: user %q already has a membership on 
allocation %q", ErrAlreadyExists, m.UserID, m.ComputeAllocationID)
+       }
+
+       if m.ID == "" {
+               m.ID = newID()
+       }
+       if m.MembershipStatus == "" {
+               m.MembershipStatus = models.ACTIVE
+       }
+
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.memberships.Create(ctx, tx, m)
+       }); err != nil {
+               return nil, fmt.Errorf("create compute allocation membership: 
%w", err)
+       }
+       return m, nil
+}
+
+// GetComputeAllocationMembership retrieves a membership by its ID. Returns
+// ErrNotFound when no membership matches.
+func (s *Service) GetComputeAllocationMembership(ctx context.Context, id 
string) (*models.ComputeAllocationMembership, error) {
+       m, err := s.memberships.FindByID(ctx, id)
+       if err != nil {
+               return nil, fmt.Errorf("get compute allocation membership: %w", 
err)
+       }
+       if m == nil {
+               return nil, ErrNotFound
+       }
+       return m, nil
+}
+
+// ListMembersForAllocation returns every membership recorded against the
+// given allocation, ordered by start_time ascending.
+func (s *Service) ListMembersForAllocation(ctx context.Context, allocationID 
string) ([]models.ComputeAllocationMembership, error) {
+       if allocationID == "" {
+               return nil, fmt.Errorf("%w: compute_allocation_id is required", 
ErrInvalidInput)
+       }
+       rows, err := s.memberships.FindByAllocation(ctx, allocationID)
+       if err != nil {
+               return nil, fmt.Errorf("list members for allocation: %w", err)
+       }
+       return rows, nil
+}
+
+// ListAllocationsForUser returns every membership held by the given user,
+// ordered by start_time ascending.
+func (s *Service) ListAllocationsForUser(ctx context.Context, userID string) 
([]models.ComputeAllocationMembership, error) {
+       if userID == "" {
+               return nil, fmt.Errorf("%w: user_id is required", 
ErrInvalidInput)
+       }
+       rows, err := s.memberships.FindByUser(ctx, userID)
+       if err != nil {
+               return nil, fmt.Errorf("list allocations for user: %w", err)
+       }
+       return rows, nil
+}
+
+// UpdateComputeAllocationMembership replaces mutable fields of an existing
+// membership. Fields left blank/zero on the supplied record fall back to the
+// stored value (so callers can perform partial updates).
+func (s *Service) UpdateComputeAllocationMembership(ctx context.Context, m 
*models.ComputeAllocationMembership) (*models.ComputeAllocationMembership, 
error) {
+       if m == nil || m.ID == "" {
+               return nil, fmt.Errorf("%w: compute allocation membership id is 
required", ErrInvalidInput)
+       }
+       existing, err := s.memberships.FindByID(ctx, m.ID)
+       if err != nil {
+               return nil, fmt.Errorf("lookup compute allocation membership: 
%w", err)
+       }
+       if existing == nil {
+               return nil, ErrNotFound
+       }
+       if m.ComputeAllocationID == "" {
+               m.ComputeAllocationID = existing.ComputeAllocationID
+       }
+       if m.UserID == "" {
+               m.UserID = existing.UserID
+       }
+       if m.StartTime.IsZero() {
+               m.StartTime = existing.StartTime
+       }
+       if m.EndTime.IsZero() {
+               m.EndTime = existing.EndTime
+       }
+       if m.MembershipStatus == "" {
+               m.MembershipStatus = existing.MembershipStatus
+       }
+
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.memberships.Update(ctx, tx, m)
+       }); err != nil {
+               return nil, fmt.Errorf("update compute allocation membership: 
%w", err)
+       }
+       return m, nil
+}
+
+// UpdateMembershipAllocationAmount sets the SU sub-allocation granted to the
+// user identified by the given membership ID. Other fields are preserved.
+func (s *Service) UpdateMembershipAllocationAmount(ctx context.Context, id 
string, amount int64) (*models.ComputeAllocationMembership, error) {
+       if id == "" {
+               return nil, fmt.Errorf("%w: compute allocation membership id is 
required", ErrInvalidInput)
+       }
+       if amount < 0 {
+               return nil, fmt.Errorf("%w: allocation_amount must be 
non-negative", ErrInvalidInput)
+       }
+       existing, err := s.memberships.FindByID(ctx, id)
+       if err != nil {
+               return nil, fmt.Errorf("lookup compute allocation membership: 
%w", err)
+       }
+       if existing == nil {
+               return nil, ErrNotFound
+       }
+       existing.AllocationAmount = amount
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.memberships.Update(ctx, tx, existing)
+       }); err != nil {
+               return nil, fmt.Errorf("update compute allocation membership 
amount: %w", err)
+       }
+       return existing, nil
+}
+
+// UpdateMembershipStatus sets the lifecycle status (ACTIVE, INACTIVE, etc.)
+// of the membership identified by the given ID. Other fields are preserved.
+func (s *Service) UpdateMembershipStatus(ctx context.Context, id string, 
status models.AllocationStatus) (*models.ComputeAllocationMembership, error) {
+       if id == "" {
+               return nil, fmt.Errorf("%w: compute allocation membership id is 
required", ErrInvalidInput)
+       }
+       if status == "" {
+               return nil, fmt.Errorf("%w: membership_status is required", 
ErrInvalidInput)
+       }
+       existing, err := s.memberships.FindByID(ctx, id)
+       if err != nil {
+               return nil, fmt.Errorf("lookup compute allocation membership: 
%w", err)
+       }
+       if existing == nil {
+               return nil, ErrNotFound
+       }
+       existing.MembershipStatus = status
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.memberships.Update(ctx, tx, existing)
+       }); err != nil {
+               return nil, fmt.Errorf("update compute allocation membership 
status: %w", err)
+       }
+       return existing, nil
+}
+
+// DeleteComputeAllocationMembership removes a membership by ID.
+func (s *Service) DeleteComputeAllocationMembership(ctx context.Context, id 
string) error {
+       if id == "" {
+               return fmt.Errorf("%w: compute allocation membership id is 
required", ErrInvalidInput)
+       }
+       existing, err := s.memberships.FindByID(ctx, id)
+       if err != nil {
+               return fmt.Errorf("lookup compute allocation membership: %w", 
err)
+       }
+       if existing == nil {
+               return ErrNotFound
+       }
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.memberships.Delete(ctx, tx, id)
+       }); err != nil {
+               return fmt.Errorf("delete compute allocation membership: %w", 
err)
+       }
+       return nil
+}
diff --git a/pkg/service/service.go b/pkg/service/service.go
index 1942451be..e8145c0d5 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -45,6 +45,7 @@ type Service struct {
        allocDiffs       store.ComputeAllocationDiffStore
        changeRequests   store.ComputeAllocationChangeRequestStore
        changeEvents     store.ComputeAllocationChangeRequestEventStore
+       memberships      store.ComputeAllocationMembershipStore
 }
 
 // New constructs a Service backed by the supplied database handle.
@@ -63,6 +64,7 @@ func New(database *sqlx.DB) *Service {
                allocDiffs:       store.NewComputeAllocationDiffStore(database),
                changeRequests:   
store.NewComputeAllocationChangeRequestStore(database),
                changeEvents:     
store.NewComputeAllocationChangeRequestEventStore(database),
+               memberships:      
store.NewComputeAllocationMembershipStore(database),
        }
 }
 
@@ -82,6 +84,7 @@ func NewWithStores(
        allocDiffs store.ComputeAllocationDiffStore,
        changeRequests store.ComputeAllocationChangeRequestStore,
        changeEvents store.ComputeAllocationChangeRequestEventStore,
+       memberships store.ComputeAllocationMembershipStore,
 ) *Service {
        return &Service{
                db:               database,
@@ -96,6 +99,7 @@ func NewWithStores(
                allocDiffs:       allocDiffs,
                changeRequests:   changeRequests,
                changeEvents:     changeEvents,
+               memberships:      memberships,
        }
 }
 

Reply via email to