fix: stabilize development config and account settings
This commit is contained in:
+17
-17
@@ -3,7 +3,7 @@
|
||||
# Copy to .env and fill in your values. NEVER commit .env to git.
|
||||
|
||||
# ---- Application ----
|
||||
GOCHAT_ENV=production
|
||||
GOCHAT_ENV=prod
|
||||
GOCHAT_VERSION=v1.0.0
|
||||
|
||||
# ---- Server ----
|
||||
@@ -12,21 +12,21 @@ GOCHAT_SERVER_PORT=3000
|
||||
GOCHAT_SERVER_MODE=release # debug, release, test
|
||||
|
||||
# ---- Database ----
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=gochat
|
||||
POSTGRES_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
|
||||
POSTGRES_DB=gochat_production
|
||||
POSTGRES_SSLMODE=disable
|
||||
GOCHAT_DB_MAX_IDLE_CONNS=10
|
||||
GOCHAT_DB_MAX_OPEN_CONNS=100
|
||||
GOCHAT_DB_CONN_MAX_LIFETIME=3600
|
||||
GOCHAT_DATABASE_HOST=localhost
|
||||
GOCHAT_DATABASE_PORT=5432
|
||||
GOCHAT_DATABASE_USER=gochat
|
||||
GOCHAT_DATABASE_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
|
||||
GOCHAT_DATABASE_DBNAME=gochat_production
|
||||
GOCHAT_DATABASE_SSLMODE=disable
|
||||
GOCHAT_DATABASE_MAX_IDLE_CONNS=10
|
||||
GOCHAT_DATABASE_MAX_OPEN_CONNS=100
|
||||
GOCHAT_DATABASE_CONN_MAX_LIFETIME=3600
|
||||
|
||||
# ---- Redis ----
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
|
||||
REDIS_DB=0
|
||||
GOCHAT_REDIS_HOST=localhost
|
||||
GOCHAT_REDIS_PORT=6379
|
||||
GOCHAT_REDIS_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
|
||||
GOCHAT_REDIS_DB=0
|
||||
|
||||
# ---- JWT ----
|
||||
GOCHAT_JWT_SECRET=CHANGE_ME_TO_A_RANDOM_64_CHAR_STRING
|
||||
@@ -39,7 +39,7 @@ GOCHAT_LOG_LEVEL=info # debug, info, warn, error
|
||||
GOCHAT_LOG_FORMAT=json # json, text
|
||||
|
||||
# ---- SMTP / Email ----
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_ADDRESS=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=your-email@example.com
|
||||
SMTP_PASSWORD=your-email-password
|
||||
@@ -149,7 +149,7 @@ GOCHAT_OIDC_DEFAULT_JWKS_URL=
|
||||
GOCHAT_OIDC_DEFAULT_SCOPES=openid,profile,email
|
||||
|
||||
# ---- Security ----
|
||||
GOCHAT_RATE_LIMIT_REQUESTS=100 # requests per minute per IP
|
||||
GOCHAT_RATE_LIMIT_WINDOW=60 # seconds
|
||||
GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE=100 # requests per minute per IP
|
||||
GOCHAT_RATE_LIMIT_WINDOW_SECONDS=60 # seconds
|
||||
GOCHAT_ADMIN_EMAIL=admin@example.com
|
||||
GOCHAT_ADMIN_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
config.LoadDotEnvEnvironment()
|
||||
database.SanitizePostgresEnvironment()
|
||||
|
||||
cmd := "serve"
|
||||
if len(os.Args) > 1 {
|
||||
cmd = os.Args[1]
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
config.LoadDotEnvEnvironment()
|
||||
database.SanitizePostgresEnvironment()
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
@@ -17,8 +20,12 @@ func main() {
|
||||
|
||||
command := os.Args[1]
|
||||
|
||||
// Load config to get database connection info
|
||||
cfg, err := config.Load()
|
||||
// Load the same root .env and environment overlay as the application.
|
||||
env := os.Getenv("GOCHAT_ENV")
|
||||
if env == "" {
|
||||
env = "development"
|
||||
}
|
||||
cfg, err := config.LoadWithEnv(env)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -106,4 +113,4 @@ func printUsage() {
|
||||
fmt.Println(" steps <n> Apply n steps (positive=up, negative=down)")
|
||||
fmt.Println("")
|
||||
fmt.Println("Environment: Uses database config from Viper (config.yaml)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,19 @@ import (
|
||||
// NewRedisClient creates a Redis client for caching, Pub/Sub, and sessions.
|
||||
// Reference: Chatwoot config/cable.yml Redis adapter configuration
|
||||
func NewRedisClient(cfg *config.RedisConfig) (*redis.Client, error) {
|
||||
opts, err := parseRedisURL(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Redis URL: %w", err)
|
||||
var opts *redis.Options
|
||||
var err error
|
||||
if cfg.Host != "" && cfg.Port > 0 {
|
||||
opts = &redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
}
|
||||
} else {
|
||||
opts, err = parseRedisURL(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Redis URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Password != "" {
|
||||
@@ -39,7 +49,7 @@ func NewRedisClient(cfg *config.RedisConfig) (*redis.Client, error) {
|
||||
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
|
||||
}
|
||||
|
||||
logger.L().Infof("Connected to Redis: %s (pool=%d)", cfg.URL, poolSize)
|
||||
logger.L().Infof("Connected to Redis: %s (db=%d, pool=%d)", opts.Addr, opts.DB, poolSize)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRedisClientPrefersHostAndPortOverURL(t *testing.T) {
|
||||
server := miniredis.RunT(t)
|
||||
host, portText, err := net.SplitHostPort(server.Addr())
|
||||
require.NoError(t, err)
|
||||
port, err := strconv.Atoi(portText)
|
||||
require.NoError(t, err)
|
||||
|
||||
client, err := NewRedisClient(&config.RedisConfig{
|
||||
URL: "redis://localhost:6397/0",
|
||||
Host: host,
|
||||
Port: port,
|
||||
DB: 0,
|
||||
PoolSize: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
require.Equal(t, server.Addr(), client.Options().Addr)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ type Config struct {
|
||||
|
||||
type WorkerConfig struct {
|
||||
Concurrency int `mapstructure:"concurrency"`
|
||||
StreamPrefix string `mapstructure:"redis_stream_prefix"` // default "gochat:jobs"
|
||||
StreamPrefix string `mapstructure:"redis_stream_prefix"` // default "gochat:jobs"
|
||||
ConsumerGroup string `mapstructure:"redis_consumer_group"` // default "gochat-workers"
|
||||
BlockTimeoutS int `mapstructure:"redis_block_timeout_s"` // default 5
|
||||
SweepIntervalS int `mapstructure:"redis_sweep_interval_s"` // default 30
|
||||
@@ -627,45 +627,58 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
// Bind specific env keys that viper can't auto-infer for nested structs
|
||||
// These are common overrides that users set via environment variables
|
||||
envBindings := map[string]string{
|
||||
"GOCHAT_SERVER_HOST": "server.host",
|
||||
"GOCHAT_SERVER_PORT": "server.port",
|
||||
"GOCHAT_SERVER_MODE": "server.mode",
|
||||
"GOCHAT_DATABASE_HOST": "database.host",
|
||||
"GOCHAT_DATABASE_PORT": "database.port",
|
||||
"GOCHAT_DATABASE_USER": "database.user",
|
||||
"GOCHAT_DATABASE_PASSWORD": "database.password",
|
||||
"GOCHAT_DATABASE_NAME": "database.name",
|
||||
"GOCHAT_DATABASE_DBNAME": "database.dbname",
|
||||
"GOCHAT_DATABASE_SSLMODE": "database.sslmode",
|
||||
"GOCHAT_DATABASE_RUN_MIGRATIONS": "database.run_migrations",
|
||||
"GOCHAT_DATABASE_MIGRATIONS_PATH": "database.migrations_path",
|
||||
"GOCHAT_REDIS_URL": "redis.url",
|
||||
"GOCHAT_REDIS_HOST": "redis.host",
|
||||
"GOCHAT_REDIS_PORT": "redis.port",
|
||||
"GOCHAT_REDIS_PASSWORD": "redis.password",
|
||||
"GOCHAT_REDIS_POOL_SIZE": "redis.pool_size",
|
||||
"GOCHAT_JWT_SECRET": "jwt.secret",
|
||||
"JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix)
|
||||
"GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours",
|
||||
"GOCHAT_LOG_LEVEL": "log.level",
|
||||
"GOCHAT_LOG_FORMAT": "log.format",
|
||||
"GOCHAT_CAPTAIN_ENABLED": "captain.enabled",
|
||||
"GOCHAT_CAPTAIN_LLM_PROVIDER": "captain.llm_provider",
|
||||
"GOCHAT_CAPTAIN_LLM_MODEL": "captain.llm_model",
|
||||
"GOCHAT_CAPTAIN_LLM_API_KEY": "captain.llm_api_key",
|
||||
"GOCHAT_WORKER_CONCURRENCY": "worker.concurrency",
|
||||
"GOCHAT_WORKER_REDIS_STREAM_PREFIX": "worker.redis_stream_prefix",
|
||||
"GOCHAT_WORKER_REDIS_CONSUMER_GROUP": "worker.redis_consumer_group",
|
||||
"GOCHAT_WORKER_REDIS_BLOCK_TIMEOUT_S": "worker.redis_block_timeout_s",
|
||||
"GOCHAT_WORKER_REDIS_SWEEP_INTERVAL_S": "worker.redis_sweep_interval_s",
|
||||
"GOCHAT_SEARCH_ENGINE": "search.engine",
|
||||
"GOCHAT_SEARCH_HOST": "search.host",
|
||||
"GOCHAT_SEARCH_API_KEY": "search.api_key",
|
||||
"GOCHAT_SEARCH_INDEX_PREFIX": "search.index_prefix",
|
||||
"GOCHAT_SEARCH_TIMEOUT_SECONDS": "search.timeout_seconds",
|
||||
"GOCHAT_STORAGE_PROVIDER": "storage.provider",
|
||||
"GOCHAT_STORAGE_LOCAL_PATH": "storage.local_path",
|
||||
"GOCHAT_STORAGE_MAX_FILE_SIZE": "storage.max_file_size",
|
||||
"GOCHAT_SERVER_HOST": "server.host",
|
||||
"GOCHAT_SERVER_PORT": "server.port",
|
||||
"GOCHAT_SERVER_MODE": "server.mode",
|
||||
"GOCHAT_DATABASE_HOST": "database.host",
|
||||
"GOCHAT_DATABASE_PORT": "database.port",
|
||||
"GOCHAT_DATABASE_USER": "database.user",
|
||||
"GOCHAT_DATABASE_PASSWORD": "database.password",
|
||||
"GOCHAT_DATABASE_NAME": "database.name",
|
||||
"GOCHAT_DATABASE_DBNAME": "database.dbname",
|
||||
"GOCHAT_DATABASE_SSLMODE": "database.sslmode",
|
||||
"GOCHAT_DATABASE_MAX_IDLE_CONNS": "database.max_idle_conns",
|
||||
"GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns",
|
||||
"GOCHAT_DATABASE_CONN_MAX_LIFETIME": "database.conn_max_lifetime",
|
||||
"GOCHAT_DATABASE_RUN_MIGRATIONS": "database.run_migrations",
|
||||
"GOCHAT_DATABASE_MIGRATIONS_PATH": "database.migrations_path",
|
||||
"GOCHAT_REDIS_URL": "redis.url",
|
||||
"GOCHAT_REDIS_HOST": "redis.host",
|
||||
"GOCHAT_REDIS_PORT": "redis.port",
|
||||
"GOCHAT_REDIS_PASSWORD": "redis.password",
|
||||
"GOCHAT_REDIS_DB": "redis.db",
|
||||
"GOCHAT_REDIS_POOL_SIZE": "redis.pool_size",
|
||||
"GOCHAT_JWT_SECRET": "jwt.secret",
|
||||
"JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix)
|
||||
"GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours",
|
||||
"GOCHAT_JWT_ACCESS_EXPIRY_MINUTES": "jwt.access_expiry_minutes",
|
||||
"GOCHAT_JWT_REFRESH_EXPIRY_HOURS": "jwt.refresh_expiry_hours",
|
||||
"GOCHAT_LOG_LEVEL": "log.level",
|
||||
"GOCHAT_LOG_FORMAT": "log.format",
|
||||
"GOCHAT_CAPTAIN_ENABLED": "captain.enabled",
|
||||
"GOCHAT_CAPTAIN_LLM_PROVIDER": "captain.llm_provider",
|
||||
"GOCHAT_CAPTAIN_LLM_MODEL": "captain.llm_model",
|
||||
"GOCHAT_CAPTAIN_LLM_API_KEY": "captain.llm_api_key",
|
||||
"GOCHAT_CAPTAIN_LLM_BASE_URL": "captain.llm_base_url",
|
||||
"GOCHAT_CAPTAIN_EMBEDDING_MODEL": "captain.embedding_model",
|
||||
"GOCHAT_CAPTAIN_EMBEDDING_DIMS": "captain.embedding_dims",
|
||||
"GOCHAT_CAPTAIN_MAX_TOKENS": "captain.max_tokens",
|
||||
"GOCHAT_CAPTAIN_TEMPERATURE": "captain.temperature",
|
||||
"GOCHAT_WORKER_CONCURRENCY": "worker.concurrency",
|
||||
"GOCHAT_WORKER_REDIS_STREAM_PREFIX": "worker.redis_stream_prefix",
|
||||
"GOCHAT_WORKER_REDIS_CONSUMER_GROUP": "worker.redis_consumer_group",
|
||||
"GOCHAT_WORKER_REDIS_BLOCK_TIMEOUT_S": "worker.redis_block_timeout_s",
|
||||
"GOCHAT_WORKER_REDIS_SWEEP_INTERVAL_S": "worker.redis_sweep_interval_s",
|
||||
"GOCHAT_SEARCH_ENGINE": "search.engine",
|
||||
"GOCHAT_SEARCH_HOST": "search.host",
|
||||
"GOCHAT_SEARCH_API_KEY": "search.api_key",
|
||||
"GOCHAT_SEARCH_INDEX_PREFIX": "search.index_prefix",
|
||||
"GOCHAT_SEARCH_TIMEOUT_SECONDS": "search.timeout_seconds",
|
||||
"GOCHAT_STORAGE_PROVIDER": "storage.provider",
|
||||
"GOCHAT_STORAGE_LOCAL_PATH": "storage.local_path",
|
||||
"GOCHAT_STORAGE_MAX_FILE_SIZE": "storage.max_file_size",
|
||||
"GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE": "rate_limit.requests_per_minute",
|
||||
"GOCHAT_RATE_LIMIT_WINDOW_SECONDS": "rate_limit.window_seconds",
|
||||
// G10: OAuth config for new channel integrations (Twitter, Microsoft, Google)
|
||||
"GOCHAT_OAUTH_TWITTER_CLIENT_ID": "oauth.twitter.client_id",
|
||||
"GOCHAT_OAUTH_TWITTER_CLIENT_SECRET": "oauth.twitter.client_secret",
|
||||
@@ -722,7 +735,7 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
}
|
||||
|
||||
// Load .env file if present (common in Docker/local dev setups)
|
||||
loadDotEnv(v)
|
||||
loadDotEnv(v, envBindings)
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
@@ -735,10 +748,36 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// LoadDotEnvEnvironment loads workspace environment values into the process.
|
||||
// It is called before GOCHAT_ENV is read so the root .env can select the
|
||||
// environment-specific config overlay as well as configure direct os.Getenv
|
||||
// consumers throughout the application.
|
||||
func LoadDotEnvEnvironment() {
|
||||
dotEnvPaths := []string{".env", "../.env", "/etc/gochat/.env"}
|
||||
for _, path := range dotEnvPaths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
key, val, ok := parseDotEnvLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// LoadDotEnvFile reads a .env file and sets values into viper.
|
||||
// Supports simple KEY=VALUE format, ignores comments and blank lines.
|
||||
func loadDotEnv(v *viper.Viper) {
|
||||
dotEnvPaths := []string{".env", "./.env", "/etc/gochat/.env"}
|
||||
func loadDotEnv(v *viper.Viper, envBindings map[string]string) {
|
||||
// The backend normally runs with backend/ as its working directory, while
|
||||
// the workspace-level .env lives one directory above it.
|
||||
dotEnvPaths := []string{".env", "../.env", "/etc/gochat/.env"}
|
||||
for _, path := range dotEnvPaths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -746,22 +785,19 @@ func loadDotEnv(v *viper.Viper) {
|
||||
}
|
||||
applogger.L().Infof("Loading .env file: %s", path)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
key, val, ok := parseDotEnvLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
// Existing process environment has priority over values from .env.
|
||||
if existing, ok := os.LookupEnv(key); ok {
|
||||
val = existing
|
||||
} else {
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
// Strip surrounding quotes
|
||||
val = strings.Trim(val, "\"'")
|
||||
// Map GOCHAT_ prefixed keys to viper config keys
|
||||
if strings.HasPrefix(key, "GOCHAT_") {
|
||||
configKey := strings.ToLower(strings.TrimPrefix(key, "GOCHAT_"))
|
||||
configKey = strings.ReplaceAll(configKey, "_", ".")
|
||||
// Use explicit bindings so compound field names such as
|
||||
// max_idle_conns are not incorrectly split into max.idle.conns.
|
||||
if configKey, ok := envBindings[key]; ok {
|
||||
v.Set(configKey, val)
|
||||
}
|
||||
}
|
||||
@@ -770,6 +806,25 @@ func loadDotEnv(v *viper.Viper) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseDotEnvLine(line string) (string, string, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
if len(val) >= 2 && ((val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'')) {
|
||||
val = val[1 : len(val)-1]
|
||||
} else if comment := strings.Index(val, " #"); comment >= 0 {
|
||||
val = strings.TrimSpace(val[:comment])
|
||||
}
|
||||
return key, val, key != ""
|
||||
}
|
||||
|
||||
// setDefaults sets all viper defaults in one place.
|
||||
func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.host", "0.0.0.0")
|
||||
|
||||
@@ -190,84 +190,23 @@ func TestLoadWithEnv_EnvironmentOverlay(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadWithEnv_DotEnvFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
key, value, ok := parseDotEnvLine("GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE=50 # requests per minute")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE", key)
|
||||
assert.Equal(t, "50", value)
|
||||
|
||||
// Create .env file
|
||||
envContent := `
|
||||
GOCHAT_LOG_LEVEL=warn
|
||||
GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE=50
|
||||
# Comment line should be ignored
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test the loadDotEnv parsing logic directly
|
||||
v := viper.New()
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, ".env"))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.Trim(val, "\"'")
|
||||
|
||||
if strings.HasPrefix(key, "GOCHAT_") {
|
||||
configKey := strings.ToLower(strings.TrimPrefix(key, "GOCHAT_"))
|
||||
configKey = strings.ReplaceAll(configKey, "_", ".")
|
||||
v.Set(configKey, val)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, "warn", v.Get("log.level"))
|
||||
// Note: RATE_LIMIT_REQUESTS_PER_MINUTE maps to rate.limit.requests.per.minute
|
||||
// This is the .env → viper key mapping: underscores become dots after lowering
|
||||
assert.Equal(t, "50", v.Get("rate.limit.requests.per.minute"))
|
||||
_, _, ok = parseDotEnvLine("# Comment line should be ignored")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestLoadDotEnv_QuotedValues(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
_, value, ok := parseDotEnvLine(`GOCHAT_JWT_SECRET="my-super-secret-key-with-quotes"`)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "my-super-secret-key-with-quotes", value)
|
||||
|
||||
envContent := `GOCHAT_JWT_SECRET="my-super-secret-key-with-quotes"
|
||||
GOCHAT_REDIS_URL='redis://special-host:6379'
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
v := viper.New()
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, ".env"))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.Trim(val, "\"'")
|
||||
|
||||
if strings.HasPrefix(key, "GOCHAT_") {
|
||||
configKey := strings.ToLower(strings.TrimPrefix(key, "GOCHAT_"))
|
||||
configKey = strings.ReplaceAll(configKey, "_", ".")
|
||||
v.Set(configKey, val)
|
||||
}
|
||||
}
|
||||
|
||||
// Quotes should be stripped
|
||||
assert.Equal(t, "my-super-secret-key-with-quotes", v.Get("jwt.secret"))
|
||||
assert.Equal(t, "redis://special-host:6379", v.Get("redis.url"))
|
||||
_, value, ok = parseDotEnvLine("GOCHAT_REDIS_URL='redis://special-host:6379'")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "redis://special-host:6379", value)
|
||||
}
|
||||
|
||||
// --- Env Binding Tests ---
|
||||
@@ -331,14 +270,14 @@ func TestApplyZeroDefaults(t *testing.T) {
|
||||
|
||||
func validTestConfig() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Port: 5432, User: "gochat", Password: "secret", Name: "gochat_db", DBName: "gochat_db"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Captain: CaptainConfig{Enabled: false},
|
||||
Worker: WorkerConfig{Concurrency: 4},
|
||||
OAuth: OAuthConfig{},
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Port: 5432, User: "gochat", Password: "secret", Name: "gochat_db", DBName: "gochat_db"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Captain: CaptainConfig{Enabled: false},
|
||||
Worker: WorkerConfig{Concurrency: 4},
|
||||
OAuth: OAuthConfig{},
|
||||
RateLimit: RateLimitConfig{Enabled: true, RequestsPerMinute: 100, WindowSeconds: 60},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
@@ -10,6 +11,17 @@ import (
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
)
|
||||
|
||||
var unsupportedPQEnvironmentKeys = []string{"PGSERVICE", "PGSERVICEFILE", "PGREALM"}
|
||||
|
||||
// SanitizePostgresEnvironment removes libpq service-file settings that are
|
||||
// incompatible with GoChat's explicit database configuration and can make
|
||||
// both pgx and lib/pq ignore or reject the configured host/user/database.
|
||||
func SanitizePostgresEnvironment() {
|
||||
for _, key := range unsupportedPQEnvironmentKeys {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
// RunMigrations applies all pending database migrations from the given path.
|
||||
// dbURL should be a database connection string (PostgreSQL or SQLite).
|
||||
// migrationsPath should be a file path to the migrations directory (e.g. "migrations").
|
||||
@@ -20,6 +32,12 @@ import (
|
||||
// - A migration fails to apply
|
||||
// - The database is in a dirty state (a previous migration partially failed)
|
||||
func RunMigrations(dbURL string, migrationsPath string) error {
|
||||
return withSanitizedPQEnvironment(func() error {
|
||||
return runMigrations(dbURL, migrationsPath)
|
||||
})
|
||||
}
|
||||
|
||||
func runMigrations(dbURL string, migrationsPath string) error {
|
||||
m, err := migrate.New(
|
||||
fmt.Sprintf("file://%s", migrationsPath),
|
||||
dbURL,
|
||||
@@ -45,6 +63,36 @@ func RunMigrations(dbURL string, migrationsPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// withSanitizedPQEnvironment prevents lib/pq from panicking when PostgreSQL
|
||||
// service-file variables are inherited from the user's shell. GoChat passes a
|
||||
// complete connection URL to golang-migrate, and lib/pq does not support these
|
||||
// libpq service variables. Restore them after the migration operation so this
|
||||
// workaround remains scoped to the legacy migration driver.
|
||||
func withSanitizedPQEnvironment(fn func() error) error {
|
||||
type savedValue struct {
|
||||
value string
|
||||
set bool
|
||||
}
|
||||
saved := make(map[string]savedValue, len(unsupportedPQEnvironmentKeys))
|
||||
for _, key := range unsupportedPQEnvironmentKeys {
|
||||
value, set := os.LookupEnv(key)
|
||||
saved[key] = savedValue{value: value, set: set}
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
defer func() {
|
||||
for _, key := range unsupportedPQEnvironmentKeys {
|
||||
previous := saved[key]
|
||||
if previous.set {
|
||||
_ = os.Setenv(key, previous.value)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
// MigrateSteps applies N migration steps (positive = up, negative = down).
|
||||
func MigrateSteps(dbURL string, migrationsPath string, steps int) error {
|
||||
m, err := migrate.New(
|
||||
@@ -128,4 +176,4 @@ func CurrentVersion(dbURL string, migrationsPath string) (uint, bool, error) {
|
||||
}
|
||||
|
||||
return version, dirty, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithSanitizedPQEnvironment(t *testing.T) {
|
||||
t.Setenv("PGSERVICE", "local-service")
|
||||
t.Setenv("PGSERVICEFILE", "/tmp/pg_service.conf")
|
||||
t.Setenv("PGREALM", "local-realm")
|
||||
|
||||
err := withSanitizedPQEnvironment(func() error {
|
||||
for _, key := range []string{"PGSERVICE", "PGSERVICEFILE", "PGREALM"} {
|
||||
_, exists := os.LookupEnv(key)
|
||||
require.False(t, exists, "%s should be unset during migration", key)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "local-service", os.Getenv("PGSERVICE"))
|
||||
require.Equal(t, "/tmp/pg_service.conf", os.Getenv("PGSERVICEFILE"))
|
||||
require.Equal(t, "local-realm", os.Getenv("PGREALM"))
|
||||
}
|
||||
|
||||
func TestSanitizePostgresEnvironment(t *testing.T) {
|
||||
t.Setenv("PGSERVICE", "local-service")
|
||||
t.Setenv("PGSERVICEFILE", "/tmp/pg_service.conf")
|
||||
t.Setenv("PGREALM", "local-realm")
|
||||
|
||||
SanitizePostgresEnvironment()
|
||||
|
||||
for _, key := range unsupportedPQEnvironmentKeys {
|
||||
_, exists := os.LookupEnv(key)
|
||||
require.False(t, exists, "%s should be removed at application startup", key)
|
||||
}
|
||||
}
|
||||
@@ -454,7 +454,7 @@ func serializeAccountSettings(account *model.Account) map[string]any {
|
||||
"auto_resolve_duration": account.AutoResolveDuration,
|
||||
"auto_resolve_message": "",
|
||||
"auto_resolve_ignore_waiting": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_transcriptions": account.AudioTranscriptions,
|
||||
"auto_resolve_label": "",
|
||||
"reporting_timezone": account.ReportingTimezone,
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ func (s *AccountHandlerTestSuite) SetupSuite() {
|
||||
accountsGroup.GET("", s.handler.List)
|
||||
accountsGroup.GET("/:account_id", s.handler.Get)
|
||||
accountsGroup.POST("", s.handler.Create)
|
||||
accountsGroup.PATCH("/:account_id", s.handler.Update)
|
||||
accountsGroup.PUT("/:account_id", s.handler.Update)
|
||||
accountsGroup.PATCH("/:account_id/onboarding", s.handler.UpdateOnboarding)
|
||||
accountsGroup.DELETE("/:account_id", s.handler.Delete)
|
||||
@@ -313,6 +314,55 @@ func (s *AccountHandlerTestSuite) TestUpdate_Success() {
|
||||
assert.Equal(s.T(), "After Update", resp["name"])
|
||||
}
|
||||
|
||||
func (s *AccountHandlerTestSuite) TestPatchUpdate_AudioTranscriptions() {
|
||||
acc := s.seedAccount("Audio Transcription Account")
|
||||
|
||||
body := `{"audio_transcriptions":true}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
resp := s.unmarshalResponse(w)
|
||||
settings := resp["settings"].(map[string]interface{})
|
||||
assert.Equal(s.T(), true, settings["audio_transcriptions"])
|
||||
|
||||
var stored model.Account
|
||||
require.NoError(s.T(), s.db.First(&stored, acc.ID).Error)
|
||||
assert.True(s.T(), stored.AudioTranscriptions)
|
||||
|
||||
body = `{"audio_transcriptions":false}`
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
require.NoError(s.T(), s.db.First(&stored, acc.ID).Error)
|
||||
assert.False(s.T(), stored.AudioTranscriptions)
|
||||
}
|
||||
|
||||
func (s *AccountHandlerTestSuite) TestPatchUpdate_ChatwootGeneralSettingsPayload() {
|
||||
acc := s.seedAccount("Test Account")
|
||||
|
||||
body := `{"locale":"zh_CN","name":"Test Account11","domain":"","support_email":null}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
resp := s.unmarshalResponse(w)
|
||||
assert.Equal(s.T(), "zh_CN", resp["locale"])
|
||||
assert.Equal(s.T(), "Test Account11", resp["name"])
|
||||
|
||||
var stored model.Account
|
||||
require.NoError(s.T(), s.db.First(&stored, acc.ID).Error)
|
||||
assert.Equal(s.T(), "zh_CN", stored.Locale)
|
||||
assert.Equal(s.T(), "Test Account11", stored.Name)
|
||||
}
|
||||
|
||||
func (s *AccountHandlerTestSuite) TestUpdateOnboarding_FinalizesAccountDetails() {
|
||||
acc := &model.Account{Name: "Before Onboarding", Active: true, Status: "active", OnboardingStep: "account_details"}
|
||||
s.Require().NoError(acc.SetCustomAttributesMap(map[string]any{
|
||||
|
||||
@@ -20,9 +20,10 @@ type Account struct {
|
||||
CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"`
|
||||
FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags
|
||||
AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days
|
||||
Limits datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"limits,omitempty"` // Chatwoot account limits, e.g. emails
|
||||
AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents]
|
||||
InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes]
|
||||
AudioTranscriptions bool `gorm:"default:false" json:"audio_transcriptions"`
|
||||
Limits datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"limits,omitempty"` // Chatwoot account limits, e.g. emails
|
||||
AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents]
|
||||
InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes]
|
||||
CaptainModels datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_models,omitempty"`
|
||||
CaptainFeatures datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_features,omitempty"`
|
||||
// Chatwoot: store_accessor :settings, :keep_pending_on_bot_failure
|
||||
|
||||
@@ -702,6 +702,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
accounts.GET("/", h.Account.List)
|
||||
accounts.POST("/", h.Account.Create)
|
||||
accounts.GET("/:account_id", h.Account.Get)
|
||||
accounts.PATCH("/:account_id", h.Account.Update)
|
||||
accounts.PUT("/:account_id", h.Account.Update)
|
||||
accounts.DELETE("/:account_id", h.Account.Delete)
|
||||
// Account onboarding update (ref: Chatwoot resource :onboarding, only: [:update])
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *AccountService) GetByUserAndID(ctx context.Context, userID, accountID u
|
||||
type CreateAccountRequest struct {
|
||||
Name string `json:"name,omitempty" validate:"omitempty,min=2"`
|
||||
AccountName string `json:"account_name,omitempty" validate:"omitempty,min=2"`
|
||||
Locale string `json:"locale,omitempty" validate:"omitempty,len=2"`
|
||||
Locale string `json:"locale,omitempty" validate:"omitempty,max=10"`
|
||||
Domain string `json:"domain,omitempty" validate:"omitempty,min=3"`
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func (s *AccountService) Create(ctx context.Context, userID uint, req CreateAcco
|
||||
// UpdateAccountRequest is the DTO for updating an account.
|
||||
type UpdateAccountRequest struct {
|
||||
Name string `json:"name,omitempty" validate:"omitempty,min=2"`
|
||||
Locale string `json:"locale,omitempty" validate:"omitempty,len=2"`
|
||||
Locale string `json:"locale,omitempty" validate:"omitempty,max=10"`
|
||||
Domain string `json:"domain,omitempty" validate:"omitempty,min=3"`
|
||||
SupportEmail string `json:"support_email,omitempty"`
|
||||
FeatureFlags string `json:"feature_flags,omitempty"`
|
||||
@@ -142,6 +142,9 @@ func (s *AccountService) Update(ctx context.Context, id uint, req UpdateAccountR
|
||||
} else if req.AutoResolveDuration > 0 {
|
||||
account.AutoResolveDuration = req.AutoResolveDuration
|
||||
}
|
||||
if req.AudioTranscriptions != nil {
|
||||
account.AudioTranscriptions = *req.AudioTranscriptions
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, account); err != nil {
|
||||
return nil, err
|
||||
@@ -232,7 +235,7 @@ func (s *AccountService) RemoveUser(ctx context.Context, accountID, userID uint)
|
||||
// UpdateAccountSettingsRequest is the DTO for updating account settings.
|
||||
type UpdateAccountSettingsRequest struct {
|
||||
AutoResolveDuration int `json:"auto_resolve_duration" validate:"gte=0"`
|
||||
Locale string `json:"locale" validate:"omitempty,len=2"`
|
||||
Locale string `json:"locale" validate:"omitempty,max=10"`
|
||||
ReportingTimezone string `json:"reporting_timezone" validate:"omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -100,12 +100,12 @@ func TestAccountService_Create_验证失败_名称过短(t *testing.T) {
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestAccountService_Create_验证失败_Locale长度不对(t *testing.T) {
|
||||
func TestAccountService_Create_验证失败_Locale超过字段上限(t *testing.T) {
|
||||
_, _, svc := setupAccountService(t)
|
||||
|
||||
req := CreateAccountRequest{
|
||||
Name: "有效名称",
|
||||
Locale: "xxx", // len=2 不满足
|
||||
Locale: "zh_Hans_CN_x", // accounts.locale 最大长度为 10
|
||||
}
|
||||
|
||||
result, err := svc.Create(context.Background(), 1, req)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
-- Reference: Chatwoot enterprise/app/models/captain/ models
|
||||
-- Adds AI assistant, document, response, scenario, custom_tool, and inbox tables
|
||||
|
||||
-- The vector type is used by captain_assistant_responses below, so the
|
||||
-- extension must exist before that table is created.
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- Captain Assistants
|
||||
CREATE TABLE IF NOT EXISTS captain_assistants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -161,6 +165,3 @@ CREATE TABLE IF NOT EXISTS copilot_messages (
|
||||
CREATE INDEX idx_copilot_messages_deleted_at ON copilot_messages(deleted_at);
|
||||
CREATE INDEX idx_copilot_messages_account_id ON copilot_messages(account_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_copilot_messages_thread_id ON copilot_messages(copilot_thread_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Enable pgvector extension if not already enabled
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE accounts
|
||||
DROP COLUMN IF EXISTS audio_transcriptions;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN IF NOT EXISTS audio_transcriptions BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -18,230 +18,254 @@ import AudioTranscription from './components/AudioTranscription.vue';
|
||||
import SectionLayout from './components/SectionLayout.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BaseSettingsHeader,
|
||||
NextButton,
|
||||
AccountId,
|
||||
BuildInfo,
|
||||
AccountDelete,
|
||||
AudioTranscription,
|
||||
SectionLayout,
|
||||
WithLabel,
|
||||
NextInput,
|
||||
},
|
||||
setup() {
|
||||
const { updateUISettings, uiSettings } = useUISettings();
|
||||
const { enabledLanguages } = useConfig();
|
||||
const { accountId } = useAccount();
|
||||
const v$ = useVuelidate();
|
||||
components: {
|
||||
BaseSettingsHeader,
|
||||
NextButton,
|
||||
AccountId,
|
||||
BuildInfo,
|
||||
AccountDelete,
|
||||
AudioTranscription,
|
||||
SectionLayout,
|
||||
WithLabel,
|
||||
NextInput,
|
||||
},
|
||||
setup() {
|
||||
const { updateUISettings, uiSettings } = useUISettings();
|
||||
const { enabledLanguages } = useConfig();
|
||||
const { accountId } = useAccount();
|
||||
const v$ = useVuelidate();
|
||||
|
||||
return { updateUISettings, uiSettings, v$, enabledLanguages, accountId };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
locale: 'en',
|
||||
domain: '',
|
||||
supportEmail: '',
|
||||
features: {},
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
name: {
|
||||
required,
|
||||
},
|
||||
locale: {
|
||||
required,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
uiFlags: 'accounts/getUIFlags',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
showAudioTranscriptionConfig() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CAPTAIN
|
||||
);
|
||||
},
|
||||
languagesSortedByCode() {
|
||||
const enabledLanguages = [...this.enabledLanguages];
|
||||
return enabledLanguages.sort((l1, l2) =>
|
||||
l1.iso_639_1_code.localeCompare(l2.iso_639_1_code)
|
||||
);
|
||||
},
|
||||
isUpdating() {
|
||||
return this.uiFlags.isUpdating;
|
||||
},
|
||||
featureInboundEmailEnabled() {
|
||||
return !!this.features?.inbound_emails;
|
||||
},
|
||||
featureCustomReplyDomainEnabled() {
|
||||
return (
|
||||
this.featureInboundEmailEnabled && !!this.features.custom_reply_domain
|
||||
);
|
||||
},
|
||||
featureCustomReplyEmailEnabled() {
|
||||
return (
|
||||
this.featureInboundEmailEnabled && !!this.features.custom_reply_email
|
||||
);
|
||||
},
|
||||
currentAccount() {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeAccount();
|
||||
},
|
||||
methods: {
|
||||
async initializeAccount() {
|
||||
try {
|
||||
const { name, locale, id, domain, support_email, features } =
|
||||
this.getAccount(this.accountId);
|
||||
return {
|
||||
updateUISettings,
|
||||
uiSettings,
|
||||
v$,
|
||||
enabledLanguages,
|
||||
accountId,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
locale: 'en',
|
||||
domain: '',
|
||||
supportEmail: '',
|
||||
features: {},
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
name: {
|
||||
required,
|
||||
},
|
||||
locale: {
|
||||
required,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
uiFlags: 'accounts/getUIFlags',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
showAudioTranscriptionConfig() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CAPTAIN
|
||||
);
|
||||
},
|
||||
languagesSortedByCode() {
|
||||
const enabledLanguages = [...this.enabledLanguages];
|
||||
return enabledLanguages.sort((l1, l2) =>
|
||||
l1.iso_639_1_code.localeCompare(l2.iso_639_1_code)
|
||||
);
|
||||
},
|
||||
isUpdating() {
|
||||
return this.uiFlags.isUpdating;
|
||||
},
|
||||
featureInboundEmailEnabled() {
|
||||
return !!this.features?.inbound_emails;
|
||||
},
|
||||
featureCustomReplyDomainEnabled() {
|
||||
return (
|
||||
this.featureInboundEmailEnabled &&
|
||||
!!this.features.custom_reply_domain
|
||||
);
|
||||
},
|
||||
featureCustomReplyEmailEnabled() {
|
||||
return (
|
||||
this.featureInboundEmailEnabled &&
|
||||
!!this.features.custom_reply_email
|
||||
);
|
||||
},
|
||||
currentAccount() {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentAccount: {
|
||||
handler(account) {
|
||||
this.initializeAccount(account);
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
initializeAccount(account = {}) {
|
||||
if (!account.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveLocale = this.uiSettings?.locale || locale;
|
||||
if (effectiveLocale) {
|
||||
this.$root.$i18n.locale = effectiveLocale;
|
||||
}
|
||||
this.name = name;
|
||||
this.locale = locale;
|
||||
this.id = id;
|
||||
this.domain = domain;
|
||||
this.supportEmail = support_email;
|
||||
this.features = features;
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
const { name, locale, id, domain, support_email, features } =
|
||||
account;
|
||||
const effectiveLocale = this.uiSettings?.locale || locale;
|
||||
if (effectiveLocale) {
|
||||
this.$root.$i18n.locale = effectiveLocale;
|
||||
}
|
||||
this.name = name || '';
|
||||
this.locale = locale || 'en';
|
||||
this.id = id;
|
||||
this.domain = domain || '';
|
||||
this.supportEmail = support_email || '';
|
||||
this.features = features || {};
|
||||
},
|
||||
|
||||
async updateAccount() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
useAlert(this.$t('GENERAL_SETTINGS.FORM.ERROR'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.$store.dispatch('accounts/update', {
|
||||
locale: this.locale,
|
||||
name: this.name,
|
||||
domain: this.domain,
|
||||
support_email: this.supportEmail,
|
||||
});
|
||||
// If user locale is set, update the locale with user locale
|
||||
const updatedLocale = this.uiSettings?.locale || this.locale;
|
||||
if (updatedLocale) {
|
||||
this.$root.$i18n.locale = updatedLocale;
|
||||
}
|
||||
this.getAccount(this.id).locale = this.locale;
|
||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
|
||||
}
|
||||
},
|
||||
},
|
||||
async updateAccount() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
useAlert(this.$t('GENERAL_SETTINGS.FORM.ERROR'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.$store.dispatch('accounts/update', {
|
||||
locale: this.locale,
|
||||
name: this.name,
|
||||
domain: this.domain,
|
||||
support_email: this.supportEmail,
|
||||
});
|
||||
// If user locale is set, update the locale with user locale
|
||||
const updatedLocale = this.uiSettings?.locale || this.locale;
|
||||
if (updatedLocale) {
|
||||
this.$root.$i18n.locale = updatedLocale;
|
||||
}
|
||||
this.getAccount(this.id).locale = this.locale;
|
||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
|
||||
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
|
||||
<div class="flex-grow flex-shrink min-w-0 mt-3">
|
||||
<SectionLayout
|
||||
:title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
|
||||
:description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
|
||||
class="!pt-0"
|
||||
>
|
||||
<form
|
||||
v-if="!uiFlags.isFetchingItem"
|
||||
class="grid gap-4"
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<WithLabel
|
||||
name="account-name"
|
||||
:has-error="v$.name.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
|
||||
>
|
||||
<NextInput
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="w-full"
|
||||
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
|
||||
@blur="v$.name.$touch"
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
name="site-language"
|
||||
:has-error="v$.locale.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
|
||||
>
|
||||
<select v-model="locale" class="!mb-0 text-sm">
|
||||
<option
|
||||
v-for="lang in languagesSortedByCode"
|
||||
:key="lang.iso_639_1_code"
|
||||
:value="lang.iso_639_1_code"
|
||||
>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
</select>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyDomainEnabled"
|
||||
name="custom-domain"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
v-model="domain"
|
||||
type="text"
|
||||
class="w-full"
|
||||
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
|
||||
/>
|
||||
<template #help>
|
||||
{{
|
||||
featureInboundEmailEnabled &&
|
||||
$t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED')
|
||||
}}
|
||||
<div class="flex flex-col w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
|
||||
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
|
||||
<div class="flex-grow flex-shrink min-w-0 mt-3">
|
||||
<SectionLayout
|
||||
:title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
|
||||
:description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
|
||||
class="!pt-0"
|
||||
>
|
||||
<form
|
||||
v-if="!uiFlags.isFetchingItem"
|
||||
class="grid gap-4"
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<WithLabel
|
||||
name="account-name"
|
||||
:has-error="v$.name.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
|
||||
>
|
||||
<NextInput
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.name.$touch"
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
name="site-language"
|
||||
:has-error="v$.locale.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
|
||||
:error-message="
|
||||
$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')
|
||||
"
|
||||
>
|
||||
<select v-model="locale" class="!mb-0 text-sm">
|
||||
<option
|
||||
v-for="lang in languagesSortedByCode"
|
||||
:key="lang.iso_639_1_code"
|
||||
:value="lang.iso_639_1_code"
|
||||
>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
</select>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyDomainEnabled"
|
||||
name="custom-domain"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
v-model="domain"
|
||||
type="text"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<template #help>
|
||||
{{
|
||||
featureInboundEmailEnabled &&
|
||||
$t(
|
||||
'GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED'
|
||||
)
|
||||
}}
|
||||
|
||||
{{
|
||||
featureCustomReplyDomainEnabled &&
|
||||
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
|
||||
}}
|
||||
</template>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyEmailEnabled"
|
||||
name="support-email"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
v-model="supportEmail"
|
||||
type="text"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</WithLabel>
|
||||
<div>
|
||||
<NextButton blue :is-loading="isUpdating" type="submit">
|
||||
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</form>
|
||||
</SectionLayout>
|
||||
{{
|
||||
featureCustomReplyDomainEnabled &&
|
||||
$t(
|
||||
'GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED'
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyEmailEnabled"
|
||||
name="support-email"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
v-model="supportEmail"
|
||||
type="text"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t(
|
||||
'GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</WithLabel>
|
||||
<div>
|
||||
<NextButton blue :is-loading="isUpdating" type="submit">
|
||||
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</form>
|
||||
</SectionLayout>
|
||||
|
||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||
</div>
|
||||
<AudioTranscription v-if="showAudioTranscriptionConfig" />
|
||||
<AccountId />
|
||||
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
||||
<AccountDelete />
|
||||
</div>
|
||||
<BuildInfo />
|
||||
</div>
|
||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||
</div>
|
||||
<AudioTranscription v-if="showAudioTranscriptionConfig" />
|
||||
<AccountId />
|
||||
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
||||
<AccountDelete />
|
||||
</div>
|
||||
<BuildInfo />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user