Subject: [PATCH links] api: add `getBaseURL` query.

From: Peter Sanchez <[email-masked]>
Date: Thu, 5 Mar 2026 16:52:13 -0600

also fixed issue with a naming conflict when running `make schema`

Changelog-added: `getBaseURL` query to api
Changelog-updated: api version to 0.11.0
Changelog-fixed: naming conflict with `models.TagInput` and api
generated `TagInput`
---
 api/api_test.go               |  51 ++++++++++++
 api/graph/generated.go        | 152 ++++++++++++++++++++++++++++++++++
 api/graph/schema.graphqls     |   3 +
 api/graph/schema.resolvers.go |  46 +++++++++-
 helpers.go                    |   6 +-
 models/base_url.go            |   5 +-
 models/models.go              |   4 +-
 models/tag_link_shorts.go     |   2 +-
 models/tag_links.go           |   2 +-
 models/tag_listing.go         |   2 +-
 10 files changed, 261 insertions(+), 12 deletions(-)

diff --git a/api/api_test.go b/api/api_test.go
index 9b97a7a..edf5ab6 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -3578,4 +3578,55 @@ func TestAPI(t *testing.T) {
 		c.NoError(err)
 		c.Equal(0, len(tagLinksAAfter), "Link should have 0 tags after update with empty tags")
 	})
+
+	t.Run("get base url by hash", func(t *testing.T) {
+		_, err := sq.Update("base_urls").
+			Set("public_ready", true).
+			Where("id = ?", 1).
+			PlaceholderFormat(database.GetPlaceholderFormat()).
+			RunWith(srv.DB).
+			Exec()
+		c.NoError(err)
+
+		type GraphQLResponse struct {
+			BaseURL *models.BaseURL `json:"getBaseURL"`
+		}
+		var result GraphQLResponse
+		op := gqlclient.NewOperation(`query GetBaseURL($hash: String!) {
+			getBaseURL(hash: $hash) {
+				id
+				url
+				hash
+				counter
+				visibility
+				createdOn
+				updatedOn
+			}
+		}`)
+		op.Var("hash", "abcdefg")
+		err = links.Execute(ctx, op, &result)
+		c.NoError(err)
+		c.NotNil(result.BaseURL)
+		c.Equal("http://base.com", result.BaseURL.URL)
+		c.Equal("abcdefg", result.BaseURL.Hash)
+	})
+
+	t.Run("get base url not found", func(t *testing.T) {
+		type GraphQLResponse struct {
+			BaseURL *models.BaseURL `json:"getBaseURL"`
+		}
+		var result GraphQLResponse
+		op := gqlclient.NewOperation(`query GetBaseURL($hash: String!) {
+			getBaseURL(hash: $hash) {
+				id
+				url
+				hash
+			}
+		}`)
+		op.Var("hash", "nonexistent-hash")
+		err := links.Execute(ctx, op, &result)
+		c.Error(err)
+		c.Contains(err.Error(), "BaseURL Not Found")
+	})
+
 }
