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