theorose49 995dd36167
All checks were successful
build-and-push / build (push) Successful in 32s
feat: 대시보드 내 이슈(/my/tasks) + 전체 캘린더(CalendarEvent) + 메일 AI 요약(OpenAI)
- GET /my/tasks: 전 프로젝트에서 나에게 배정된 작업 + projectName (대시보드 JIRA 보드용)
  · fix: ORDER BY "end"(예약어) 따옴표 처리
- CalendarEvent 모델 + /calendar/events CRUD(본인 소유), nav에 캘린더 추가
- internal/ai(OpenAI, stdlib): 메일 동기화 시 신규 메일에 한 줄 AI 요약 생성(OPENAI_API_KEY)
  · ProjectMailMsg.Summary, 회당 40건 상한
- nav inbox 라벨 쪽지함으로 통일

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:06:55 +09:00

155 lines
5.5 KiB
Go

package config
import (
"net/url"
"os"
"strings"
"time"
)
// Config holds all runtime configuration loaded from environment variables.
//
// Variable names follow the cluster/CI contract documented in .env.sample
// (PGHOST/PGUSER/…, AWS_ACCESS_KEY_ID/…, S3_ENDPOINT/S3_BUCKET/S3_PREFIX) so the
// same binary runs unchanged in the Special Partners infra. Local docker-compose
// supplies the same variables. This mirrors the sister eQMS (Mallard) service.
type Config struct {
Port string
DatabaseURL string
S3Endpoint string
S3PublicEndpoint string
S3Region string
S3Bucket string
S3Prefix string
S3AccessKey string
S3SecretKey string
DevAuth bool
SeedData bool
// FCMCredentialsFile is the path to a Firebase service-account JSON used to
// send push notifications to spin-mobile. Empty = push disabled (no-op).
FCMCredentialsFile string
// GoogleSACredentialsFile is the path to a Google Workspace service-account
// JSON with DOMAIN-WIDE DELEGATION (gmail.readonly) used to read project
// client-domain mail. Empty = mail integration disabled.
GoogleSACredentialsFile string
// MailSyncInterval is how often the background mail sync runs (MAIL_SYNC_INTERVAL,
// e.g. "15m"). 0 disables periodic sync (on-demand backfill still works).
MailSyncInterval time.Duration
// OpenAIKey enables short AI mail summaries (OPENAI_API_KEY). Empty = disabled.
OpenAIKey string
// LogoutURL is the common SSO logout path injected by infra (LOGOUT_URL):
// ends the oauth2-proxy session then redirects to Keycloak end-session.
// The static frontend reads it via /me. Defaults to the infra-common value.
LogoutURL string
// AdminGroups are the Keycloak groups whose members are super-admins
// (manage everything across the company: incentive console, accounting,
// approvals, all member/project data).
AdminGroups []string
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// parseDur parses a Go duration string (e.g. "15m"); invalid/"0" yields 0 (disabled).
func parseDur(s string) time.Duration {
d, err := time.ParseDuration(strings.TrimSpace(s))
if err != nil {
return 0
}
return d
}
// firstEnv returns the first non-empty env var among keys, else def.
func firstEnv(def string, keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
return v
}
}
return def
}
// Load reads configuration from the environment, applying sane local defaults
// so `go run ./cmd/server` works without docker-compose.
func Load() Config {
return Config{
Port: env("PORT", "8080"),
DatabaseURL: databaseURL(),
S3Endpoint: withScheme(env("S3_ENDPOINT", "http://localhost:9000")),
S3PublicEndpoint: publicEndpoint(),
S3Region: env("S3_REGION", "us-east-1"),
S3Bucket: env("S3_BUCKET", "spin"),
S3Prefix: env("S3_PREFIX", ""),
// Cluster secrets arrive as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY;
// the S3_* aliases are kept for convenience/back-compat.
S3AccessKey: firstEnv("minioadmin", "AWS_ACCESS_KEY_ID", "S3_ACCESS_KEY"),
S3SecretKey: firstEnv("minioadmin", "AWS_SECRET_ACCESS_KEY", "S3_SECRET_KEY"),
DevAuth: env("DEV_AUTH", "true") != "false",
// Sample data is seeded ONLY when SEED=true is explicitly set. Default is
// OFF so production never seeds (avoids confusion); local docker-compose /
// `make be-dev` opt in with SEED=true.
SeedData: env("SEED", "false") == "true",
// Super-admin Keycloak groups (comma-separated). Default: admin
// (shared group name across all internal apps, not app-specific).
AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")),
FCMCredentialsFile: env("FCM_CREDENTIALS_FILE", ""),
GoogleSACredentialsFile: env("GOOGLE_SA_CREDENTIALS_FILE", ""),
MailSyncInterval: parseDur(env("MAIL_SYNC_INTERVAL", "15m")),
OpenAIKey: env("OPENAI_API_KEY", ""),
LogoutURL: env("LOGOUT_URL",
"/oauth2/sign_out?rd=https%3A%2F%2Fauth.special-partners.com%2Frealms%2Fsp%2Fprotocol%2Fopenid-connect%2Flogout"),
}
}
// splitCSV splits a comma-separated list, trimming whitespace and dropping
// empty entries.
func splitCSV(raw string) []string {
var out []string
for _, p := range strings.Split(raw, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// databaseURL prefers an explicit DATABASE_URL (override), otherwise assembles a
// DSN from the discrete PG* variables injected from the DB-creds Secret.
func databaseURL() string {
if v := os.Getenv("DATABASE_URL"); v != "" {
return v
}
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(env("PGUSER", "spin"), env("PGPASSWORD", "spin")),
Host: env("PGHOST", "localhost") + ":" + env("PGPORT", "5432"),
Path: "/" + env("PGDATABASE", "spin"),
}
q := url.Values{}
q.Set("sslmode", env("PGSSLMODE", "disable"))
u.RawQuery = q.Encode()
return u.String()
}
// publicEndpoint is the browser-reachable S3 host used for presigned URLs.
// Falls back to the in-cluster endpoint when not separately provided.
func publicEndpoint() string {
if v := os.Getenv("S3_PUBLIC_ENDPOINT"); v != "" {
return withScheme(v)
}
return withScheme(env("S3_ENDPOINT", "http://localhost:9000"))
}
// withScheme guarantees an http(s):// prefix so the AWS SDK accepts the endpoint
// (the .env.sample shows bare hosts like "localhost").
func withScheme(s string) string {
if s == "" || strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
return s
}
return "http://" + s
}