Peter Sanchez: 1 account: ability to fully delete your account 18 files changed, 566 insertions(+), 75 deletions(-)
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 -3Learn more about email & git
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