362 lines
11 KiB
Go
362 lines
11 KiB
Go
// Path: Backend/internal/bootstrap/service.go
|
|
|
|
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) {
|
|
row := service.db.Pool.QueryRow(ctx, `
|
|
INSERT INTO installations (singleton, name, mode, access, protocol, host)
|
|
VALUES (
|
|
TRUE,
|
|
COALESCE((SELECT name FROM installations WHERE singleton = TRUE LIMIT 1), ''),
|
|
COALESCE((SELECT mode FROM installations WHERE singleton = TRUE LIMIT 1), 'personal'::instance_mode),
|
|
$1::instance_access,
|
|
$2::instance_protocol,
|
|
$3
|
|
)
|
|
ON CONFLICT (singleton) DO UPDATE
|
|
SET
|
|
access = EXCLUDED.access,
|
|
protocol = EXCLUDED.protocol,
|
|
host = EXCLUDED.host,
|
|
updated_at = NOW()
|
|
RETURNING
|
|
id::text,
|
|
name,
|
|
mode::text,
|
|
access::text,
|
|
protocol::text,
|
|
host,
|
|
is_bootstrapped,
|
|
materialization_status::text,
|
|
materialization_error;
|
|
`, input.Access, input.Protocol, input.Host)
|
|
|
|
return scanInstallationRecord(row)
|
|
}
|
|
|
|
func (service *Service) SaveMode(ctx context.Context, input SaveModeInput) (InstallationRecord, error) {
|
|
row := service.db.Pool.QueryRow(ctx, `
|
|
INSERT INTO installations (singleton, name, mode, access, protocol, host)
|
|
VALUES (
|
|
TRUE,
|
|
$2,
|
|
$1::instance_mode,
|
|
COALESCE((SELECT access FROM installations WHERE singleton = TRUE LIMIT 1), 'local'::instance_access),
|
|
COALESCE((SELECT protocol FROM installations WHERE singleton = TRUE LIMIT 1), 'http'::instance_protocol),
|
|
COALESCE((SELECT host FROM installations WHERE singleton = TRUE LIMIT 1), $3)
|
|
)
|
|
ON CONFLICT (singleton) DO UPDATE
|
|
SET
|
|
name = EXCLUDED.name,
|
|
mode = EXCLUDED.mode,
|
|
updated_at = NOW()
|
|
RETURNING
|
|
id::text,
|
|
name,
|
|
mode::text,
|
|
access::text,
|
|
protocol::text,
|
|
host,
|
|
is_bootstrapped,
|
|
materialization_status::text,
|
|
materialization_error;
|
|
`, input.Mode, input.Name, defaultInstallationHost)
|
|
|
|
return scanInstallationRecord(row)
|
|
}
|
|
|
|
func (service *Service) SaveAdmin(ctx context.Context, input SaveAdminInput) (AdminRecord, error) {
|
|
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return AdminRecord{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE users
|
|
SET is_instance_admin = FALSE, updated_at = NOW()
|
|
WHERE is_instance_admin = TRUE;
|
|
`); err != nil {
|
|
return AdminRecord{}, err
|
|
}
|
|
|
|
var record AdminRecord
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO users (email, display_name, password_hash, is_instance_admin)
|
|
VALUES ($1, $2, crypt($3, gen_salt('bf')), TRUE)
|
|
ON CONFLICT ((LOWER(email))) DO UPDATE
|
|
SET
|
|
email = EXCLUDED.email,
|
|
display_name = EXCLUDED.display_name,
|
|
password_hash = crypt($3, gen_salt('bf')),
|
|
is_instance_admin = TRUE,
|
|
updated_at = NOW()
|
|
RETURNING id::text, email, display_name, is_instance_admin;
|
|
`, input.Email, input.DisplayName, input.Password).Scan(
|
|
&record.ID,
|
|
&record.Email,
|
|
&record.DisplayName,
|
|
&record.IsInstanceAdmin,
|
|
); err != nil {
|
|
return AdminRecord{}, err
|
|
}
|
|
|
|
record.HomeTitle = personalHomeTitle(record.DisplayName)
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO user_homes (user_id, title)
|
|
VALUES ($1::uuid, $2)
|
|
ON CONFLICT (user_id) DO UPDATE
|
|
SET title = EXCLUDED.title, updated_at = NOW()
|
|
RETURNING title;
|
|
`, record.ID, record.HomeTitle).Scan(&record.HomeTitle); err != nil {
|
|
return AdminRecord{}, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return AdminRecord{}, err
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// SaveStructure persists the bootstrap domain records synchronously, then hands the
|
|
// slow POSIX/projector materialization work to the background worker.
|
|
//
|
|
// This keeps the API request responsible for validation and durable relational writes,
|
|
// while the worker owns retryable filesystem/projection side effects.
|
|
func (service *Service) SaveStructure(ctx context.Context, input SaveStructureInput) (StructureRecord, error) {
|
|
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
prerequisites, err := service.loadBootstrapStructurePrerequisites(ctx, tx)
|
|
if err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
organizationName := strings.TrimSpace(input.OrganizationName)
|
|
if organizationName == "" {
|
|
organizationName = defaultRootOrganizationName(
|
|
prerequisites.installation.Name,
|
|
prerequisites.installation.Mode,
|
|
prerequisites.installation.Host,
|
|
prerequisites.admin.DisplayName,
|
|
)
|
|
}
|
|
|
|
organization, err := upsertNamedRecord(ctx, tx, `
|
|
INSERT INTO organizations (name, slug, created_by_user_id)
|
|
VALUES ($1, $2, $3::uuid)
|
|
ON CONFLICT (slug) DO UPDATE
|
|
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
|
|
RETURNING id::text, name, slug;
|
|
`, organizationName, primaryOrganizationSlug, prerequisites.admin.ID)
|
|
if err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO organization_memberships (organization_id, user_id, role)
|
|
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
|
|
ON CONFLICT (organization_id, user_id) DO UPDATE
|
|
SET role = EXCLUDED.role;
|
|
`, organization.ID, prerequisites.admin.ID); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
department, err := upsertNamedRecord(ctx, tx, `
|
|
INSERT INTO departments (organization_id, name, slug, created_by_user_id)
|
|
VALUES ($1::uuid, $2, $3, $4::uuid)
|
|
ON CONFLICT (organization_id, slug) DO UPDATE
|
|
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
|
|
RETURNING id::text, name, slug;
|
|
`, organization.ID, input.DepartmentName, primaryDepartmentSlug, prerequisites.admin.ID)
|
|
if err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
team, err := upsertNamedRecord(ctx, tx, `
|
|
INSERT INTO teams (organization_id, department_id, name, slug, created_by_user_id)
|
|
VALUES ($1::uuid, $2::uuid, $3, $4, $5::uuid)
|
|
ON CONFLICT (organization_id, slug) DO UPDATE
|
|
SET department_id = EXCLUDED.department_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
|
|
RETURNING id::text, name, slug;
|
|
`, organization.ID, department.ID, input.TeamName, primaryTeamSlug, prerequisites.admin.ID)
|
|
if err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO team_memberships (team_id, user_id, role)
|
|
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
|
|
ON CONFLICT (team_id, user_id) DO UPDATE
|
|
SET role = EXCLUDED.role;
|
|
`, team.ID, prerequisites.admin.ID); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
project, err := upsertNamedRecord(ctx, tx, `
|
|
INSERT INTO projects (organization_id, department_id, team_id, name, slug, created_by_user_id)
|
|
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6::uuid)
|
|
ON CONFLICT (organization_id, slug) DO UPDATE
|
|
SET department_id = EXCLUDED.department_id, team_id = EXCLUDED.team_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
|
|
RETURNING id::text, name, slug;
|
|
`, organization.ID, department.ID, team.ID, input.ProjectName, primaryProjectSlug, prerequisites.admin.ID)
|
|
if err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO project_memberships (project_id, user_id, role)
|
|
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
|
|
ON CONFLICT (project_id, user_id) DO UPDATE
|
|
SET role = EXCLUDED.role;
|
|
`, project.ID, prerequisites.admin.ID); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, prerequisites.admin.ID, nil, nil, nil); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, prerequisites.admin.ID, &department.ID, nil, nil); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, prerequisites.admin.ID, &department.ID, &team.ID, nil); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, prerequisites.admin.ID, &department.ID, &team.ID, &project.ID); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
installation, err := updateBootstrappedInstallation(ctx, tx)
|
|
if err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if err := service.enqueueBootstrapStructureMaterialization(ctx, &installation); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
return StructureRecord{
|
|
Installation: installation,
|
|
Organization: organization,
|
|
Department: department,
|
|
Team: team,
|
|
Project: project,
|
|
Admin: prerequisites.admin,
|
|
}, nil
|
|
}
|
|
|
|
func (service *Service) ResetDevelopmentState(ctx context.Context) error {
|
|
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
TRUNCATE TABLE
|
|
project_memberships,
|
|
team_memberships,
|
|
organization_memberships,
|
|
workspaces,
|
|
projects,
|
|
teams,
|
|
departments,
|
|
user_homes,
|
|
users,
|
|
organizations,
|
|
background_jobs,
|
|
installations
|
|
RESTART IDENTITY;
|
|
`); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) {
|
|
var record AdminRecord
|
|
err := service.db.Pool.QueryRow(ctx, `
|
|
SELECT
|
|
u.id::text,
|
|
u.email,
|
|
u.display_name,
|
|
u.is_instance_admin,
|
|
COALESCE(uh.title, '')
|
|
FROM users u
|
|
LEFT JOIN user_homes uh ON uh.user_id = u.id
|
|
WHERE u.is_instance_admin = TRUE
|
|
ORDER BY u.created_at ASC
|
|
LIMIT 1;
|
|
`).Scan(
|
|
&record.ID,
|
|
&record.Email,
|
|
&record.DisplayName,
|
|
&record.IsInstanceAdmin,
|
|
&record.HomeTitle,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
themePresetID, err := service.loadAdminThemePresetID(ctx, record.DisplayName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if themePresetID != "" {
|
|
record.ThemePresetID = themePresetID
|
|
}
|
|
|
|
return &record, nil
|
|
}
|
|
|
|
func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureState, error) {
|
|
workspaces, err := service.listWorkspaces(ctx)
|
|
if err != nil {
|
|
return BootstrapStructureState{}, err
|
|
}
|
|
organization, err := service.loadPrimaryOrganization(ctx)
|
|
if err != nil {
|
|
return BootstrapStructureState{}, err
|
|
}
|
|
department, err := service.loadPrimaryDepartment(ctx)
|
|
if err != nil {
|
|
return BootstrapStructureState{}, err
|
|
}
|
|
team, err := service.loadPrimaryTeam(ctx)
|
|
if err != nil {
|
|
return BootstrapStructureState{}, err
|
|
}
|
|
project, err := service.loadPrimaryProject(ctx)
|
|
if err != nil {
|
|
return BootstrapStructureState{}, err
|
|
}
|
|
|
|
return BootstrapStructureState{
|
|
Organization: organization,
|
|
Department: department,
|
|
Team: team,
|
|
Project: project,
|
|
Workspaces: workspaces,
|
|
}, nil
|
|
}
|