// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package provisioning import ( "context" "database/sql" "errors" "fmt" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" ) // DefaultPoolKey is the key the console writes on every organization's // default resource pool (entity-keys §4: the system writes a key only as a // fixed constant for a row it creates by design). It is scoped to the // organization by uq_resource_pools_org_id_key, so every organization has its // own `default`. const DefaultPoolKey = "default" // WorkspaceCreationResult carries the records // CreateWorkspaceWithPrimaryAssignment creates: the workspace and its primary // assignment to the organization's default resource pool. type WorkspaceCreationResult struct { Workspace organization.Workspace Pool entitlements.ResourcePool PoolAssignment entitlements.PoolAssignment } // CreateWorkspaceWithPrimaryAssignment creates a workspace for orgID and gives // it a primary assignment to the organization's default resource pool, all on // the caller's transaction so the workspace insert, the default-pool // resolution, and the assignment insert commit or roll back together. Both // first-login auto-provisioning (AutoProvision) and the member "create // workspace" handler call this, so the rule "a workspace never exists without // a pool assignment" has exactly one implementation instead of two // hand-synchronized copies (schema-hardening design D2). // // The organization's default pool is resolved by lookup; if none exists yet // (a brand-new organization, as at signup) one is created here. An // organization that already has a default pool (the ordinary // member-created-workspace case) gets that pool, never a second one. // // The name is the only thing this function is given, and nothing is derived // from it. The workspace gets no key: a key is a declarative address a caller // outside the database supplies, and deriving one from a display name is the // defect the retired derived-identifier column had (entity-keys §4). A name that duplicates // a live workspace in the same organization is refused by // uq_workspaces_org_id_name_ci, and the caller renders that as a field-level // message rather than silently picking another name. // // The default pool, by contrast, does get a key: `default` is a fixed // constant for the one pool the console creates for every organization by // design, so a seed, a script, or an API client can name it without knowing // the organization's generated pool ID. uq_resource_pools_one_default_per_org // already guarantees at most one per organization, so the constant cannot // collide with a second pool. func CreateWorkspaceWithPrimaryAssignment(ctx context.Context, tx *sql.Tx, orgID, name string) (WorkspaceCreationResult, error) { orgQ := organization.New(tx) entQ := entitlements.New(tx) workspace, err := orgQ.CreateWorkspace(ctx, organization.CreateWorkspaceParams{ OrgID: orgID, Name: name, }) if err != nil { return WorkspaceCreationResult{}, fmt.Errorf("create workspace: %w", err) } pool, err := entQ.GetDefaultPoolByOrgID(ctx, orgID) if err != nil { if !errors.Is(err, sql.ErrNoRows) { return WorkspaceCreationResult{}, fmt.Errorf("resolve default pool for org %s: %w", orgID, err) } pool, err = entQ.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{ OrgID: orgID, Name: "Default", PoolType: "default", IsAutoManaged: true, Key: sql.NullString{String: DefaultPoolKey, Valid: true}, }) if err != nil { return WorkspaceCreationResult{}, fmt.Errorf("create default pool: %w", err) } } assignment, err := entQ.CreatePoolAssignment(ctx, entitlements.CreatePoolAssignmentParams{ PoolID: pool.PoolID, WorkspaceID: workspace.WorkspaceID, IsPrimary: true, }) if err != nil { return WorkspaceCreationResult{}, fmt.Errorf("create pool assignment: %w", err) } return WorkspaceCreationResult{Workspace: workspace, Pool: pool, PoolAssignment: assignment}, nil }