~netlandish/links-dev

links: api: add `organizations` and `domains` to the User type v1 APPLIED

Peter Sanchez: 1
 api: add `organizations` and `domains` to the User type

 5 files changed, 458 insertions(+), 83 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/227/mbox | git am -3
Learn more about email & git

[PATCH links] api: add `organizations` and `domains` to the User type Export this patch

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
Applied.

To git@git.code.netlandish.com:~netlandish/links
   6a64e3b..4a69bb1  master -> master