~netlandish/links-dev

links: account: ability to fully delete your account v1 APPLIED

Peter Sanchez: 1
 account: ability to fully delete your account

 18 files changed, 566 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/224/mbox | git am -3
Learn more about email & git

[PATCH links] account: ability to fully delete your account Export this patch

Implements: https://todo.code.netlandish.com/~netlandish/links/115
Changelog-updated: api version to 0.12.0
Changelog-added: ability to fully delete your account.
---
 accounts/input.go                             |  28 +++
 accounts/routes.go                            |  75 +++++++
 api/graph/generated.go                        | 190 +++++++++++++++---
 api/graph/model/models_gen.go                 |   8 +-
 api/graph/schema.graphqls                     |  11 +-
 api/graph/schema.resolvers.go                 |  84 +++++++-
 billing/processors.go                         |  22 +-
 billing/routes_test.go                        |   5 +-
 cmd/migrations.go                             |   7 +
 .../0010_preserve_billing_on_delete.down.sql  |  21 ++
 .../0010_preserve_billing_on_delete.up.sql    |  19 ++
 models/audit_log.go                           |   1 +
 models/base_url.go                            |  51 +++++
 models/invoice.go                             |   4 +-
 models/models.go                              |  54 ++---
 models/user.go                                |  15 ++
 templates/delete_account.html                 |  40 ++++
 templates/settings.html                       |   6 +
 18 files changed, 566 insertions(+), 75 deletions(-)
 create mode 100644 migrations/0010_preserve_billing_on_delete.down.sql
 create mode 100644 migrations/0010_preserve_billing_on_delete.up.sql
 create mode 100644 templates/delete_account.html

diff --git a/accounts/input.go b/accounts/input.go
index e114803..782d5e0 100644
--- a/accounts/input.go
+++ b/accounts/input.go
@@ -145,3 +145,31 @@ func (p *ProfileForm) Validate(c echo.Context) error {
	// Example, valid email address, etc.
	return c.Validate(p)
}

// DeleteAccountForm ...
type DeleteAccountForm struct {
	Password     string `form:"password" validate:"required,max=100"`
	Confirmation string `form:"confirmation" validate:"required"`
}

// Validate ...
func (d *DeleteAccountForm) Validate(c echo.Context) error {
	errs := validate.FormFieldBinder(c, d).
		FailFast(false).
		String("password", &d.Password).
		String("confirmation", &d.Confirmation).
		BindErrors()
	if errs != nil {
		return validate.GetInputErrors(errs)
	}
	if err := c.Validate(d); err != nil {
		return err
	}
	lt := localizer.GetSessionLocalizer(c)
	if strings.ToLower(strings.TrimSpace(d.Confirmation)) != "delete" {
		return validate.InputErrors{
			"Confirmation": lt.Translate("Please type 'delete' to confirm."),
		}
	}
	return nil
}
diff --git a/accounts/routes.go b/accounts/routes.go
index 3b97912..5e0aac9 100644
--- a/accounts/routes.go
+++ b/accounts/routes.go
@@ -62,6 +62,8 @@ func (s *Service) RegisterRoutes() {
	s.Group.GET("/settings/log", s.UserLog).Name = s.RouteName("settings_log")
	s.Group.GET("/profile", s.EditProfile).Name = s.RouteName("profile_edit")
	s.Group.POST("/profile", s.EditProfile).Name = s.RouteName("profile_edit_post")
	s.Group.GET("/delete-account", s.DeleteAccount).Name = s.RouteName("delete_account")
	s.Group.POST("/delete-account", s.DeleteAccount).Name = s.RouteName("delete_account_post")
}

