feat(19-04): add users.handle, HandleGenerator and user.Created

Generate a unique mention handle on API register and admin create, and fire user.Created from both paths so listeners run in the same write.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-17 21:44:59 +02:00
parent 569768e657
commit 15b19c7021
12 changed files with 349 additions and 19 deletions

View File

@@ -649,6 +649,7 @@ func Register(app *backpack.App) http.HandlerFunc {
return
}
_ = FireRegisterEvent(r.Context(), app, user, fields)
_ = FireCreatedEvent(r.Context(), app, user)
ctx := r.Context()
if mode == "user" {
vars := mailVars(displayName(user), "", "")

View File

@@ -77,6 +77,9 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
if err := db.WithContext(ctx).Create(user).Error; err != nil {
return nil, nil, err
}
if err := classes.AssignHandle(ctx, db, user); err != nil {
return nil, nil, err
}
return user, nil, nil
}
@@ -97,6 +100,16 @@ func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User
return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload})
}
// FireCreatedEvent fires classes.CreatedEvent (user.Created) for user and
// returns the listeners' error. /register ignores that error, as it does
// for FireRegisterEvent.
func FireCreatedEvent(ctx context.Context, app *backpack.App, user *models.User) error {
if app == nil || app.Events == nil {
return nil
}
return app.Events.Fire(ctx, &classes.CreatedEvent{User: user})
}
// IssueToken mints the session JWT for user exactly as /register and
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
// URL as the issuer.

View File

@@ -302,23 +302,33 @@ func (c usersAdminController) storePassword(ctx context.Context, user *models.Us
return true, nil
}
// FormAfterCreate sends the invitation when the send_invite checkbox was
// set: an activation code is issued inside the create's transaction, and
// exactly one golem15.user::mail.invite message with the activation link
// leaves after the commit. A create that rolls back sends nothing.
// FormAfterCreate assigns a unique handle, fires user.Created
// (CreatedEventName) inside the create transaction, and sends the
// invitation when the send_invite checkbox was set: an activation code is
// issued inside the same transaction, and exactly one
// golem15.user::mail.invite message with the activation link leaves after
// the commit. A create that rolls back sends nothing.
func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error {
user, ok := model.(*models.User)
if !ok || user == nil {
return errors.New("users admin: form model is not a user")
}
values, _ := cabana.VirtualFieldsFromContext(ctx)
if invite, _ := values["send_invite"].(bool); !invite {
return nil
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return errors.New("users admin: a create needs its transaction")
}
if err := classes.AssignHandle(ctx, tx.Session(&gorm.Session{NewDB: true}), user); err != nil {
return err
}
if app := c.application(); app != nil && app.Events != nil {
if err := app.Events.Fire(ctx, &classes.CreatedEvent{User: user, Tx: tx}); err != nil {
return fmt.Errorf("%s: %w", classes.CreatedEventName, err)
}
}
values, _ := cabana.VirtualFieldsFromContext(ctx)
if invite, _ := values["send_invite"].(bool); !invite {
return nil
}
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
if err != nil {
return err