diff --git a/api/graph/generated.go b/api/graph/generated.go
index 9b78c73..78b7cce 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -414,6 +414,7 @@ type ComplexityRoot struct {
 		GetAdminOrgStats      func(childComplexity int, id int) int
 		GetAdminOrganizations func(childComplexity int, input *model.GetAdminOrganizationsInput) int
 		GetAuditLogs          func(childComplexity int, input *model.AuditLogInput) int
+		GetBaseURL            func(childComplexity int, hash string) int
 		GetBookmarks          func(childComplexity int, hash string, tags *string) int
 		GetDomain             func(childComplexity int, id int) int
 		GetDomains            func(childComplexity int, orgSlug *string, service *model.DomainService) int
@@ -574,6 +575,7 @@ type QueryResolver interface {
 	GetPaymentHistory(ctx context.Context, input *model.GetPaymentInput) (*model.PaymentCursor, error)
 	GetPopularLinks(ctx context.Context, input *model.PopularLinksInput) (*model.PopularLinkCursor, error)
 	GetOrgLink(ctx context.Context, hash string) (*models.OrgLink, error)
+	GetBaseURL(ctx context.Context, hash string) (*models.BaseURL, 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)
@@ -2481,6 +2483,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Query.GetAuditLogs(childComplexity, args["input"].(*model.AuditLogInput)), true
 
+	case "Query.getBaseURL":
+		if e.complexity.Query.GetBaseURL == nil {
+			break
+		}
+
+		args, err := ec.field_Query_getBaseURL_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Query.GetBaseURL(childComplexity, args["hash"].(string)), true
+
 	case "Query.getBookmarks":
 		if e.complexity.Query.GetBookmarks == nil {
 			break
@@ -3606,6 +3620,17 @@ func (ec *executionContext) field_Query_getAuditLogs_args(ctx context.Context, r
 	return args, nil
 }
 
+func (ec *executionContext) field_Query_getBaseURL_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, "hash", ec.unmarshalNString2string)
+	if err != nil {
+		return nil, err
+	}
+	args["hash"] = arg0
+	return args, nil
+}
+
 func (ec *executionContext) field_Query_getBookmarks_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
 	var err error
 	args := map[string]any{}
@@ -18739,6 +18764,114 @@ func (ec *executionContext) fieldContext_Query_getOrgLink(ctx context.Context, f
 	return fc, nil
 }
 
+func (ec *executionContext) _Query_getBaseURL(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Query_getBaseURL(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().GetBaseURL(rctx, fc.Args["hash"].(string))
+		}
+
+		directive1 := func(ctx context.Context) (any, error) {
+			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "LINKS")
+			if err != nil {
+				var zeroVal *models.BaseURL
+				return zeroVal, err
+			}
+			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
+			if err != nil {
+				var zeroVal *models.BaseURL
+				return zeroVal, err
+			}
+			if ec.directives.Access == nil {
+				var zeroVal *models.BaseURL
+				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.(*models.BaseURL); ok {
+			return data, nil
+		}
+		return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/models.BaseURL`, tmp)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*models.BaseURL)
+	fc.Result = res
+	return ec.marshalOBaseURL2ᚖlinksᚋmodelsᚐBaseURL(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Query_getBaseURL(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 "id":
+				return ec.fieldContext_BaseURL_id(ctx, field)
+			case "title":
+				return ec.fieldContext_BaseURL_title(ctx, field)
+			case "url":
+				return ec.fieldContext_BaseURL_url(ctx, field)
+			case "counter":
+				return ec.fieldContext_BaseURL_counter(ctx, field)
+			case "tags":
+				return ec.fieldContext_BaseURL_tags(ctx, field)
+			case "publicReady":
+				return ec.fieldContext_BaseURL_publicReady(ctx, field)
+			case "hash":
+				return ec.fieldContext_BaseURL_hash(ctx, field)
+			case "data":
+				return ec.fieldContext_BaseURL_data(ctx, field)
+			case "visibility":
+				return ec.fieldContext_BaseURL_visibility(ctx, field)
+			case "createdOn":
+				return ec.fieldContext_BaseURL_createdOn(ctx, field)
+			case "updatedOn":
+				return ec.fieldContext_BaseURL_updatedOn(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type BaseURL", 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_getBaseURL_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _Query_getBookmarks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_Query_getBookmarks(ctx, field)
 	if err != nil {
@@ -30067,6 +30200,25 @@ 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 "getBaseURL":
+			field := field
+
+			innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Query_getBaseURL(ctx, field)
+				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 "getBookmarks":
 			field := field
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index 361ffb3..f590b98 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -870,6 +870,9 @@ type Query {
     "Returns a specific organization link"
     getOrgLink(hash: String!): OrgLink @access(scope: LINKS, kind: RO)
 
+    "Returns BaseURL. Accepts BaseURL hash or full url"
+    getBaseURL(hash: String!): BaseURL @access(scope: LINKS, kind: RO)
+
     "Returns saved links (bookmarks) for a given URL. Accepts BaseURL hash or full url"
     getBookmarks(hash: String!, tags: String): BookmarkCursor! @access(scope: LINKS, kind: RO)
 
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index aab1ba5..6146a0e 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -5126,8 +5126,8 @@ func (r *qRCodeResolver) ImageURL(ctx context.Context, obj *models.QRCode) (*str
 func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
 	return &model.Version{
 		Major:           0,
-		Minor:           10,
-		Patch:           3,
+		Minor:           11,
+		Patch:           0,
 		DeprecationDate: nil,
 	}, nil
 }
@@ -5456,6 +5456,48 @@ func (r *queryResolver) GetOrgLink(ctx context.Context, hash string) (*models.Or
 	return orgLinks[0], nil
 }
 
+// GetBaseURL is the resolver for the getBaseURL field.
+func (r *queryResolver) GetBaseURL(ctx context.Context, hash string) (*models.BaseURL, 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)
+
+	opts := &database.FilterOptions{
+		Filter: sq.Eq{"b.public_ready": true},
+		Limit:  1,
+	}
+	_, err := url.Parse(hash)
+	if err != nil {
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Eq{"b.url": links.StripURLFragment(hash)},
+		}
+	} else {
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Eq{"b.hash": hash},
+		}
+	}
+
+	burls, err := models.GetBaseURLs(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+	if len(burls) == 0 {
+		validator.Error("%s", lt.Translate("BaseURL Not Found")).
+			WithCode(valid.ErrNotFoundCode)
+		return nil, nil
+	}
+	return burls[0], nil
+}
+
 // GetBookmarks is the resolver for the getBookmarks field.
 func (r *queryResolver) GetBookmarks(ctx context.Context, hash string, tags *string) (*model.BookmarkCursor, error) {
 	tokenUser := oauth2.ForContext(ctx)
diff --git a/helpers.go b/helpers.go
index eb77b00..44c4c91 100644
--- a/helpers.go
+++ b/helpers.go
@@ -762,8 +762,8 @@ func LangForContext(ctx context.Context) string {
 	return lang
 }
 
-func ProcessTags(ctx context.Context, tags []string) ([]models.TagInput, error) {
-	tagInputs := make([]models.TagInput, 0)
+func ProcessTags(ctx context.Context, tags []string) ([]models.ProcessedTag, error) {
+	tagInputs := make([]models.ProcessedTag, 0)
 	for _, tag := range tags {
 		originalName := strings.TrimSpace(tag)
 		originalName = strings.TrimPrefix(originalName, "#")
@@ -780,7 +780,7 @@ func ProcessTags(ctx context.Context, tags []string) ([]models.TagInput, error)
 			if err != nil {
 				return nil, err
 			}
-			tagInputs = append(tagInputs, models.TagInput{
+			tagInputs = append(tagInputs, models.ProcessedTag{
 				ID:   Tag.ID,
 				Name: originalName,
 			})
diff --git a/models/base_url.go b/models/base_url.go
index 5510991..140fdd0 100644
--- a/models/base_url.go
+++ b/models/base_url.go
@@ -64,7 +64,7 @@ func GetBaseURLs(ctx context.Context, opts *database.FilterOptions) ([]*BaseURL,
 		q := opts.GetBuilder(nil)
 		rows, err := q.
 			Columns("b.id", "b.url", "b.title", "b.counter", "b.data", "b.public_ready", "b.hash",
-				"b.parse_attempts", "b.last_parse_attempt", "b.created_on", "b.visibility",
+				"b.parse_attempts", "b.last_parse_attempt", "b.created_on", "b.updated_on", "b.visibility",
 				fmt.Sprintf("json_agg(CASE WHEN t.id IS NOT NULL THEN json_build_object('id', t.id, 'name', tl.name, 'slug', t.slug, 'createdOn', t.created_on) END ORDER BY %s)::jsonb", tagOrder)).
 			From("base_urls b").
 			LeftJoin("org_links ol ON ol.base_url_id = b.id").
@@ -89,7 +89,8 @@ func GetBaseURLs(ctx context.Context, opts *database.FilterOptions) ([]*BaseURL,
 			var tags string
 			if err = rows.Scan(&url.ID, &url.URL, &url.Title, &url.Counter,
 				&url.Data, &url.PublicReady, &url.Hash, &url.ParseAttempts,
-				&url.LastParseAttempt, &url.CreatedOn, &url.Visibility, &tags); err != nil {
+				&url.LastParseAttempt, &url.CreatedOn, &url.UpdatedOn,
+				&url.Visibility, &tags); err != nil {
 				return err
 			}
 
diff --git a/models/models.go b/models/models.go
index bb61912..87ea649 100644
--- a/models/models.go
+++ b/models/models.go
@@ -138,8 +138,8 @@ type Tag struct {
 	Count int `db:"-" json:"count"`
 }
 
-// TagInput represents a tag with its ID and user's original name
-type TagInput struct {
+// ProcessedTag represents a tag after processing - ID resolved + user's display name
+type ProcessedTag struct {
 	ID   int
 	Name string
 }
diff --git a/models/tag_link_shorts.go b/models/tag_link_shorts.go
index 50ff3c8..b968bde 100644
--- a/models/tag_link_shorts.go
+++ b/models/tag_link_shorts.go
@@ -60,7 +60,7 @@ func GetTagLinkShort(ctx context.Context, id int) (*TagLinkShort, error) {
 	return tl, err
 }
 
-func CreateBatchTagLinkShorts(ctx context.Context, linkShortID int, tags []TagInput) error {
+func CreateBatchTagLinkShorts(ctx context.Context, linkShortID int, tags []ProcessedTag) error {
 	if len(tags) == 0 {
 		return nil
 	}
diff --git a/models/tag_links.go b/models/tag_links.go
index 81f804c..7f17e65 100644
--- a/models/tag_links.go
+++ b/models/tag_links.go
@@ -60,7 +60,7 @@ func GetTagLink(ctx context.Context, id int) (*TagLink, error) {
 	return tl, err
 }
 
-func CreateBatchTagLinks(ctx context.Context, linkID int, tags []TagInput) error {
+func CreateBatchTagLinks(ctx context.Context, linkID int, tags []ProcessedTag) error {
 	if len(tags) == 0 {
 		return nil
 	}
diff --git a/models/tag_listing.go b/models/tag_listing.go
index dc0939d..c543620 100644
--- a/models/tag_listing.go
+++ b/models/tag_listing.go
@@ -60,7 +60,7 @@ func GetTagListing(ctx context.Context, id int) (*TagListing, error) {
 	return tl, err
 }
 
-func CreateBatchTagListings(ctx context.Context, listingID int, tags []TagInput) error {
+func CreateBatchTagListings(ctx context.Context, listingID int, tags []ProcessedTag) error {
 	if len(tags) == 0 {
 		return nil
 	}
-- 
2.52.0