implementation done

This commit is contained in:
adlerhurst
2025-07-29 17:59:02 +02:00
parent 432295ba76
commit ef74f5ee40
13 changed files with 820 additions and 169 deletions

View File

@@ -25,81 +25,275 @@ type Organization struct {
Domains []*OrganizationDomain `json:"domains,omitempty" db:"-"`
}
// OrgIdentifierCondition is used to help specify a single Organization,
// it will either be used as the organization ID or organization name,
// as organizations can be identified either using (instanceID + ID) OR (instanceID + name)
type OrgIdentifierCondition interface {
database.Condition
}
var _ Commander = (*CreateOrganizationCommand)(nil)
// organizationColumns define all the columns of the instance table.
type organizationColumns interface {
// IDColumn returns the column for the id field.
// `qualified` indicates if the column should be qualified with the table name.
IDColumn(qualified bool) database.Column
// NameColumn returns the column for the name field.
// `qualified` indicates if the column should be qualified with the table name.
NameColumn(qualified bool) database.Column
// InstanceIDColumn returns the column for the default org id field
// `qualified` indicates if the column should be qualified with the table name.
InstanceIDColumn(qualified bool) database.Column
// StateColumn returns the column for the name field.
// `qualified` indicates if the column should be qualified with the table name.
StateColumn(qualified bool) database.Column
// CreatedAtColumn returns the column for the created at field.
// `qualified` indicates if the column should be qualified with the table name.
CreatedAtColumn(qualified bool) database.Column
// UpdatedAtColumn returns the column for the updated at field.
// `qualified` indicates if the column should be qualified with the table name.
UpdatedAtColumn(qualified bool) database.Column
}
// organizationConditions define all the conditions for the instance table.
type organizationConditions interface {
// IDCondition returns an equal filter on the id field.
IDCondition(instanceID string) OrgIdentifierCondition
// NameCondition returns a filter on the name field.
NameCondition(name string) OrgIdentifierCondition
// InstanceIDCondition returns a filter on the instance id field.
InstanceIDCondition(instanceID string) database.Condition
// StateCondition returns a filter on the name field.
StateCondition(state OrgState) database.Condition
}
// organizationChanges define all the changes for the instance table.
type organizationChanges interface {
// SetName sets the name column.
SetName(name string) database.Change
// SetState sets the name column.
SetState(state OrgState) database.Change
}
// OrganizationRepository is the interface for the instance repository.
type OrganizationRepository interface {
organizationColumns
organizationConditions
organizationChanges
Get(ctx context.Context, opts ...database.QueryOption) (*Organization, error)
List(ctx context.Context, opts ...database.QueryOption) ([]*Organization, error)
Create(ctx context.Context, instance *Organization) error
Update(ctx context.Context, id OrgIdentifierCondition, instance_id string, changes ...database.Change) (int64, error)
Delete(ctx context.Context, id OrgIdentifierCondition, instance_id string) (int64, error)
// Domains returns the domain sub repository for the organization.
// If shouldLoad is true, the domains will be loaded from the database and written to the [Organization].Domains field.
// If shouldLoad is set to true once, the Domains field will be set even if shouldLoad is false in the future.
Domains(shouldLoad bool) OrganizationDomainRepository
}
type CreateOrganization struct {
type CreateOrganizationCommand struct {
InstanceID string `json:"instanceId"`
// ID is optional, if not set a new ID will be generated.
// It can be set using the [WithOrganizationID] option in [NewCreateOrganizationCommand].
ID string `json:"id,omitempty"`
Name string `json:"name"`
// CreatedAt MUST NOT be set by the caller.
CreatedAt time.Time `json:"createdAt,omitzero"`
// Admins represent the commands to create the administrators.
// The Commanders MUST either be [AddOrgMemberCommand] or [CreateOrgMemberCommand].
Admins []Commander `json:"admins,omitempty"`
}
// MemberRepository is a sub repository of the org repository and maybe the instance repository.
type MemberRepository interface {
AddMember(ctx context.Context, orgID, userID string, roles []string) error
SetMemberRoles(ctx context.Context, orgID, userID string, roles []string) error
RemoveMember(ctx context.Context, orgID, userID string) error
type CreateOrganizationCommandOpts interface {
applyOnCreateOrganizationCommand(cmd *CreateOrganizationCommand)
}
func NewCreateOrganizationCommand(instanceID, name string, opts ...CreateOrganizationCommandOpts) *CreateOrganizationCommand {
cmd := &CreateOrganizationCommand{
InstanceID: instanceID,
Name: name,
}
for _, opt := range opts {
opt.applyOnCreateOrganizationCommand(cmd)
}
return cmd
}
// Execute implements [Commander].
//
// DISCUSS(adlerhurst): As we need to do validation to make sure a command contains all the data required
// we can consider the following options:
// 1. Validate the command before executing it, which is what we do here.
// 2. Create an invoker which checks if the struct has a `Validate() error` method and call it in the chain of invokers.
// While the the first one is more straightforward it bloats the execute method with validation logic.
// The second one would allow us to keep the execute method clean, but could be more error prone if the method gets missed during implementation.
func (cmd *CreateOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) {
if cmd.ID == "" {
cmd.ID, err = generateID()
if err != nil {
return err
}
}
close, err := opts.EnsureTx(ctx)
if err != nil {
return err
}
defer func() { err = close(ctx, err) }()
err = orgRepo(opts.DB).Create(ctx, &Organization{
ID: cmd.ID,
Name: cmd.Name,
InstanceID: cmd.InstanceID,
State: OrgStateActive,
})
if err != nil {
return err
}
for _, admin := range cmd.Admins {
if err = opts.Invoke(ctx, admin); err != nil {
return err
}
}
return nil
}
// String implements [Commander].
func (CreateOrganizationCommand) String() string {
return "CreateOrganizationCommand"
}
var (
_ Commander = (*ActivateOrganizationCommand)(nil)
)
type ActivateOrganizationCommand struct {
InstanceID string `json:"instanceId"`
OrgID string `json:"orgId"`
// UpdatedAt MUST NOT be set by the caller.
UpdatedAt time.Time `json:"updatedAt,omitzero"`
}
func NewActivateOrganizationCommand(instanceID, orgID string) *ActivateOrganizationCommand {
return &ActivateOrganizationCommand{
InstanceID: instanceID,
OrgID: orgID,
}
}
// Execute implements [Commander].
func (cmd *ActivateOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) {
repo := orgRepo(opts.DB)
_, err = repo.Update(ctx,
repo.IDCondition(cmd.OrgID),
cmd.InstanceID,
repo.SetState(OrgStateActive),
)
return err
}
// String implements [Commander].
func (ActivateOrganizationCommand) String() string {
return "ActivateOrganizationCommand"
}
var (
_ Commander = (*DeactivateOrganizationCommand)(nil)
)
type DeactivateOrganizationCommand struct {
InstanceID string `json:"instanceId"`
OrgID string `json:"orgId"`
// UpdatedAt MUST NOT be set by the caller.
UpdatedAt time.Time `json:"updatedAt,omitzero"`
}
func NewDeactivateOrganizationCommand(instanceID, orgID string) *DeactivateOrganizationCommand {
return &DeactivateOrganizationCommand{
InstanceID: instanceID,
OrgID: orgID,
}
}
// Execute implements [Commander].
func (cmd *DeactivateOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) {
repo := orgRepo(opts.DB)
_, err = repo.Update(ctx,
repo.IDCondition(cmd.OrgID),
cmd.InstanceID,
repo.SetState(OrgStateInactive),
)
return err
}
// String implements [Commander].
func (DeactivateOrganizationCommand) String() string {
return "DeactivateOrganizationCommand"
}
var (
_ Commander = (*DeleteOrganizationCommand)(nil)
)
type DeleteOrganizationCommand struct {
InstanceID string `json:"instanceId"`
OrgID string `json:"orgId"`
}
func NewDeleteOrganizationCommand(instanceID, orgID string) *DeleteOrganizationCommand {
return &DeleteOrganizationCommand{
InstanceID: instanceID,
OrgID: orgID,
}
}
// Execute implements [Commander].
func (cmd *DeleteOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) {
repo := orgRepo(opts.DB)
_, err = repo.Delete(ctx,
repo.IDCondition(cmd.OrgID),
cmd.InstanceID,
)
return err
}
// String implements [Commander].
func (DeleteOrganizationCommand) String() string {
return "DeleteOrganizationCommand"
}
var _ Commander = (*UpdateOrganizationCommand)(nil)
type UpdateOrganizationCommand struct {
InstanceID string `json:"instanceId"`
OrgID string `json:"orgId"`
repo OrganizationRepository
changes database.Changes
opts []UpdateOrganizationCommandOpts
}
func NewUpdateOrganizationCommand(instanceID, orgID string, opts ...UpdateOrganizationCommandOpts) *UpdateOrganizationCommand {
return &UpdateOrganizationCommand{
InstanceID: instanceID,
OrgID: orgID,
opts: opts,
}
}
type UpdateOrganizationCommandOpts interface {
applyOnUpdateOrganizationCommand(cmd *UpdateOrganizationCommand)
}
// Execute implements [Commander].
func (cmd *UpdateOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) {
cmd.repo = orgRepo(opts.DB)
for _, opt := range cmd.opts {
opt.applyOnUpdateOrganizationCommand(cmd)
}
if len(cmd.changes) == 0 {
return nil // No update needed if no changes are provided.
}
_, err = cmd.repo.Update(ctx,
cmd.repo.IDCondition(cmd.OrgID),
cmd.InstanceID,
cmd.changes,
)
return err
}
// String implements [Commander].
func (UpdateOrganizationCommand) String() string {
return "UpdateOrganizationCommand"
}
type OrgsQueryOpts interface {
applyOnOrgsQuery(query *OrgsQuery)
}
var _ Commander = (*OrgsQuery)(nil)
type OrgsQuery struct {
InstanceID string
opts []OrgsQueryOpts
repo OrganizationRepository
domainRepo OrganizationDomainRepository
conditions []database.Condition
pagination Pagination
Result []*Organization
}
func NewOrgsQuery(instanceID string, opts ...OrgsQueryOpts) *OrgsQuery {
return &OrgsQuery{
InstanceID: instanceID,
opts: opts,
}
}
// Execute implements [Commander].
func (q *OrgsQuery) Execute(ctx context.Context, opts *CommandOpts) (err error) {
q.repo = orgRepo(opts.DB)
q.domainRepo = q.repo.Domains(true)
q.conditions = append(q.conditions, q.repo.InstanceIDCondition(q.InstanceID))
for _, opt := range q.opts {
opt.applyOnOrgsQuery(q)
}
q.Result, err = q.repo.List(ctx,
database.WithCondition(database.And(q.conditions...)),
database.WithLimit(q.pagination.Limit),
database.WithOffset(q.pagination.Offset),
database.WithOrderBy(!q.pagination.Ascending, q.pagination.OrderColumns...),
)
return err
}
// String implements [Commander].
func (OrgsQuery) String() string {
return "OrgsQuery"
}

View File

@@ -1,10 +1,7 @@
package domain
import (
"context"
"time"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type OrganizationDomain struct {
@@ -34,54 +31,3 @@ type AddOrganizationDomain struct {
// It is set by the repository and should not be set by the caller.
UpdatedAt time.Time `json:"updatedAt,omitzero" db:"updated_at"`
}
type organizationDomainColumns interface {
domainColumns
// OrgIDColumn returns the column for the org id field.
// `qualified` indicates if the column should be qualified with the table name.
OrgIDColumn(qualified bool) database.Column
// IsVerifiedColumn returns the column for the is verified field.
// `qualified` indicates if the column should be qualified with the table name.
IsVerifiedColumn(qualified bool) database.Column
// ValidationTypeColumn returns the column for the verification type field.
// `qualified` indicates if the column should be qualified with the table name.
ValidationTypeColumn(qualified bool) database.Column
}
type organizationDomainConditions interface {
domainConditions
// OrgIDCondition returns a filter on the org id field.
OrgIDCondition(orgID string) database.Condition
// IsVerifiedCondition returns a filter on the is verified field.
IsVerifiedCondition(isVerified bool) database.Condition
}
type organizationDomainChanges interface {
domainChanges
// SetVerified sets the is verified column to true.
SetVerified() database.Change
// SetValidationType sets the verification type column.
// If the domain is already verified, this is a no-op.
SetValidationType(verificationType DomainValidationType) database.Change
}
type OrganizationDomainRepository interface {
organizationDomainColumns
organizationDomainConditions
organizationDomainChanges
// Get returns a single domain based on the criteria.
// If no domain is found, it returns an error of type [database.ErrNotFound].
// If multiple domains are found, it returns an error of type [database.ErrMultipleRows].
Get(ctx context.Context, opts ...database.QueryOption) (*OrganizationDomain, error)
// List returns a list of domains based on the criteria.
// If no domains are found, it returns an empty slice.
List(ctx context.Context, opts ...database.QueryOption) ([]*OrganizationDomain, error)
// Add adds a new domain to the organization.
Add(ctx context.Context, domain *AddOrganizationDomain) error
// Update updates an existing domain in the organization.
Update(ctx context.Context, condition database.Condition, changes ...database.Change) (int64, error)
// Remove removes a domain from the organization.
Remove(ctx context.Context, condition database.Condition) (int64, error)
}

View File

@@ -0,0 +1,58 @@
package domain
import (
"context"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type OrganizationDomainRepository interface {
organizationDomainColumns
organizationDomainConditions
organizationDomainChanges
// Get returns a single domain based on the criteria.
// If no domain is found, it returns an error of type [database.ErrNotFound].
// If multiple domains are found, it returns an error of type [database.ErrMultipleRows].
Get(ctx context.Context, opts ...database.QueryOption) (*OrganizationDomain, error)
// List returns a list of domains based on the criteria.
// If no domains are found, it returns an empty slice.
List(ctx context.Context, opts ...database.QueryOption) ([]*OrganizationDomain, error)
// Add adds a new domain to the organization.
Add(ctx context.Context, domain *AddOrganizationDomain) error
// Update updates an existing domain in the organization.
Update(ctx context.Context, condition database.Condition, changes ...database.Change) (int64, error)
// Remove removes a domain from the organization.
Remove(ctx context.Context, condition database.Condition) (int64, error)
}
type organizationDomainColumns interface {
domainColumns
// OrgIDColumn returns the column for the org id field.
// `qualified` indicates if the column should be qualified with the table name.
OrgIDColumn(qualified bool) database.Column
// IsVerifiedColumn returns the column for the is verified field.
// `qualified` indicates if the column should be qualified with the table name.
IsVerifiedColumn(qualified bool) database.Column
// ValidationTypeColumn returns the column for the verification type field.
// `qualified` indicates if the column should be qualified with the table name.
ValidationTypeColumn(qualified bool) database.Column
}
type organizationDomainConditions interface {
domainConditions
// OrgIDCondition returns a filter on the org id field.
OrgIDCondition(orgID string) database.Condition
// IsVerifiedCondition returns a filter on the is verified field.
IsVerifiedCondition(isVerified bool) database.Condition
}
type organizationDomainChanges interface {
domainChanges
// SetVerified sets the is verified column to true.
SetVerified() database.Change
// SetValidationType sets the verification type column.
// If the domain is already verified, this is a no-op.
SetValidationType(verificationType DomainValidationType) database.Change
}

View File

@@ -0,0 +1,45 @@
package domain
import "context"
var _ Commander = (*AddOrgMemberCommand)(nil)
// AddOrgMemberCommand adds an existing user as an organization member.
type AddOrgMemberCommand struct {
InstanceID string `json:"instanceId"`
OrgID string `json:"orgId"`
UserID string `json:"userId"`
Roles []string `json:"roles"`
}
// Execute implements [Commander].
func (a *AddOrgMemberCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) {
panic("unimplemented")
}
// String implements [Commander].
func (a *AddOrgMemberCommand) String() string {
return "AddOrgMemberCommand"
}
var _ Commander = (*CreateOrgMemberCommand)(nil)
// CreateOrgMemberCommand creates a new user and adds them as an organization member.
type CreateOrgMemberCommand struct{}
// Execute implements [Commander].
func (c *CreateOrgMemberCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) {
panic("unimplemented")
}
// String implements [Commander].
func (c *CreateOrgMemberCommand) String() string {
panic("unimplemented")
}
// MemberRepository is a sub repository of the org repository and maybe the instance repository.
type MemberRepository interface {
AddMember(ctx context.Context, orgID, userID string, roles []string) error
SetMemberRoles(ctx context.Context, orgID, userID string, roles []string) error
RemoveMember(ctx context.Context, orgID, userID string) error
}

View File

@@ -0,0 +1,127 @@
package domain
import "github.com/zitadel/zitadel/backend/v3/storage/database"
var _ CreateOrganizationCommandOpts = (*withOrganizationID)(nil)
type withOrganizationID struct {
id string
}
func WithOrganizationID(id string) *withOrganizationID {
return &withOrganizationID{
id: id,
}
}
func (opt *withOrganizationID) applyOnCreateOrganizationCommand(cmd *CreateOrganizationCommand) {
cmd.ID = opt.id
}
var _ UpdateOrganizationCommandOpts = (*withOrganizationName)(nil)
type withOrganizationName struct {
name string
}
func WithOrganizationName(name string) *withOrganizationName {
return &withOrganizationName{
name: name,
}
}
func (opt *withOrganizationName) applyOnUpdateOrganizationCommand(cmd *UpdateOrganizationCommand) {
cmd.changes = append(cmd.changes, cmd.repo.SetName(opt.name))
}
var _ OrgsQueryOpts = (*orgByNameQueryOpt)(nil)
type orgByNameQueryOpt struct {
name string
op database.TextOperation
}
func WithOrgByNameQuery(op database.TextOperation, name string) *orgByNameQueryOpt {
return &orgByNameQueryOpt{
name: name,
op: op,
}
}
func (opt *orgByNameQueryOpt) applyOnOrgsQuery(query *OrgsQuery) {
query.conditions = append(query.conditions, query.repo.NameCondition(opt.op, opt.name))
}
var _ OrgsQueryOpts = (*orgByDomainQueryOpt)(nil)
type orgByDomainQueryOpt struct {
name string
op database.TextOperation
}
func WithOrgByDomainQuery(op database.TextOperation, name string) *orgByDomainQueryOpt {
return &orgByDomainQueryOpt{
name: name,
op: op,
}
}
func (opt *orgByDomainQueryOpt) applyOnOrgsQuery(query *OrgsQuery) {
query.conditions = append(query.conditions, query.domainRepo.DomainCondition(opt.op, opt.name))
}
var _ OrgsQueryOpts = (*orgByIDQueryOpt)(nil)
type orgByIDQueryOpt struct {
id string
}
func WithOrgByIDQuery(id string) *orgByIDQueryOpt {
return &orgByIDQueryOpt{
id: id,
}
}
func (opt *orgByIDQueryOpt) applyOnOrgsQuery(query *OrgsQuery) {
query.conditions = append(query.conditions, query.repo.IDCondition(opt.id))
}
var _ OrgsQueryOpts = (*orgByIDQueryOpt)(nil)
type orgByStateQueryOpt struct {
state OrgState
}
func WithOrgByStateQuery(state OrgState) *orgByStateQueryOpt {
return &orgByStateQueryOpt{
state: state,
}
}
func (opt *orgByStateQueryOpt) applyOnOrgsQuery(query *OrgsQuery) {
query.conditions = append(query.conditions, query.repo.StateCondition(opt.state))
}
var _ OrgsQueryOpts = (*orgByIDQueryOpt)(nil)
type orgQuerySortingColumnOpt struct {
getColumn func(query *OrgsQuery) database.Column
}
func WithOrgQuerySortingColumn(getColumn func(query *OrgsQuery) database.Column) *orgQuerySortingColumnOpt {
return &orgQuerySortingColumnOpt{
getColumn: getColumn,
}
}
func OrderOrgsByCreationDate(query *OrgsQuery) database.Column {
return query.repo.CreatedAtColumn(true)
}
func OrderOrgsByName(query *OrgsQuery) database.Column {
return query.repo.NameColumn(true)
}
func (opt *orgQuerySortingColumnOpt) applyOnOrgsQuery(query *OrgsQuery) {
query.pagination.OrderColumns = append(query.pagination.OrderColumns, opt.getColumn(query))
}

View File

@@ -0,0 +1,75 @@
package domain
import (
"context"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
// OrganizationRepository is the interface for the instance repository.
type OrganizationRepository interface {
organizationColumns
organizationConditions
organizationChanges
Get(ctx context.Context, opts ...database.QueryOption) (*Organization, error)
List(ctx context.Context, opts ...database.QueryOption) ([]*Organization, error)
Create(ctx context.Context, instance *Organization) error
Update(ctx context.Context, id OrgIdentifierCondition, instance_id string, changes ...database.Change) (int64, error)
Delete(ctx context.Context, id OrgIdentifierCondition, instance_id string) (int64, error)
// Domains returns the domain sub repository for the organization.
// If shouldLoad is true, the domains will be loaded from the database and written to the [Organization].Domains field.
// If shouldLoad is set to true once, the Domains field will be set even if shouldLoad is false in the future.
Domains(shouldLoad bool) OrganizationDomainRepository
}
// OrgIdentifierCondition is used to help specify a single Organization,
// it will either be used as the organization ID or organization name,
// as organizations can be identified either using (instanceID + ID) OR (instanceID + name)
type OrgIdentifierCondition interface {
database.Condition
}
// organizationColumns define all the columns of the instance table.
type organizationColumns interface {
// IDColumn returns the column for the id field.
// `qualified` indicates if the column should be qualified with the table name.
IDColumn(qualified bool) database.Column
// NameColumn returns the column for the name field.
// `qualified` indicates if the column should be qualified with the table name.
NameColumn(qualified bool) database.Column
// InstanceIDColumn returns the column for the default org id field
// `qualified` indicates if the column should be qualified with the table name.
InstanceIDColumn(qualified bool) database.Column
// StateColumn returns the column for the name field.
// `qualified` indicates if the column should be qualified with the table name.
StateColumn(qualified bool) database.Column
// CreatedAtColumn returns the column for the created at field.
// `qualified` indicates if the column should be qualified with the table name.
CreatedAtColumn(qualified bool) database.Column
// UpdatedAtColumn returns the column for the updated at field.
// `qualified` indicates if the column should be qualified with the table name.
UpdatedAtColumn(qualified bool) database.Column
}
// organizationConditions define all the conditions for the instance table.
type organizationConditions interface {
// IDCondition returns an equal filter on the id field.
IDCondition(id string) OrgIdentifierCondition
// NameCondition returns a filter on the name field.
NameCondition(name string) OrgIdentifierCondition
// InstanceIDCondition returns a filter on the instance id field.
InstanceIDCondition(instanceID string) database.Condition
// StateCondition returns a filter on the name field.
StateCondition(state OrgState) database.Condition
}
// organizationChanges define all the changes for the instance table.
type organizationChanges interface {
// SetName sets the name column.
SetName(name string) database.Change
// SetState sets the name column.
SetState(state OrgState) database.Change
}

View File

@@ -0,0 +1,17 @@
package domain
import "github.com/zitadel/zitadel/backend/v3/storage/database"
type Pagination struct {
Limit uint32
Offset uint32
Ascending bool
OrderColumns database.Columns
}
// applyOnOrgsQuery implements OrgsQueryOpts.
func (p Pagination) applyOnOrgsQuery(query *OrgsQuery) {
query.pagination = p
}
var _ OrgsQueryOpts = (*Pagination)(nil)