fix: stabilize development config and account settings

This commit is contained in:
2026-07-11 17:15:38 +08:00
parent d2cb6bbbc6
commit 7f2d5579ff
19 changed files with 607 additions and 392 deletions
+17 -17
View File
@@ -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
+3
View File
@@ -20,6 +20,9 @@ import (
)
func main() {
config.LoadDotEnvEnvironment()
database.SanitizePostgresEnvironment()
cmd := "serve"
if len(os.Args) > 1 {
cmd = os.Args[1]
+10 -3
View File
@@ -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)")
}
}
+14 -4
View File
@@ -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
}
+30
View File
@@ -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)
}
+111 -56
View File
@@ -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")
+21 -82
View File
@@ -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},
}
}
}
+49 -1
View File
@@ -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
}
}
+39
View File
@@ -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{
+4 -3
View File
@@ -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
+1
View File
@@ -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])
+6 -3
View File
@@ -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>