// EditProfile ..
@@ -271,6 +273,9 @@ func (s *Service) Settings(c echo.Context) error {
	pd.Data["drag_drop"] = lt.Translate("Drag and drop this button to your web browser toolbar or your bookmarks.")
	pd.Data["email_posting"] = lt.Translate("Email Posting")
	pd.Data["email_posting_warning"] = lt.Translate("Do not share this address. Anyone with this email can post links to your account.")
	pd.Data["danger_zone"] = lt.Translate("Danger Zone")
	pd.Data["danger_zone_detail"] = lt.Translate("Permanently delete your account and all associated data.")
	pd.Data["delete_account"] = lt.Translate("Delete Account")

	user := gctx.User.(*models.User)
	langTrans := map[string]string{
@@ -552,6 +557,76 @@ func (s *Service) Register(c echo.Context) error {
	return s.Render(c, http.StatusOK, "register.html", gmap)
}

// DeleteAccount handles the account deletion page
func (s *Service) DeleteAccount(c echo.Context) error {
	lt := localizer.GetSessionLocalizer(c)
	pd := localizer.NewPageData(lt.Translate("Delete Account"))
	pd.Data["delete_account"] = lt.Translate("Delete Account")
	pd.Data["warning"] = lt.Translate("This action is permanent and cannot be undone.")
	pd.Data["warning_detail"] = lt.Translate("All your organizations, bookmarks, short links, listings, and related data will be permanently deleted.")
	pd.Data["password"] = lt.Translate("Password")
	pd.Data["confirmation_label"] = lt.Translate("Type 'delete' to confirm")
	pd.Data["submit"] = lt.Translate("Delete My Account")
	pd.Data["cancel"] = lt.Translate("Cancel")

	gmap := gobwebs.Map{
		"pd":             pd,
		"settingSection": true,
		"navFlag":        "settings",
	}

	req := c.Request()
	if req.Method == http.MethodPost {
		form := &DeleteAccountForm{}
		if err := form.Validate(c); err != nil {
			switch err.(type) {
			case validate.InputErrors:
				gmap["errors"] = err
				gmap["form"] = form
				return s.Render(c, http.StatusOK, "delete_account.html", gmap)
			default:
				return err
			}
		}

		type GraphQLResponse struct {
			Result struct {
				Success  bool   `json:"success"`
				ObjectID string `json:"objectId"`
				Message  string `json:"message"`
			} `json:"deleteAccount"`
		}
		var result GraphQLResponse
		op := gqlclient.NewOperation(
			`mutation DeleteAccount($password: String!) {
				deleteAccount(input: {password: $password}) {
					success
					objectId
					message
				}
			}`)
		op.Var("password", form.Password)
		err := links.Execute(c.Request().Context(), op, &result)
		if err != nil {
			if graphError, ok := err.(*gqlclient.Error); ok {
				err = links.ParseInputErrors(c, graphError, gobwebs.Map{})
				switch err.(type) {
				case validate.InputErrors:
					gmap["errors"] = err
					gmap["form"] = form
					return s.Render(c, http.StatusOK, "delete_account.html", gmap)
				}
			}
			return err
		}

		auth.UserLogout(c)
		messages.Success(c, lt.Translate("Your account has been permanently deleted."))
		return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse("accounts:login"))
	}
	return s.Render(c, http.StatusOK, "delete_account.html", gmap)
}

// NewService return service
func NewService(eg *echo.Group, render validate.TemplateRenderFunc) *Service {
	baseService := server.NewService(eg, "accounts", render)
diff --git a/api/graph/generated.go b/api/graph/generated.go
index 78b7cce..bcd11e0 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -252,6 +252,7 @@ type ComplexityRoot struct {
		AddQRCode              func(childComplexity int, input model.AddQRCodeInput) int
		CompleteRegister       func(childComplexity int, input *model.CompleteRegisterInput) int
		ConfirmMember          func(childComplexity int, key string) int
		DeleteAccount          func(childComplexity int, input model.DeleteAccountInput) int
		DeleteDomain           func(childComplexity int, id int) int
		DeleteLink             func(childComplexity int, hash string) int
		DeleteLinkShort        func(childComplexity int, id int) int
@@ -541,6 +542,7 @@ type MutationResolver interface {
	Unfollow(ctx context.Context, orgSlug string) (*model.OperationPayload, error)
	DeleteTag(ctx context.Context, input model.TagInput) (*model.DeletePayload, error)
	RenameTag(ctx context.Context, input model.TagInput) (*model.OperationPayload, error)
	DeleteAccount(ctx context.Context, input model.DeleteAccountInput) (*model.DeletePayload, error)
	UpdateAdminOrg(ctx context.Context, input model.AdminUpdateOrgInput) (*models.Organization, error)
	AddAdminDomain(ctx context.Context, input model.AdminDomainInput) (*models.Domain, error)
	UpdateAdminDomain(ctx context.Context, input model.UpdateAdminDomainInput) (*models.Domain, error)
@@ -1545,6 +1547,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin

		return e.complexity.Mutation.ConfirmMember(childComplexity, args["key"].(string)), true

	case "Mutation.deleteAccount":
		if e.complexity.Mutation.DeleteAccount == nil {
			break
		}

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

		return e.complexity.Mutation.DeleteAccount(childComplexity, args["input"].(model.DeleteAccountInput)), true

	case "Mutation.deleteDomain":
		if e.complexity.Mutation.DeleteDomain == nil {
			break
@@ -2993,6 +3007,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
		ec.unmarshalInputAnalyticsInput,
		ec.unmarshalInputAuditLogInput,
		ec.unmarshalInputCompleteRegisterInput,
		ec.unmarshalInputDeleteAccountInput,
		ec.unmarshalInputDomainInput,
		ec.unmarshalInputGetAdminDomainInput,
		ec.unmarshalInputGetAdminOrganizationsInput,
@@ -3296,6 +3311,17 @@ func (ec *executionContext) field_Mutation_confirmMember_args(ctx context.Contex
	return args, nil
}

func (ec *executionContext) field_Mutation_deleteAccount_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.unmarshalNDeleteAccountInput2linksᚋapiᚋgraphᚋmodelᚐDeleteAccountInput)
	if err != nil {
		return nil, err
	}
	args["input"] = arg0
	return args, nil
}

func (ec *executionContext) field_Mutation_deleteDomain_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
	var err error
	args := map[string]any{}
@@ -13025,6 +13051,91 @@ func (ec *executionContext) fieldContext_Mutation_renameTag(ctx context.Context,
	return fc, nil
}

func (ec *executionContext) _Mutation_deleteAccount(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_Mutation_deleteAccount(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.Mutation().DeleteAccount(rctx, fc.Args["input"].(model.DeleteAccountInput))
		}

		directive1 := func(ctx context.Context) (any, error) {
			if ec.directives.Private == nil {
				var zeroVal *model.DeletePayload
				return zeroVal, errors.New("directive private is not implemented")
			}
			return ec.directives.Private(ctx, nil, directive0)
		}

		tmp, err := directive1(rctx)
		if err != nil {
			return nil, graphql.ErrorOnPath(ctx, err)
		}
		if tmp == nil {
			return nil, nil
		}
		if data, ok := tmp.(*model.DeletePayload); ok {
			return data, nil
		}
		return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/api/graph/model.DeletePayload`, 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.DeletePayload)
	fc.Result = res
	return ec.marshalNDeletePayload2ᚖlinksᚋapiᚋgraphᚋmodelᚐDeletePayload(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_Mutation_deleteAccount(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
	fc = &graphql.FieldContext{
		Object:     "Mutation",
		Field:      field,
		IsMethod:   true,
		IsResolver: true,
		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
			switch field.Name {
			case "success":
				return ec.fieldContext_DeletePayload_success(ctx, field)
			case "objectId":
				return ec.fieldContext_DeletePayload_objectId(ctx, field)
			case "message":
				return ec.fieldContext_DeletePayload_message(ctx, field)
			}
			return nil, fmt.Errorf("no field named %q was found under type DeletePayload", 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_Mutation_deleteAccount_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
		ec.Error(ctx, err)
		return fc, err
	}
	return fc, nil
}

func (ec *executionContext) _Mutation_updateAdminOrg(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_Mutation_updateAdminOrg(ctx, field)
	if err != nil {
@@ -16919,16 +17030,16 @@ func (ec *executionContext) _Payment_orgId(ctx context.Context, field graphql.Co
		directive1 := func(ctx context.Context) (any, error) {
			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "BILLING")
			if err != nil {
				var zeroVal int
				var zeroVal *int
				return zeroVal, err
			}
			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
			if err != nil {
				var zeroVal int
				var zeroVal *int
				return zeroVal, err
			}
			if ec.directives.Access == nil {
				var zeroVal int
				var zeroVal *int
				return zeroVal, errors.New("directive access is not implemented")
			}
			return ec.directives.Access(ctx, obj, directive0, scope, kind)
@@ -16941,24 +17052,21 @@ func (ec *executionContext) _Payment_orgId(ctx context.Context, field graphql.Co
		if tmp == nil {
			return nil, nil
		}
		if data, ok := tmp.(int); ok {
		if data, ok := tmp.(*int); ok {
			return data, nil
		}
		return nil, fmt.Errorf(`unexpected type %T from directive, should be int`, tmp)
		return nil, fmt.Errorf(`unexpected type %T from directive, should be *int`, 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.(int)
	res := resTmp.(*int)
	fc.Result = res
	return ec.marshalNInt2int(ctx, field.Selections, res)
	return ec.marshalOInt2ᚖint(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_Payment_orgId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -16995,16 +17103,16 @@ func (ec *executionContext) _Payment_orgSlug(ctx context.Context, field graphql.
		directive1 := func(ctx context.Context) (any, error) {
			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "BILLING")
			if err != nil {
				var zeroVal string
				var zeroVal *string
				return zeroVal, err
			}
			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
			if err != nil {
				var zeroVal string
				var zeroVal *string
				return zeroVal, err
			}
			if ec.directives.Access == nil {
				var zeroVal string
				var zeroVal *string
				return zeroVal, errors.New("directive access is not implemented")
			}
			return ec.directives.Access(ctx, obj, directive0, scope, kind)
@@ -17017,24 +17125,21 @@ func (ec *executionContext) _Payment_orgSlug(ctx context.Context, field graphql.
		if tmp == nil {
			return nil, nil
		}
		if data, ok := tmp.(string); ok {
		if data, ok := tmp.(*string); ok {
			return data, nil
		}
		return nil, fmt.Errorf(`unexpected type %T from directive, should be string`, tmp)
		return nil, fmt.Errorf(`unexpected type %T from directive, should be *string`, 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.(string)
	res := resTmp.(*string)
	fc.Result = res
	return ec.marshalNString2string(ctx, field.Selections, res)
	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_Payment_orgSlug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -25225,6 +25330,33 @@ func (ec *executionContext) unmarshalInputCompleteRegisterInput(ctx context.Cont
	return it, nil
}

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

	fieldsInOrder := [...]string{"password"}
	for _, k := range fieldsInOrder {
		v, ok := asMap[k]
		if !ok {
			continue
		}
		switch k {
		case "password":
			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password"))
			data, err := ec.unmarshalNString2string(ctx, v)
			if err != nil {
				return it, err
			}
			it.Password = data
		}
	}

	return it, nil
}

func (ec *executionContext) unmarshalInputDomainInput(ctx context.Context, obj any) (model.DomainInput, error) {
	var it model.DomainInput
	asMap := map[string]any{}
@@ -28827,6 +28959,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
			if out.Values[i] == graphql.Null {
				out.Invalids++
			}
		case "deleteAccount":
			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
				return ec._Mutation_deleteAccount(ctx, field)
			})
			if out.Values[i] == graphql.Null {
				out.Invalids++
			}
		case "updateAdminOrg":
			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
				return ec._Mutation_updateAdminOrg(ctx, field)
@@ -29719,14 +29858,8 @@ func (ec *executionContext) _Payment(ctx context.Context, sel ast.SelectionSet,
			}
		case "orgId":
			out.Values[i] = ec._Payment_orgId(ctx, field, obj)
			if out.Values[i] == graphql.Null {
				out.Invalids++
			}
		case "orgSlug":
			out.Values[i] = ec._Payment_orgSlug(ctx, field, obj)
			if out.Values[i] == graphql.Null {
				out.Invalids++
			}
		default:
			panic("unknown field " + strconv.Quote(field.Name))
		}
@@ -31737,6 +31870,11 @@ func (ec *executionContext) marshalNCursor2linksᚋapiᚋgraphᚋmodelᚐCursor(
	return v
}

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

func (ec *executionContext) marshalNDeletePayload2linksᚋapiᚋgraphᚋmodelᚐDeletePayload(ctx context.Context, sel ast.SelectionSet, v model.DeletePayload) graphql.Marshaler {
	return ec._DeletePayload(ctx, sel, &v)
}
diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go
index c70c0ee..79ddf39 100644
--- a/api/graph/model/models_gen.go
+++ b/api/graph/model/models_gen.go
@@ -138,6 +138,10 @@ type CompleteRegisterInput struct {
	Key      string `json:"key"`
}

type DeleteAccountInput struct {
	Password string `json:"password"`
}

type DeletePayload struct {
	Success  bool    `json:"success"`
	ObjectID string  `json:"objectId"`
@@ -381,8 +385,8 @@ type Payment struct {
	AmountPaid       int       `json:"amountPaid"`
	AmountNet        int       `json:"amountNet"`
	PaymentFee       int       `json:"paymentFee"`
	OrgID            int       `json:"orgId"`
	OrgSlug          string    `json:"orgSlug"`
	OrgID            *int      `json:"orgId,omitempty"`
	OrgSlug          *string   `json:"orgSlug,omitempty"`
}

type PaymentCursor struct {
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index f590b98..a4d3713 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -208,8 +208,8 @@ type Payment {
    amountPaid: Int!
    amountNet: Int! @admin
    paymentFee: Int!
    orgId: Int! @access(scope: BILLING, kind: RO)
    orgSlug: String! @access(scope: BILLING, kind: RO)
    orgId: Int @access(scope: BILLING, kind: RO)
    orgSlug: String @access(scope: BILLING, kind: RO)
}

type HTMLMeta {
@@ -939,6 +939,10 @@ type Query {
    getAdminDomains(input: GetAdminDomainInput): DomainCursor! @admin
}

input DeleteAccountInput {
    password: String!
}

type Mutation {
    "Create a new organization"
    addOrganization(input: OrganizationInput!): Organization! @access(scope: ORGS, kind: RW)
@@ -995,6 +999,9 @@ type Mutation {
    deleteTag(input: TagInput!): DeletePayload! @access(scope: ORGS, kind: RW)
    renameTag(input: TagInput!): OperationPayload! @access(scope: ORGS, kind: RW)

    "Delete the authenticated user's account permanently. Requires password verification."
    deleteAccount(input: DeleteAccountInput!): DeletePayload! @private

    #
    # Admin only. Not open to public calls
    #
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index 9610a1d..3dc1210 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -4479,6 +4479,77 @@ func (r *mutationResolver) RenameTag(ctx context.Context, input model.TagInput)
	return payload, nil
}

func (r *mutationResolver) DeleteAccount(ctx context.Context, input model.DeleteAccountInput) (*model.DeletePayload, error) {
	tokenUser := oauth2.ForContext(ctx)
	if tokenUser == nil {
		return nil, valid.ErrAuthorization
	}
	if tokenUser.Token.ClientID != "" {
		return nil, fmt.Errorf("Account deletion not allowed via OAuth2 client")
	}
	user := tokenUser.User.(*models.User)
	c := server.EchoForContext(ctx)
	lang := links.GetLangFromRequest(c.Request(), user)
	lt := localizer.GetLocalizer(lang)

	validator := valid.New(ctx)

	match, err := user.ComparePassword(input.Password)
	if err != nil || !match {
		validator.Error("%s", lt.Translate("The password you entered is incorrect.")).
			WithField("password").WithCode(valid.ErrValidationCode)
		return nil, nil
	}

	opts := &database.FilterOptions{
		Filter: sq.Eq{"o.owner_id": user.ID},
	}
	ownedOrgs, err := models.GetOrganizations(ctx, opts)
	if err != nil {
		return nil, err
	}
	orgIDs := make([]int, len(ownedOrgs))
	for i, o := range ownedOrgs {
		orgIDs[i] = o.ID
	}

	subOpts := &database.FilterOptions{
		Filter: sq.And{
			sq.Eq{"s.org_id": orgIDs},
			sq.Eq{"s.is_active": true},
		},
		Limit: 1,
	}
	subs, err := models.GetSubscriptions(ctx, subOpts)
	if err != nil {
		return nil, err
	}
	if len(subs) > 0 {
		validator.Error("%s", lt.Translate("Please cancel all active subscriptions before deleting your account.")).
			WithCode(valid.ErrValidationCode)
		return nil, nil
	}

	baseURLIDs, err := models.GetBaseURLIDsForOrgs(ctx, orgIDs)
	if err != nil {
		return nil, err
	}

	if err := user.Delete(ctx); err != nil {
		return nil, err
	}

	if err := models.BulkUpdateCounters(ctx, baseURLIDs); err != nil {
		srv := server.ForContext(ctx)
		srv.Logger().Printf("Error updating base_url counters after account deletion: %v", err)
	}

	return &model.DeletePayload{
		Success:  true,
		ObjectID: fmt.Sprintf("%d", user.ID),
	}, nil
}

// UpdateAdminOrgType is the resolver for the updateAdminOrgType field.
func (r *mutationResolver) UpdateAdminOrg(ctx context.Context, input model.AdminUpdateOrgInput) (*models.Organization, error) {
	tokenUser := oauth2.ForContext(ctx)
@@ -5126,8 +5197,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:           11,
		Patch:           3,
		Minor:           12,
		Patch:           0,
		DeprecationDate: nil,
	}, nil
}
@@ -5364,8 +5435,13 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP
			p.AmountPaid = i.AmountPaid
			p.AmountNet = i.AmountNet
			p.PaymentFee = i.PaymentFee
			p.OrgSlug = i.OrgSlug
			p.OrgID = i.OrgID
			if i.OrgSlug.Valid {
				p.OrgSlug = &i.OrgSlug.String
			}
			if i.OrgID.Valid {
				orgID := int(i.OrgID.Int64)
				p.OrgID = &orgID
			}
		}
		payments = append(payments, p)
	}
diff --git a/billing/processors.go b/billing/processors.go
index d73a599..5ff086e 100644
--- a/billing/processors.go
@@ -2,6 +2,7 @@ package billing

import (
	"context"
	"database/sql"
	"fmt"
	"links"
	"links/accounts"
@@ -73,8 +74,9 @@ func ProcessCheckoutSessionCompletedTask(ctx context.Context, conf *config.Confi
	stripeSub, err := stripeClient.Subscriptions.Get(subID, nil)
	plan := plans[0]
	subscription := &models.Subscription{
		UserID:             org.OwnerID, // NOTE we assume that always the owner will pay a subscription
		OrgID:              org.ID,
		// assume that owner always pays for a subscription
		UserID:             sql.NullInt64{Int64: int64(org.OwnerID), Valid: true},
		OrgID:              sql.NullInt64{Int64: int64(org.ID), Valid: true},
		StripeID:           subID,
		SubscriptionPlanID: plan.ID,
		StartDate:          time.Unix(stripeSub.CurrentPeriodStart, 0),
@@ -273,7 +275,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
	}

	opts = &database.FilterOptions{
		Filter: sq.Eq{"o.id": sub.OrgID},
		Filter: sq.Eq{"o.id": sub.OrgID.Int64},
		Limit:  1,
	}
	orgs, err := models.GetOrganizations(ctx, opts)
@@ -322,7 +324,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
	// Disable domain
	opts = &database.FilterOptions{
		Filter: sq.And{
			sq.Eq{"org_id": sub.OrgID},
			sq.Eq{"org_id": sub.OrgID.Int64},
			sq.Eq{"level": models.DomainLevelUser},
		},
	}
@@ -334,7 +336,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
	// Disable listing
	opts = &database.FilterOptions{
		Filter: sq.And{
			sq.Eq{"l.org_id": sub.OrgID},
			sq.Eq{"l.org_id": sub.OrgID.Int64},
			sq.Eq{"d.level": models.DomainLevelSystem},
		},
		OrderBy: "l.created_on ASC",
@@ -345,7 +347,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
		return err
	}
	opts = &database.FilterOptions{
		Filter: sq.Eq{"org_id": sub.OrgID},
		Filter: sq.Eq{"org_id": sub.OrgID.Int64},
	}
	if len(listings) > 0 {
		opts.Filter = sq.And{
@@ -361,7 +363,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
	// Disable organization members
	opts = &database.FilterOptions{
		Filter: sq.And{
			sq.Eq{"org_id": sub.OrgID},
			sq.Eq{"org_id": sub.OrgID.Int64},
			sq.Eq{"is_active": true},
		},
	}
@@ -384,7 +386,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat

	// Send email
	opts = &database.FilterOptions{
		Filter: sq.Eq{"u.id": sub.UserID},
		Filter: sq.Eq{"u.id": sub.UserID.Int64},
		Limit:  1,
	}
	users, err := models.GetUsers(ctx, opts)
@@ -479,7 +481,7 @@ func ProcessSubscriptionUpdatedTask(ctx context.Context, srv *server.Server, dat
	// The subscription was marked to be cancelled at the end of its period
	if sub.CancelAtEnd {
		opts = &database.FilterOptions{
			Filter: sq.Eq{"u.id": sub.UserID},
			Filter: sq.Eq{"u.id": sub.UserID.Int64},
			Limit:  1,
		}
		users, err := models.GetUsers(ctx, opts)
@@ -509,7 +511,7 @@ func ProcessSubscriptionUpdatedTask(ctx context.Context, srv *server.Server, dat
		}

		opts = &database.FilterOptions{
			Filter: sq.Eq{"o.id": sub.OrgID},
			Filter: sq.Eq{"o.id": sub.OrgID.Int64},
			Limit:  1,
		}
		orgs, err := models.GetOrganizations(ctx, opts)
diff --git a/billing/routes_test.go b/billing/routes_test.go
index 3082be4..f9a9c2e 100644
--- a/billing/routes_test.go
@@ -1,6 +1,7 @@
package billing_test

import (
	"database/sql"
	"links"
	"links/billing"
	"links/cmd"
@@ -130,8 +131,8 @@ func TestHandlers(t *testing.T) {
		c.Equal(http.StatusMovedPermanently, recorder.Code)

		subscription := &models.Subscription{
			UserID:             1,
			OrgID:              1,
			UserID:             sql.NullInt64{Int64: 1, Valid: true},
			OrgID:              sql.NullInt64{Int64: 1, Valid: true},
			StripeID:           "stripe",
			SubscriptionPlanID: 1,
			StartDate:          time.Now(),
diff --git a/cmd/migrations.go b/cmd/migrations.go
index 8e7c71a..bdb131d 100644
--- a/cmd/migrations.go
+++ b/cmd/migrations.go
@@ -87,5 +87,12 @@ func GetMigrations() []migrate.Migration {
			0,
			links.MigrateFS,
		),
		migrate.FSFileMigration(
			"0010_preserve_billing_on_delete",
			"migrations/0010_preserve_billing_on_delete.up.sql",
			"migrations/0010_preserve_billing_on_delete.down.sql",
			0,
			links.MigrateFS,
		),
	}
}
diff --git a/migrations/0010_preserve_billing_on_delete.down.sql b/migrations/0010_preserve_billing_on_delete.down.sql
new file mode 100644
index 0000000..72dfd59
--- /dev/null
+++ b/migrations/0010_preserve_billing_on_delete.down.sql
@@ -0,0 +1,21 @@
-- Reverse: delete orphaned rows first, then restore NOT NULL + CASCADE
DELETE FROM invoices WHERE user_id IS NULL OR org_id IS NULL;
DELETE FROM subscriptions WHERE user_id IS NULL OR org_id IS NULL;

ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_user_id_fkey;
ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_user_id_fkey
    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE subscriptions ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_org_id_fkey;
ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_org_id_fkey
    FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE CASCADE;
ALTER TABLE subscriptions ALTER COLUMN org_id SET NOT NULL;

ALTER TABLE invoices DROP CONSTRAINT invoices_user_id_fkey;
ALTER TABLE invoices ADD CONSTRAINT invoices_user_id_fkey
    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE invoices ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE invoices DROP CONSTRAINT invoices_org_id_fkey;
ALTER TABLE invoices ADD CONSTRAINT invoices_org_id_fkey
    FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE CASCADE;
ALTER TABLE invoices ALTER COLUMN org_id SET NOT NULL;
diff --git a/migrations/0010_preserve_billing_on_delete.up.sql b/migrations/0010_preserve_billing_on_delete.up.sql
new file mode 100644
index 0000000..aa00f87
--- /dev/null
+++ b/migrations/0010_preserve_billing_on_delete.up.sql
@@ -0,0 +1,19 @@
-- subscriptions: make user_id and org_id nullable, change FK to SET NULL
ALTER TABLE subscriptions ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE subscriptions ALTER COLUMN org_id DROP NOT NULL;
ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_user_id_fkey;
ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_user_id_fkey
    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL;
ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_org_id_fkey;
ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_org_id_fkey
    FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE SET NULL;

-- invoices: make user_id and org_id nullable, change FK to SET NULL
ALTER TABLE invoices ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE invoices ALTER COLUMN org_id DROP NOT NULL;
ALTER TABLE invoices DROP CONSTRAINT invoices_user_id_fkey;
ALTER TABLE invoices ADD CONSTRAINT invoices_user_id_fkey
    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL;
ALTER TABLE invoices DROP CONSTRAINT invoices_org_id_fkey;
ALTER TABLE invoices ADD CONSTRAINT invoices_org_id_fkey
    FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE SET NULL;
diff --git a/models/audit_log.go b/models/audit_log.go
index 4327625..17670af 100644
--- a/models/audit_log.go
+++ b/models/audit_log.go
@@ -13,6 +13,7 @@ const (
	LOG_ACCT_EMAIL_CONF      = "account_email_confirmation"
	LOG_ACCT_PASSWORD_CHANGE = "account_password_change"
	LOG_ACCT_PASSWORD_RESET  = "account_password_reset"
	LOG_ACCT_DELETED         = "account_deleted"

	LOG_PROFILE_UPDATED = "profile_updated"

diff --git a/models/base_url.go b/models/base_url.go
index 140fdd0..926a75d 100644
--- a/models/base_url.go
+++ b/models/base_url.go
@@ -11,6 +11,7 @@ import (
	"time"

	sq "github.com/Masterminds/squirrel"
	"github.com/lib/pq"
	"github.com/segmentio/ksuid"
	"netlandish.com/x/gobwebs/database"
	"netlandish.com/x/gobwebs/timezone"
@@ -258,3 +259,53 @@ func (b *BaseURL) QueryParams() url.Values {
func (b *BaseURL) GetID() int {
	return b.ID
}

// GetBaseURLIDsForOrgs returns unique base_url_ids referenced by org_links
// belonging to the given organization IDs
func GetBaseURLIDsForOrgs(ctx context.Context, orgIDs []int) ([]int, error) {
	if len(orgIDs) == 0 {
		return nil, nil
	}
	var ids []int
	err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
		rows, err := sq.Select("DISTINCT base_url_id").
			From("org_links").
			Where(sq.Eq{"org_id": orgIDs}).
			Where(sq.NotEq{"base_url_id": nil}).
			PlaceholderFormat(database.GetPlaceholderFormat()).
			RunWith(tx).
			QueryContext(ctx)
		if err != nil {
			return err
		}
		defer rows.Close()
		for rows.Next() {
			var id int
			if err := rows.Scan(&id); err != nil {
				return err
			}
			ids = append(ids, id)
		}
		return nil
	})
	return ids, err
}

// BulkUpdateCounters recalculates the counter for the given base_url IDs
// by counting PUBLIC org_links that reference each base_url
func BulkUpdateCounters(ctx context.Context, ids []int) error {
	if len(ids) == 0 {
		return nil
	}
	query := `
		UPDATE base_urls SET counter = (
			SELECT count(*) FROM org_links
			WHERE base_url_id = base_urls.id AND visibility = 'PUBLIC'
		), updated_on = now()
		WHERE id = ANY($1)
	`
	return database.WithTx(ctx, nil, func(tx *sql.Tx) error {
		_, err := tx.ExecContext(ctx, query, pq.Array(ids))
		return err
	})
}
diff --git a/models/invoice.go b/models/invoice.go
index bf98547..8da73e0 100644
--- a/models/invoice.go
+++ b/models/invoice.go
@@ -32,7 +32,7 @@ func GetInvoices(ctx context.Context, opts *database.FilterOptions) ([]*Invoice,
				"i.amount_paid", "i.amount_net", "i.amount_refunded", "i.payment_fee", "i.hosted_invoice_url", "i.created_on",
				"i.updated_on", "o.slug").
			From("invoices i").
			Join("organizations o ON o.id = i.org_id").
			LeftJoin("organizations o ON o.id = i.org_id").
			PlaceholderFormat(database.GetPlaceholderFormat()).
			RunWith(tx).
			QueryContext(ctx)
@@ -196,7 +196,7 @@ func GetInvoicesTotal(ctx context.Context, opts *database.FilterOptions) (map[st
			Columns("COALESCE(SUM(i.amount), 0)", "COALESCE(SUM(i.payment_fee), 0)",
				"COALESCE(SUM(i.amount_net), 0)", "COALESCE(SUM(i.amount_refunded), 0)").
			From("invoices i").
			Join("organizations o ON o.id = i.org_id").
			LeftJoin("organizations o ON o.id = i.org_id").
			PlaceholderFormat(database.GetPlaceholderFormat()).
			RunWith(tx).
			QueryContext(ctx)
diff --git a/models/models.go b/models/models.go
index 87ea649..0113e44 100644
--- a/models/models.go
+++ b/models/models.go
@@ -402,39 +402,39 @@ type SubscriptionPlan struct {
}

type Subscription struct {
	ID                 int       `db:"id"`
	UserID             int       `db:"user_id"`
	OrgID              int       `db:"org_id"`
	StripeID           string    `db:"stripe_id"`
	SubscriptionPlanID int       `db:"subscription_plan_id"`
	StartDate          time.Time `db:"start_date"`
	EndDate            time.Time `db:"end_date"`
	CancelAtEnd        bool      `db:"cancel_end"`
	IsActive           bool      `db:"is_active"`
	CreatedOn          time.Time `db:"created_on"`
	UpdatedOn          time.Time `db:"updated_on"`
	ID                 int           `db:"id"`
	UserID             sql.NullInt64 `db:"user_id"`
	OrgID              sql.NullInt64 `db:"org_id"`
	StripeID           string        `db:"stripe_id"`
	SubscriptionPlanID int           `db:"subscription_plan_id"`
	StartDate          time.Time     `db:"start_date"`
	EndDate            time.Time     `db:"end_date"`
	CancelAtEnd        bool          `db:"cancel_end"`
	IsActive           bool          `db:"is_active"`
	CreatedOn          time.Time     `db:"created_on"`
	UpdatedOn          time.Time     `db:"updated_on"`

	PlanName sql.NullString `db:"-"`
}

type Invoice struct {
	ID               int       `db:"id"`
	UserID           int       `db:"user_id"`
	Status           string    `db:"status"`
	OrgID            int       `db:"org_id"`
	SubscriptionID   int       `db:"subscription_id"`
	StripeID         string    `db:"stripe_id"`
	Currency         string    `db:"currency"`
	Amount           int       `db:"amount"`
	AmountPaid       int       `db:"amount_paid"`
	AmountNet        int       `db:"amount_net"`
	AmountRefunded   int       `db:"amount_net"`
	PaymentFee       int       `db:"payment_fee"`
	HostedInvoiceURL string    `db:"hosted_invoice_url"`
	CreatedOn        time.Time `db:"created_on"`
	UpdatedOn        time.Time `db:"updated_on"`
	ID               int            `db:"id"`
	UserID           sql.NullInt64  `db:"user_id"`
	Status           string         `db:"status"`
	OrgID            sql.NullInt64  `db:"org_id"`
	SubscriptionID   int            `db:"subscription_id"`
	StripeID         string         `db:"stripe_id"`
	Currency         string         `db:"currency"`
	Amount           int            `db:"amount"`
	AmountPaid       int            `db:"amount_paid"`
	AmountNet        int            `db:"amount_net"`
	AmountRefunded   int            `db:"amount_net"`
	PaymentFee       int            `db:"payment_fee"`
	HostedInvoiceURL string         `db:"hosted_invoice_url"`
	CreatedOn        time.Time      `db:"created_on"`
	UpdatedOn        time.Time      `db:"updated_on"`

	OrgSlug string `db:"-"`
	OrgSlug sql.NullString `db:"-"`
}

type Follower struct {
diff --git a/models/user.go b/models/user.go
index 64f2c6f..4aa3a7c 100644
--- a/models/user.go
+++ b/models/user.go
@@ -229,6 +229,21 @@ func (u *User) WritePassword(ctx context.Context) error {
	return err
}

// Delete removes the user from the database
func (u *User) Delete(ctx context.Context) error {
	if u.ID == 0 {
		return fmt.Errorf("User object is not populated")
	}
	return database.WithTx(ctx, nil, func(tx *sql.Tx) error {
		_, err := sq.Delete("users").
			Where("id = ?", u.ID).
			PlaceholderFormat(database.GetPlaceholderFormat()).
			RunWith(tx).
			ExecContext(ctx)
		return err
	})
}

// GetOrgs ...
func (u *User) GetOrgs(ctx context.Context, perm string) ([]*Organization, error) {
	if u.ID == 0 {
diff --git a/templates/delete_account.html b/templates/delete_account.html
new file mode 100644
index 0000000..9fdb5dd
--- /dev/null
+++ b/templates/delete_account.html
@@ -0,0 +1,40 @@
{{template "base" .}}
{{ define "title" }}{{ .pd.Data.delete_account }}{{ end }}
<section class="app-header">
  <h1 class="app-header__title">{{.pd.Data.delete_account}}</h1>
  <a class="button primary is-small" href="{{reverse "accounts:settings"}}">{{.pd.Data.cancel}}</a>
</section>

<section class="card shadow-card">
  <div class="alert alert-error mb-2">
    <p><strong>{{.pd.Data.warning}}</strong></p>
    <p>{{.pd.Data.warning_detail}}</p>
  </div>
  <form id="delete-account-form" method="POST" action="{{ reverse "accounts:delete_account_post"}}">
    <input type="hidden" name="csrf" value="{{ .CSRF }}">
    {{ if .errors._global_ -}}
    {{- range .errors._global_ -}}
    <p class="error">{{ . }}</p>
    {{- end }}
    {{- end }}
    <div>
      <label>{{.pd.Data.password}}</label>
      <input type="password" name="password" required>
      {{ with .errors.Password }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>{{.pd.Data.confirmation_label}}</label>
      <input type="text" name="confirmation" required autocomplete="off">
      {{ with .errors.Confirmation }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
  </form>
  <footer class="is-right">
    <button class="button error" form="delete-account-form" type="submit">{{.pd.Data.submit}}</button>
    <a class="button dark" href="{{reverse "accounts:settings"}}">{{.pd.Data.cancel}}</a>
  </footer>
</section>
{{template "base_footer" .}}
diff --git a/templates/settings.html b/templates/settings.html
index cc3f0cd..137f0d8 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -118,4 +118,10 @@
    </table>

</section>

<section class="card shadow-card mt-2">
  <h3 class="text-error">{{.pd.Data.danger_zone}}</h3>
  <p>{{.pd.Data.danger_zone_detail}}</p>
  <a class="button error is-small" href="{{reverse "accounts:delete_account"}}">{{.pd.Data.delete_account}}</a>
</section>
{{template "base_footer" .}}
-- 
2.52.0
Applied.

To git@git.code.netlandish.com:~netlandish/links
   270b525..7244089  master -> master