diff --git a/Backend/db/migrations/000002_bootstrap_foundation.sql b/Backend/db/migrations/000002_bootstrap_foundation.sql new file mode 100644 index 0000000..e42ee06 --- /dev/null +++ b/Backend/db/migrations/000002_bootstrap_foundation.sql @@ -0,0 +1,180 @@ +-- +goose Up + +CREATE TYPE instance_mode AS ENUM ('personal', 'organizational'); +CREATE TYPE instance_access AS ENUM ('local', 'remote'); +CREATE TYPE instance_protocol AS ENUM ('http', 'https'); +CREATE TYPE workspace_kind AS ENUM ('organization', 'department', 'team', 'project'); +CREATE TYPE membership_role AS ENUM ('owner', 'admin', 'member'); + +CREATE TABLE IF NOT EXISTS installations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + singleton BOOLEAN NOT NULL DEFAULT TRUE UNIQUE, + mode instance_mode NOT NULL, + access instance_access NOT NULL, + protocol instance_protocol NOT NULL DEFAULT 'http', + host TEXT NOT NULL, + is_bootstrapped BOOLEAN NOT NULL DEFAULT FALSE, + bootstrapped_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_instance_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users (LOWER(email)); + +CREATE TABLE IF NOT EXISTS user_homes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS organization_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role membership_role NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_organization_memberships_user_id ON organization_memberships (user_id); + +CREATE TABLE IF NOT EXISTS departments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + slug TEXT NOT NULL, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, slug) +); + +CREATE INDEX IF NOT EXISTS idx_departments_organization_id ON departments (organization_id); + +CREATE TABLE IF NOT EXISTS teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, slug) +); + +CREATE INDEX IF NOT EXISTS idx_teams_organization_id ON teams (organization_id); +CREATE INDEX IF NOT EXISTS idx_teams_department_id ON teams (department_id); + +CREATE TABLE IF NOT EXISTS team_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role membership_role NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (team_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_team_memberships_user_id ON team_memberships (user_id); + +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + team_id UUID REFERENCES teams(id) ON DELETE SET NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, slug) +); + +CREATE INDEX IF NOT EXISTS idx_projects_organization_id ON projects (organization_id); +CREATE INDEX IF NOT EXISTS idx_projects_department_id ON projects (department_id); +CREATE INDEX IF NOT EXISTS idx_projects_team_id ON projects (team_id); + +CREATE TABLE IF NOT EXISTS project_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role membership_role NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_memberships_user_id ON project_memberships (user_id); + +ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS kind workspace_kind NOT NULL DEFAULT 'organization', + ADD COLUMN IF NOT EXISTS created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_workspaces_department_id ON workspaces (department_id); +CREATE INDEX IF NOT EXISTS idx_workspaces_team_id ON workspaces (team_id); +CREATE INDEX IF NOT EXISTS idx_workspaces_project_id ON workspaces (project_id); + +-- +goose Down + +DROP INDEX IF EXISTS idx_workspaces_project_id; +DROP INDEX IF EXISTS idx_workspaces_team_id; +DROP INDEX IF EXISTS idx_workspaces_department_id; + +ALTER TABLE workspaces + DROP COLUMN IF EXISTS project_id, + DROP COLUMN IF EXISTS team_id, + DROP COLUMN IF EXISTS department_id, + DROP COLUMN IF EXISTS created_by_user_id, + DROP COLUMN IF EXISTS kind; + +DROP INDEX IF EXISTS idx_project_memberships_user_id; +DROP TABLE IF EXISTS project_memberships; + +DROP INDEX IF EXISTS idx_projects_team_id; +DROP INDEX IF EXISTS idx_projects_department_id; +DROP INDEX IF EXISTS idx_projects_organization_id; +DROP TABLE IF EXISTS projects; + +DROP INDEX IF EXISTS idx_team_memberships_user_id; +DROP TABLE IF EXISTS team_memberships; + +DROP INDEX IF EXISTS idx_teams_department_id; +DROP INDEX IF EXISTS idx_teams_organization_id; +DROP TABLE IF EXISTS teams; + +DROP INDEX IF EXISTS idx_departments_organization_id; +DROP TABLE IF EXISTS departments; + +DROP INDEX IF EXISTS idx_organization_memberships_user_id; +DROP TABLE IF EXISTS organization_memberships; + +ALTER TABLE organizations + DROP COLUMN IF EXISTS created_by_user_id; + +DROP TABLE IF EXISTS user_homes; +DROP INDEX IF EXISTS idx_users_email_unique; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS installations; + +DROP TYPE IF EXISTS membership_role; +DROP TYPE IF EXISTS workspace_kind; +DROP TYPE IF EXISTS instance_protocol; +DROP TYPE IF EXISTS instance_access; +DROP TYPE IF EXISTS instance_mode; diff --git a/Backend/docker-bake.hcl b/Backend/docker-bake.hcl index d848cab..b670879 100644 --- a/Backend/docker-bake.hcl +++ b/Backend/docker-bake.hcl @@ -23,14 +23,50 @@ target "dev-image" { tags = ["${REGISTRY}/moku/work-backend:dev-${TAG}"] } +target "prod-api" { + inherits = ["_app"] + target = "runtime" + args = { + SERVICE_NAME = "api" + } + tags = ["moku/work-backend:local-prod-api"] +} + +target "prod-worker" { + inherits = ["_app"] + target = "runtime" + args = { + SERVICE_NAME = "worker" + } + tags = ["moku/work-backend:local-prod-worker"] +} + +target "prod-api-image" { + inherits = ["_app"] + target = "runtime" + args = { + SERVICE_NAME = "api" + } + tags = ["${REGISTRY}/moku/work-backend:prod-api-${TAG}"] +} + +target "prod-worker-image" { + inherits = ["_app"] + target = "runtime" + args = { + SERVICE_NAME = "worker" + } + tags = ["${REGISTRY}/moku/work-backend:prod-worker-${TAG}"] +} + group "local" { - targets = ["dev"] + targets = ["dev", "prod-api", "prod-worker"] } group "registry" { - targets = ["dev-image"] + targets = ["dev-image", "prod-api-image", "prod-worker-image"] } group "default" { - targets = ["dev"] + targets = ["dev", "prod-api", "prod-worker"] } diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go new file mode 100644 index 0000000..d7a1cd2 --- /dev/null +++ b/Backend/internal/bootstrap/service.go @@ -0,0 +1,863 @@ +// 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) 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) +} diff --git a/Backend/internal/httpx/api_bootstrap_routes.go b/Backend/internal/httpx/api_bootstrap_routes.go new file mode 100644 index 0000000..8063e60 --- /dev/null +++ b/Backend/internal/httpx/api_bootstrap_routes.go @@ -0,0 +1,363 @@ +// Path: Backend/internal/httpx/api_bootstrap_routes.go + +package httpx + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + bootstrapservice "moku-backend/internal/bootstrap" +) + +type bootstrapInstanceStepRequest struct { + Protocol string `json:"protocol"` + Access string `json:"access"` + Host string `json:"host"` +} + +type bootstrapModeStepRequest struct { + Mode string `json:"mode"` +} + +type bootstrapAdminStepRequest struct { + DisplayName string `json:"displayName"` + Email string `json:"email"` + Password string `json:"password"` +} + +type bootstrapStructureStepRequest struct { + OrganizationName string `json:"organizationName"` + DepartmentName string `json:"departmentName"` + TeamName string `json:"teamName"` + ProjectName string `json:"projectName"` +} + +func (routes apiRoutes) handleBootstrapOverview(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "resource": "bootstrap", + "status": "persisted", + "steps": []map[string]string{ + { + "id": "instance", + "method": http.MethodPost, + "path": "/v1/bootstrap/steps/instance", + }, + { + "id": "mode", + "method": http.MethodPost, + "path": "/v1/bootstrap/steps/mode", + }, + { + "id": "admin", + "method": http.MethodPost, + "path": "/v1/bootstrap/steps/admin", + }, + { + "id": "structure", + "method": http.MethodPost, + "path": "/v1/bootstrap/steps/structure", + }, + { + "id": "installation", + "method": http.MethodGet, + "path": "/v1/bootstrap/installation", + }, + { + "id": "admin-state", + "method": http.MethodGet, + "path": "/v1/bootstrap/admin", + }, + { + "id": "structure-state", + "method": http.MethodGet, + "path": "/v1/bootstrap/structure", + }, + { + "id": "bootstrap-state", + "method": http.MethodGet, + "path": "/v1/bootstrap/state", + }, + { + "id": "app-shell", + "method": http.MethodGet, + "path": "/v1/app-shell", + }, + }, + }, + }) +} + +func (routes apiRoutes) handleBootstrapInstallation(w http.ResponseWriter, r *http.Request) { + record, err := routes.bootstrapService().GetInstallation(r.Context()) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": record, + "meta": map[string]any{ + "resource": "bootstrap-installation", + }, + }) +} + +func (routes apiRoutes) handleBootstrapAdmin(w http.ResponseWriter, r *http.Request) { + record, err := routes.bootstrapService().GetAdmin(r.Context()) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": record, + "meta": map[string]any{ + "resource": "bootstrap-admin", + }, + }) +} + +func (routes apiRoutes) handleBootstrapStructure(w http.ResponseWriter, r *http.Request) { + record, err := routes.bootstrapService().GetStructure(r.Context()) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": record, + "meta": map[string]any{ + "resource": "bootstrap-structure", + }, + }) +} + +func (routes apiRoutes) handleBootstrapState(w http.ResponseWriter, r *http.Request) { + record, err := routes.bootstrapService().GetState(r.Context()) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": record, + "meta": map[string]any{ + "resource": "bootstrap-state", + }, + }) +} + +func (routes apiRoutes) handleAppShellState(w http.ResponseWriter, r *http.Request) { + record, err := routes.bootstrapService().GetAppShellState(r.Context()) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": record, + "meta": map[string]any{ + "resource": "app-shell", + }, + }) +} + +func (routes apiRoutes) handleBootstrapInstanceStep(w http.ResponseWriter, r *http.Request) { + payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r) + if !ok { + return + } + payload.Protocol = strings.ToLower(strings.TrimSpace(payload.Protocol)) + payload.Access = strings.ToLower(strings.TrimSpace(payload.Access)) + payload.Host = strings.TrimSpace(payload.Host) + + if payload.Protocol != "http" && payload.Protocol != "https" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Protocol must be either 'http' or 'https'.") + return + } + + if payload.Access != "local" && payload.Access != "remote" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Access must be either 'local' or 'remote'.") + return + } + + if payload.Host == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Host is required.") + return + } + + record, err := routes.bootstrapService().SaveInstance(r.Context(), bootstrapservice.SaveInstanceInput{ + Protocol: payload.Protocol, + Access: payload.Access, + Host: payload.Host, + }) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + routes.writeBootstrapStepResponse(w, http.StatusOK, "instance", map[string]any{ + "request": payload, + "installation": record, + }) +} + +func (routes apiRoutes) handleBootstrapModeStep(w http.ResponseWriter, r *http.Request) { + payload, ok := decodeBootstrapRequest[bootstrapModeStepRequest](w, r) + if !ok { + return + } + payload.Mode = strings.ToLower(strings.TrimSpace(payload.Mode)) + + if payload.Mode != "personal" && payload.Mode != "organizational" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Mode must be either 'personal' or 'organizational'.") + return + } + + record, err := routes.bootstrapService().SaveMode(r.Context(), bootstrapservice.SaveModeInput{Mode: payload.Mode}) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + routes.writeBootstrapStepResponse(w, http.StatusOK, "mode", map[string]any{ + "request": payload, + "installation": record, + }) +} + +func (routes apiRoutes) handleBootstrapAdminStep(w http.ResponseWriter, r *http.Request) { + payload, ok := decodeBootstrapRequest[bootstrapAdminStepRequest](w, r) + if !ok { + return + } + payload.DisplayName = strings.TrimSpace(payload.DisplayName) + payload.Email = strings.ToLower(strings.TrimSpace(payload.Email)) + + if payload.DisplayName == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Display name is required.") + return + } + + if payload.Email == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Email is required.") + return + } + + if strings.TrimSpace(payload.Password) == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Password is required.") + return + } + + record, err := routes.bootstrapService().SaveAdmin(r.Context(), bootstrapservice.SaveAdminInput{ + DisplayName: payload.DisplayName, + Email: payload.Email, + Password: payload.Password, + }) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + routes.writeBootstrapStepResponse(w, http.StatusOK, "admin", map[string]any{ + "request": payload, + "admin": record, + }) +} + +func (routes apiRoutes) handleBootstrapStructureStep(w http.ResponseWriter, r *http.Request) { + payload, ok := decodeBootstrapRequest[bootstrapStructureStepRequest](w, r) + if !ok { + return + } + payload.OrganizationName = strings.TrimSpace(payload.OrganizationName) + payload.DepartmentName = strings.TrimSpace(payload.DepartmentName) + payload.TeamName = strings.TrimSpace(payload.TeamName) + payload.ProjectName = strings.TrimSpace(payload.ProjectName) + + if payload.DepartmentName == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Department name is required.") + return + } + + if payload.TeamName == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Team name is required.") + return + } + + if payload.ProjectName == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project name is required.") + return + } + + record, err := routes.bootstrapService().SaveStructure(r.Context(), bootstrapservice.SaveStructureInput{ + OrganizationName: payload.OrganizationName, + DepartmentName: payload.DepartmentName, + TeamName: payload.TeamName, + ProjectName: payload.ProjectName, + }) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + routes.writeBootstrapStepResponse(w, http.StatusOK, "structure", map[string]any{ + "request": payload, + "structure": record, + }) +} + +func (routes apiRoutes) bootstrapService() *bootstrapservice.Service { + return bootstrapservice.NewService(routes.cfg.Database) +} + +func (routes apiRoutes) writeBootstrapStepResponse(w http.ResponseWriter, status int, step string, payload any) { + WriteJSON(w, status, map[string]any{ + "data": map[string]any{ + "step": step, + "result": payload, + }, + "meta": map[string]any{ + "resource": "bootstrap-step", + "persisted": true, + }, + }) +} + +func (routes apiRoutes) writeBootstrapPersistenceError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, bootstrapservice.ErrInstallationNotConfigured), errors.Is(err, bootstrapservice.ErrAdminNotConfigured): + WriteError(w, http.StatusConflict, RequestIDFromContext(r.Context()), "bootstrap_prerequisite_missing", err.Error()) + default: + routes.cfg.Logger.Error("persist bootstrap step", "error", err, "path", r.URL.Path) + WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "bootstrap_persist_failed", "Failed to persist bootstrap data.") + } +} + +func decodeBootstrapRequest[T any](w http.ResponseWriter, r *http.Request) (T, bool) { + var payload T + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&payload); err != nil { + if errors.Is(err, io.EOF) { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON for this bootstrap step.") + return payload, false + } + + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON for this bootstrap step.") + return payload, false + } + + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") + return payload, false + } + + return payload, true +} diff --git a/Backend/internal/httpx/api_routes.go b/Backend/internal/httpx/api_routes.go index c99d72f..00f4a38 100644 --- a/Backend/internal/httpx/api_routes.go +++ b/Backend/internal/httpx/api_routes.go @@ -19,6 +19,18 @@ func newAPIRoutes(cfg RouterConfig) routeRegistrar { func (routes apiRoutes) Register(router chi.Router) { router.Route("/v1", func(apiRouter chi.Router) { apiRouter.Get("/", routes.handleIndex) + apiRouter.Get("/bootstrap", routes.handleBootstrapOverview) + apiRouter.Get("/bootstrap/installation", routes.handleBootstrapInstallation) + apiRouter.Get("/bootstrap/admin", routes.handleBootstrapAdmin) + apiRouter.Get("/bootstrap/structure", routes.handleBootstrapStructure) + apiRouter.Get("/bootstrap/state", routes.handleBootstrapState) + apiRouter.Route("/bootstrap/steps", func(bootstrapRouter chi.Router) { + bootstrapRouter.Post("/instance", routes.handleBootstrapInstanceStep) + bootstrapRouter.Post("/mode", routes.handleBootstrapModeStep) + bootstrapRouter.Post("/admin", routes.handleBootstrapAdminStep) + bootstrapRouter.Post("/structure", routes.handleBootstrapStructureStep) + }) + apiRouter.Get("/app-shell", routes.handleAppShellState) apiRouter.Get("/organizations", routes.handleOrganizations) apiRouter.Get("/workspaces", routes.handleWorkspaces) }) diff --git a/Commands/Local/prod.just b/Commands/Local/prod.just index 03a6819..42ab001 100644 --- a/Commands/Local/prod.just +++ b/Commands/Local/prod.just @@ -1,11 +1,15 @@ project_root := justfile_directory() proxy_bake := project_root + "/Proxy/docker-bake.hcl" +backend_bake := project_root + "/Backend/docker-bake.hcl" local_compose := project_root + "/Docker/docker-compose.local.prod.yaml" proxy_image := "moku/work-proxy:local-prod" +backend_api_image := "moku/work-backend:local-prod-api" +backend_worker_image := "moku/work-backend:local-prod-worker" # Build the local production proxy image locally. build: cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod + cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' prod-api prod-worker # Start the local production stack in the background using the current image. up: @@ -17,6 +21,7 @@ start: build up # Rebuild the local production proxy image locally. rebuild: cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod + cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' --set '*.no-cache=true' prod-api prod-worker docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate # Stop and remove the local production stack. @@ -35,3 +40,5 @@ restart: clean: docker compose -f '{{local_compose}}' down --remove-orphans --volumes docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true + docker image rm -f '{{backend_api_image}}' >/dev/null 2>&1 || true + docker image rm -f '{{backend_worker_image}}' >/dev/null 2>&1 || true diff --git a/Docker/docker-compose.local.prod.yaml b/Docker/docker-compose.local.prod.yaml index 95ed45e..574e29e 100644 --- a/Docker/docker-compose.local.prod.yaml +++ b/Docker/docker-compose.local.prod.yaml @@ -1,7 +1,67 @@ +x-backend-service: &backend-service + restart: unless-stopped + env_file: + - ../Env/.env.local + environment: + DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable + VALKEY_URL: redis://valkey:6379/0 + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + services: - proxy: - image: moku/work-proxy:local-prod - container_name: moku-work-proxy-local - restart: unless-stopped - ports: - - "8080:80" + postgres: + image: postgres:17-alpine + container_name: moku-work-postgres-prod + restart: unless-stopped + environment: + POSTGRES_DB: moku + POSTGRES_USER: moku + POSTGRES_PASSWORD: moku_dev_password + volumes: + - moku_work_postgres_prod_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U moku -d moku"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + valkey: + image: valkey/valkey:8-alpine + container_name: moku-work-valkey-prod + restart: unless-stopped + volumes: + - moku_work_valkey_prod_data:/data + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + + api: + <<: *backend-service + image: moku/work-backend:local-prod-api + container_name: moku-work-backend-api-prod + + worker: + <<: *backend-service + image: moku/work-backend:local-prod-worker + container_name: moku-work-backend-worker-prod + + proxy: + image: moku/work-proxy:local-prod + container_name: moku-work-proxy-local + restart: unless-stopped + depends_on: + api: + condition: service_started + ports: + - "8080:80" + +volumes: + moku_work_postgres_prod_data: + moku_work_valkey_prod_data: diff --git a/Env/.env.example b/Env/.env.example index 65b3261..52c8dc5 100644 --- a/Env/.env.example +++ b/Env/.env.example @@ -10,3 +10,9 @@ BACKEND_SHUTDOWN_TIMEOUT=10s DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable VALKEY_URL=redis://localhost:6379/0 + +VITE_API_BASE_URL=/v1 + +# Local frontend dev uses the Vite proxy for /v1 requests. +# Override only if the browser must call the API directly: +# VITE_API_BASE_URL=http://localhost:8081/v1 diff --git a/Frontend/vite.config.ts b/Frontend/vite.config.ts index 2badcf4..0b93d5f 100644 --- a/Frontend/vite.config.ts +++ b/Frontend/vite.config.ts @@ -9,10 +9,22 @@ const extraAllowedHosts = (process.env.ALLOWED_HOSTS ?? "") .map((host) => host.trim()) .filter(Boolean); +const backendAPIport = process.env.BACKEND_API_PORT?.trim() || "8081"; +const devAPIProxyTarget = + process.env.FRONTEND_API_PROXY_TARGET?.trim() || + process.env.VITE_DEV_PROXY_TARGET?.trim() || + `http://api:${backendAPIport}`; + export default defineConfig({ plugins: [solidStart({ ssr: false })], server: { allowedHosts: ["localhost", ...extraAllowedHosts], + proxy: { + "/v1": { + target: devAPIProxyTarget, + changeOrigin: true, + }, + }, }, preview: { allowedHosts: ["localhost", ...extraAllowedHosts], diff --git a/Proxy/Local/default.conf b/Proxy/Local/default.conf index adfa1ca..5ce525a 100644 --- a/Proxy/Local/default.conf +++ b/Proxy/Local/default.conf @@ -25,6 +25,15 @@ server { try_files $uri $uri/ $moku_bootstrap_document; } + location ^~ /v1/ { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://api:8081; + } + location /favicon.ico { try_files $uri =404; access_log off;