Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions apps/csm-portal/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ func main() {
route("GET /team-schedule/catalogue", handler.PermView, scheduleHandler.GetScheduleCatalogue)
route("POST /team-schedule/assignments/search", handler.PermView, scheduleHandler.SearchScheduleAssignments)
route("POST /team-schedule/absences/search", handler.PermView, scheduleHandler.SearchScheduleAbsences)
route("POST /team-schedule/engagement-allocations/search", handler.PermView, scheduleHandler.SearchScheduleEngagementAllocations)
route("GET /team-schedule/on-duty", handler.PermView, scheduleHandler.GetScheduleOnDuty)

// Lead edit. PermWrite keeps the control away from a caller who could not
Expand Down
8 changes: 8 additions & 0 deletions apps/csm-portal/backend/internal/entity/customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,14 @@ func (c *CustomerEntityClient) SearchScheduleAbsences(ctx context.Context, body
return c.do(ctx, http.MethodPost, "/team-schedule/absences/search", body)
}

// SearchScheduleEngagementAllocations calls
// POST /team-schedule/engagement-allocations/search on the entity service: the
// confirmed customer engagement allocations of the given users over a date
// window. Response is returned as raw JSON.
func (c *CustomerEntityClient) SearchScheduleEngagementAllocations(ctx context.Context, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPost, "/team-schedule/engagement-allocations/search", body)
}

// GetScheduleOnDuty calls GET /team-schedule/on-duty on the entity service, passing
// the optional `at` instant through unchanged. Response is raw JSON.
func (c *CustomerEntityClient) GetScheduleOnDuty(ctx context.Context, at string) ([]byte, error) {
Expand Down
22 changes: 22 additions & 0 deletions apps/csm-portal/backend/internal/handler/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type entityScheduleClient interface {
GetScheduleCatalogue(ctx context.Context) ([]byte, error)
SearchScheduleAssignments(ctx context.Context, body []byte) ([]byte, error)
SearchScheduleAbsences(ctx context.Context, body []byte) ([]byte, error)
SearchScheduleEngagementAllocations(ctx context.Context, body []byte) ([]byte, error)
GetScheduleOnDuty(ctx context.Context, at string) ([]byte, error)

// Lead edit. The portal proxies these unchanged -- entity-service is where
Expand Down Expand Up @@ -167,6 +168,27 @@ func (h *ScheduleHandler) SearchScheduleAbsences(w http.ResponseWriter, r *http.
writeJSON(w, http.StatusOK, result)
}

// SearchScheduleEngagementAllocations handles
// POST /team-schedule/engagement-allocations/search: the confirmed customer
// engagement allocations of the given engineers over a date window, shown
// read-only on the schedule. The body (userIds, from, to) is validated by
// entity-service and forwarded unchanged; the response is returned as sent.
func (h *ScheduleHandler) SearchScheduleEngagementAllocations(w http.ResponseWriter, r *http.Request) {
body, userID, ok := readScheduleBody(w, r)
if !ok {
return
}

result, err := h.entity.SearchScheduleEngagementAllocations(r.Context(), body)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchScheduleEngagementAllocations failed", "userID", userID, "err", err)
mapUpstreamErrorGeneric(w, err, "Failed to search engagement allocations.")
return
}

writeJSON(w, http.StatusOK, result)
}

// GetScheduleOnDuty handles GET /team-schedule/on-duty[?at=RFC3339] -- who is
// responsible at this instant. The `at` parameter is passed through unchanged;
// the entity service validates it.
Expand Down
99 changes: 99 additions & 0 deletions apps/csm-portal/backend/internal/handler/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
type mockEntityScheduleClient struct {
catalogueFn func(ctx context.Context) ([]byte, error)
assignmentsFn func(ctx context.Context, body []byte) ([]byte, error)
allocationsFn func(ctx context.Context, body []byte) ([]byte, error)
absencesFn func(ctx context.Context, body []byte) ([]byte, error)
onDutyFn func(ctx context.Context, at string) ([]byte, error)

Expand Down Expand Up @@ -185,6 +186,13 @@ func (m *mockEntityScheduleClient) SearchScheduleAbsences(ctx context.Context, b
return []byte(`{"absences":[],"count":0}`), nil
}

func (m *mockEntityScheduleClient) SearchScheduleEngagementAllocations(ctx context.Context, body []byte) ([]byte, error) {
if m.allocationsFn != nil {
return m.allocationsFn(ctx, body)
}
return []byte(`{"allocations":[],"count":0}`), nil
}

func (m *mockEntityScheduleClient) GetScheduleOnDuty(ctx context.Context, at string) ([]byte, error) {
if m.onDutyFn != nil {
return m.onDutyFn(ctx, at)
Expand Down Expand Up @@ -319,6 +327,97 @@ func TestGetScheduleOnDuty(t *testing.T) {
})
}

func TestSearchScheduleEngagementAllocations(t *testing.T) {
const path = "/team-schedule/engagement-allocations/search"
const payload = `{"userIds":["00000000-0000-0000-0000-000000000000"],"from":"2026-10-19","to":"2026-10-25"}`

t.Run("requires an authenticated user", func(t *testing.T) {
h := NewScheduleHandler(&mockEntityScheduleClient{})
w := httptest.NewRecorder()
h.SearchScheduleEngagementAllocations(w, httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)))
assertStatus(t, w, http.StatusUnauthorized)
assertErrorMessage(t, w, ErrMsgUnauthorized)
})

t.Run("rejects a body that is not JSON", func(t *testing.T) {
h := NewScheduleHandler(&mockEntityScheduleClient{})
w := httptest.NewRecorder()
h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path, strings.NewReader(`not-json`))))
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgBadRequest)
})

