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

@@ -0,0 +1,30 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// addHandleToUsers adds users.handle, the unique @mention identity (D-15),
// only when it is missing: an application whose users table already has the
// column (added on PHP by chatchannels) keeps it and its data. The rollback
// is a no-op for the same reason: the column may predate this plugin.
var addHandleToUsers = []*gormigrate.Migration{
{
ID: "202610160002_add_handle_to_users",
Migrate: func(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS handle VARCHAR(255) NULL`).Error; err != nil {
return err
}
return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS users_handle_unique ON users (handle)`).Error
},
Rollback: func(*gorm.DB) error {
// No-op: the shared users column may predate the plugin.
return nil
},
},
}
func init() {
Register(addHandleToUsers...)
}

View File

@@ -50,6 +50,7 @@ func TestCreateOrganisationsMigration(t *testing.T) {
"202610040002_add_users_last_seen",
"202610040003_create_frontend_permissions",
"202610050001_create_oauth_identities",
"202610160002_add_handle_to_users",
}
if len(ids) != len(want) {
t.Fatalf("history = %v", ids)
@@ -60,9 +61,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
}
}
// OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// throttle, extend-users, then organisations.
for range 10 {
for range 11 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -54,8 +54,8 @@ func TestUserGroupsMigration(t *testing.T) {
t.Fatalf("users_groups primary key = %q", pk)
}
// OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 6 {
// Handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 7 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
t.Fatalf("defaults = %+v", row)
}
// OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// throttle, then extend-users.
for range 9 {
for range 10 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -88,8 +88,8 @@ func TestCreateUserThrottleMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 8 {
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 9 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -120,8 +120,8 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
for range 7 {
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
for range 8 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}