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).
173 lines
6.3 KiB
Go
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
|
|
}
|