t.Run("rejects a body over the size limit", func(t *testing.T) {
h := NewScheduleHandler(&mockEntityScheduleClient{})
w := httptest.NewRecorder()
h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path,
strings.NewReader(strings.Repeat("x", maxRequestBodyBytes+1)))))
assertStatus(t, w, http.StatusRequestEntityTooLarge)
})

t.Run("forwards the search unchanged and returns the response as sent", func(t *testing.T) {
const upstream = `{"allocations":[{"id":"00000000-0000-0000-0000-000000000001","userId":"00000000-0000-0000-0000-000000000000","engagementId":"00000000-0000-0000-0000-000000000002","startDate":"2026-10-19","endDate":"2026-10-21","accountName":"Example Corp","engagementType":"CONSULTANCY","modeOfDelivery":"ONSITE","utilization":50}],"count":1}`
var captured []byte
h := NewScheduleHandler(&mockEntityScheduleClient{
allocationsFn: func(_ context.Context, body []byte) ([]byte, error) {
captured = body
return []byte(upstream), nil
},
})
w := httptest.NewRecorder()
h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))))

assertStatus(t, w, http.StatusOK)
assertContentType(t, w, "application/json")
if string(captured) != payload {
t.Fatalf("body was altered in transit:\n got %s\nwant %s", captured, payload)
}
if w.Body.String() != upstream {
t.Fatalf("response was altered (enums must stay raw):\n got %s\nwant %s", w.Body.String(), upstream)
}
})

t.Run("an empty result is an empty list, not null", func(t *testing.T) {
h := NewScheduleHandler(&mockEntityScheduleClient{})
w := httptest.NewRecorder()
h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))))
assertStatus(t, w, http.StatusOK)
if !strings.Contains(w.Body.String(), `"allocations":[]`) || strings.Contains(w.Body.String(), "null") {
t.Fatalf("want an empty list, got %s", w.Body.String())
}
})

t.Run("maps upstream failures", func(t *testing.T) {
for _, c := range []struct {
name string
err error
wantStatus int
}{
{"400 from the window or id limits", &apierror.Error{StatusCode: http.StatusBadRequest, Body: `{"message":"window too large"}`}, http.StatusBadRequest},
{"403 stays forbidden", &apierror.Error{StatusCode: http.StatusForbidden, Body: `{"message":"no"}`}, http.StatusForbidden},
{"500 stays generic", &apierror.Error{StatusCode: http.StatusInternalServerError, Body: `{"message":"pq: relation does not exist"}`}, http.StatusInternalServerError},
{"transport error is not a 200", errors.New("upstream is down"), 0},
} {
t.Run(c.name, func(t *testing.T) {
h := NewScheduleHandler(&mockEntityScheduleClient{
allocationsFn: func(context.Context, []byte) ([]byte, error) { return nil, c.err },
})
w := httptest.NewRecorder()
h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))))
if w.Code == http.StatusOK {
t.Fatal("an upstream error must not come back as 200")
}
if c.wantStatus != 0 {
assertStatus(t, w, c.wantStatus)
}
if strings.Contains(w.Body.String(), "pq:") {
t.Fatalf("internal detail leaked: %s", w.Body.String())
}
})
}
})
}

func TestSearchScheduleAbsences(t *testing.T) {
t.Run("forwards the search and returns the response", func(t *testing.T) {
h := NewScheduleHandler(&mockEntityScheduleClient{
Expand Down
84 changes: 84 additions & 0 deletions apps/csm-portal/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14248,6 +14248,90 @@ paths:
schema:
$ref: '#/components/schemas/ErrorPayload'

/team-schedule/engagement-allocations/search:
post:
summary: Search confirmed customer engagement allocations.
operationId: searchTeamScheduleEngagementAllocations
description: >
The confirmed customer engagement allocations of the given engineers
over a date window, shown read-only on the team schedule. Body is
validated by the entity service and forwarded unchanged; the response
is returned as sent, with enum values raw (the caller formats them).
`userIds` are user ids as found on a schedule assignment's
`engineer.userId`. Optional fields are omitted when empty.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [userIds, from, to]
properties:
userIds:
type: array
minItems: 1
maxItems: 500
items: { type: string, format: uuid }
from: { type: string, format: date, description: First day of the window, inclusive. }
to: { type: string, format: date, description: "Last day of the window, inclusive; at most 70 days after `from`." }
responses:
"200":
description: Ok.
content:
application/json:
schema:
type: object
required: [allocations, count]
properties:
allocations:
type: array
items:
type: object
required: [id, userId, engagementId, startDate, endDate]
properties:
id: { type: string, format: uuid }
userId: { type: string, format: uuid }
engagementId: { type: string, format: uuid }
startDate: { type: string, format: date }
endDate: { type: string, format: date }
allocationId: { type: string }
engagementDisplayId: { type: string }
accountName: { type: string }
engagementType: { type: string, description: "Raw enum value, for example CONSULTANCY." }
modeOfDelivery: { type: string, description: "Raw enum value, ONSITE or OFFSITE." }
utilization: { type: number }
count: { type: integer }
"400":
description: Bad request.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorPayload'
"401":
description: Unauthorized.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorPayload'
"403":
description: Forbidden.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorPayload'
"404":
description: Not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorPayload'
"500":
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorPayload'

/team-schedule/on-duty:
get:
summary: Who is on duty at an instant.
Expand Down
Loading
Loading