Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166])
	by code.netlandish.com (Postfix) with ESMTP id 8A0532A7
	for <~netlandish/links-dev@lists.code.netlandish.com>; Tue, 31 Mar 2026 00:39:22 +0000 (UTC)
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.222.42; helo=mail-ua1-f42.google.com; envelope-from=peter@netlandish.com; receiver=<UNKNOWN> 
Authentication-Results: mail.netlandish.com;
	dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=HET4IJ+z
Received: from mail-ua1-f42.google.com (mail-ua1-f42.google.com [209.85.222.42])
	by mail.netlandish.com (Postfix) with ESMTP id 6CDDB1D6440
	for <~netlandish/links-dev@lists.code.netlandish.com>; Tue, 31 Mar 2026 00:39:21 +0000 (UTC)
Received: by mail-ua1-f42.google.com with SMTP id a1e0cc1a2514c-953a2634777so793241241.3
        for <~netlandish/links-dev@lists.code.netlandish.com>; Mon, 30 Mar 2026 17:39:21 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=netlandish.com; s=google; t=1774917560; x=1775522360; darn=lists.code.netlandish.com;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:from:to:cc:subject:date:message-id:reply-to;
        bh=jNF8jj/L0wd+kqkVZGnOUaSnAyJ4AXVBmow4iIJjkC8=;
        b=HET4IJ+zT7kw2wE6rHZ1FHBOSjudz26G1c/imKypxGrc0SS3u2foDOgcjrew6cCbyO
         lP16UxTwgWKxgcA1SKJ7lJjH5qq9prpyJw9ZKGYWGqgkNsA0yRZmaNdfKRbQnRtxzLBr
         aKdpTUdLl659c98H5gaouXqMAHmTlZm02X2Zg=
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=1e100.net; s=20251104; t=1774917560; x=1775522360;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:x-gm-gg:x-gm-message-state:from:to:cc:subject:date
         :message-id:reply-to;
        bh=jNF8jj/L0wd+kqkVZGnOUaSnAyJ4AXVBmow4iIJjkC8=;
        b=IjDPl+OlZhNpnYq5IvjUkN+jRL+6P/EyJPKAl9XRY3p33/DzTwAsq5pG2jWztHDUIK
         oWEzp5kebu1pPMFT60GOeWpMci2Qv/sn+Yqn0+yqMhfZHtCr5tIuZNfUp5G7tFWFxlYi
         hrH1DHOlC1hX6pX3nUMZBqN/Bm0iaSR5HqsWQVD39nNQ4E/D3jb8nAVDwDNFCQjvMPck
         UGiDy0fwjijLh2y8tzbE68EWTPy9Y7+atBj2OTe9LIDpBD5PmB4CtmVmC+V79uw+XDZi
         y1qzoQ6behWVPYNhBvahRtnOHWFgoia/1ur6sZn9gghSuGrjHmj9q7v+Sjnqa5rbS040
         snpQ==
X-Gm-Message-State: AOJu0YwJE2COVEREyNdb2ltAbg5PpeaUc2Y83uJVZ6icRUXYM3zeewDf
	ejz+oo4zBYilAYmbP2JSIRwdzU2cUq5qDUqm6OEk5fdpSDytMDgdJe1PM2GmA5SkNRgRBWJJ+Jy
	1X3Q84Bo=
X-Gm-Gg: ATEYQzzsdd9Lg05Y9QET2dOkbrCJc9kjsdaivjKRjfuq77Wr+M6vbVVCMdMYfluZLFm
	cuPJwlq4YEbR9oSlvZwAW6abCkre3UymabnkdyeZPahPHrWLhu4aNfFVnh2u8cOErvUGMHRbAw8
	KouhDO+e8aUUNGNCmhmAFSRMYF/yFzMOL7hFRHiDVlVNUy0xpj9Sr2wMmT+x60JnazX+SFUeRPT
	7YlWIe0bmcmr3evjk8wNqa/AtG/S7yiFjWpX9OJlLvHZhgAIQoUN5cL0z+6DZ5xX3U0ZfHZewJB
	X81++bhJH20SgqB+n4yMUB0IbBBDHfYeQWSWY0355JsbIJyA5SVfNJoFDZCHXv+wr93R8oCKjhY
	52i89l72ZkK3outTw/WW+N43gdXvVJ3I9fjKqe+4fhrxbJmzNKLtxqwqg/jV5WJt/vOD1EjHIcM
	dMrx/+sqf1Ej4mLgaJmy+p/SNM
