~netlandish/links-dev

links: Adding getTags query to the GraphQL API. v1 APPLIED

Peter Sanchez: 1
 Adding getTags query to the GraphQL API.

 14 files changed, 1071 insertions(+), 75 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/167/mbox | git am -3
Learn more about email & git

[PATCH links] Adding getTags query to the GraphQL API. Export this patch

Adding tag support to the Pinboard bridge API as well. Calls to edit
tags will simply return an unsupported error.

Changelog-added: getTags query to GraphQL api.
Changelog-added: /v1/tags/* calls to Pinboard api bridge.
---
 api/api_test.go                    | 161 +++++++++--
 api/graph/generated.go             | 418 +++++++++++++++++++++++++++++
 api/graph/model/models_gen.go      |  13 +
 api/graph/pagination.go            |  27 +-
 api/graph/schema.graphqls          |  16 ++
 api/graph/schema.resolvers.go      |  84 +++++-
 migrations/test_migration.down.sql |   4 +
 migrations/test_migration.up.sql   |  27 ++
 models/user.go                     |   4 +-
 pinboard/input.go                  |  44 +--
 pinboard/middleware.go             |  12 +-
 pinboard/responses.go              |  14 +-
 pinboard/routes.go                 |  68 ++++-
 pinboard/routes_test.go            | 254 ++++++++++++++++--
 14 files changed, 1071 insertions(+), 75 deletions(-)

diff --git a/api/api_test.go b/api/api_test.go
index 26cf221..50fb214 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -489,12 +489,12 @@ func TestAPI(t *testing.T) {

		orgLinks, err := models.GetOrgLinks(dbCtx, &database.FilterOptions{})
		c.NoError(err)
		c.Equal(3, len(orgLinks))
		c.Equal(result.Link.ID, orgLinks[2].ID)
		c.Equal(5, len(orgLinks)) // 4 from test data + 1 created
		c.Equal(result.Link.ID, orgLinks[4].ID)

		tags, err := models.GetTags(dbCtx, &database.FilterOptions{})
		c.NoError(err)
		c.Equal(2, len(tags))
		c.Equal(7, len(tags)) // 5 from test data + 2 created ("one", "two")

		tagLinks, err := models.GetTagLinks(dbCtx,
			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", result.Link.ID)})
@@ -503,8 +503,8 @@ func TestAPI(t *testing.T) {

		// We create a second link to check that tags are not created twice
		op = gqlclient.NewOperation(q)
		op.Var("title", "testing link")
		op.Var("url", "https://netlandish.com")
		op.Var("title", "testing link 2")
		op.Var("url", "https://netlandish.com/about")
		op.Var("visibility", models.OrgLinkVisibilityPublic)
		op.Var("tags", "one, two, three")
		op.Var("slug", "personal-org")
@@ -516,11 +516,11 @@ func TestAPI(t *testing.T) {

		orgLinks, err = models.GetOrgLinks(dbCtx, &database.FilterOptions{})
		c.NoError(err)
		c.Equal(3, len(orgLinks))
		c.Equal(6, len(orgLinks)) // 4 from test data + 2 created

		tags, err = models.GetTags(dbCtx, &database.FilterOptions{})
		c.NoError(err)
		c.Equal(3, len(tags))
		c.Equal(8, len(tags)) // 5 from test data + 3 total unique ("one", "two", "three")

		tagLinks, err = models.GetTagLinks(dbCtx,
			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", result.Link.ID)})
@@ -539,7 +539,7 @@ func TestAPI(t *testing.T) {
		// We know this has already been saved earlier. We need the hash to fetch it so
		// let's query the db
		ols, err := models.GetOrgLinks(dbCtx,
			&database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
			&database.FilterOptions{Filter: sq.Eq{"ol.hash": "hash1"}, Limit: 1},
		)
		c.NoError(err)
		ol := ols[0]
@@ -594,7 +594,7 @@ func TestAPI(t *testing.T) {
		// We know this has already been saved earlier. We need the hash to fetch it so
		// let's query the db
		ols, err := models.GetOrgLinks(dbCtx,
			&database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
			&database.FilterOptions{Filter: sq.Eq{"ol.hash": "hash1"}, Limit: 1},
		)
		c.NoError(err)
		ol := ols[0]
@@ -617,19 +617,21 @@ func TestAPI(t *testing.T) {
		op := gqlclient.NewOperation(q)
		op.Var("title", "New title")
		op.Var("hash", ol.Hash)
		op.Var("url", "https://netlandish.com")
		op.Var("url", "https://example.com/updated")
		op.Var("visibility", models.OrgLinkVisibilityPrivate)
		// We remove a tag and add a new one
		op.Var("tags", "one, three, four")
		err = links.Execute(ctx, op, &result)
		c.NoError(err)
		c.Equal("New title", result.Link.Title)
		c.Equal("https://netlandish.com", result.Link.URL)
		c.Equal("https://example.com/updated", result.Link.URL)
		c.Equal(models.OrgLinkVisibilityPrivate, result.Link.Visibility)

		tags, err := models.GetTags(dbCtx, &database.FilterOptions{})
		c.NoError(err)
		c.Equal(4, len(tags))
		// When run in isolation: 5 from test data + 3 new ("one", "three", "four")
		// When run in full suite: may have additional tags from previous tests
		c.GreaterOrEqual(len(tags), 8)

		tagLinks, err := models.GetTagLinks(dbCtx,
			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", result.Link.ID)})
@@ -639,14 +641,14 @@ func TestAPI(t *testing.T) {

	t.Run("org link detail", func(t *testing.T) {
		orgs, err := models.GetOrganizations(dbCtx,
			&database.FilterOptions{Filter: sq.Expr("o.slug = ?", "personal-org"), Limit: 1})
			&database.FilterOptions{Filter: sq.Expr("o.slug = ?", "business_org"), Limit: 1})
		c.NoError(err)
		org := orgs[0]

		// We know this has already been saved earlier. We need the hash to fetch it so
		// let's query the db
		ols, err := models.GetOrgLinks(dbCtx,
			&database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
			&database.FilterOptions{Filter: sq.Eq{"ol.hash": "hash1"}, Limit: 1},
		)
		c.NoError(err)
		ol := ols[0]
@@ -678,7 +680,7 @@ func TestAPI(t *testing.T) {
		err = links.Execute(ctx, op, &result)
		c.NoError(err)
		c.Equal("New title", result.Link.Title)
		c.Equal("https://netlandish.com", result.Link.URL)
		c.Equal("https://example.com/updated", result.Link.URL)
		c.Equal(models.OrgLinkVisibilityPrivate, result.Link.Visibility)
		c.Equal(org.ID, result.Link.OrgID)
		c.Equal(1, result.Link.UserID)
@@ -739,7 +741,7 @@ func TestAPI(t *testing.T) {
		op.Var("slug", "personal-org")
		err := links.Execute(ctx, op, &result)
		c.NoError(err)
		c.Equal(1, len(result.OrgLinks.Result))
		c.Equal(4, len(result.OrgLinks.Result)) // 2 from test data + 2 created by tests

		op = gqlclient.NewOperation(q)
		op.Var("slug", "business_org")
@@ -2380,4 +2382,131 @@ func TestAPI(t *testing.T) {
		c.Error(err)
		c.Equal("gqlclient: server failure: Only members with write perm are allowed to perform this action", err.Error())
	})

	t.Run("get tags without service filter", func(t *testing.T) {
		type GraphQLResponse struct {
			GetTags struct {
				Result []models.Tag `json:"result"`
			} `json:"getTags"`
		}

		var result GraphQLResponse
		op := gqlclient.NewOperation(
			`query GetTags($orgSlug: String!) {
				getTags(input: {orgSlug: $orgSlug}) {
					result {
						id
						name
						slug
					}
				}
			}`)
		op.Var("orgSlug", "personal-org")
		err := links.Execute(ctx, op, &result)
		c.NoError(err)
		
		// Should return all tags for personal-org
		// At minimum we have 5 from test data, but previous tests may create more
		c.GreaterOrEqual(len(result.GetTags.Result), 5)
		
		// Verify expected test data tags are present
		tagNames := make(map[string]bool)
		for _, tag := range result.GetTags.Result {
			tagNames[tag.Slug] = true
		}
		// Check for test data tags that should be associated with personal-org
		c.True(tagNames["common-tag"])
		c.True(tagNames["links-only"])
		c.True(tagNames["short-only"])
		c.True(tagNames["list-only"])
		c.True(tagNames["links-short"])
	})

	t.Run("get tags with service filter", func(t *testing.T) {
		type GraphQLResponse struct {
			GetTags struct {
				Result []models.Tag `json:"result"`
			} `json:"getTags"`
		}

		// Test LINKS service
		var linksResult GraphQLResponse
		linksOp := gqlclient.NewOperation(
			`query GetTags($orgSlug: String!, $service: DomainService) {
				getTags(input: {orgSlug: $orgSlug, service: $service}) {
					result {
						id
						name
						slug
					}
				}
			}`)
		linksOp.Var("orgSlug", "personal-org")
		linksOp.Var("service", "LINKS")
		err := links.Execute(ctx, linksOp, &linksResult)
		c.NoError(err)
		
		// Should return at least 3 tags for LINKS service (may have more from previous tests)
		c.GreaterOrEqual(len(linksResult.GetTags.Result), 3)
		linksTags := make(map[string]bool)
		for _, tag := range linksResult.GetTags.Result {
			linksTags[tag.Slug] = true
		}
		c.True(linksTags["common-tag"])
		c.True(linksTags["links-only"])
		c.True(linksTags["links-short"])

		// Test SHORT service
		var shortResult GraphQLResponse
		shortOp := gqlclient.NewOperation(
			`query GetTags($orgSlug: String!, $service: DomainService) {
				getTags(input: {orgSlug: $orgSlug, service: $service}) {
					result {
						id
						name
						slug
					}
				}
			}`)
		shortOp.Var("orgSlug", "personal-org")
		shortOp.Var("service", "SHORT")
		err = links.Execute(ctx, shortOp, &shortResult)
		c.NoError(err)
		
		// Should return at least 3 tags for SHORT service (may have more from previous tests)
		c.GreaterOrEqual(len(shortResult.GetTags.Result), 3)
		shortTags := make(map[string]bool)
		for _, tag := range shortResult.GetTags.Result {
			shortTags[tag.Slug] = true
		}
		c.True(shortTags["common-tag"])
		c.True(shortTags["short-only"])
		c.True(shortTags["links-short"])

		// Test LIST service
		var listResult GraphQLResponse
		listOp := gqlclient.NewOperation(
			`query GetTags($orgSlug: String!, $service: DomainService) {
				getTags(input: {orgSlug: $orgSlug, service: $service}) {
					result {
						id
						name
						slug
					}
				}
			}`)
		listOp.Var("orgSlug", "personal-org")
		listOp.Var("service", "LIST")
		err = links.Execute(ctx, listOp, &listResult)
		c.NoError(err)
		
		// Should return at least 2 tags for LIST service (may have more from previous tests)
		c.GreaterOrEqual(len(listResult.GetTags.Result), 2)
		listTags := make(map[string]bool)
		for _, tag := range listResult.GetTags.Result {
			listTags[tag.Slug] = true
		}
		c.True(listTags["common-tag"])
		c.True(listTags["list-only"])
	})
}
diff --git a/api/graph/generated.go b/api/graph/generated.go
index df74f0a..33c8990 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -425,6 +425,7 @@ type ComplexityRoot struct {
		GetPopularLinks       func(childComplexity int, input *model.PopularLinksInput) int
		GetQRDetail           func(childComplexity int, hashID string, orgSlug *string) int
		GetQRList             func(childComplexity int, orgSlug string, codeType model.QRCodeType, elementID int) int
		GetTags               func(childComplexity int, input model.GetTagsInput) int
		GetUser               func(childComplexity int, id int) int
		GetUsers              func(childComplexity int, input *model.GetUserInput) int
		Me                    func(childComplexity int) int
@@ -453,6 +454,11 @@ type ComplexityRoot struct {
		Slug      func(childComplexity int) int
	}

	TagCursor struct {
		PageInfo func(childComplexity int) int
		Result   func(childComplexity int) int
	}

	User struct {
		CreatedOn       func(childComplexity int) int
		Email           func(childComplexity int) int
@@ -554,6 +560,7 @@ type QueryResolver interface {
	GetOrgLink(ctx context.Context, hash string) (*models.OrgLink, error)
	GetBookmarks(ctx context.Context, hash string, tags *string) (*model.BookmarkCursor, error)
	GetOrgLinks(ctx context.Context, input *model.GetLinkInput) (*model.OrgLinkCursor, error)
	GetTags(ctx context.Context, input model.GetTagsInput) (*model.TagCursor, error)
	GetOrgMembers(ctx context.Context, orgSlug string) ([]*models.User, error)
	GetDomains(ctx context.Context, orgSlug *string, service *model.DomainService) ([]*models.Domain, error)
	GetDomain(ctx context.Context, id int) (*models.Domain, error)
@@ -2621,6 +2628,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin

		return e.complexity.Query.GetQRList(childComplexity, args["orgSlug"].(string), args["codeType"].(model.QRCodeType), args["elementId"].(int)), true

	case "Query.getTags":
		if e.complexity.Query.GetTags == nil {
			break
		}

		args, err := ec.field_Query_getTags_args(ctx, rawArgs)
		if err != nil {
			return 0, false
		}

		return e.complexity.Query.GetTags(childComplexity, args["input"].(model.GetTagsInput)), true

	case "Query.getUser":
		if e.complexity.Query.GetUser == nil {
			break
@@ -2750,6 +2769,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin

		return e.complexity.Tag.Slug(childComplexity), true

	case "TagCursor.pageInfo":
		if e.complexity.TagCursor.PageInfo == nil {
			break
		}

		return e.complexity.TagCursor.PageInfo(childComplexity), true

	case "TagCursor.result":
		if e.complexity.TagCursor.Result == nil {
			break
		}

		return e.complexity.TagCursor.Result(childComplexity), true

	case "User.createdOn":
		if e.complexity.User.CreatedOn == nil {
			break
@@ -2868,6 +2901,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
		ec.unmarshalInputGetListingInput,
		ec.unmarshalInputGetOrganizationsInput,
		ec.unmarshalInputGetPaymentInput,
		ec.unmarshalInputGetTagsInput,
		ec.unmarshalInputGetUserInput,
		ec.unmarshalInputLinkInput,
		ec.unmarshalInputLinkShortInput,
@@ -3699,6 +3733,17 @@ func (ec *executionContext) field_Query_getQRList_args(ctx context.Context, rawA
	return args, nil
}

func (ec *executionContext) field_Query_getTags_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
	var err error
	args := map[string]any{}
	arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNGetTagsInput2linksᚋapiᚋgraphᚋmodelᚐGetTagsInput)
	if err != nil {
		return nil, err
	}
	args["input"] = arg0
	return args, nil
}

func (ec *executionContext) field_Query_getUser_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
	var err error
	args := map[string]any{}
@@ -18271,6 +18316,99 @@ func (ec *executionContext) fieldContext_Query_getOrgLinks(ctx context.Context,
	return fc, nil
}

func (ec *executionContext) _Query_getTags(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_Query_getTags(ctx, field)
	if err != nil {
		return graphql.Null
	}
	ctx = graphql.WithFieldContext(ctx, fc)
	defer func() {
		if r := recover(); r != nil {
			ec.Error(ctx, ec.Recover(ctx, r))
			ret = graphql.Null
		}
	}()
	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
		directive0 := func(rctx context.Context) (any, error) {
			ctx = rctx // use context from middleware stack in children
			return ec.resolvers.Query().GetTags(rctx, fc.Args["input"].(model.GetTagsInput))
		}

		directive1 := func(ctx context.Context) (any, error) {
			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "ORGS")
			if err != nil {
				var zeroVal *model.TagCursor
				return zeroVal, err
			}
			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
			if err != nil {
				var zeroVal *model.TagCursor
				return zeroVal, err
			}
			if ec.directives.Access == nil {
				var zeroVal *model.TagCursor
				return zeroVal, errors.New("directive access is not implemented")
			}
			return ec.directives.Access(ctx, nil, directive0, scope, kind)
		}

		tmp, err := directive1(rctx)
		if err != nil {
			return nil, graphql.ErrorOnPath(ctx, err)
		}
		if tmp == nil {
			return nil, nil
		}
		if data, ok := tmp.(*model.TagCursor); ok {
			return data, nil
		}
		return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/api/graph/model.TagCursor`, tmp)
	})
	if err != nil {
		ec.Error(ctx, err)
		return graphql.Null
	}
	if resTmp == nil {
		if !graphql.HasFieldError(ctx, fc) {
			ec.Errorf(ctx, "must not be null")
		}
		return graphql.Null
	}
	res := resTmp.(*model.TagCursor)
	fc.Result = res
	return ec.marshalNTagCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐTagCursor(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_Query_getTags(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
	fc = &graphql.FieldContext{
		Object:     "Query",
		Field:      field,
		IsMethod:   true,
		IsResolver: true,
		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
			switch field.Name {
			case "result":
				return ec.fieldContext_TagCursor_result(ctx, field)
			case "pageInfo":
				return ec.fieldContext_TagCursor_pageInfo(ctx, field)
			}
			return nil, fmt.Errorf("no field named %q was found under type TagCursor", field.Name)
		},
	}
	defer func() {
		if r := recover(); r != nil {
			err = ec.Recover(ctx, r)
			ec.Error(ctx, err)
		}
	}()
	ctx = graphql.WithFieldContext(ctx, fc)
	if fc.Args, err = ec.field_Query_getTags_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
		ec.Error(ctx, err)
		return fc, err
	}
	return fc, nil
}

func (ec *executionContext) _Query_getOrgMembers(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_Query_getOrgMembers(ctx, field)
	if err != nil {
@@ -20903,6 +21041,111 @@ func (ec *executionContext) fieldContext_Tag_count(_ context.Context, field grap
	return fc, nil
}

func (ec *executionContext) _TagCursor_result(ctx context.Context, field graphql.CollectedField, obj *model.TagCursor) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_TagCursor_result(ctx, field)
	if err != nil {
		return graphql.Null
	}
	ctx = graphql.WithFieldContext(ctx, fc)
	defer func() {
		if r := recover(); r != nil {
			ec.Error(ctx, ec.Recover(ctx, r))
			ret = graphql.Null
		}
	}()
	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
		ctx = rctx // use context from middleware stack in children
		return obj.Result, nil
	})
	if err != nil {
		ec.Error(ctx, err)
		return graphql.Null
	}
	if resTmp == nil {
		if !graphql.HasFieldError(ctx, fc) {
			ec.Errorf(ctx, "must not be null")
		}
		return graphql.Null
	}
	res := resTmp.([]*models.Tag)
	fc.Result = res
	return ec.marshalNTag2ᚕᚖlinksᚋmodelsᚐTag(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_TagCursor_result(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
	fc = &graphql.FieldContext{
		Object:     "TagCursor",
		Field:      field,
		IsMethod:   false,
		IsResolver: false,
		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
			switch field.Name {
			case "id":
				return ec.fieldContext_Tag_id(ctx, field)
			case "name":
				return ec.fieldContext_Tag_name(ctx, field)
			case "slug":
				return ec.fieldContext_Tag_slug(ctx, field)
			case "createdOn":
				return ec.fieldContext_Tag_createdOn(ctx, field)
			case "count":
				return ec.fieldContext_Tag_count(ctx, field)
			}
			return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name)
		},
	}
	return fc, nil
}

func (ec *executionContext) _TagCursor_pageInfo(ctx context.Context, field graphql.CollectedField, obj *model.TagCursor) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_TagCursor_pageInfo(ctx, field)
	if err != nil {
		return graphql.Null
	}
	ctx = graphql.WithFieldContext(ctx, fc)
	defer func() {
		if r := recover(); r != nil {
			ec.Error(ctx, ec.Recover(ctx, r))
			ret = graphql.Null
		}
	}()
	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
		ctx = rctx // use context from middleware stack in children
		return obj.PageInfo, nil
	})
	if err != nil {
		ec.Error(ctx, err)
		return graphql.Null
	}
	if resTmp == nil {
		return graphql.Null
	}
	res := resTmp.(*model.PageInfo)
	fc.Result = res
	return ec.marshalOPageInfo2ᚖlinksᚋapiᚋgraphᚋmodelᚐPageInfo(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_TagCursor_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
	fc = &graphql.FieldContext{
		Object:     "TagCursor",
		Field:      field,
		IsMethod:   false,
		IsResolver: false,
		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
			switch field.Name {
			case "cursor":
				return ec.fieldContext_PageInfo_cursor(ctx, field)
			case "hasNextPage":
				return ec.fieldContext_PageInfo_hasNextPage(ctx, field)
			case "hasPrevPage":
				return ec.fieldContext_PageInfo_hasPrevPage(ctx, field)
			}
			return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name)
		},
	}
	return fc, nil
}

func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_User_id(ctx, field)
	if err != nil {
@@ -24820,6 +25063,61 @@ func (ec *executionContext) unmarshalInputGetPaymentInput(ctx context.Context, o
	return it, nil
}

func (ec *executionContext) unmarshalInputGetTagsInput(ctx context.Context, obj any) (model.GetTagsInput, error) {
	var it model.GetTagsInput
	asMap := map[string]any{}
	for k, v := range obj.(map[string]any) {
		asMap[k] = v
	}

	fieldsInOrder := [...]string{"orgSlug", "service", "after", "before", "limit"}
	for _, k := range fieldsInOrder {
		v, ok := asMap[k]
		if !ok {
			continue
		}
		switch k {
		case "orgSlug":
			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("orgSlug"))
			data, err := ec.unmarshalNString2string(ctx, v)
			if err != nil {
				return it, err
			}
			it.OrgSlug = data
		case "service":
			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("service"))
			data, err := ec.unmarshalODomainService2ᚖlinksᚋapiᚋgraphᚋmodelᚐDomainService(ctx, v)
			if err != nil {
				return it, err
			}
			it.Service = data
		case "after":
			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
			if err != nil {
				return it, err
			}
			it.After = data
		case "before":
			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
			if err != nil {
				return it, err
			}
			it.Before = data
		case "limit":
			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("limit"))
			data, err := ec.unmarshalOInt2ᚖint(ctx, v)
			if err != nil {
				return it, err
			}
			it.Limit = data
		}
	}

	return it, nil
}

func (ec *executionContext) unmarshalInputGetUserInput(ctx context.Context, obj any) (model.GetUserInput, error) {
	var it model.GetUserInput
	asMap := map[string]any{}
@@ -28946,6 +29244,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
					func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
			}

			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
		case "getTags":
			field := field

			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
				defer func() {
					if r := recover(); r != nil {
						ec.Error(ctx, ec.Recover(ctx, r))
					}
				}()
				res = ec._Query_getTags(ctx, field)
				if res == graphql.Null {
					atomic.AddUint32(&fs.Invalids, 1)
				}
				return res
			}

			rrm := func(ctx context.Context) graphql.Marshaler {
				return ec.OperationContext.RootResolverMiddleware(ctx,
					func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
			}

			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
		case "getOrgMembers":
			field := field
@@ -29561,6 +29881,47 @@ func (ec *executionContext) _Tag(ctx context.Context, sel ast.SelectionSet, obj
	return out
}

var tagCursorImplementors = []string{"TagCursor"}

func (ec *executionContext) _TagCursor(ctx context.Context, sel ast.SelectionSet, obj *model.TagCursor) graphql.Marshaler {
	fields := graphql.CollectFields(ec.OperationContext, sel, tagCursorImplementors)

	out := graphql.NewFieldSet(fields)
	deferred := make(map[string]*graphql.FieldSet)
	for i, field := range fields {
		switch field.Name {
		case "__typename":
			out.Values[i] = graphql.MarshalString("TagCursor")
		case "result":
			out.Values[i] = ec._TagCursor_result(ctx, field, obj)
			if out.Values[i] == graphql.Null {
				out.Invalids++
			}
		case "pageInfo":
			out.Values[i] = ec._TagCursor_pageInfo(ctx, field, obj)
		default:
			panic("unknown field " + strconv.Quote(field.Name))
		}
	}
	out.Dispatch(ctx)
	if out.Invalids > 0 {
		return graphql.Null
	}

	atomic.AddInt32(&ec.deferred, int32(len(deferred)))

	for label, dfs := range deferred {
		ec.processDeferredGroup(graphql.DeferredGroup{
			Label:    label,
			Path:     graphql.GetPath(ctx),
			FieldSet: dfs,
			Context:  ctx,
		})
	}

	return out
}

var userImplementors = []string{"User"}

func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *models.User) graphql.Marshaler {
@@ -30471,6 +30832,11 @@ func (ec *executionContext) marshalNFollowPayload2ᚖlinksᚋapiᚋgraphᚋmodel
	return ec._FollowPayload(ctx, sel, v)
}

func (ec *executionContext) unmarshalNGetTagsInput2linksᚋapiᚋgraphᚋmodelᚐGetTagsInput(ctx context.Context, v any) (model.GetTagsInput, error) {
	res, err := ec.unmarshalInputGetTagsInput(ctx, v)
	return res, graphql.ErrorOnPath(ctx, err)
}

func (ec *executionContext) unmarshalNID2string(ctx context.Context, v any) (string, error) {
	res, err := graphql.UnmarshalID(v)
	return res, graphql.ErrorOnPath(ctx, err)
@@ -31192,6 +31558,58 @@ func (ec *executionContext) marshalNTag2ᚕlinksᚋmodelsᚐTag(ctx context.Cont
	return ret
}

func (ec *executionContext) marshalNTag2ᚕᚖlinksᚋmodelsᚐTag(ctx context.Context, sel ast.SelectionSet, v []*models.Tag) graphql.Marshaler {
	ret := make(graphql.Array, len(v))
	var wg sync.WaitGroup
	isLen1 := len(v) == 1
	if !isLen1 {
		wg.Add(len(v))
	}
	for i := range v {
		i := i
		fc := &graphql.FieldContext{
			Index:  &i,
			Result: &v[i],
		}
		ctx := graphql.WithFieldContext(ctx, fc)
		f := func(i int) {
			defer func() {
				if r := recover(); r != nil {
					ec.Error(ctx, ec.Recover(ctx, r))
					ret = nil
				}
			}()
			if !isLen1 {
				defer wg.Done()
			}
			ret[i] = ec.marshalOTag2ᚖlinksᚋmodelsᚐTag(ctx, sel, v[i])
		}
		if isLen1 {
			f(i)
		} else {
			go f(i)
		}

	}
	wg.Wait()

	return ret
}

func (ec *executionContext) marshalNTagCursor2linksᚋapiᚋgraphᚋmodelᚐTagCursor(ctx context.Context, sel ast.SelectionSet, v model.TagCursor) graphql.Marshaler {
	return ec._TagCursor(ctx, sel, &v)
}

func (ec *executionContext) marshalNTagCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐTagCursor(ctx context.Context, sel ast.SelectionSet, v *model.TagCursor) graphql.Marshaler {
	if v == nil {
		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
		}
		return graphql.Null
	}
	return ec._TagCursor(ctx, sel, v)
}

func (ec *executionContext) unmarshalNTime2timeᚐTime(ctx context.Context, v any) (time.Time, error) {
	res, err := graphql.UnmarshalTime(v)
	return res, graphql.ErrorOnPath(ctx, err)
diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go
index 860a6d5..0fbf74d 100644
--- a/api/graph/model/models_gen.go
+++ b/api/graph/model/models_gen.go
@@ -257,6 +257,14 @@ type GetPaymentInput struct {
	Filter    *bool   `json:"filter,omitempty"`
}

type GetTagsInput struct {
	OrgSlug string         `json:"orgSlug"`
	Service *DomainService `json:"service,omitempty"`
	After   *Cursor        `json:"after,omitempty"`
	Before  *Cursor        `json:"before,omitempty"`
	Limit   *int           `json:"limit,omitempty"`
}

type GetUserInput struct {
	Limit  *int    `json:"limit,omitempty"`
	After  *Cursor `json:"after,omitempty"`
@@ -423,6 +431,11 @@ type RegisterInvitation struct {
	Email string `json:"email"`
}

type TagCursor struct {
	Result   []*models.Tag `json:"result"`
	PageInfo *PageInfo     `json:"pageInfo,omitempty"`
}

type UpdateAdminDomainInput struct {
	ID           int           `json:"id"`
	Name         string        `json:"name"`
diff --git a/api/graph/pagination.go b/api/graph/pagination.go
index 6312c5a..0c16595 100644
--- a/api/graph/pagination.go
+++ b/api/graph/pagination.go
@@ -8,6 +8,26 @@ import (
	"netlandish.com/x/gobwebs/database"
)

var paginationCtxKey = &contextKey{"pagination"}

type contextKey struct {
	name string
}

// PaginationContext is used to override `model.PaginationMax` value
func PaginationContext(ctx context.Context, limit int) context.Context {
	return context.WithValue(ctx, paginationCtxKey, limit)
}

// ForPaginationContext pulls max pagination value for context
func ForPaginationContext(ctx context.Context) int {
	limit, ok := ctx.Value(paginationCtxKey).(int)
	if !ok {
		return model.PaginationMax
	}
	return limit
}

func PaginateResults[T any](items []T, limit int, before, after *model.Cursor,
	getID func(T) int) ([]T, *model.PageInfo) {
	overFetched := len(items) > limit
@@ -89,8 +109,11 @@ func QueryModel[T any](
	if limit != nil && *limit > 0 {
		numElements = *limit
	}
	if numElements > model.PaginationMax {
		numElements = model.PaginationMax

	// maxLimit will default to model.PaginationMax
	maxLimit := ForPaginationContext(ctx)
	if numElements > maxLimit {
		numElements = maxLimit
	}

	opts.Limit = numElements + 1
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index 02f9db8..54aeffa 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -402,6 +402,11 @@ type OrgLinkCursor {
    tagCloud: [Tag]
}

type TagCursor {
    result: [Tag]!
    pageInfo: PageInfo
}

type PopularLinkCursor {
    result: [BaseURL]!
    tagCloud: [Tag]
@@ -783,6 +788,14 @@ input AddQRCodeInput {
    image: Upload
}

input GetTagsInput {
    orgSlug: String!
    service: DomainService
    after: Cursor
    before: Cursor
    limit: Int
}

type DeletePayload {
    success: Boolean!
    objectId: ID!
@@ -842,6 +855,9 @@ type Query {
    "Returns an array of organization links"
    getOrgLinks(input: GetLinkInput): OrgLinkCursor! @access(scope: LINKS, kind: RO)

    "Returns tags of an organization. Can be by service or all services"
    getTags(input: GetTagsInput!): TagCursor! @access(scope: ORGS, kind: RO)

    "Returns members of an organization"
    getOrgMembers(orgSlug: String!): [User]! @access(scope: ORGS, kind: RO)

diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index a111149..547d662 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -40,8 +40,8 @@ import (
	"golang.org/x/image/draw"
	"golang.org/x/net/idna"
	"netlandish.com/x/gobwebs"
	"netlandish.com/x/gobwebs-auditlog"
	"netlandish.com/x/gobwebs-oauth2"
	auditlog "netlandish.com/x/gobwebs-auditlog"
	oauth2 "netlandish.com/x/gobwebs-oauth2"
	gaccounts "netlandish.com/x/gobwebs/accounts"
	gcore "netlandish.com/x/gobwebs/core"
	"netlandish.com/x/gobwebs/crypto"
@@ -5434,6 +5434,86 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp
	}, nil
}

// GetTags is the resolver for the getTags field.
func (r *queryResolver) GetTags(ctx context.Context, input model.GetTagsInput) (*model.TagCursor, error) {
	tokenUser := oauth2.ForContext(ctx)
	if tokenUser == nil {
		return nil, valid.ErrAuthorization
	}
	user := tokenUser.User.(*models.User)
	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
	lt := localizer.GetLocalizer(lang)

	ctx = timezone.Context(ctx, links.GetUserTZ(user))
	validator := valid.New(ctx)

	if input.After != nil && input.Before != nil {
		validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
			WithCode(valid.ErrValidationGlobalCode)
		return nil, nil
	}

	org, err := user.GetOrgsSlug(ctx, models.OrgUserPermissionRead, input.OrgSlug)
	if err != nil {
		return nil, err
	}
	if org == nil {
		validator.Error("%s", lt.Translate("Organization not found.")).
			WithField("orgSlug").
			WithCode(valid.ErrNotFoundCode)
		return nil, nil
	}

	var fq sq.Sqlizer
	if input.Service != nil {
		switch *input.Service {
		case model.DomainServiceLinks:
			fq = sq.Eq{"ol.org_id": org.ID}
		case model.DomainServiceShort:
			fq = sq.Eq{"s.org_id": org.ID}
		case model.DomainServiceList:
			fq = sq.Eq{"ll.org_id": org.ID}
		}
	} else {
		// No service specified. Give tags for all services
		fq = sq.Or{
			sq.Eq{"ol.org_id": org.ID},
			sq.Eq{"ll.org_id": org.ID},
			sq.Eq{"s.org_id": org.ID},
		}
	}

	opts := &database.FilterOptions{
		Filter:  fq,
		Limit:   500,
		OrderBy: "name ASC",
	}

	// Set pagination limit to 250 for tag pagination
	ctx = PaginationContext(ctx, 250)
	tags, pageInfo, err := QueryModel(
		ctx,
		opts,
		"t.id",
		"ASC",
		input.Limit,
		input.Before,
		input.After,
		models.GetTags,
		func(tag *models.Tag) int {
			return tag.ID
		},
	)
	if err != nil {
		return nil, err
	}

	return &model.TagCursor{
		Result:   tags,
		PageInfo: pageInfo,
	}, nil
}

// GetOrgMembers is the resolver for the getOrgMembers field.
func (r *queryResolver) GetOrgMembers(ctx context.Context, orgSlug string) ([]*models.User, error) {
	tokenUser := oauth2.ForContext(ctx)
diff --git a/migrations/test_migration.down.sql b/migrations/test_migration.down.sql
index e69de29..e879b88 100644
--- a/migrations/test_migration.down.sql
+++ b/migrations/test_migration.down.sql
@@ -0,0 +1,4 @@
DELETE FROM tag_listings;
DELETE FROM tag_link_shorts;
DELETE FROM tag_links;
DELETE FROM tags;
\ No newline at end of file
diff --git a/migrations/test_migration.up.sql b/migrations/test_migration.up.sql
index 4b9d26f..13f75f0 100644
--- a/migrations/test_migration.up.sql
+++ b/migrations/test_migration.up.sql
@@ -17,6 +17,12 @@ INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, has
INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, hash) VALUES
    ('Private Business url', 'http://base2.com?vis=private', 2, 1, 2, 'PRIVATE', 'hash2');

INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, hash) VALUES
    ('Personal org link 1', 'http://base.com?personal=1', 1, 1, 1, 'PUBLIC', 'hash3');

INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, hash) VALUES
    ('Personal org link 2', 'http://base2.com?personal=2', 2, 1, 1, 'PUBLIC', 'hash4');

INSERT INTO domains (name, lookup_name, org_id, level, service, status) VALUES ('short domain', 'short.domain.org', 1, 'SYSTEM', 'SHORT', 'APPROVED');
INSERT INTO domains (name, lookup_name, org_id, service, status, level) VALUES ('listing domain', 'list.domain.org', 1, 'LIST', 'APPROVED', 'USER');

@@ -29,3 +35,24 @@ INSERT INTO qr_codes (id, hash_id, url, org_id, code_type, image_path, user_id,

INSERT INTO subscription_plans (id, name, plan_id, stripe_price_id, price, type) VALUES (1, 'Personal', 'plan_personal', 'price_personal', 1000, 'PERSONAL');
INSERT INTO subscription_plans (id, name, plan_id, stripe_price_id, price, type) VALUES (2, 'Business', 'plan_business', 'price_business', 2000, 'BUSINESS');

INSERT INTO tags (name, slug) VALUES 
  ('common-tag', 'common-tag'),
  ('links-only', 'links-only'),
  ('short-only', 'short-only'),
  ('list-only', 'list-only'),
  ('links-short', 'links-short');

INSERT INTO tag_links (org_link_id, tag_id) VALUES
  ((SELECT id FROM org_links WHERE hash = 'hash3'), (SELECT id FROM tags WHERE slug = 'common-tag')),
  ((SELECT id FROM org_links WHERE hash = 'hash3'), (SELECT id FROM tags WHERE slug = 'links-only')),
  ((SELECT id FROM org_links WHERE hash = 'hash4'), (SELECT id FROM tags WHERE slug = 'links-short'));

INSERT INTO tag_link_shorts (link_short_id, tag_id) VALUES
  (100, (SELECT id FROM tags WHERE slug = 'common-tag')),
  (100, (SELECT id FROM tags WHERE slug = 'short-only')),
  (100, (SELECT id FROM tags WHERE slug = 'links-short'));

INSERT INTO tag_listings (listing_id, tag_id) VALUES
  (100, (SELECT id FROM tags WHERE slug = 'common-tag')),
  (100, (SELECT id FROM tags WHERE slug = 'list-only'));
diff --git a/models/user.go b/models/user.go
index 6f36ea9..e25653f 100644
--- a/models/user.go
+++ b/models/user.go
@@ -34,7 +34,7 @@ func (u UserSettings) Value() (driver.Value, error) {
}

// Scan ...
func (u *UserSettings) Scan(value interface{}) error {
func (u *UserSettings) Scan(value any) error {
	b, ok := value.([]byte)
	if !ok {
		return errors.New("type assertion to []byte failed")
@@ -260,7 +260,7 @@ func (u *User) GetOrgsSlug(ctx context.Context, perm string, slug string) (*Orga
		return nil, err
	}
	for _, o := range orgs {
		if strings.ToLower(o.Slug) == strings.ToLower(slug) {
		if strings.EqualFold(o.Slug, slug) {
			return o, nil
		}
	}
diff --git a/pinboard/input.go b/pinboard/input.go
index 55a9374..1a72e47 100644
--- a/pinboard/input.go
+++ b/pinboard/input.go
@@ -2,46 +2,46 @@ package pinboard

// AddPostInput represents the input for /v1/posts/add
type AddPostInput struct {
	URL         string `form:"url" validate:"required,url"`
	Description string `form:"description" validate:"required"`
	Extended    string `form:"extended"`
	Tags        string `form:"tags"`
	Dt          string `form:"dt"`
	Replace     string `form:"replace"`
	Shared      string `form:"shared"`
	Toread      string `form:"toread"`
	URL         string `query:"url" validate:"required,url"`
	Description string `query:"description" validate:"required"`
	Extended    string `query:"extended"`
	Tags        string `query:"tags"`
	Dt          string `query:"dt"`
	Replace     string `query:"replace"`
	Shared      string `query:"shared"`
	Toread      string `query:"toread"`
}

// DeletePostInput represents the input for /v1/posts/delete
type DeletePostInput struct {
	URL string `form:"url" validate:"required,url"`
	URL string `query:"url" validate:"required,url"`
}

// GetPostInput represents the input for /v1/posts/get
type GetPostInput struct {
	Tag  string `form:"tag"`
	Dt   string `form:"dt"`
	URL  string `form:"url"`
	Meta string `form:"meta"`
	Tag  string `query:"tag"`
	Dt   string `query:"dt"`
	URL  string `query:"url"`
	Meta string `query:"meta"`
}

// RecentPostInput represents the input for /v1/posts/recent
type RecentPostInput struct {
	Tag   string `form:"tag"`
	Count int    `form:"count"`
	Tag   string `query:"tag"`
	Count int    `query:"count"`
}

// DatesPostInput represents the input for /v1/posts/dates
type DatesPostInput struct {
	Tag string `form:"tag"`
	Tag string `query:"tag"`
}

// AllPostInput represents the input for /v1/posts/all
type AllPostInput struct {
	Tag     string `form:"tag"`
	Start   int    `form:"start"`
	Results int    `form:"results"`
	Fromdt  string `form:"fromdt"`
	Todt    string `form:"todt"`
	Meta    string `form:"meta"`
	Tag     string `query:"tag"`
	Start   int    `query:"start"`
	Results int    `query:"results"`
	Fromdt  string `query:"fromdt"`
	Todt    string `query:"todt"`
	Meta    string `query:"meta"`
}
\ No newline at end of file
diff --git a/pinboard/middleware.go b/pinboard/middleware.go
index 5675543..f02507d 100644
--- a/pinboard/middleware.go
+++ b/pinboard/middleware.go
@@ -16,7 +16,17 @@ func PinboardAuthMiddleware() echo.MiddlewareFunc {
			// 1. Check for auth_token parameter
			authToken := c.QueryParam("auth_token")
			if authToken != "" {
				token = authToken
				// Pinboard docs say value should be in form of "username:TOKEN"
				parts := strings.SplitN(authToken, ":", 2)
				if len(parts) == 2 {
					// Allow specification of the org to use in the username field
					c.Set("use_org", parts[0])
					// Use password as token (Pinboard uses username:token format)
					token = parts[1]
				} else {
					// Fallback with just token and fall back to default user org
					token = authToken
				}
			} else {
				// 2. Check for HTTP Basic Auth
				auth := c.Request().Header.Get("Authorization")
diff --git a/pinboard/responses.go b/pinboard/responses.go
index cdc1338..b96ee97 100644
--- a/pinboard/responses.go
+++ b/pinboard/responses.go
@@ -82,10 +82,22 @@ type PinboardNoteContent struct {
	Text      string   `xml:",chardata" json:"text"`
}

// PinboardTag represents a single tag in Pinboard format
type PinboardTag struct {
	Tag   string `xml:"tag,attr" json:"tag"`
	Count int    `xml:"count,attr" json:"count"`
}

// PinboardTags represents the tags response
type PinboardTags struct {
	XMLName xml.Name      `xml:"tags" json:"-"`
	Tags    []PinboardTag `xml:"tag" json:"tags"`
}

// Helper functions

// formatResponse returns the response in XML or JSON based on format parameter
func formatResponse(c echo.Context, data interface{}) error {
func formatResponse(c echo.Context, data any) error {
	// Check both query parameter and form value
	format := c.QueryParam("format")
	if format == "" {
diff --git a/pinboard/routes.go b/pinboard/routes.go
index 17b1d89..69c1311 100644
--- a/pinboard/routes.go
+++ b/pinboard/routes.go
@@ -34,8 +34,8 @@ func (s *Service) RegisterRoutes() {
	v1 := s.Group.Group("/v1")

	// Posts endpoints
	v1.POST("/posts/add", s.PostsAdd).Name = s.RouteName("posts_add")
	v1.POST("/posts/delete", s.PostsDelete).Name = s.RouteName("posts_delete")
	v1.GET("/posts/add", s.PostsAdd).Name = s.RouteName("posts_add")
	v1.GET("/posts/delete", s.PostsDelete).Name = s.RouteName("posts_delete")
	v1.GET("/posts/get", s.PostsGet).Name = s.RouteName("posts_get")
	v1.GET("/posts/recent", s.PostsRecent).Name = s.RouteName("posts_recent")
	v1.GET("/posts/dates", s.PostsDates).Name = s.RouteName("posts_dates")
@@ -44,6 +44,11 @@ func (s *Service) RegisterRoutes() {
	// Notes endpoints
	v1.GET("/notes/list", s.NotesList).Name = s.RouteName("notes_list")
	v1.GET("/notes/:id", s.NotesGet).Name = s.RouteName("notes_get")

	// Tags endpoints
	v1.GET("/tags/get", s.TagsGet).Name = s.RouteName("tags_get")
	v1.GET("/tags/rename", s.TagsRename).Name = s.RouteName("tags_rename")
	v1.GET("/tags/delete", s.TagsDelete).Name = s.RouteName("tags_delete")
}

// NewService creates a new Pinboard API service
@@ -764,3 +769,62 @@ func (s *Service) NotesGet(c echo.Context) error {

	return formatResponse(c, &response)
}

// TagsGet handles /v1/tags/get
func (s *Service) TagsGet(c echo.Context) error {
	// Get user's default organization
	org, err := s.getUserOrg(c)
	if err != nil {
		return formatError(c, "Failed to get organization")
	}

	// Prepare GraphQL query
	type GraphQLResponse struct {
		GetTags struct {
			Result []*models.Tag `json:"result"`
		} `json:"getTags"`
	}

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query GetTags($orgSlug: String!) {
			getTags(input: {orgSlug: $orgSlug}) {
				result {
					name
					count
				}
			}
		}`)

	op.Var("orgSlug", org.Slug)

	err = links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		return formatError(c, err.Error())
	}

	// Convert to Pinboard format
	tags := make([]PinboardTag, len(result.GetTags.Result))
	for i, tag := range result.GetTags.Result {
		tags[i] = PinboardTag{
			Tag:   tag.Name,
			Count: tag.Count,
		}
	}

	response := &PinboardTags{
		Tags: tags,
	}

	return formatResponse(c, response)
}

// TagsRename handles /v1/tags/rename
func (s *Service) TagsRename(c echo.Context) error {
	return formatError(c, "Tag renaming is unsupported")
}

// TagsDelete handles /v1/tags/delete
func (s *Service) TagsDelete(c echo.Context) error {
	return formatError(c, "Tag deletion is unsupported")
}
diff --git a/pinboard/routes_test.go b/pinboard/routes_test.go
index 60b9e42..b08294b 100644
--- a/pinboard/routes_test.go
+++ b/pinboard/routes_test.go
@@ -114,7 +114,7 @@ func TestPinboardHandlers(t *testing.T) {
				},
			}))

		formData := url.Values{
		params := url.Values{
			"url":         {"https://example.com"},
			"description": {"Example Title"},
			"extended":    {"Extended description"},
@@ -123,8 +123,7 @@ func TestPinboardHandlers(t *testing.T) {
			"toread":      {"no"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
@@ -140,13 +139,12 @@ func TestPinboardHandlers(t *testing.T) {
	})

	t.Run("posts/add missing required fields", func(t *testing.T) {
		formData := url.Values{
		params := url.Values{
			"url": {"https://example.com"},
			// Missing description
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
@@ -173,14 +171,13 @@ func TestPinboardHandlers(t *testing.T) {
				},
			}))

		formData := url.Values{
		params := url.Values{
			"url":         {"https://example.com"},
			"description": {"Example Title"},
			"format":      {"json"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
@@ -229,12 +226,11 @@ func TestPinboardHandlers(t *testing.T) {
				})
			})

		formData := url.Values{
		params := url.Values{
			"url": {"https://example.com"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/delete?"+params.Encode(), nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
@@ -262,12 +258,11 @@ func TestPinboardHandlers(t *testing.T) {
				},
			}))

		formData := url.Values{
		params := url.Values{
			"url": {"https://notfound.com"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/delete?"+params.Encode(), nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
@@ -704,14 +699,13 @@ func TestTagConversion(t *testing.T) {
				})
			})

		formData := url.Values{
		params := url.Values{
			"url":         {"https://example.com"},
			"description": {"Example"},
			"tags":        {`simple "tag with spaces" another`},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

@@ -1153,13 +1147,12 @@ func TestEdgeCases(t *testing.T) {
			}))

		longDesc := strings.Repeat("a", 1000)
		formData := url.Values{
		params := url.Values{
			"url":         {"https://example.com"},
			"description": {longDesc},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

@@ -1187,13 +1180,12 @@ func TestEdgeCases(t *testing.T) {
				},
			}))

		formData := url.Values{
		params := url.Values{
			"url":         {"https://example.com/path?query=value&foo=bar#anchor"},
			"description": {"URL with params"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

@@ -1221,14 +1213,13 @@ func TestEdgeCases(t *testing.T) {
				},
			}))

		formData := url.Values{
		params := url.Values{
			"url":         {"https://example.com"},
			"description": {"No tags"},
			"tags":        {""},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

@@ -1316,3 +1307,212 @@ func TestEdgeCases(t *testing.T) {
		}
	})
}

func TestTagEndpoints(t *testing.T) {
	c := require.New(t)
	srv, e := test.NewWebTestServer(t)
	cmd.RunMigrations(t, srv.DB)

	// Use existing test user ID 1 which has 'personal-org' from test migration
	user := test.NewTestUser(1, false, false, true, true)

	pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
	defer srv.Shutdown()
	go srv.Run()

	// Helper function to create authenticated context
	createAuthContext := func(request *http.Request, recorder *httptest.ResponseRecorder) *server.Context {
		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		return ctx
	}

	// Helper function to check XML response structure
	checkXMLResponse := func(body string, rootElement string) {
		c.True(strings.HasPrefix(body, "<?xml"))
		c.True(strings.Contains(body, "<"+rootElement))
		c.True(strings.Contains(body, "</"+rootElement+">"))
	}

	// Helper function to check JSON response structure
	checkJSONResponse := func(body string) {
		var js json.RawMessage
		err := json.Unmarshal([]byte(body), &js)
		c.NoError(err, "Response should be valid JSON")
	}

	t.Run("tags/get success", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getTags": map[string]interface{}{
						"result": []map[string]interface{}{
							{
								"name":  "golang",
								"count": 42,
							},
							{
								"name":  "web",
								"count": 23,
							},
							{
								"name":  "api",
								"count": 15,
							},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/tags/get")

		err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "tags")
		c.True(strings.Contains(body, "<tag"))
		c.True(strings.Contains(body, `tag="golang"`))
		c.True(strings.Contains(body, `count="42"`))
		c.True(strings.Contains(body, `tag="web"`))
		c.True(strings.Contains(body, `count="23"`))
	})

	t.Run("tags/get with JSON format", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getTags": map[string]interface{}{
						"result": []map[string]interface{}{
							{
								"name":  "test",
								"count": 10,
							},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get?format=json", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/tags/get")

		err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkJSONResponse(body)
		c.True(strings.Contains(body, `"tags":`))
		c.True(strings.Contains(body, `"tag":"test"`))
		c.True(strings.Contains(body, `"count":10`))
	})

	t.Run("tags/get empty response", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getTags": map[string]interface{}{
						"result": []map[string]interface{}{},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/tags/get")

		err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "tags")
		// Should contain tags element but no individual tag elements
		c.False(strings.Contains(body, "<tag "))
	})

	t.Run("tags/rename unsupported", func(t *testing.T) {
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=oldtag&new=newtag", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/tags/rename")

		err := test.MakeRequest(srv, pinboardService.TagsRename, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "result")
		c.True(strings.Contains(body, "Tag renaming is unsupported"))
	})

	t.Run("tags/delete unsupported", func(t *testing.T) {
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete?tag=unwanted", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/tags/delete")

		err := test.MakeRequest(srv, pinboardService.TagsDelete, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "result")
		c.True(strings.Contains(body, "Tag deletion is unsupported"))
	})

	t.Run("tags/get with GraphQL error", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"errors": []map[string]interface{}{
					{
						"message": "getTags query failed",
						"extensions": map[string]interface{}{
							"code": "INTERNAL_SERVER_ERROR",
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/tags/get")

		err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "result")
		c.True(strings.Contains(body, "something went wrong"))
	})
}
-- 
2.49.1
Applied.

To git@git.code.netlandish.com:~netlandish/links
   3ca09cc..a22df5a  master -> master