Feat: Add bootstrap persistence and shell routes
This commit is contained in:
180
Backend/db/migrations/000002_bootstrap_foundation.sql
Normal file
180
Backend/db/migrations/000002_bootstrap_foundation.sql
Normal file
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
863
Backend/internal/bootstrap/service.go
Normal file
863
Backend/internal/bootstrap/service.go
Normal file
@@ -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)
|
||||
}
|
||||
363
Backend/internal/httpx/api_bootstrap_routes.go
Normal file
363
Backend/internal/httpx/api_bootstrap_routes.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user