Skip to content

Commit 5c8f398

Browse files
committed
feat: add workspace bulk link read (#218)
1 parent e9dfb75 commit 5c8f398

6 files changed

Lines changed: 383 additions & 1 deletion

File tree

api/openapi.json

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13058,6 +13058,126 @@
1305813058
}
1305913059
},
1306013060
"/links": {
13061+
"get": {
13062+
"description": "Returns a paginated, ID-ordered dependency graph for one workspace. Only non-custom-field item-to-item links with both endpoints in that workspace are returned. Cross-workspace and non-item links are excluded so link metadata from another workspace or permission domain cannot leak.",
13063+
"parameters": [
13064+
{
13065+
"description": "Workspace ID",
13066+
"in": "query",
13067+
"name": "workspace_id",
13068+
"required": true,
13069+
"schema": {
13070+
"type": "integer"
13071+
}
13072+
},
13073+
{
13074+
"description": "Page number (1-based)",
13075+
"in": "query",
13076+
"name": "page",
13077+
"schema": {
13078+
"minimum": 1,
13079+
"type": "integer"
13080+
}
13081+
},
13082+
{
13083+
"description": "Links per page",
13084+
"in": "query",
13085+
"name": "limit",
13086+
"schema": {
13087+
"maximum": 100,
13088+
"minimum": 1,
13089+
"type": "integer"
13090+
}
13091+
}
13092+
],
13093+
"responses": {
13094+
"200": {
13095+
"content": {
13096+
"application/json": {
13097+
"schema": {
13098+
"allOf": [
13099+
{
13100+
"$ref": "#/components/schemas/internal_restapi_v1_handlers.PaginatedResponse"
13101+
},
13102+
{
13103+
"properties": {
13104+
"data": {
13105+
"items": {
13106+
"$ref": "#/components/schemas/models.ItemLink"
13107+
},
13108+
"type": "array"
13109+
}
13110+
},
13111+
"type": "object"
13112+
}
13113+
]
13114+
}
13115+
}
13116+
},
13117+
"description": "OK"
13118+
},
13119+
"400": {
13120+
"content": {
13121+
"application/json": {
13122+
"schema": {
13123+
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
13124+
}
13125+
}
13126+
},
13127+
"description": "Invalid workspace ID or pagination"
13128+
},
13129+
"401": {
13130+
"content": {
13131+
"application/json": {
13132+
"schema": {
13133+
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
13134+
}
13135+
}
13136+
},
13137+
"description": "Unauthorized"
13138+
},
13139+
"403": {
13140+
"content": {
13141+
"application/json": {
13142+
"schema": {
13143+
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
13144+
}
13145+
}
13146+
},
13147+
"description": "Token lacks the items:read scope"
13148+
},
13149+
"404": {
13150+
"content": {
13151+
"application/json": {
13152+
"schema": {
13153+
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
13154+
}
13155+
}
13156+
},
13157+
"description": "Workspace not found or not visible to caller"
13158+
},
13159+
"500": {
13160+
"content": {
13161+
"application/json": {
13162+
"schema": {
13163+
"$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse"
13164+
}
13165+
}
13166+
},
13167+
"description": "Internal Server Error"
13168+
}
13169+
},
13170+
"security": [
13171+
{
13172+
"BearerAuth": []
13173+
}
13174+
],
13175+
"summary": "List a workspace's direct work-item links",
13176+
"tags": [
13177+
"links",
13178+
"workspaces"
13179+
]
13180+
},
1306113181
"post": {
1306213182
"description": "Creates a link between two entities (item/page/test_case). The link type must allow the given entity types; pages must share a workspace with the source item.",
1306313183
"requestBody": {

api/openapi.yaml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8412,6 +8412,81 @@ paths:
84128412
tags:
84138413
- links
84148414
/links:
8415+
get:
8416+
description: Returns a paginated, ID-ordered dependency graph for one workspace.
8417+
Only non-custom-field item-to-item links with both endpoints in that workspace
8418+
are returned. Cross-workspace and non-item links are excluded so link metadata
8419+
from another workspace or permission domain cannot leak.
8420+
parameters:
8421+
- description: Workspace ID
8422+
in: query
8423+
name: workspace_id
8424+
required: true
8425+
schema:
8426+
type: integer
8427+
- description: Page number (1-based)
8428+
in: query
8429+
name: page
8430+
schema:
8431+
minimum: 1
8432+
type: integer
8433+
- description: Links per page
8434+
in: query
8435+
name: limit
8436+
schema:
8437+
maximum: 100
8438+
minimum: 1
8439+
type: integer
8440+
responses:
8441+
"200":
8442+
content:
8443+
application/json:
8444+
schema:
8445+
allOf:
8446+
- $ref: '#/components/schemas/internal_restapi_v1_handlers.PaginatedResponse'
8447+
- properties:
8448+
data:
8449+
items:
8450+
$ref: '#/components/schemas/models.ItemLink'
8451+
type: array
8452+
type: object
8453+
description: OK
8454+
"400":
8455+
content:
8456+
application/json:
8457+
schema:
8458+
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
8459+
description: Invalid workspace ID or pagination
8460+
"401":
8461+
content:
8462+
application/json:
8463+
schema:
8464+
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
8465+
description: Unauthorized
8466+
"403":
8467+
content:
8468+
application/json:
8469+
schema:
8470+
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
8471+
description: Token lacks the items:read scope
8472+
"404":
8473+
content:
8474+
application/json:
8475+
schema:
8476+
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
8477+
description: Workspace not found or not visible to caller
8478+
"500":
8479+
content:
8480+
application/json:
8481+
schema:
8482+
$ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse'
8483+
description: Internal Server Error
8484+
security:
8485+
- BearerAuth: []
8486+
summary: List a workspace's direct work-item links
8487+
tags:
8488+
- links
8489+
- workspaces
84158490
post:
84168491
description: Creates a link between two entities (item/page/test_case). The
84178492
link type must allow the given entity types; pages must share a workspace

internal/restapi/v1/handlers/links.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package handlers
22

33
import (
44
"errors"
5+
"math"
56
"net/http"
67
"strconv"
78
"strings"
@@ -48,6 +49,45 @@ type linkCreateRequest struct {
4849

4950
const maxBatchLinkItems = 100
5051

52+
// parseWorkspaceLinkPagination rejects page values whose offset would
53+
// overflow an int before the workspace link service performs any database
54+
// access. The shared parser remains permissive for legacy endpoints.
55+
func parseWorkspaceLinkPagination(r *http.Request) (restapi.PaginationParams, error) {
56+
query := r.URL.Query()
57+
if query.Has("sort") || query.Has("order") {
58+
return restapi.PaginationParams{}, errors.New("sort and order are not supported; links are ordered by ID ascending")
59+
}
60+
61+
pagination := restapi.PaginationParams{
62+
Page: restapi.DefaultPage,
63+
Limit: restapi.DefaultLimit,
64+
}
65+
limitRaw := strings.TrimSpace(query.Get("limit"))
66+
if query.Has("limit") {
67+
limit, err := strconv.Atoi(limitRaw)
68+
if err != nil || limit <= 0 || limit > restapi.MaxLimit {
69+
return restapi.PaginationParams{}, errors.New("limit must be an integer between 1 and 100")
70+
}
71+
pagination.Limit = limit
72+
}
73+
pageRaw := strings.TrimSpace(query.Get("page"))
74+
if !query.Has("page") {
75+
pagination.Offset = 0
76+
return pagination, nil
77+
}
78+
79+
page, err := strconv.Atoi(pageRaw)
80+
if err != nil || page <= 0 {
81+
return restapi.PaginationParams{}, errors.New("page must be a positive integer")
82+
}
83+
if page-1 > math.MaxInt/pagination.Limit {
84+
return restapi.PaginationParams{}, errors.New("page is too large")
85+
}
86+
pagination.Page = page
87+
pagination.Offset = (page - 1) * pagination.Limit
88+
return pagination, nil
89+
}
90+
5191
type batchItemLinksResponse struct {
5292
ItemID int `json:"item_id"`
5393
Outgoing []models.ItemLink `json:"outgoing"`
@@ -218,6 +258,49 @@ func (h *LinkHandler) GetLinksBatch(w http.ResponseWriter, r *http.Request) {
218258
h.RespondPaginated(w, response, pagination, total)
219259
}
220260

261+
// ListWorkspaceItemLinks handles GET /rest/api/v1/links?workspace_id={id}.
262+
//
263+
// @Summary List a workspace's direct work-item links
264+
// @Description Returns a paginated, ID-ordered dependency graph for one workspace. Only non-custom-field item-to-item links with both endpoints in that workspace are returned. Cross-workspace and non-item links are excluded so link metadata from another workspace or permission domain cannot leak.
265+
// @Tags links, workspaces
266+
// @Produce json
267+
// @Security BearerAuth
268+
// @Param workspace_id query int true "Workspace ID"
269+
// @Param page query int false "Page number (1-based)" minimum(1)
270+
// @Param limit query int false "Links per page" minimum(1) maximum(100)
271+
// @Success 200 {object} handlers.PaginatedResponse{data=[]models.ItemLink}
272+
// @Failure 400 {object} handlers.ErrorResponse "Invalid workspace ID or pagination"
273+
// @Failure 401 {object} handlers.ErrorResponse
274+
// @Failure 403 {object} handlers.ErrorResponse "Token lacks the items:read scope"
275+
// @Failure 404 {object} handlers.ErrorResponse "Workspace not found or not visible to caller"
276+
// @Failure 500 {object} handlers.ErrorResponse
277+
// @Router /links [get]
278+
func (h *LinkHandler) ListWorkspaceItemLinks(w http.ResponseWriter, r *http.Request) {
279+
user, ok := h.RequireAuth(w, r)
280+
if !ok {
281+
return
282+
}
283+
workspaceID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("workspace_id")))
284+
if err != nil || workspaceID <= 0 {
285+
h.RespondError(w, r, restapi.NewAPIError(http.StatusBadRequest, restapi.ErrCodeInvalidInput, "workspace_id must be a positive integer"))
286+
return
287+
}
288+
pagination, err := parseWorkspaceLinkPagination(r)
289+
if err != nil {
290+
h.RespondError(w, r, restapi.NewAPIError(http.StatusBadRequest, restapi.ErrCodeInvalidInput, err.Error()))
291+
return
292+
}
293+
294+
links, total, err := h.svc.ListWorkspaceItemLinksWithChecks(
295+
r.Context(), user.ID, workspaceID, pagination.Limit, pagination.Offset,
296+
)
297+
if err != nil {
298+
h.respondLinkServiceError(w, r, "workspace", err)
299+
return
300+
}
301+
h.RespondPaginated(w, links, pagination, total)
302+
}
303+
221304
// CreateLink handles POST /rest/api/v1/links
222305
//
223306
// CreateLink creates a cross-entity link. Scope: items:write. The

internal/restapi/v1/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ func RegisterRoutes(deps restapi.Deps) {
301301

302302
v1.HandleWithMiddleware("GET /link-types", linkHandler.ListLinkTypes, bearerAuth.RequirePermission("items:read"))
303303
v1.HandleWithMiddleware("GET /links/batch", linkHandler.GetLinksBatch, bearerAuth.RequirePermission("items:read"))
304+
v1.HandleWithMiddleware("GET /links", linkHandler.ListWorkspaceItemLinks, bearerAuth.RequirePermission("items:read"))
304305
v1.HandleWithMiddleware("POST /links", linkHandler.CreateLink, bearerAuth.RequirePermission("items:write"))
305306
v1.HandleWithMiddleware("DELETE /links/{id}", linkHandler.DeleteLink, bearerAuth.RequirePermission("items:write"), router.RequireNumericID)
306307
v1.HandleWithMiddleware("GET /items/{id}/links", linkHandler.GetLinksForEntity, bearerAuth.RequirePermission("items:read"), router.RequireNumericID)

internal/services/item_link_orchestration.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,13 @@ func getLinksWhereContext(ctx context.Context, db database.Database, whereClause
12041204
}
12051205

12061206
func itemLinksWhereQuery(whereClause string) string {
1207+
return itemLinksWhereQueryWithOrder(whereClause, "lt.name, il.created_at DESC")
1208+
}
1209+
1210+
// itemLinksWhereQueryWithOrder shares the joined response projection between
1211+
// link reads that need different stable orderings. orderBy is always supplied
1212+
// by service-owned constants, never request input.
1213+
func itemLinksWhereQueryWithOrder(whereClause, orderBy string) string {
12071214
return `
12081215
SELECT il.id, il.link_type_id, il.source_type, il.source_id, il.target_type, il.target_id,
12091216
il.created_by, il.created_at,
@@ -1256,7 +1263,7 @@ func itemLinksWhereQuery(whereClause string) string {
12561263
LEFT JOIN workspaces tpw ON tp.workspace_id = tpw.id
12571264
LEFT JOIN custom_field_definitions cfd ON il.custom_field_id = cfd.id
12581265
WHERE ` + whereClause + `
1259-
ORDER BY lt.name, il.created_at DESC
1266+
ORDER BY ` + orderBy + `
12601267
`
12611268
}
12621269

0 commit comments

Comments
 (0)