X-Received: by 2002:a05:6102:3311:b0:5ff:e769:44bb with SMTP id ada2fe7eead31-604f92e778emr5434230137.30.1774917559972;
        Mon, 30 Mar 2026 17:39:19 -0700 (PDT)
Received: from localhost ([2803:2d60:1107:d27:e6e9:eeff:34aa:aef9])
        by smtp.gmail.com with ESMTPSA id ada2fe7eead31-605123e7e88sm10327527137.0.2026.03.30.17.39.18
        (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
        Mon, 30 Mar 2026 17:39:19 -0700 (PDT)
From: Peter Sanchez <peter@netlandish.com>
To: ~netlandish/links-dev@lists.code.netlandish.com
Cc: Peter Sanchez <peter@netlandish.com>
Subject: [PATCH links] api: add `organizations` and `domains` to the User type
Date: Mon, 30 Mar 2026 18:39:07 -0600
Message-ID: <20260331003916.26782-1-peter@netlandish.com>
X-Mailer: git-send-email 2.52.0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Changelog-added: organizations and domains to GraphQL User type
Changelog-updated: api version bumped to 0.13.0
---
 api/graph/generated.go        | 333 ++++++++++++++++++++++++++++++++++
 api/graph/helpers.go          |  91 ++++++++++
 api/graph/schema.graphqls     |   2 +
 api/graph/schema.resolvers.go | 111 +++---------
 models/listing_link.go        |   4 +-
 5 files changed, 458 insertions(+), 83 deletions(-)
 create mode 100644 api/graph/helpers.go

diff --git a/api/graph/generated.go b/api/graph/generated.go
index bcd11e0..af1105f 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -472,12 +472,14 @@ type ComplexityRoot struct {
 
 	User struct {
 		CreatedOn       func(childComplexity int) int
+		Domains         func(childComplexity int) int
 		Email           func(childComplexity int) int
 		ID              func(childComplexity int) int
 		IsEmailVerified func(childComplexity int) int
 		IsLocked        func(childComplexity int) int
 		LockReason      func(childComplexity int) int
 		Name            func(childComplexity int) int
+		Organizations   func(childComplexity int) int
 	}
 
 	UserCursor struct {
@@ -605,6 +607,9 @@ type QueryResolver interface {
 }
 type UserResolver interface {
 	ID(ctx context.Context, obj *models.User) (int, error)
+
+	Organizations(ctx context.Context, obj *models.User) ([]*models.Organization, error)
+	Domains(ctx context.Context, obj *models.User) ([]*models.Domain, error)
 }
 
 type executableSchema struct {
@@ -2906,6 +2911,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.User.CreatedOn(childComplexity), true
 
+	case "User.domains":
+		if e.complexity.User.Domains == nil {
+			break
+		}
+
+		return e.complexity.User.Domains(childComplexity), true
+
 	case "User.email":
 		if e.complexity.User.Email == nil {
 			break
@@ -2948,6 +2960,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.User.Name(childComplexity), true
 
+	case "User.organizations":
+		if e.complexity.User.Organizations == nil {
+			break
+		}
+
+		return e.complexity.User.Organizations(childComplexity), true
+
 	case "UserCursor.pageInfo":
 		if e.complexity.UserCursor.PageInfo == nil {
 			break
@@ -7104,6 +7123,10 @@ func (ec *executionContext) fieldContext_EmailPostUser_user(_ context.Context, f
 				return ec.fieldContext_User_isLocked(ctx, field)
 			case "lockReason":
 				return ec.fieldContext_User_lockReason(ctx, field)
+			case "organizations":
+				return ec.fieldContext_User_organizations(ctx, field)
+			case "domains":
+				return ec.fieldContext_User_domains(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
 		},
@@ -11080,6 +11103,10 @@ func (ec *executionContext) fieldContext_Mutation_register(ctx context.Context,
 				return ec.fieldContext_User_isLocked(ctx, field)
 			case "lockReason":
 				return ec.fieldContext_User_lockReason(ctx, field)
+			case "organizations":
+				return ec.fieldContext_User_organizations(ctx, field)
+			case "domains":
+				return ec.fieldContext_User_domains(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
 		},
@@ -11183,6 +11210,10 @@ func (ec *executionContext) fieldContext_Mutation_completeRegister(ctx context.C
 				return ec.fieldContext_User_isLocked(ctx, field)
 			case "lockReason":
 				return ec.fieldContext_User_lockReason(ctx, field)
+			case "organizations":
+				return ec.fieldContext_User_organizations(ctx, field)
+			case "domains":
+				return ec.fieldContext_User_domains(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
 		},
@@ -11286,6 +11317,10 @@ func (ec *executionContext) fieldContext_Mutation_updateProfile(ctx context.Cont
 				return ec.fieldContext_User_isLocked(ctx, field)
 			case "lockReason":
 				return ec.fieldContext_User_lockReason(ctx, field)
+			case "organizations":
+				return ec.fieldContext_User_organizations(ctx, field)
+			case "domains":
+				return ec.fieldContext_User_domains(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
 		},
@@ -13520,6 +13555,10 @@ func (ec *executionContext) fieldContext_Mutation_updateAdminUser(ctx context.Co
 				return ec.fieldContext_User_isLocked(ctx, field)
 			case "lockReason":
 				return ec.fieldContext_User_lockReason(ctx, field)
+			case "organizations":
+				return ec.fieldContext_User_organizations(ctx, field)
+			case "domains":
+				return ec.fieldContext_User_domains(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
 		},
@@ -18315,6 +18354,10 @@ func (ec *executionContext) fieldContext_Query_me(_ context.Context, field graph
 				return ec.fieldContext_User_isLocked(ctx, field)
 			case "lockReason":
 				return ec.fieldContext_User_lockReason(ctx, field)
+			case "organizations":
+				return ec.fieldContext_User_organizations(ctx, field)
+			case "domains":
+				return ec.fieldContext_User_domains(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
 		},
@@ -19353,6 +19396,10 @@ func (ec *executionContext) fieldContext_Query_getOrgMembers(ctx context.Context
 				return ec.fieldContext_User_isLocked(ctx, field)
 			case "lockReason":
 				return ec.fieldContext_User_lockReason(ctx, field)
+			case "organizations":
+				return ec.fieldContext_User_organizations(ctx, field)
+			case "domains":
+				return ec.fieldContext_User_domains(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
 		},
@@ -20941,6 +20988,10 @@ func (ec *executionContext) fieldContext_Query_getUser(ctx context.Context, fiel
 				return ec.fieldContext_User_isLocked(ctx, field)
 			case "lockReason":
 				return ec.fieldContext_User_lockReason(ctx, field)
+			case "organizations":
+				return ec.fieldContext_User_organizations(ctx, field)
+			case "domains":
+				return ec.fieldContext_User_domains(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
 		},
@@ -22525,6 +22576,212 @@ func (ec *executionContext) fieldContext_User_lockReason(_ context.Context, fiel
 	return fc, nil
 }
 
+func (ec *executionContext) _User_organizations(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_User_organizations(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.User().Organizations(rctx, obj)
+		}
+
+		directive1 := func(ctx context.Context) (any, error) {
+			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "ORGS")
+			if err != nil {
+				var zeroVal []*models.Organization
+				return zeroVal, err
+			}
+			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
+			if err != nil {
+				var zeroVal []*models.Organization
+				return zeroVal, err
+			}
+			if ec.directives.Access == nil {
+				var zeroVal []*models.Organization
+				return zeroVal, errors.New("directive access is not implemented")
+			}
+			return ec.directives.Access(ctx, obj, 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.Organization); ok {
+			return data, nil
+		}
+		return nil, fmt.Errorf(`unexpected type %T from directive, should be []*links/models.Organization`, 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.([]*models.Organization)
+	fc.Result = res
+	return ec.marshalNOrganization2ᚕᚖlinksᚋmodelsᚐOrganizationᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_User_organizations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "User",
+		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_Organization_id(ctx, field)
+			case "ownerId":
+				return ec.fieldContext_Organization_ownerId(ctx, field)
+			case "orgType":
+				return ec.fieldContext_Organization_orgType(ctx, field)
+			case "name":
+				return ec.fieldContext_Organization_name(ctx, field)
+			case "slug":
+				return ec.fieldContext_Organization_slug(ctx, field)
+			case "image":
+				return ec.fieldContext_Organization_image(ctx, field)
+			case "imageUrl":
+				return ec.fieldContext_Organization_imageUrl(ctx, field)
+			case "timezone":
+				return ec.fieldContext_Organization_timezone(ctx, field)
+			case "settings":
+				return ec.fieldContext_Organization_settings(ctx, field)
+			case "isActive":
+				return ec.fieldContext_Organization_isActive(ctx, field)
+			case "visibility":
+				return ec.fieldContext_Organization_visibility(ctx, field)
+			case "createdOn":
+				return ec.fieldContext_Organization_createdOn(ctx, field)
+			case "updatedOn":
+				return ec.fieldContext_Organization_updatedOn(ctx, field)
+			case "ownerName":
+				return ec.fieldContext_Organization_ownerName(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _User_domains(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_User_domains(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.User().Domains(rctx, obj)
+		}
+
+		directive1 := func(ctx context.Context) (any, error) {
+			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "DOMAINS")
+			if err != nil {
+				var zeroVal []*models.Domain
+				return zeroVal, err
+			}
+			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
+			if err != nil {
+				var zeroVal []*models.Domain
+				return zeroVal, err
+			}
+			if ec.directives.Access == nil {
+				var zeroVal []*models.Domain
+				return zeroVal, errors.New("directive access is not implemented")
+			}
+			return ec.directives.Access(ctx, obj, 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.Domain); ok {
+			return data, nil
+		}
+		return nil, fmt.Errorf(`unexpected type %T from directive, should be []*links/models.Domain`, 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.([]*models.Domain)
+	fc.Result = res
+	return ec.marshalNDomain2ᚕᚖlinksᚋmodelsᚐDomain(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_User_domains(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "User",
+		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_Domain_id(ctx, field)
+			case "name":
+				return ec.fieldContext_Domain_name(ctx, field)
+			case "lookupName":
+				return ec.fieldContext_Domain_lookupName(ctx, field)
+			case "orgId":
+				return ec.fieldContext_Domain_orgId(ctx, field)
+			case "orgSlug":
+				return ec.fieldContext_Domain_orgSlug(ctx, field)
+			case "service":
+				return ec.fieldContext_Domain_service(ctx, field)
+			case "level":
+				return ec.fieldContext_Domain_level(ctx, field)
+			case "status":
+				return ec.fieldContext_Domain_status(ctx, field)
+			case "isActive":
+				return ec.fieldContext_Domain_isActive(ctx, field)
+			case "createdOn":
+				return ec.fieldContext_Domain_createdOn(ctx, field)
+			case "updatedOn":
+				return ec.fieldContext_Domain_updatedOn(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Domain", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _UserCursor_result(ctx context.Context, field graphql.CollectedField, obj *model.UserCursor) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_UserCursor_result(ctx, field)
 	if err != nil {
@@ -22578,6 +22835,10 @@ func (ec *executionContext) fieldContext_UserCursor_result(_ context.Context, fi
 				return ec.fieldContext_User_isLocked(ctx, field)
 			case "lockReason":
 				return ec.fieldContext_User_lockReason(ctx, field)
+			case "organizations":
+				return ec.fieldContext_User_organizations(ctx, field)
+			case "domains":
+				return ec.fieldContext_User_domains(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
 		},
@@ -31170,6 +31431,78 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj
 			if out.Values[i] == graphql.Null {
 				atomic.AddUint32(&out.Invalids, 1)
 			}
+		case "organizations":
+			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._User_organizations(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			if field.Deferrable != nil {
+				dfs, ok := deferred[field.Deferrable.Label]
+				di := 0
+				if ok {
+					dfs.AddField(field)
+					di = len(dfs.Values) - 1
+				} else {
+					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
+					deferred[field.Deferrable.Label] = dfs
+				}
+				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
+					return innerFunc(ctx, dfs)
+				})
+
+				// don't run the out.Concurrently() call below
+				out.Values[i] = graphql.Null
+				continue
+			}
+
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+		case "domains":
+			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._User_domains(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			if field.Deferrable != nil {
+				dfs, ok := deferred[field.Deferrable.Label]
+				di := 0
+				if ok {
+					dfs.AddField(field)
+					di = len(dfs.Values) - 1
+				} else {
+					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
+					deferred[field.Deferrable.Label] = dfs
+				}
+				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
+					return innerFunc(ctx, dfs)
+				})
+
+				// don't run the out.Concurrently() call below
+				out.Values[i] = graphql.Null
+				continue
+			}
+
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
diff --git a/api/graph/helpers.go b/api/graph/helpers.go
new file mode 100644
index 0000000..5dc7d08
--- /dev/null
+++ b/api/graph/helpers.go
@@ -0,0 +1,91 @@
+package graph
+
+import (
+	"context"
+	"links"
+	"links/api/graph/model"
+	"links/models"
+
+	sq "github.com/Masterminds/squirrel"
+	"netlandish.com/x/gobwebs/database"
+)
+
+// freshDBContext returns a context with a fresh DBI instance, preventing
+// concurrent reuse of the request's shared transaction. Required when gqlgen
+// resolves multiple DB-hitting fields in parallel on the same parent type.
+func freshDBContext(ctx context.Context) context.Context {
+	dbi := database.DBIForContext(ctx)
+	if dbi != nil {
+		ctx = database.DBIContext(ctx, database.NewDBI(dbi.GetDB()))
+	}
+	return ctx
+}
+
+func getOrganizationsForUser(ctx context.Context, userID int, search *string) ([]*models.Organization, error) {
+	opts := &database.FilterOptions{
+		Filter:  sq.Eq{"o.owner_id": userID},
+		OrderBy: "o.created_on ASC",
+	}
+	if search != nil && *search != "" {
+		s := links.ParseSearch(*search)
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Expr(`to_tsvector('simple', o.name || ' ' || o.slug )
+				@@ to_tsquery('simple', ?)`, s),
+		}
+	}
+	return models.GetOrganizations(ctx, opts)
+}
+
+func getDomainsForUser(ctx context.Context, user *models.User, orgSlug *string, service *model.DomainService) ([]*models.Domain, bool, error) {
+	var (
+		org     *models.Organization
+		orgSent bool
+	)
+	orgs, err := user.GetOrgs(ctx, models.OrgUserPermissionRead)
+	if err != nil {
+		return nil, false, err
+	}
+	if orgSlug == nil || *orgSlug == "" {
+		for _, o := range orgs {
+			if o.OwnerID == int(user.ID) && o.OrgType == models.OrgTypeUser {
+				org = o
+				break
+			}
+		}
+	} else {
+		orgSent = true
+		for _, o := range orgs {
+			if o.Slug == *orgSlug {
+				org = o
+				break
+			}
+		}
+	}
+	if org == nil {
+		return nil, true, nil
+	}
+	opts := &database.FilterOptions{
+		Filter: sq.Eq{"d.org_id": org.ID},
+	}
+	if !orgSent {
+		opts.Filter = sq.Or{
+			opts.Filter,
+			sq.And{
+				sq.Eq{"d.org_id": nil},
+				sq.Eq{"d.level": models.DomainLevelSystem},
+			},
+		}
+	}
+	if service != nil {
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Eq{"d.service": *service},
+		}
+	}
+	domains, err := models.GetDomains(ctx, opts)
+	if err != nil {
+		return nil, false, err
+	}
+	return domains, false, nil
+}
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index a4d3713..efe1df7 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -170,6 +170,8 @@ type User {
     isEmailVerified: Boolean! @access(scope: PROFILE, kind: RO)
     isLocked: Boolean! @access(scope: PROFILE, kind: RO)
     lockReason: String! @access(scope: PROFILE, kind: RO)
+    organizations: [Organization!]! @access(scope: ORGS, kind: RO)
+    domains: [Domain]! @access(scope: DOMAINS, kind: RO)
 }
 
 type BillingSettings {
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index 3dc1210..bdb6ccc 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -4479,6 +4479,7 @@ func (r *mutationResolver) RenameTag(ctx context.Context, input model.TagInput)
 	return payload, nil
 }
 
+// DeleteAccount is the resolver for the deleteAccount field.
 func (r *mutationResolver) DeleteAccount(ctx context.Context, input model.DeleteAccountInput) (*model.DeletePayload, error) {
 	tokenUser := oauth2.ForContext(ctx)
 	if tokenUser == nil {
@@ -5197,7 +5198,7 @@ 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:           12,
+		Minor:           13,
 		Patch:           0,
 		DeprecationDate: nil,
 	}, nil
@@ -5220,36 +5221,7 @@ func (r *queryResolver) GetOrganizations(ctx context.Context, input *model.GetOr
 		return nil, valid.ErrAuthorization
 	}
 	user := tokenUser.User.(*models.User)
-	opts := &database.FilterOptions{
-		Filter:  sq.Eq{"o.owner_id": user.ID},
-		OrderBy: "o.created_on ASC",
-	}
-	// XXX Uncomment when we decide on a path for org members with admin write permissions
-	//opts := &database.FilterOptions{
-	//    Filter: sq.Or{
-	//        sq.Eq{"o.owner_id": user.ID},
-	//        sq.And{
-	//            sq.Eq{"ou.user_id": user.ID},
-	//            sq.GtOrEq{"ou.permission": models.OrgUserPermissionAdminWrite},
-	//            sq.Eq{"ou.is_active": true},
-	//        },
-	//    },
-	//    OrderBy: "o.created_on ASC",
-	//}
-	if input.Search != nil && *input.Search != "" {
-		s := links.ParseSearch(*input.Search)
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.Expr(`to_tsvector('simple', o.name || ' ' || o.slug )
-				@@ to_tsquery('simple', ?)`, s),
-		}
-	}
-
-	orgs, err := models.GetOrganizations(ctx, opts)
-	if err != nil {
-		return nil, err
-	}
-	return orgs, err
+	return getOrganizationsForUser(ctx, int(user.ID), input.Search)
 }
 
 // GetOrganization is the resolver for the getOrganization field.
@@ -6036,67 +6008,19 @@ func (r *queryResolver) GetDomains(ctx context.Context, orgSlug *string, service
 	user := tokenUser.User.(*models.User)
 	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
 	lt := localizer.GetLocalizer(lang)
-
 	ctx = timezone.Context(ctx, links.GetUserTZ(user))
 
-	var (
-		org     *models.Organization
-		orgSent bool
-	)
-	orgs, err := user.GetOrgs(ctx, models.OrgUserPermissionRead)
+	domains, notFound, err := getDomainsForUser(ctx, user, orgSlug, service)
 	if err != nil {
 		return nil, err
 	}
-	if orgSlug == nil || *orgSlug == "" {
-		// No org given, default to user
-		for _, o := range orgs {
-			if o.OwnerID == int(user.ID) && o.OrgType == models.OrgTypeUser {
-				org = o
-				break
-			}
-		}
-	} else {
-		orgSent = true
-		for _, o := range orgs {
-			if o.Slug == *orgSlug {
-				org = o
-				break
-			}
-		}
-	}
-
-	if org == nil {
+	if notFound {
 		validator := valid.New(ctx)
 		validator.Error(
 			"%s", lt.Translate("Unable to find suitable organization")).
 			WithCode(valid.ErrNotFoundCode)
 		return nil, nil
 	}
-
-	opts := &database.FilterOptions{
-		Filter: sq.Eq{"d.org_id": org.ID},
-	}
-	if !orgSent {
-		// If no organization is specified then include system level domains as well
-		opts.Filter = sq.Or{
-			opts.Filter,
-			sq.And{
-				sq.Eq{"d.org_id": nil},
-				sq.Eq{"d.level": models.DomainLevelSystem},
-			},
-		}
-	}
-	if service != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.Eq{"d.service": *service},
-		}
-	}
-
-	domains, err := models.GetDomains(ctx, opts)
-	if err != nil {
-		return nil, err
-	}
 	return domains, nil
 }
 
@@ -7701,6 +7625,31 @@ func (r *userResolver) ID(ctx context.Context, obj *models.User) (int, error) {
 	return int(obj.ID), nil
 }
 
+// Organizations is the resolver for the organizations field.
+func (r *userResolver) Organizations(ctx context.Context, obj *models.User) ([]*models.Organization, error) {
+	return getOrganizationsForUser(freshDBContext(ctx), int(obj.ID), nil)
+}
+
+// Domains is the resolver for the domains field.
+func (r *userResolver) Domains(ctx context.Context, obj *models.User) ([]*models.Domain, error) {
+	ctx = freshDBContext(ctx)
+	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), obj)
+	lt := localizer.GetLocalizer(lang)
+	ctx = timezone.Context(ctx, links.GetUserTZ(obj))
+
+	domains, notFound, err := getDomainsForUser(ctx, obj, nil, nil)
+	if err != nil {
+		return nil, err
+	}
+	if notFound {
+		validator := valid.New(ctx)
+		validator.Error("%s", lt.Translate("Unable to find suitable organization")).
+			WithCode(valid.ErrNotFoundCode)
+		return nil, nil
+	}
+	return domains, nil
+}
+
 // AuditLog returns AuditLogResolver implementation.
 func (r *Resolver) AuditLog() AuditLogResolver { return &auditLogResolver{r} }
 
diff --git a/models/listing_link.go b/models/listing_link.go
index 36f7cec..addbec1 100644
--- a/models/listing_link.go
+++ b/models/listing_link.go
@@ -162,7 +162,7 @@ func GetListingLinksAnalytics(ctx context.Context, opts *database.FilterOptions)
 	if err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		q := opts.GetBuilder(nil)
 		rows, err := q.
-			Columns("ll.id", "ll.title", "ll.url", "SUM(dt.clicks) AS counter").
+			Columns("ll.id", "ll.title", "ll.url", "ll.created_on", "ll.updated_on", "SUM(dt.clicks) AS counter").
 			From("listing_links ll").
 			Join("daily_totals dt ON ll.id = dt.listing_link_id").
 			GroupBy("ll.id").
@@ -180,7 +180,7 @@ func GetListingLinksAnalytics(ctx context.Context, opts *database.FilterOptions)
 
 		for rows.Next() {
 			var l ListingLink
-			if err = rows.Scan(&l.ID, &l.Title, &l.URL, &l.Clicks); err != nil {
+			if err = rows.Scan(&l.ID, &l.Title, &l.URL, &l.CreatedOn, &l.UpdatedOn, &l.Clicks); err != nil {
 				return err
 			}
 			err = l.ToLocalTZ(tz)
-- 
2.52.0

