894 lines
25 KiB
Go
894 lines
25 KiB
Go
// Path: Backend/internal/bootstrap/service.go
|
|
|
|
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"moku-backend/internal/database"
|
|
)
|
|
|
|
const (
|
|
primaryOrganizationSlug = "primary-organization"
|
|
primaryDepartmentSlug = "primary-department"
|
|
primaryTeamSlug = "primary-team"
|
|
primaryProjectSlug = "primary-project"
|
|
organizationWorkspaceSlug = "organization-home"
|
|
departmentWorkspaceSlug = "department-home"
|
|
teamWorkspaceSlug = "team-home"
|
|
projectWorkspaceSlug = "project-home"
|
|
defaultInstallationHost = "localhost"
|
|
defaultInstallationMode = "personal"
|
|
defaultInstallationAccess = "local"
|
|
defaultInstallationProtocol = "http"
|
|
defaultOrganizationName = "Moku"
|
|
defaultPersonalServerSuffix = "Personal"
|
|
defaultPersonalDisplayName = "Personal"
|
|
bootstrapWorkspaceKindOrg = "organization"
|
|
bootstrapWorkspaceKindDept = "department"
|
|
bootstrapWorkspaceKindTeam = "team"
|
|
bootstrapWorkspaceKindProject = "project"
|
|
)
|
|
|
|
var (
|
|
ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed")
|
|
ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
|
|
)
|
|
|
|
type Service struct {
|
|
db *database.DB
|
|
}
|
|
|
|
type SaveInstanceInput struct {
|
|
Protocol string
|
|
Access string
|
|
Host string
|
|
}
|
|
|
|
type SaveModeInput struct {
|
|
Mode string
|
|
}
|
|
|
|
type SaveAdminInput struct {
|
|
DisplayName string
|
|
Email string
|
|
Password string
|
|
}
|
|
|
|
type SaveStructureInput struct {
|
|
OrganizationName string
|
|
DepartmentName string
|
|
TeamName string
|
|
ProjectName string
|
|
}
|
|
|
|
type InstallationRecord struct {
|
|
ID string `json:"id"`
|
|
Mode string `json:"mode"`
|
|
Access string `json:"access"`
|
|
Protocol string `json:"protocol"`
|
|
Host string `json:"host"`
|
|
IsBootstrapped bool `json:"isBootstrapped"`
|
|
}
|
|
|
|
type AdminRecord struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"displayName"`
|
|
IsInstanceAdmin bool `json:"isInstanceAdmin"`
|
|
HomeTitle string `json:"homeTitle"`
|
|
}
|
|
|
|
type OrganizationRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
type DepartmentRecord struct {
|
|
ID string `json:"id"`
|
|
OrganizationID string `json:"organizationId"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
type TeamRecord struct {
|
|
ID string `json:"id"`
|
|
OrganizationID string `json:"organizationId"`
|
|
DepartmentID *string `json:"departmentId,omitempty"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
type ProjectRecord struct {
|
|
ID string `json:"id"`
|
|
OrganizationID string `json:"organizationId"`
|
|
DepartmentID *string `json:"departmentId,omitempty"`
|
|
TeamID *string `json:"teamId,omitempty"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
type WorkspaceRecord struct {
|
|
ID string `json:"id"`
|
|
OrganizationID string `json:"organizationId"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Kind string `json:"kind"`
|
|
DepartmentID *string `json:"departmentId,omitempty"`
|
|
TeamID *string `json:"teamId,omitempty"`
|
|
ProjectID *string `json:"projectId,omitempty"`
|
|
}
|
|
|
|
type StructureRecord struct {
|
|
Installation InstallationRecord `json:"installation"`
|
|
Organization namedRecord `json:"organization"`
|
|
Department namedRecord `json:"department"`
|
|
Team namedRecord `json:"team"`
|
|
Project namedRecord `json:"project"`
|
|
Admin AdminSummary `json:"admin"`
|
|
}
|
|
|
|
type AdminSummary struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
|
|
type BootstrapStructureState struct {
|
|
Organization *OrganizationRecord `json:"organization,omitempty"`
|
|
Department *DepartmentRecord `json:"department,omitempty"`
|
|
Team *TeamRecord `json:"team,omitempty"`
|
|
Project *ProjectRecord `json:"project,omitempty"`
|
|
Workspaces []WorkspaceRecord `json:"workspaces"`
|
|
}
|
|
|
|
type BootstrapState struct {
|
|
Installation *InstallationRecord `json:"installation,omitempty"`
|
|
Admin *AdminRecord `json:"admin,omitempty"`
|
|
Structure BootstrapStructureState `json:"structure"`
|
|
}
|
|
|
|
type AppShellState struct {
|
|
Installation *InstallationRecord `json:"installation,omitempty"`
|
|
Admin *AdminRecord `json:"admin,omitempty"`
|
|
Organizations []OrganizationRecord `json:"organizations"`
|
|
Departments []DepartmentRecord `json:"departments"`
|
|
Teams []TeamRecord `json:"teams"`
|
|
Projects []ProjectRecord `json:"projects"`
|
|
Workspaces []WorkspaceRecord `json:"workspaces"`
|
|
}
|
|
|
|
type namedRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
func NewService(db *database.DB) *Service {
|
|
return &Service{db: db}
|
|
}
|
|
|
|
func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) {
|
|
row := service.db.Pool.QueryRow(ctx, `
|
|
INSERT INTO installations (singleton, mode, access, protocol, host)
|
|
VALUES (
|
|
TRUE,
|
|
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, mode::text, access::text, protocol::text, host, is_bootstrapped;
|
|
`, 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, mode, access, protocol, host)
|
|
VALUES (
|
|
TRUE,
|
|
$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), $2)
|
|
)
|
|
ON CONFLICT (singleton) DO UPDATE
|
|
SET
|
|
mode = EXCLUDED.mode,
|
|
updated_at = NOW()
|
|
RETURNING id::text, mode::text, access::text, protocol::text, host, is_bootstrapped;
|
|
`, input.Mode, 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
|
|
}
|
|
|
|
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)
|
|
}()
|
|
|
|
installation, err := loadInstallation(ctx, tx)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return StructureRecord{}, ErrInstallationNotConfigured
|
|
}
|
|
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
admin, err := loadPrimaryAdmin(ctx, tx)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return StructureRecord{}, ErrAdminNotConfigured
|
|
}
|
|
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
organizationName := strings.TrimSpace(input.OrganizationName)
|
|
if organizationName == "" {
|
|
organizationName = defaultRootOrganizationName(installation.Mode, installation.Host, 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, 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, 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, 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, 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, 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, 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, admin.ID); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, admin.ID, nil, nil, nil); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, admin.ID, &department.ID, nil, nil); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, admin.ID, &department.ID, &team.ID, nil); err != nil {
|
|
return StructureRecord{}, err
|
|
}
|
|
|
|
if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, 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
|
|
}
|
|
|
|
return StructureRecord{
|
|
Installation: installation,
|
|
Organization: organization,
|
|
Department: department,
|
|
Team: team,
|
|
Project: project,
|
|
Admin: 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,
|
|
installations
|
|
RESTART IDENTITY;
|
|
`); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
|
|
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
|
|
SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped
|
|
FROM installations
|
|
WHERE singleton = TRUE
|
|
LIMIT 1;
|
|
`))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return &record, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (service *Service) GetState(ctx context.Context) (BootstrapState, error) {
|
|
installation, err := service.GetInstallation(ctx)
|
|
if err != nil {
|
|
return BootstrapState{}, err
|
|
}
|
|
|
|
admin, err := service.GetAdmin(ctx)
|
|
if err != nil {
|
|
return BootstrapState{}, err
|
|
}
|
|
|
|
structure, err := service.GetStructure(ctx)
|
|
if err != nil {
|
|
return BootstrapState{}, err
|
|
}
|
|
|
|
return BootstrapState{
|
|
Installation: installation,
|
|
Admin: admin,
|
|
Structure: structure,
|
|
}, nil
|
|
}
|
|
|
|
func (service *Service) GetAppShellState(ctx context.Context) (AppShellState, error) {
|
|
installation, err := service.GetInstallation(ctx)
|
|
if err != nil {
|
|
return AppShellState{}, err
|
|
}
|
|
|
|
admin, err := service.GetAdmin(ctx)
|
|
if err != nil {
|
|
return AppShellState{}, err
|
|
}
|
|
|
|
organizations, err := service.listOrganizations(ctx)
|
|
if err != nil {
|
|
return AppShellState{}, err
|
|
}
|
|
|
|
departments, err := service.listDepartments(ctx)
|
|
if err != nil {
|
|
return AppShellState{}, err
|
|
}
|
|
|
|
teams, err := service.listTeams(ctx)
|
|
if err != nil {
|
|
return AppShellState{}, err
|
|
}
|
|
|
|
projects, err := service.listProjects(ctx)
|
|
if err != nil {
|
|
return AppShellState{}, err
|
|
}
|
|
|
|
workspaces, err := service.listWorkspaces(ctx)
|
|
if err != nil {
|
|
return AppShellState{}, err
|
|
}
|
|
|
|
return AppShellState{
|
|
Installation: installation,
|
|
Admin: admin,
|
|
Organizations: organizations,
|
|
Departments: departments,
|
|
Teams: teams,
|
|
Projects: projects,
|
|
Workspaces: workspaces,
|
|
}, nil
|
|
}
|
|
|
|
func scanInstallationRecord(row pgx.Row) (InstallationRecord, error) {
|
|
var record InstallationRecord
|
|
if err := row.Scan(&record.ID, &record.Mode, &record.Access, &record.Protocol, &record.Host, &record.IsBootstrapped); err != nil {
|
|
return InstallationRecord{}, err
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) {
|
|
var record OrganizationRecord
|
|
err := service.db.Pool.QueryRow(ctx, `
|
|
SELECT id::text, name, slug
|
|
FROM organizations
|
|
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
|
|
LIMIT 1;
|
|
`, primaryOrganizationSlug).Scan(&record.ID, &record.Name, &record.Slug)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return &record, nil
|
|
}
|
|
|
|
func (service *Service) loadPrimaryDepartment(ctx context.Context) (*DepartmentRecord, error) {
|
|
var record DepartmentRecord
|
|
err := service.db.Pool.QueryRow(ctx, `
|
|
SELECT id::text, organization_id::text, name, slug
|
|
FROM departments
|
|
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
|
|
LIMIT 1;
|
|
`, primaryDepartmentSlug).Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return &record, nil
|
|
}
|
|
|
|
func (service *Service) loadPrimaryTeam(ctx context.Context) (*TeamRecord, error) {
|
|
var record TeamRecord
|
|
err := service.db.Pool.QueryRow(ctx, `
|
|
SELECT id::text, organization_id::text, department_id::text, name, slug
|
|
FROM teams
|
|
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
|
|
LIMIT 1;
|
|
`, primaryTeamSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return &record, nil
|
|
}
|
|
|
|
func (service *Service) loadPrimaryProject(ctx context.Context) (*ProjectRecord, error) {
|
|
var record ProjectRecord
|
|
err := service.db.Pool.QueryRow(ctx, `
|
|
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
|
|
FROM projects
|
|
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
|
|
LIMIT 1;
|
|
`, primaryProjectSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return &record, nil
|
|
}
|
|
|
|
func (service *Service) listOrganizations(ctx context.Context) ([]OrganizationRecord, error) {
|
|
rows, err := service.db.Pool.Query(ctx, `
|
|
SELECT id::text, name, slug
|
|
FROM organizations
|
|
ORDER BY created_at ASC;
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []OrganizationRecord
|
|
for rows.Next() {
|
|
var record OrganizationRecord
|
|
if err := rows.Scan(&record.ID, &record.Name, &record.Slug); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, rows.Err()
|
|
}
|
|
|
|
func (service *Service) listDepartments(ctx context.Context) ([]DepartmentRecord, error) {
|
|
rows, err := service.db.Pool.Query(ctx, `
|
|
SELECT id::text, organization_id::text, name, slug
|
|
FROM departments
|
|
ORDER BY created_at ASC;
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []DepartmentRecord
|
|
for rows.Next() {
|
|
var record DepartmentRecord
|
|
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, rows.Err()
|
|
}
|
|
|
|
func (service *Service) listTeams(ctx context.Context) ([]TeamRecord, error) {
|
|
rows, err := service.db.Pool.Query(ctx, `
|
|
SELECT id::text, organization_id::text, department_id::text, name, slug
|
|
FROM teams
|
|
ORDER BY created_at ASC;
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []TeamRecord
|
|
for rows.Next() {
|
|
var record TeamRecord
|
|
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, rows.Err()
|
|
}
|
|
|
|
func (service *Service) listProjects(ctx context.Context) ([]ProjectRecord, error) {
|
|
rows, err := service.db.Pool.Query(ctx, `
|
|
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
|
|
FROM projects
|
|
ORDER BY created_at ASC;
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []ProjectRecord
|
|
for rows.Next() {
|
|
var record ProjectRecord
|
|
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, rows.Err()
|
|
}
|
|
|
|
func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) {
|
|
rows, err := service.db.Pool.Query(ctx, `
|
|
SELECT id::text, organization_id::text, name, slug, kind::text, department_id::text, team_id::text, project_id::text
|
|
FROM workspaces
|
|
ORDER BY created_at ASC;
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []WorkspaceRecord
|
|
for rows.Next() {
|
|
var record WorkspaceRecord
|
|
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug, &record.Kind, &record.DepartmentID, &record.TeamID, &record.ProjectID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, rows.Err()
|
|
}
|
|
|
|
func loadInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
|
|
return scanInstallationRecord(tx.QueryRow(ctx, `
|
|
SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped
|
|
FROM installations
|
|
WHERE singleton = TRUE
|
|
LIMIT 1;
|
|
`))
|
|
}
|
|
|
|
func loadPrimaryAdmin(ctx context.Context, tx pgx.Tx) (AdminSummary, error) {
|
|
var admin AdminSummary
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id::text, email, display_name
|
|
FROM users
|
|
WHERE is_instance_admin = TRUE
|
|
ORDER BY created_at ASC
|
|
LIMIT 1;
|
|
`).Scan(&admin.ID, &admin.Email, &admin.DisplayName); err != nil {
|
|
return AdminSummary{}, err
|
|
}
|
|
|
|
return admin, nil
|
|
}
|
|
|
|
func updateBootstrappedInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
|
|
return scanInstallationRecord(tx.QueryRow(ctx, `
|
|
UPDATE installations
|
|
SET is_bootstrapped = TRUE, bootstrapped_at = COALESCE(bootstrapped_at, NOW()), updated_at = NOW()
|
|
WHERE singleton = TRUE
|
|
RETURNING id::text, mode::text, access::text, protocol::text, host, is_bootstrapped;
|
|
`))
|
|
}
|
|
|
|
func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) {
|
|
var record namedRecord
|
|
if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil {
|
|
return namedRecord{}, err
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, kind, createdByUserID string, departmentID, teamID, projectID *string) error {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO workspaces (organization_id, name, slug, kind, created_by_user_id, department_id, team_id, project_id)
|
|
VALUES ($1::uuid, $2, $3, $4::workspace_kind, $5::uuid, $6::uuid, $7::uuid, $8::uuid)
|
|
ON CONFLICT (organization_id, slug) DO UPDATE
|
|
SET
|
|
name = EXCLUDED.name,
|
|
kind = EXCLUDED.kind,
|
|
created_by_user_id = EXCLUDED.created_by_user_id,
|
|
department_id = EXCLUDED.department_id,
|
|
team_id = EXCLUDED.team_id,
|
|
project_id = EXCLUDED.project_id,
|
|
updated_at = NOW();
|
|
`, organizationID, name, slug, kind, createdByUserID, departmentID, teamID, projectID)
|
|
|
|
return err
|
|
}
|
|
|
|
func defaultRootOrganizationName(mode, host, adminDisplayName string) string {
|
|
trimmedHost := strings.TrimSpace(host)
|
|
trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName)
|
|
|
|
if strings.EqualFold(mode, defaultInstallationMode) {
|
|
if trimmedAdminDisplayName != "" {
|
|
return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix)
|
|
}
|
|
|
|
return defaultPersonalDisplayName
|
|
}
|
|
|
|
if trimmedHost != "" {
|
|
return trimmedHost
|
|
}
|
|
|
|
return defaultOrganizationName
|
|
}
|
|
|
|
func personalHomeTitle(displayName string) string {
|
|
trimmedDisplayName := strings.TrimSpace(displayName)
|
|
if trimmedDisplayName == "" {
|
|
return "Home"
|
|
}
|
|
|
|
if strings.HasSuffix(strings.ToLower(trimmedDisplayName), "s") {
|
|
return fmt.Sprintf("%s' Home", trimmedDisplayName)
|
|
}
|
|
|
|
return fmt.Sprintf("%s's Home", trimmedDisplayName)
|
|
}
|