Files
sm-user-plugin/controllers/organisations_admin_controller.go
Jakub Zych 38551a4104 fix: organisation attachment cleanup runs in FormAfterDelete
FormBeforeDelete keeps its declaration and only releases the members; the
avatar and logo rows are deleted by a new FormAfterDelete hook in the same
transaction, their blobs after the commit. Keeps the plugin additive
(no changed exported method declaration).
2026-10-23 18:40:59 +02:00

173 lines
6.3 KiB
Go

package controllers
import (
"context"
"errors"
"regexp"
"strconv"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// organisationSlugPattern is the PHP alpha_dash rule on the slug, kept to
// ASCII. lagoon.Validate has no alpha_dash token, so the Form before-hooks
// check it.
var organisationSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// membersRelation is the organisation's hasMany relation to its users.
const membersRelation = "members"
// organisationsAdminController is the Winter Organisations backend
// controller: the list, the form with the avatar, and the members relation
// manager. The screen and every write need golem15.users.access_users, as in
// PHP.
//
// The members manager sets and clears users.organisation_id, and clears
// users.organisation_role with it (a role belongs to one organisation). It
// never writes users_groups.
type organisationsAdminController struct {
app appAccess
}
func (organisationsAdminController) ID() string { return "golem15.user.organisations" }
func (organisationsAdminController) ModelName() string { return `Golem15\User\Models\Organisation` }
func (organisationsAdminController) ConfigDir() string { return "controllers/organisations" }
func (organisationsAdminController) RequiredPermissions() []string {
return []string{PermissionAccessUsers}
}
func (organisationsAdminController) NewRecord() any { return &models.Organisation{} }
// FormBeforeCreate and FormBeforeUpdate check the slug's characters.
func (c organisationsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
return c.requireSlugFormat(ctx, model)
}
func (c organisationsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
return c.requireSlugFormat(ctx, model)
}
// requireSlugFormat answers 422 on slug when it holds anything but letters,
// digits, underscore and hyphen. An empty slug is left to the required rule.
func (c organisationsAdminController) requireSlugFormat(ctx context.Context, model any) error {
organisation, ok := model.(*models.Organisation)
if !ok || organisation == nil {
return errors.New("organisations admin: form model is not an organisation")
}
if organisation.Slug == "" || organisationSlugPattern.MatchString(organisation.Slug) {
return nil
}
message := "The slug may only contain letters, numbers, dashes and underscores."
if tr := c.app.translator(); tr != nil {
const key = "golem15.user::lang.organisation.slug_invalid"
if text := tr.Get(ctx, key, nil); text != "" && text != key {
message = text
}
}
return &cabana.ValidationError{Details: map[string]any{"slug": []string{message}}}
}
// AdminRelationContracts declares the members relation manager: the users
// whose organisation_id is the organisation. Linking sets the column and
// unlinking clears it; the column is nullable, so a user is never deleted.
// Both also clear organisation_role (ClearOnChange), so a role never moves
// to another organisation or outlives the membership.
func (organisationsAdminController) AdminRelationContracts() []cabana.RelationContract {
return []cabana.RelationContract{{
Name: membersRelation,
Kind: cabana.RelationHasMany,
NewRelated: func() any { return &models.User{} },
ForeignKey: "organisation_id",
ClearOnChange: []string{"organisation_role"},
Columns: map[string]string{
"name": "name",
"email": "email",
},
}}
}
// RelationExtendManageQuery fails closed: any relation name other than
// members has no candidates.
func (organisationsAdminController) RelationExtendManageQuery(_ context.Context, relation string, db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
if relation != membersRelation {
return db.Where("1 = 0")
}
return db
}
// FormBeforeDelete releases the members of the organisation in the delete's
// transaction: organisation_id and organisation_role are cleared, on
// deactivated users too, so the delete does not fail on the users foreign key
// and no user is deleted.
func (organisationsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
organisation, ok := model.(*models.Organisation)
if !ok || organisation == nil {
return errors.New("organisations admin: form model is not an organisation")
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return errors.New("organisations admin: a delete needs its transaction")
}
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
Model(&models.User{}).Where("organisation_id = ?", organisation.ID).
Updates(map[string]any{"organisation_id": nil, "organisation_role": nil}).Error
}
// FormAfterDelete deletes the organisation's attachments (avatar and logo)
// in the delete's transaction, and their blobs once it commits, so no
// system_files row outlives the organisation row.
func (c organisationsAdminController) FormAfterDelete(ctx context.Context, model any) error {
organisation, ok := model.(*models.Organisation)
if !ok || organisation == nil {
return errors.New("organisations admin: form model is not an organisation")
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return errors.New("organisations admin: a delete needs its transaction")
}
var keys []string
if err := attach.DeleteForOwner(tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx), organisation, strconv.FormatUint(uint64(organisation.ID), 10), func(blobKeys []string) error {
keys = append(keys, blobKeys...)
return nil
}); err != nil {
return err
}
bucket := c.bucket()
if bucket == nil || len(keys) == 0 {
return nil
}
id := organisation.ID
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
// The rows are gone by now, so a blob that cannot be removed is
// orphaned, not lost data: log it and carry on.
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
pluginLog.Warn("golem15.user: remove attachment blobs of a deleted organisation", "organisation_id", id, "err", err)
}
})
return nil
}
// bucket is the application's blob bucket, or nil when none is published.
func (c organisationsAdminController) bucket() *blob.Bucket {
app := c.app.application()
if app == nil {
return nil
}
bucket, ok := app.Lookup[*blob.Bucket]()
if !ok {
return nil
}
return bucket
}