mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-03 20:13:01 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"productier/apps/backend/internal/authsession"
|
||||
"productier/apps/backend/internal/filestorage"
|
||||
"productier/apps/backend/internal/httpapi"
|
||||
"productier/apps/backend/internal/mailruntime"
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
server *httpapi.Server
|
||||
port string
|
||||
shutdownTimeout time.Duration
|
||||
stopMailRuntime context.CancelFunc
|
||||
}
|
||||
|
||||
func New(logger *zap.Logger) (*App, error) {
|
||||
runtimeConfig, err := loadRuntimeConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dataStore store.Store
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
if databaseURL != "" {
|
||||
persistentStore, err := store.NewPostgresStore(databaseURL, runtimeConfig.mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dataStore = persistentStore
|
||||
} else {
|
||||
if err := validateStoreRuntimeMode(runtimeConfig.mode, inMemoryStoreAllowed()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dataStore = store.NewSeededState(runtimeConfig.mode)
|
||||
}
|
||||
|
||||
mailService, err := mailruntime.New(dataStore, logger, runtimeConfig.mailSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files, err := filestorage.NewFromEnv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
probeCtx, cancelProbe := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancelProbe()
|
||||
if err := files.Probe(probeCtx); err != nil {
|
||||
return nil, fmt.Errorf("file storage startup probe failed: %w", err)
|
||||
}
|
||||
|
||||
mailRuntimeCtx, stopMailRuntime := context.WithCancel(context.Background())
|
||||
mailService.Start(mailRuntimeCtx)
|
||||
|
||||
return &App{
|
||||
server: httpapi.NewServer(
|
||||
dataStore,
|
||||
authsession.NewClient(runtimeConfig.authServiceURL),
|
||||
mailService,
|
||||
files,
|
||||
runtimeConfig.mode,
|
||||
runtimeConfig.corsAllowOrigins,
|
||||
runtimeConfig.metricsAuthToken,
|
||||
logger,
|
||||
),
|
||||
port: runtimeConfig.apiPort,
|
||||
shutdownTimeout: runtimeConfig.shutdownTimeout,
|
||||
stopMailRuntime: stopMailRuntime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
return a.RunContext(context.Background())
|
||||
}
|
||||
|
||||
func (a *App) RunContext(ctx context.Context) error {
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", a.port),
|
||||
Handler: a.server.Engine(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
err := httpServer.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
serverErr <- err
|
||||
}
|
||||
close(serverErr)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if a.stopMailRuntime != nil {
|
||||
a.stopMailRuntime()
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown api server: %w", err)
|
||||
}
|
||||
if err, ok := <-serverErr; ok && err != nil {
|
||||
return fmt.Errorf("run api server: %w", err)
|
||||
}
|
||||
return nil
|
||||
case err, ok := <-serverErr:
|
||||
if a.stopMailRuntime != nil {
|
||||
a.stopMailRuntime()
|
||||
}
|
||||
if !ok || err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("run api server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateStoreRuntimeMode(mode string, allowInMemory bool) error {
|
||||
if mode == "development" || allowInMemory {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("DATABASE_URL is required when APP_ENV=%q (set ALLOW_INMEMORY_STORE=true only for temporary non-production testing)", mode)
|
||||
}
|
||||
|
||||
func inMemoryStoreAllowed() bool {
|
||||
raw := strings.TrimSpace(strings.ToLower(os.Getenv("ALLOW_INMEMORY_STORE")))
|
||||
switch raw {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateStoreRuntimeMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
allowInMemory bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "development allows in-memory store",
|
||||
mode: "development",
|
||||
allowInMemory: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "production rejects in-memory store by default",
|
||||
mode: "production",
|
||||
allowInMemory: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "non-development can be explicitly overridden",
|
||||
mode: "staging",
|
||||
allowInMemory: true,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateStoreRuntimeMode(test.mode, test.allowInMemory)
|
||||
if test.expectError && err == nil {
|
||||
t.Fatalf("expected error for mode=%q allowInMemory=%v", test.mode, test.allowInMemory)
|
||||
}
|
||||
if !test.expectError && err != nil {
|
||||
t.Fatalf("did not expect error for mode=%q allowInMemory=%v: %v", test.mode, test.allowInMemory, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInMemoryStoreAllowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
allowed bool
|
||||
}{
|
||||
{name: "empty", value: "", allowed: false},
|
||||
{name: "true", value: "true", allowed: true},
|
||||
{name: "uppercase true", value: "TRUE", allowed: true},
|
||||
{name: "one", value: "1", allowed: true},
|
||||
{name: "yes", value: "yes", allowed: true},
|
||||
{name: "off", value: "off", allowed: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("ALLOW_INMEMORY_STORE", test.value)
|
||||
if got := inMemoryStoreAllowed(); got != test.allowed {
|
||||
t.Fatalf("inMemoryStoreAllowed() = %v, want %v for %q", got, test.allowed, test.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAppMode = "development"
|
||||
defaultAPIPort = "8080"
|
||||
defaultShutdownTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
defaultLocalCORSOrigins = []string{
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3001",
|
||||
}
|
||||
insecureSecretPlaceholders = map[string]struct{}{
|
||||
"": {},
|
||||
"replace-me-with-a-long-random-secret": {},
|
||||
"replace-me-with-a-dedicated-mail-secret": {},
|
||||
"productier-local-mail-key": {},
|
||||
"changeme": {},
|
||||
"change-me": {},
|
||||
"replace-me": {},
|
||||
}
|
||||
)
|
||||
|
||||
type runtimeConfig struct {
|
||||
mode string
|
||||
apiPort string
|
||||
authServiceURL string
|
||||
shutdownTimeout time.Duration
|
||||
corsAllowOrigins []string
|
||||
mailSecret string
|
||||
metricsAuthToken string
|
||||
}
|
||||
|
||||
func loadRuntimeConfig() (runtimeConfig, error) {
|
||||
mode, err := parseAppMode(os.Getenv("APP_ENV"))
|
||||
if err != nil {
|
||||
return runtimeConfig{}, err
|
||||
}
|
||||
|
||||
apiPort, err := parsePort(os.Getenv("API_PORT"), defaultAPIPort, "API_PORT")
|
||||
if err != nil {
|
||||
return runtimeConfig{}, err
|
||||
}
|
||||
|
||||
authServiceURL, err := parseAbsoluteHTTPURL(
|
||||
valueOrDefault(strings.TrimSpace(os.Getenv("AUTH_SERVICE_URL")), "http://localhost:3001"),
|
||||
"AUTH_SERVICE_URL",
|
||||
)
|
||||
if err != nil {
|
||||
return runtimeConfig{}, err
|
||||
}
|
||||
|
||||
shutdownTimeout, err := parseDuration(
|
||||
os.Getenv("API_SHUTDOWN_TIMEOUT"),
|
||||
defaultShutdownTimeout,
|
||||
"API_SHUTDOWN_TIMEOUT",
|
||||
)
|
||||
if err != nil {
|
||||
return runtimeConfig{}, err
|
||||
}
|
||||
|
||||
corsAllowOrigins, err := parseCORSAllowOrigins(mode, os.Getenv("CORS_ALLOW_ORIGINS"))
|
||||
if err != nil {
|
||||
return runtimeConfig{}, err
|
||||
}
|
||||
|
||||
mailSecret, err := resolveMailSecret(mode)
|
||||
if err != nil {
|
||||
return runtimeConfig{}, err
|
||||
}
|
||||
|
||||
metricsAuthToken, err := resolveMetricsAuthToken(mode)
|
||||
if err != nil {
|
||||
return runtimeConfig{}, err
|
||||
}
|
||||
|
||||
return runtimeConfig{
|
||||
mode: mode,
|
||||
apiPort: apiPort,
|
||||
authServiceURL: authServiceURL,
|
||||
shutdownTimeout: shutdownTimeout,
|
||||
corsAllowOrigins: corsAllowOrigins,
|
||||
mailSecret: mailSecret,
|
||||
metricsAuthToken: metricsAuthToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseAppMode(raw string) (string, error) {
|
||||
mode := strings.TrimSpace(strings.ToLower(raw))
|
||||
if mode == "" {
|
||||
return defaultAppMode, nil
|
||||
}
|
||||
switch mode {
|
||||
case "development", "test", "staging", "production":
|
||||
return mode, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported APP_ENV %q (allowed: development, test, staging, production)", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePort(raw string, fallback string, envName string) (string, error) {
|
||||
port := strings.TrimSpace(raw)
|
||||
if port == "" {
|
||||
port = fallback
|
||||
}
|
||||
numeric, err := strconv.Atoi(port)
|
||||
if err != nil || numeric < 1 || numeric > 65535 {
|
||||
return "", fmt.Errorf("%s must be a valid TCP port (1-65535)", envName)
|
||||
}
|
||||
return strconv.Itoa(numeric), nil
|
||||
}
|
||||
|
||||
func parseDuration(raw string, fallback time.Duration, envName string) (time.Duration, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a valid duration (example: 10s): %w", envName, err)
|
||||
}
|
||||
if duration <= 0 {
|
||||
return 0, fmt.Errorf("%s must be greater than zero", envName)
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func parseAbsoluteHTTPURL(raw string, envName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", envName)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s must be a valid URL: %w", envName, err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", fmt.Errorf("%s must use http or https", envName)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", fmt.Errorf("%s must include a host", envName)
|
||||
}
|
||||
return strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func parseCORSAllowOrigins(mode string, raw string) ([]string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
if mode == "staging" || mode == "production" {
|
||||
return nil, errors.New("CORS_ALLOW_ORIGINS is required in staging/production (comma-separated origins)")
|
||||
}
|
||||
return append([]string(nil), defaultLocalCORSOrigins...), nil
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ",")
|
||||
origins := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
origin := strings.TrimSpace(part)
|
||||
if origin == "" {
|
||||
continue
|
||||
}
|
||||
if origin == "*" {
|
||||
return nil, errors.New("CORS_ALLOW_ORIGINS cannot include '*' when credentials are enabled")
|
||||
}
|
||||
validated, err := parseOrigin(origin, "CORS_ALLOW_ORIGINS")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := seen[validated]; exists {
|
||||
continue
|
||||
}
|
||||
seen[validated] = struct{}{}
|
||||
origins = append(origins, validated)
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
return nil, errors.New("CORS_ALLOW_ORIGINS must include at least one valid origin")
|
||||
}
|
||||
return origins, nil
|
||||
}
|
||||
|
||||
func parseOrigin(raw string, envName string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s must contain valid origins: %w", envName, err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", fmt.Errorf("%s origins must use http or https", envName)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", fmt.Errorf("%s origins must include a host", envName)
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return "", fmt.Errorf("%s origins cannot include URL paths", envName)
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("%s origins cannot include query or fragment components", envName)
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host, nil
|
||||
}
|
||||
|
||||
func resolveMailSecret(mode string) (string, error) {
|
||||
mailSecret := strings.TrimSpace(os.Getenv("MAIL_ENCRYPTION_KEY"))
|
||||
if mailSecret == "" {
|
||||
mailSecret = strings.TrimSpace(os.Getenv("BETTER_AUTH_SECRET"))
|
||||
}
|
||||
|
||||
if mode != "staging" && mode != "production" {
|
||||
return mailSecret, nil
|
||||
}
|
||||
if isInsecureSecret(mailSecret) {
|
||||
return "", errors.New("set a strong MAIL_ENCRYPTION_KEY (or BETTER_AUTH_SECRET fallback) for staging/production")
|
||||
}
|
||||
return mailSecret, nil
|
||||
}
|
||||
|
||||
func resolveMetricsAuthToken(mode string) (string, error) {
|
||||
token := strings.TrimSpace(os.Getenv("METRICS_AUTH_TOKEN"))
|
||||
if token == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if mode == "production" && isInsecureSecret(token) {
|
||||
return "", errors.New("METRICS_AUTH_TOKEN must be a strong non-placeholder secret when set in production")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func isInsecureSecret(secret string) bool {
|
||||
normalized := strings.TrimSpace(strings.ToLower(secret))
|
||||
if _, exists := insecureSecretPlaceholders[normalized]; exists {
|
||||
return true
|
||||
}
|
||||
return len(strings.TrimSpace(secret)) < 16
|
||||
}
|
||||
|
||||
func valueOrDefault(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseAppMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
expectErr bool
|
||||
}{
|
||||
{name: "default", value: "", want: "development"},
|
||||
{name: "production", value: "production", want: "production"},
|
||||
{name: "normalized", value: " StAgInG ", want: "staging"},
|
||||
{name: "invalid", value: "prod", expectErr: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseAppMode(test.value)
|
||||
if test.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", test.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("did not expect error: %v", err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("parseAppMode(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseDuration("", 10*time.Second, "API_SHUTDOWN_TIMEOUT")
|
||||
if err != nil {
|
||||
t.Fatalf("did not expect error: %v", err)
|
||||
}
|
||||
if got != 10*time.Second {
|
||||
t.Fatalf("parseDuration default = %s, want 10s", got)
|
||||
}
|
||||
|
||||
got, err = parseDuration("15s", 10*time.Second, "API_SHUTDOWN_TIMEOUT")
|
||||
if err != nil {
|
||||
t.Fatalf("did not expect error: %v", err)
|
||||
}
|
||||
if got != 15*time.Second {
|
||||
t.Fatalf("parseDuration explicit = %s, want 15s", got)
|
||||
}
|
||||
|
||||
if _, err := parseDuration("0s", 10*time.Second, "API_SHUTDOWN_TIMEOUT"); err == nil {
|
||||
t.Fatal("expected zero duration to fail")
|
||||
}
|
||||
if _, err := parseDuration("nope", 10*time.Second, "API_SHUTDOWN_TIMEOUT"); err == nil {
|
||||
t.Fatal("expected invalid duration to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCORSAllowOrigins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
devOrigins, err := parseCORSAllowOrigins("development", "")
|
||||
if err != nil {
|
||||
t.Fatalf("did not expect error: %v", err)
|
||||
}
|
||||
if len(devOrigins) == 0 {
|
||||
t.Fatal("expected default development origins")
|
||||
}
|
||||
|
||||
if _, err := parseCORSAllowOrigins("production", ""); err == nil {
|
||||
t.Fatal("expected production with empty CORS_ALLOW_ORIGINS to fail")
|
||||
}
|
||||
|
||||
origins, err := parseCORSAllowOrigins("production", "https://app.example.com, https://app.example.com ,https://admin.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("did not expect error: %v", err)
|
||||
}
|
||||
if len(origins) != 2 {
|
||||
t.Fatalf("expected 2 deduplicated origins, got %d", len(origins))
|
||||
}
|
||||
|
||||
if _, err := parseCORSAllowOrigins("production", "*"); err == nil {
|
||||
t.Fatal("expected wildcard origin to fail")
|
||||
}
|
||||
|
||||
if _, err := parseCORSAllowOrigins("production", "https://app.example.com/path"); err == nil {
|
||||
t.Fatal("expected origin with path to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInsecureSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !isInsecureSecret("replace-me-with-a-long-random-secret") {
|
||||
t.Fatal("expected placeholder secret to be insecure")
|
||||
}
|
||||
if !isInsecureSecret("short") {
|
||||
t.Fatal("expected short secret to be insecure")
|
||||
}
|
||||
if isInsecureSecret("this-is-a-strong-enough-secret-12345") {
|
||||
t.Fatal("expected long random secret to pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMetricsAuthToken(t *testing.T) {
|
||||
t.Run("empty token is allowed", func(t *testing.T) {
|
||||
t.Setenv("METRICS_AUTH_TOKEN", "")
|
||||
token, err := resolveMetricsAuthToken("production")
|
||||
if err != nil {
|
||||
t.Fatalf("did not expect error: %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty", token)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects weak token in production", func(t *testing.T) {
|
||||
t.Setenv("METRICS_AUTH_TOKEN", "short")
|
||||
if _, err := resolveMetricsAuthToken("production"); err == nil {
|
||||
t.Fatal("expected weak production token to fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allows token in production", func(t *testing.T) {
|
||||
t.Setenv("METRICS_AUTH_TOKEN", "this-is-a-strong-enough-secret-98765")
|
||||
token, err := resolveMetricsAuthToken("production")
|
||||
if err != nil {
|
||||
t.Fatalf("did not expect error: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected resolved token")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type sessionEnvelope struct {
|
||||
User *User `json:"user"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetUser(ctx context.Context, cookieHeader string) (*User, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/auth/get-session", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create auth session request: %w", err)
|
||||
}
|
||||
|
||||
if cookieHeader != "" {
|
||||
req.Header.Set("Cookie", cookieHeader)
|
||||
}
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request auth session: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("auth session status: %s", res.Status)
|
||||
}
|
||||
|
||||
var payload *sessionEnvelope
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("decode auth session: %w", err)
|
||||
}
|
||||
|
||||
if payload == nil || payload.User == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return payload.User, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ActivityEntry struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
Title string
|
||||
Detail string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type BoardGroup struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
Name string
|
||||
Color string
|
||||
SortOrder int32
|
||||
}
|
||||
|
||||
type CalendarEvent struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
Title string
|
||||
Description string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Color string
|
||||
LinkedTaskID sql.NullString
|
||||
Attachments json.RawMessage
|
||||
}
|
||||
|
||||
type FocusSession struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
TaskID sql.NullString
|
||||
Mode string
|
||||
StartedAt time.Time
|
||||
CompletedAt sql.NullTime
|
||||
PausedAt sql.NullTime
|
||||
PausedTotalSeconds int32
|
||||
DurationSeconds int32
|
||||
}
|
||||
|
||||
type Invite struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
Email string
|
||||
Role string
|
||||
Token string
|
||||
CreatedAt time.Time
|
||||
Status string
|
||||
}
|
||||
|
||||
type Label struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
Name string
|
||||
Color string
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
MailboxID string
|
||||
RemoteUid int64
|
||||
MessageID string
|
||||
Folder string
|
||||
FromAddress json.RawMessage
|
||||
ToRecipients json.RawMessage
|
||||
CcRecipients json.RawMessage
|
||||
Subject string
|
||||
Snippet string
|
||||
TextBody string
|
||||
HtmlBody string
|
||||
ReceivedAt time.Time
|
||||
IsRead bool
|
||||
LinkedTaskID sql.NullString
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Mailbox struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
Label string
|
||||
Email string
|
||||
DisplayName string
|
||||
ImapHost string
|
||||
ImapPort int32
|
||||
ImapUsername string
|
||||
ImapPasswordCiphertext string
|
||||
ImapUseTls bool
|
||||
SmtpHost string
|
||||
SmtpPort int32
|
||||
SmtpUsername string
|
||||
SmtpPasswordCiphertext string
|
||||
SmtpUseTls bool
|
||||
SyncStatus string
|
||||
SyncError string
|
||||
LastSyncedAt sql.NullTime
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Member struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
Name string
|
||||
Email string
|
||||
Role string
|
||||
Status string
|
||||
}
|
||||
|
||||
type Note struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
Title string
|
||||
Content string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type OutgoingMail struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
MailboxID string
|
||||
ToRecipients json.RawMessage
|
||||
CcRecipients json.RawMessage
|
||||
BccRecipients json.RawMessage
|
||||
Subject string
|
||||
TextBody string
|
||||
HtmlBody string
|
||||
Status string
|
||||
ScheduledFor sql.NullTime
|
||||
SentAt sql.NullTime
|
||||
ErrorMessage string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
WorkspaceSlug string
|
||||
BoardGroupID string
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
Color string
|
||||
DueAt sql.NullTime
|
||||
ScheduledStart sql.NullTime
|
||||
ScheduledEnd sql.NullTime
|
||||
AssigneeID sql.NullString
|
||||
LabelIds json.RawMessage
|
||||
Attachments json.RawMessage
|
||||
Comments json.RawMessage
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID string
|
||||
Slug string
|
||||
Name string
|
||||
Role string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id text PRIMARY KEY,
|
||||
slug text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
role text NOT NULL,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
email text NOT NULL,
|
||||
role text NOT NULL,
|
||||
status text NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS members_workspace_email_idx ON members (workspace_slug, email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
email text NOT NULL,
|
||||
role text NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL,
|
||||
status text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_entries (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
detail text NOT NULL,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS activity_entries_workspace_created_idx ON activity_entries (workspace_slug, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS board_groups (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
color text NOT NULL,
|
||||
sort_order integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
color text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
board_group_id text NOT NULL REFERENCES board_groups(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL,
|
||||
color text NOT NULL,
|
||||
due_at timestamptz,
|
||||
scheduled_start timestamptz,
|
||||
scheduled_end timestamptz,
|
||||
assignee_id text,
|
||||
label_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
attachments jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
comments jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tasks_workspace_updated_idx ON tasks (workspace_slug, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
starts_at timestamptz NOT NULL,
|
||||
ends_at timestamptz NOT NULL,
|
||||
color text NOT NULL,
|
||||
linked_task_id text,
|
||||
attachments jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS calendar_events_workspace_starts_idx ON calendar_events (workspace_slug, starts_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
content text NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS focus_sessions (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
task_id text,
|
||||
mode text NOT NULL,
|
||||
started_at timestamptz NOT NULL,
|
||||
completed_at timestamptz,
|
||||
paused_at timestamptz,
|
||||
paused_total_seconds integer NOT NULL DEFAULT 0,
|
||||
duration_seconds integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS focus_sessions_workspace_started_idx ON focus_sessions (workspace_slug, started_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS focus_sessions;
|
||||
DROP TABLE IF EXISTS notes;
|
||||
DROP TABLE IF EXISTS calendar_events;
|
||||
DROP TABLE IF EXISTS tasks;
|
||||
DROP TABLE IF EXISTS labels;
|
||||
DROP TABLE IF EXISTS board_groups;
|
||||
DROP TABLE IF EXISTS activity_entries;
|
||||
DROP TABLE IF EXISTS invites;
|
||||
DROP INDEX IF EXISTS members_workspace_email_idx;
|
||||
DROP TABLE IF EXISTS members;
|
||||
DROP TABLE IF EXISTS workspaces;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS mailboxes (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
label text NOT NULL,
|
||||
email text NOT NULL,
|
||||
display_name text NOT NULL DEFAULT '',
|
||||
imap_host text NOT NULL,
|
||||
imap_port integer NOT NULL,
|
||||
imap_username text NOT NULL,
|
||||
imap_password_ciphertext text NOT NULL,
|
||||
imap_use_tls boolean NOT NULL DEFAULT true,
|
||||
smtp_host text NOT NULL,
|
||||
smtp_port integer NOT NULL,
|
||||
smtp_username text NOT NULL,
|
||||
smtp_password_ciphertext text NOT NULL,
|
||||
smtp_use_tls boolean NOT NULL DEFAULT true,
|
||||
sync_status text NOT NULL DEFAULT 'idle',
|
||||
sync_error text NOT NULL DEFAULT '',
|
||||
last_synced_at timestamptz,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS mailboxes_workspace_updated_idx ON mailboxes (workspace_slug, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_messages (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
mailbox_id text NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
remote_uid bigint NOT NULL,
|
||||
message_id text NOT NULL DEFAULT '',
|
||||
folder text NOT NULL DEFAULT 'INBOX',
|
||||
from_address jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
to_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
cc_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
subject text NOT NULL DEFAULT '',
|
||||
snippet text NOT NULL DEFAULT '',
|
||||
text_body text NOT NULL DEFAULT '',
|
||||
html_body text NOT NULL DEFAULT '',
|
||||
received_at timestamptz NOT NULL,
|
||||
is_read boolean NOT NULL DEFAULT false,
|
||||
linked_task_id text,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mail_messages_mailbox_folder_uid_idx ON mail_messages (mailbox_id, folder, remote_uid);
|
||||
CREATE INDEX IF NOT EXISTS mail_messages_workspace_received_idx ON mail_messages (workspace_slug, received_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outgoing_mails (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
mailbox_id text NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
to_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
cc_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
bcc_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
subject text NOT NULL DEFAULT '',
|
||||
text_body text NOT NULL DEFAULT '',
|
||||
html_body text NOT NULL DEFAULT '',
|
||||
status text NOT NULL,
|
||||
scheduled_for timestamptz,
|
||||
sent_at timestamptz,
|
||||
error_message text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS outgoing_mails_workspace_created_idx ON outgoing_mails (workspace_slug, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS outgoing_mails_status_schedule_idx ON outgoing_mails (status, scheduled_for ASC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS outgoing_mails;
|
||||
DROP TABLE IF EXISTS mail_messages;
|
||||
DROP TABLE IF EXISTS mailboxes;
|
||||
@@ -0,0 +1,79 @@
|
||||
-- +goose Up
|
||||
-- Contacts
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
first_name text NOT NULL DEFAULT '',
|
||||
last_name text NOT NULL DEFAULT '',
|
||||
email text NOT NULL DEFAULT '',
|
||||
phone text NOT NULL DEFAULT '',
|
||||
company_id text,
|
||||
title text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
avatar_url text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS contacts_workspace_updated_idx ON contacts (workspace_slug, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS contacts_workspace_email_idx ON contacts (workspace_slug, email);
|
||||
|
||||
-- Companies
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
domain text NOT NULL DEFAULT '',
|
||||
website text NOT NULL DEFAULT '',
|
||||
industry text NOT NULL DEFAULT '',
|
||||
size text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
logo_url text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS companies_workspace_updated_idx ON companies (workspace_slug, updated_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS companies_workspace_name_idx ON companies (workspace_slug, name);
|
||||
|
||||
-- Add company foreign key to contacts
|
||||
ALTER TABLE contacts ADD CONSTRAINT contacts_company_fk FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
|
||||
|
||||
-- Contact-Task links
|
||||
CREATE TABLE IF NOT EXISTS contact_tasks (
|
||||
id text PRIMARY KEY,
|
||||
contact_id text NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
task_id text NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS contact_tasks_unique_idx ON contact_tasks (contact_id, task_id);
|
||||
|
||||
-- Contact-Event links
|
||||
CREATE TABLE IF NOT EXISTS contact_events (
|
||||
id text PRIMARY KEY,
|
||||
contact_id text NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
event_id text NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS contact_events_unique_idx ON contact_events (contact_id, event_id);
|
||||
|
||||
-- Contact-Email links (track which contacts are involved in emails)
|
||||
CREATE TABLE IF NOT EXISTS contact_emails (
|
||||
id text PRIMARY KEY,
|
||||
contact_id text NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
mail_message_id text NOT NULL REFERENCES mail_messages(id) ON DELETE CASCADE,
|
||||
role text NOT NULL DEFAULT 'recipient', -- sender, recipient, cc
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS contact_emails_unique_idx ON contact_emails (contact_id, mail_message_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS contact_emails;
|
||||
DROP TABLE IF EXISTS contact_events;
|
||||
DROP TABLE IF EXISTS contact_tasks;
|
||||
ALTER TABLE contacts DROP CONSTRAINT IF EXISTS contacts_company_fk;
|
||||
DROP TABLE IF EXISTS contacts;
|
||||
DROP TABLE IF EXISTS companies;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- +goose Up
|
||||
-- Recurring tasks
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_rule text NOT NULL DEFAULT '';
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_end timestamptz;
|
||||
|
||||
-- Recurring events
|
||||
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS recurrence_rule text NOT NULL DEFAULT '';
|
||||
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS recurrence_end timestamptz;
|
||||
|
||||
-- Quick capture inbox
|
||||
CREATE TABLE IF NOT EXISTS inbox_items (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
content text NOT NULL,
|
||||
source text NOT NULL DEFAULT 'manual', -- manual, email, api
|
||||
processed boolean NOT NULL DEFAULT false,
|
||||
processed_at timestamptz,
|
||||
processed_entity_type text, -- task, note, event
|
||||
processed_entity_id text,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS inbox_items_workspace_created_idx ON inbox_items (workspace_slug, created_at DESC);
|
||||
|
||||
-- Time tracking
|
||||
CREATE TABLE IF NOT EXISTS time_entries (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
task_id text REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
started_at timestamptz NOT NULL,
|
||||
ended_at timestamptz,
|
||||
duration_seconds integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS time_entries_workspace_started_idx ON time_entries (workspace_slug, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS time_entries_task_idx ON time_entries (task_id);
|
||||
|
||||
-- Saved filters/views
|
||||
CREATE TABLE IF NOT EXISTS saved_views (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
entity_type text NOT NULL, -- tasks, contacts, companies
|
||||
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
sort_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS saved_views_workspace_type_idx ON saved_views (workspace_slug, entity_type);
|
||||
|
||||
-- Archive support
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE notes ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS recurrence_rule;
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS recurrence_end;
|
||||
ALTER TABLE calendar_events DROP COLUMN IF EXISTS recurrence_rule;
|
||||
ALTER TABLE calendar_events DROP COLUMN IF EXISTS recurrence_end;
|
||||
DROP TABLE IF EXISTS inbox_items;
|
||||
DROP TABLE IF EXISTS time_entries;
|
||||
DROP TABLE IF EXISTS saved_views;
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS archived;
|
||||
ALTER TABLE calendar_events DROP COLUMN IF EXISTS archived;
|
||||
ALTER TABLE notes DROP COLUMN IF EXISTS archived;
|
||||
@@ -0,0 +1,70 @@
|
||||
-- +goose Up
|
||||
-- Integrations table for external service connections
|
||||
CREATE TABLE IF NOT EXISTS integrations (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
provider text NOT NULL, -- google_calendar, slack, etc.
|
||||
name text NOT NULL,
|
||||
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
credentials_ciphertext text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
last_sync_at timestamptz,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS integrations_workspace_provider_idx ON integrations (workspace_slug, provider);
|
||||
|
||||
-- Webhooks for external notifications
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
url text NOT NULL,
|
||||
secret text NOT NULL,
|
||||
events jsonb NOT NULL DEFAULT '[]'::jsonb, -- ["task.created", "task.completed", etc.]
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
last_triggered_at timestamptz,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS webhooks_workspace_idx ON webhooks (workspace_slug);
|
||||
|
||||
-- Notifications for users
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
user_email text NOT NULL,
|
||||
type text NOT NULL, -- task_assigned, mention, comment, etc.
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
entity_type text, -- task, event, note
|
||||
entity_id text,
|
||||
read boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_created_idx ON notifications (user_email, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS notifications_unread_idx ON notifications (user_email, read) WHERE read = false;
|
||||
|
||||
-- Presence tracking for real-time collaboration
|
||||
CREATE TABLE IF NOT EXISTS presence (
|
||||
id text PRIMARY KEY,
|
||||
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
|
||||
user_email text NOT NULL,
|
||||
user_name text NOT NULL,
|
||||
entity_type text, -- board, task, note, etc.
|
||||
entity_id text,
|
||||
last_seen_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS presence_workspace_entity_idx ON presence (workspace_slug, entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS presence_user_idx ON presence (user_email);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS presence;
|
||||
DROP TABLE IF EXISTS notifications;
|
||||
DROP TABLE IF EXISTS webhooks;
|
||||
DROP TABLE IF EXISTS integrations;
|
||||
@@ -0,0 +1,217 @@
|
||||
-- name: CountWorkspaces :one
|
||||
SELECT COUNT(*) FROM workspaces;
|
||||
|
||||
-- name: ListWorkspaces :many
|
||||
SELECT id, slug, name, role, created_at
|
||||
FROM workspaces
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: CreateWorkspace :exec
|
||||
INSERT INTO workspaces (id, slug, name, role, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: ListMembers :many
|
||||
SELECT id, workspace_slug, name, email, role, status
|
||||
FROM members
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: CreateMember :exec
|
||||
INSERT INTO members (id, workspace_slug, name, email, role, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6);
|
||||
|
||||
-- name: GetMemberByWorkspaceAndEmail :one
|
||||
SELECT id, workspace_slug, name, email, role, status
|
||||
FROM members
|
||||
WHERE workspace_slug = $1 AND email = $2;
|
||||
|
||||
-- name: ListInvites :many
|
||||
SELECT id, workspace_slug, email, role, token, created_at, status
|
||||
FROM invites
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetInviteByToken :one
|
||||
SELECT id, workspace_slug, email, role, token, created_at, status
|
||||
FROM invites
|
||||
WHERE token = $1;
|
||||
|
||||
-- name: CreateInvite :one
|
||||
INSERT INTO invites (id, workspace_slug, email, role, token, created_at, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, workspace_slug, email, role, token, created_at, status;
|
||||
|
||||
-- name: AcceptInvite :one
|
||||
UPDATE invites
|
||||
SET status = 'accepted'
|
||||
WHERE token = $1
|
||||
RETURNING id, workspace_slug, email, role, token, created_at, status;
|
||||
|
||||
-- name: ListActivities :many
|
||||
SELECT id, workspace_slug, title, detail, created_at
|
||||
FROM activity_entries
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 40;
|
||||
|
||||
-- name: CreateActivity :exec
|
||||
INSERT INTO activity_entries (id, workspace_slug, title, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: TrimActivities :exec
|
||||
DELETE FROM activity_entries
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM activity_entries
|
||||
WHERE activity_entries.workspace_slug = $1
|
||||
ORDER BY created_at DESC
|
||||
OFFSET 40
|
||||
);
|
||||
|
||||
-- name: ListBoardGroups :many
|
||||
SELECT id, workspace_slug, name, color, sort_order
|
||||
FROM board_groups
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY sort_order ASC, name ASC;
|
||||
|
||||
-- name: GetBoardGroupByID :one
|
||||
SELECT id, workspace_slug, name, color, sort_order
|
||||
FROM board_groups
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateBoardGroup :one
|
||||
INSERT INTO board_groups (id, workspace_slug, name, color, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, workspace_slug, name, color, sort_order;
|
||||
|
||||
-- name: UpdateBoardGroup :one
|
||||
UPDATE board_groups
|
||||
SET name = $2,
|
||||
color = $3,
|
||||
sort_order = $4
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, name, color, sort_order;
|
||||
|
||||
-- name: CountBoardGroupsByWorkspace :one
|
||||
SELECT COUNT(*) FROM board_groups WHERE workspace_slug = $1;
|
||||
|
||||
-- name: ListLabels :many
|
||||
SELECT id, workspace_slug, name, color
|
||||
FROM labels
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: CreateLabel :one
|
||||
INSERT INTO labels (id, workspace_slug, name, color)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, workspace_slug, name, color;
|
||||
|
||||
-- name: ListTasks :many
|
||||
SELECT id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY updated_at DESC;
|
||||
|
||||
-- name: GetTaskByID :one
|
||||
SELECT id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateTask :one
|
||||
INSERT INTO tasks (id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at;
|
||||
|
||||
-- name: UpdateTask :one
|
||||
UPDATE tasks
|
||||
SET title = $2,
|
||||
description = $3,
|
||||
status = $4,
|
||||
board_group_id = $5,
|
||||
color = $6,
|
||||
due_at = $7,
|
||||
scheduled_start = $8,
|
||||
scheduled_end = $9,
|
||||
assignee_id = $10,
|
||||
label_ids = $11,
|
||||
attachments = $12,
|
||||
comments = $13,
|
||||
updated_at = $14
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at;
|
||||
|
||||
-- name: ListCalendarEvents :many
|
||||
SELECT id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments
|
||||
FROM calendar_events
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY starts_at ASC;
|
||||
|
||||
-- name: GetCalendarEventByID :one
|
||||
SELECT id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments
|
||||
FROM calendar_events
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateCalendarEvent :one
|
||||
INSERT INTO calendar_events (id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments;
|
||||
|
||||
-- name: UpdateCalendarEvent :one
|
||||
UPDATE calendar_events
|
||||
SET title = $2,
|
||||
description = $3,
|
||||
starts_at = $4,
|
||||
ends_at = $5,
|
||||
color = $6,
|
||||
linked_task_id = $7,
|
||||
attachments = $8
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments;
|
||||
|
||||
-- name: ListNotes :many
|
||||
SELECT id, workspace_slug, title, content, updated_at
|
||||
FROM notes
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY updated_at DESC;
|
||||
|
||||
-- name: GetNoteByID :one
|
||||
SELECT id, workspace_slug, title, content, updated_at
|
||||
FROM notes
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateNote :one
|
||||
INSERT INTO notes (id, workspace_slug, title, content, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, workspace_slug, title, content, updated_at;
|
||||
|
||||
-- name: UpdateNote :one
|
||||
UPDATE notes
|
||||
SET title = $2,
|
||||
content = $3,
|
||||
updated_at = $4
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, title, content, updated_at;
|
||||
|
||||
-- name: ListFocusSessions :many
|
||||
SELECT id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds
|
||||
FROM focus_sessions
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY started_at DESC;
|
||||
|
||||
-- name: GetFocusSessionByID :one
|
||||
SELECT id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds
|
||||
FROM focus_sessions
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateFocusSession :one
|
||||
INSERT INTO focus_sessions (id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds;
|
||||
|
||||
-- name: UpdateFocusSession :one
|
||||
UPDATE focus_sessions
|
||||
SET completed_at = $2,
|
||||
paused_at = $3,
|
||||
paused_total_seconds = $4
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds;
|
||||
@@ -0,0 +1,86 @@
|
||||
package filestorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type LocalStorage struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func NewLocal(root string) *LocalStorage {
|
||||
return &LocalStorage{root: root}
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Provider() string {
|
||||
return "local"
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Probe(_ context.Context) error {
|
||||
if err := os.MkdirAll(l.root, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
probePath := filepath.Join(l.root, ".healthcheck")
|
||||
file, err := os.OpenFile(probePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(probePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Put(_ context.Context, key string, reader io.Reader, _ string, _ int64) error {
|
||||
path := filepath.Join(l.root, filepath.Clean(key))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(file, reader); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Get(_ context.Context, key string) (Object, error) {
|
||||
path := filepath.Join(l.root, filepath.Clean(key))
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Object{}, ErrNotFound
|
||||
}
|
||||
return Object{}, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return Object{}, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
_ = file.Close()
|
||||
return Object{}, ErrNotFound
|
||||
}
|
||||
return Object{
|
||||
Body: file,
|
||||
Size: info.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Delete(_ context.Context, key string) error {
|
||||
path := filepath.Join(l.root, filepath.Clean(key))
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package filestorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
type S3Storage struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
func NewS3FromEnv() (*S3Storage, error) {
|
||||
bucket := strings.TrimSpace(os.Getenv("S3_BUCKET"))
|
||||
accessKey := strings.TrimSpace(os.Getenv("S3_ACCESS_KEY"))
|
||||
secretKey := strings.TrimSpace(os.Getenv("S3_SECRET_KEY"))
|
||||
if bucket == "" || accessKey == "" || secretKey == "" {
|
||||
return nil, errors.New("S3_BUCKET, S3_ACCESS_KEY, and S3_SECRET_KEY are required for FILE_STORAGE_PROVIDER=s3")
|
||||
}
|
||||
|
||||
region := strings.TrimSpace(os.Getenv("S3_REGION"))
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
endpoint := strings.TrimSpace(os.Getenv("S3_ENDPOINT"))
|
||||
usePathStyle, _ := strconv.ParseBool(strings.TrimSpace(os.Getenv("S3_USE_PATH_STYLE")))
|
||||
|
||||
cfg := aws.Config{
|
||||
Region: region,
|
||||
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
||||
}
|
||||
client := s3.NewFromConfig(cfg, func(options *s3.Options) {
|
||||
options.UsePathStyle = usePathStyle
|
||||
if endpoint != "" {
|
||||
options.BaseEndpoint = aws.String(endpoint)
|
||||
}
|
||||
})
|
||||
return &S3Storage{client: client, bucket: bucket}, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Provider() string {
|
||||
return "s3"
|
||||
}
|
||||
|
||||
func (s *S3Storage) Probe(ctx context.Context) error {
|
||||
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *S3Storage) Put(ctx context.Context, key string, reader io.Reader, contentType string, size int64) error {
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: reader,
|
||||
ContentType: aws.String(contentType),
|
||||
ContentLength: aws.Int64(size),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *S3Storage) Get(ctx context.Context, key string) (Object, error) {
|
||||
response, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
if isS3NotFoundError(err) {
|
||||
return Object{}, ErrNotFound
|
||||
}
|
||||
return Object{}, err
|
||||
}
|
||||
|
||||
contentType := ""
|
||||
if response.ContentType != nil {
|
||||
contentType = *response.ContentType
|
||||
}
|
||||
size := int64(0)
|
||||
if response.ContentLength != nil {
|
||||
size = *response.ContentLength
|
||||
}
|
||||
return Object{
|
||||
Body: response.Body,
|
||||
ContentType: contentType,
|
||||
Size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Delete(ctx context.Context, key string) error {
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
if isS3NotFoundError(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delete s3 object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isS3NotFoundError(err error) bool {
|
||||
var noSuchKey *types.NoSuchKey
|
||||
if errors.As(err, &noSuchKey) {
|
||||
return true
|
||||
}
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
case "NoSuchKey", "NotFound", "NoSuchBucket":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package filestorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("file storage object not found")
|
||||
|
||||
type Object struct {
|
||||
Body io.ReadCloser
|
||||
ContentType string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
Provider() string
|
||||
Probe(ctx context.Context) error
|
||||
Put(ctx context.Context, key string, reader io.Reader, contentType string, size int64) error
|
||||
Get(ctx context.Context, key string) (Object, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
func NewFromEnv() (Storage, error) {
|
||||
provider := strings.ToLower(strings.TrimSpace(os.Getenv("FILE_STORAGE_PROVIDER")))
|
||||
if provider == "" || provider == "local" {
|
||||
root := strings.TrimSpace(os.Getenv("FILE_STORAGE_DIR"))
|
||||
if root == "" {
|
||||
root = "./data/uploads"
|
||||
}
|
||||
return NewLocal(root), nil
|
||||
}
|
||||
if provider == "s3" {
|
||||
return NewS3FromEnv()
|
||||
}
|
||||
return nil, errors.New("unsupported file storage provider")
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultActivityLimit = 8
|
||||
maxActivityLimit = 40
|
||||
)
|
||||
|
||||
var activityTypes = map[string]struct{}{
|
||||
"task": {},
|
||||
"board": {},
|
||||
"calendar": {},
|
||||
"note": {},
|
||||
"focus": {},
|
||||
"mail": {},
|
||||
"invite": {},
|
||||
"system": {},
|
||||
}
|
||||
|
||||
type activityListParams struct {
|
||||
Limit int
|
||||
Type string
|
||||
Query string
|
||||
}
|
||||
|
||||
func parseActivityListParams(limitRaw string, typeRaw string, queryRaw string) (activityListParams, error) {
|
||||
params := activityListParams{
|
||||
Limit: defaultActivityLimit,
|
||||
Type: strings.TrimSpace(strings.ToLower(typeRaw)),
|
||||
Query: strings.TrimSpace(strings.ToLower(queryRaw)),
|
||||
}
|
||||
|
||||
if strings.TrimSpace(limitRaw) != "" {
|
||||
parsed, err := strconv.Atoi(limitRaw)
|
||||
if err != nil {
|
||||
return activityListParams{}, fmt.Errorf("invalid limit: expected integer between 1 and %d", maxActivityLimit)
|
||||
}
|
||||
if parsed < 1 || parsed > maxActivityLimit {
|
||||
return activityListParams{}, fmt.Errorf("invalid limit: expected integer between 1 and %d", maxActivityLimit)
|
||||
}
|
||||
params.Limit = parsed
|
||||
}
|
||||
|
||||
if params.Type != "" {
|
||||
if _, ok := activityTypes[params.Type]; !ok {
|
||||
return activityListParams{}, fmt.Errorf("invalid activity type")
|
||||
}
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func filterActivityEntries(entries []store.ActivityEntry, params activityListParams) []store.ActivityEntry {
|
||||
if len(entries) == 0 || params.Limit == 0 {
|
||||
return []store.ActivityEntry{}
|
||||
}
|
||||
|
||||
filtered := make([]store.ActivityEntry, 0, params.Limit)
|
||||
for _, entry := range entries {
|
||||
if params.Type != "" && classifyActivityEntry(entry) != params.Type {
|
||||
continue
|
||||
}
|
||||
if params.Query != "" {
|
||||
title := strings.ToLower(entry.Title)
|
||||
detail := strings.ToLower(entry.Detail)
|
||||
if !strings.Contains(title, params.Query) && !strings.Contains(detail, params.Query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
if len(filtered) == params.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func classifyActivityEntry(entry store.ActivityEntry) string {
|
||||
text := strings.ToLower(strings.TrimSpace(entry.Title + " " + entry.Detail))
|
||||
|
||||
switch {
|
||||
case strings.Contains(text, "invite"):
|
||||
return "invite"
|
||||
case strings.Contains(text, "board"):
|
||||
return "board"
|
||||
case strings.Contains(text, "mail"), strings.Contains(text, "inbox"), strings.Contains(text, "smtp"), strings.Contains(text, "imap"):
|
||||
return "mail"
|
||||
case strings.Contains(text, "calendar"), strings.Contains(text, "event"):
|
||||
return "calendar"
|
||||
case strings.Contains(text, "note"):
|
||||
return "note"
|
||||
case strings.Contains(text, "focus"), strings.Contains(text, "pomodoro"):
|
||||
return "focus"
|
||||
case strings.Contains(text, "task"):
|
||||
return "task"
|
||||
default:
|
||||
return "system"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestParseActivityListParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params, err := parseActivityListParams("", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if params.Limit != defaultActivityLimit {
|
||||
t.Fatalf("default limit = %d, want %d", params.Limit, defaultActivityLimit)
|
||||
}
|
||||
|
||||
params, err = parseActivityListParams("12", "task", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if params.Limit != 12 || params.Type != "task" || params.Query != "foo" {
|
||||
t.Fatalf("unexpected parsed params: %+v", params)
|
||||
}
|
||||
|
||||
if _, err := parseActivityListParams("0", "", ""); err == nil {
|
||||
t.Fatal("expected error for out-of-range limit")
|
||||
}
|
||||
if _, err := parseActivityListParams("abc", "", ""); err == nil {
|
||||
t.Fatal("expected error for non-numeric limit")
|
||||
}
|
||||
if _, err := parseActivityListParams("5", "unknown", ""); err == nil {
|
||||
t.Fatal("expected error for invalid activity type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterActivityEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries := []store.ActivityEntry{
|
||||
{ID: "1", Title: "Task created", Detail: "Write docs", CreatedAt: time.Now().Add(-1 * time.Hour)},
|
||||
{ID: "2", Title: "Invite accepted", Detail: "alex@example.com joined", CreatedAt: time.Now().Add(-2 * time.Hour)},
|
||||
{ID: "3", Title: "Mail synced", Detail: "Inbox updated", CreatedAt: time.Now().Add(-3 * time.Hour)},
|
||||
}
|
||||
|
||||
filtered := filterActivityEntries(entries, activityListParams{
|
||||
Limit: 5,
|
||||
Type: "invite",
|
||||
})
|
||||
if len(filtered) != 1 || filtered[0].ID != "2" {
|
||||
t.Fatalf("expected invite entry only, got %+v", filtered)
|
||||
}
|
||||
|
||||
filtered = filterActivityEntries(entries, activityListParams{
|
||||
Limit: 5,
|
||||
Query: "docs",
|
||||
})
|
||||
if len(filtered) != 1 || filtered[0].ID != "1" {
|
||||
t.Fatalf("expected docs match only, got %+v", filtered)
|
||||
}
|
||||
|
||||
filtered = filterActivityEntries(entries, activityListParams{
|
||||
Limit: 2,
|
||||
})
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("expected two entries due to limit, got %d", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyActivityEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
entry store.ActivityEntry
|
||||
want string
|
||||
}{
|
||||
{entry: store.ActivityEntry{Title: "Task updated", Detail: "Done"}, want: "task"},
|
||||
{entry: store.ActivityEntry{Title: "Board group added", Detail: "Inbox"}, want: "board"},
|
||||
{entry: store.ActivityEntry{Title: "Event moved", Detail: "Calendar item"}, want: "calendar"},
|
||||
{entry: store.ActivityEntry{Title: "Note saved", Detail: "Draft"}, want: "note"},
|
||||
{entry: store.ActivityEntry{Title: "Focus started", Detail: "Pomodoro"}, want: "focus"},
|
||||
{entry: store.ActivityEntry{Title: "Mail sent", Detail: "SMTP ok"}, want: "mail"},
|
||||
{entry: store.ActivityEntry{Title: "Invite revoked", Detail: "guest"}, want: "invite"},
|
||||
{entry: store.ActivityEntry{Title: "Workspace synced", Detail: "ok"}, want: "system"},
|
||||
}
|
||||
|
||||
for _, test := range cases {
|
||||
got := classifyActivityEntry(test.entry)
|
||||
if got != test.want {
|
||||
t.Fatalf("classifyActivityEntry(%q) = %q, want %q", test.entry.Title, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerCRMRoutes(group *gin.RouterGroup) {
|
||||
// Contacts
|
||||
group.GET("/contacts", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListContacts(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.GET("/contacts/:contactId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": contact})
|
||||
})
|
||||
|
||||
group.POST("/contacts", func(c *gin.Context) {
|
||||
var input store.CreateContactInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
contact := s.store.CreateContact(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": contact})
|
||||
})
|
||||
|
||||
group.PATCH("/contacts/:contactId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateContactInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateContact(c.Param("contactId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
group.DELETE("/contacts/:contactId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DeleteContact(c.Param("contactId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Companies
|
||||
group.GET("/companies", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListCompanies(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.GET("/companies/:companyId", func(c *gin.Context) {
|
||||
company, err := s.store.GetCompanyByID(c.Param("companyId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": company})
|
||||
})
|
||||
|
||||
group.POST("/companies", func(c *gin.Context) {
|
||||
var input store.CreateCompanyInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
company := s.store.CreateCompany(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": company})
|
||||
})
|
||||
|
||||
group.PATCH("/companies/:companyId", func(c *gin.Context) {
|
||||
company, err := s.store.GetCompanyByID(c.Param("companyId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateCompanyInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateCompany(c.Param("companyId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
group.DELETE("/companies/:companyId", func(c *gin.Context) {
|
||||
company, err := s.store.GetCompanyByID(c.Param("companyId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DeleteCompany(c.Param("companyId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Contact-Task linking
|
||||
group.POST("/contacts/:contactId/tasks/:taskId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.LinkContactToTask(c.Param("contactId"), c.Param("taskId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
group.DELETE("/contacts/:contactId/tasks/:taskId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UnlinkContactFromTask(c.Param("contactId"), c.Param("taskId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Contact-Event linking
|
||||
group.POST("/contacts/:contactId/events/:eventId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.LinkContactToEvent(c.Param("contactId"), c.Param("eventId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const requestIDContextKey = "requestID"
|
||||
|
||||
func requestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := strings.TrimSpace(c.GetHeader("X-Request-Id"))
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
}
|
||||
c.Set(requestIDContextKey, requestID)
|
||||
c.Header("X-Request-Id", requestID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDFromContext(c *gin.Context) string {
|
||||
if value, exists := c.Get(requestIDContextKey); exists {
|
||||
if requestID, ok := value.(string); ok {
|
||||
return requestID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) writeStatusError(c *gin.Context, status int, message string) {
|
||||
code := "internal_error"
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
code = "bad_request"
|
||||
case http.StatusUnauthorized:
|
||||
code = "unauthorized"
|
||||
case http.StatusForbidden:
|
||||
code = "forbidden"
|
||||
case http.StatusNotFound:
|
||||
code = "not_found"
|
||||
case http.StatusConflict:
|
||||
code = "conflict"
|
||||
case http.StatusBadGateway:
|
||||
code = "upstream_error"
|
||||
case http.StatusServiceUnavailable:
|
||||
code = "service_unavailable"
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = http.StatusText(status)
|
||||
}
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"code": code,
|
||||
"message": message,
|
||||
"requestId": requestIDFromContext(c),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerIntegrationRoutes(group *gin.RouterGroup) {
|
||||
// Integrations
|
||||
group.GET("/integrations", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListIntegrations(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/integrations", func(c *gin.Context) {
|
||||
var input store.CreateIntegrationInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
integration := s.store.CreateIntegration(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": integration})
|
||||
})
|
||||
|
||||
group.DELETE("/integrations/:integrationId", func(c *gin.Context) {
|
||||
integration, err := s.store.GetIntegrationByID(c.Param("integrationId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, integration.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteIntegration(integration.ID); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Webhooks
|
||||
group.GET("/webhooks", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListWebhooks(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/webhooks", func(c *gin.Context) {
|
||||
var input store.CreateWebhookInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
webhook := s.store.CreateWebhook(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": webhook})
|
||||
})
|
||||
|
||||
group.DELETE("/webhooks/:webhookId", func(c *gin.Context) {
|
||||
if err := s.store.DeleteWebhook(c.Param("webhookId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Notifications
|
||||
group.GET("/notifications", func(c *gin.Context) {
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListNotifications(user.Email, limit)})
|
||||
})
|
||||
|
||||
group.POST("/notifications/:notificationId/read", func(c *gin.Context) {
|
||||
if err := s.store.MarkNotificationRead(c.Param("notificationId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
group.POST("/notifications/read-all", func(c *gin.Context) {
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
if err := s.store.MarkAllNotificationsRead(user.Email); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
group.GET("/notifications/unread-count", func(c *gin.Context) {
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": s.store.UnreadNotificationCount(user.Email)})
|
||||
})
|
||||
|
||||
// Presence
|
||||
group.POST("/presence", func(c *gin.Context) {
|
||||
var input store.UpdatePresenceInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
presence := s.store.UpdatePresence(input)
|
||||
c.JSON(http.StatusOK, gin.H{"data": presence})
|
||||
})
|
||||
|
||||
// Create notification (internal use)
|
||||
group.POST("/notifications", func(c *gin.Context) {
|
||||
var input store.CreateNotificationInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
notification := s.store.CreateNotification(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": notification})
|
||||
})
|
||||
|
||||
group.GET("/presence", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
entityType := c.Query("entityType")
|
||||
entityID := c.Query("entityId")
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListPresence(workspaceSlug, entityType, entityID)})
|
||||
})
|
||||
|
||||
group.DELETE("/presence", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
if err := s.store.ClearPresence(workspaceSlug, user.Email); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func requestLogMiddleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
baseLogger := logger
|
||||
if baseLogger == nil {
|
||||
baseLogger = zap.NewNop()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
startedAt := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
c.Next()
|
||||
|
||||
status := c.Writer.Status()
|
||||
latency := time.Since(startedAt)
|
||||
requestID := requestIDFromContext(c)
|
||||
|
||||
if path == "/v1/health" && status < 400 {
|
||||
return
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("requestId", requestID),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.Int("status", status),
|
||||
zap.Duration("latency", latency),
|
||||
zap.String("clientIP", c.ClientIP()),
|
||||
zap.String("userAgent", c.Request.UserAgent()),
|
||||
zap.Int("responseBytes", c.Writer.Size()),
|
||||
}
|
||||
if query != "" {
|
||||
fields = append(fields, zap.String("query", query))
|
||||
}
|
||||
if len(c.Errors) > 0 {
|
||||
fields = append(fields, zap.String("errors", c.Errors.String()))
|
||||
}
|
||||
|
||||
switch {
|
||||
case status >= 500:
|
||||
baseLogger.Error("http request completed with server error", fields...)
|
||||
case status >= 400:
|
||||
baseLogger.Warn("http request completed with client error", fields...)
|
||||
default:
|
||||
baseLogger.Info("http request completed", fields...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/mailruntime"
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
type connectMailboxRequest struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Label string `json:"label"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IMAPHost string `json:"imapHost" binding:"required"`
|
||||
IMAPPort int `json:"imapPort"`
|
||||
IMAPUsername string `json:"imapUsername"`
|
||||
IMAPPassword string `json:"imapPassword" binding:"required"`
|
||||
IMAPUseTLS bool `json:"imapUseTls"`
|
||||
SMTPHost string `json:"smtpHost" binding:"required"`
|
||||
SMTPPort int `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPassword string `json:"smtpPassword"`
|
||||
SMTPUseTLS bool `json:"smtpUseTls"`
|
||||
}
|
||||
|
||||
type createOutgoingMailRequest struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
MailboxID string `json:"mailboxId" binding:"required"`
|
||||
To []store.MailAddress `json:"to" binding:"required"`
|
||||
Cc []store.MailAddress `json:"cc"`
|
||||
Bcc []store.MailAddress `json:"bcc"`
|
||||
Subject string `json:"subject"`
|
||||
TextBody string `json:"textBody"`
|
||||
HTMLBody string `json:"htmlBody"`
|
||||
ScheduledFor *time.Time `json:"scheduledFor"`
|
||||
}
|
||||
|
||||
type createTaskFromMailRequest struct {
|
||||
BoardGroupID string `json:"boardGroupId" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
DueAt *time.Time `json:"dueAt"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func (s *Server) registerMailRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/mailboxes", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailboxes(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/mailboxes", func(c *gin.Context) {
|
||||
var input connectMailboxRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mailbox, err := s.mail.ConnectMailbox(c.Request.Context(), mailruntime.ConnectMailboxInput{
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Label: input.Label,
|
||||
Email: input.Email,
|
||||
DisplayName: input.DisplayName,
|
||||
IMAPHost: input.IMAPHost,
|
||||
IMAPPort: input.IMAPPort,
|
||||
IMAPUsername: input.IMAPUsername,
|
||||
IMAPPassword: input.IMAPPassword,
|
||||
IMAPUseTLS: input.IMAPUseTLS,
|
||||
SMTPHost: input.SMTPHost,
|
||||
SMTPPort: input.SMTPPort,
|
||||
SMTPUsername: input.SMTPUsername,
|
||||
SMTPPassword: input.SMTPPassword,
|
||||
SMTPUseTLS: input.SMTPUseTLS,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": mailbox})
|
||||
})
|
||||
|
||||
group.POST("/mailboxes/:mailboxId/sync", func(c *gin.Context) {
|
||||
mailbox, err := s.store.GetMailboxByID(c.Param("mailboxId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, mailbox.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
if err := s.mail.SyncMailbox(c.Request.Context(), mailbox.ID); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
updated, err := s.store.GetMailboxByID(mailbox.ID)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
group.GET("/mail/messages", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailMessages(workspaceSlug, c.Query("mailboxId"))})
|
||||
})
|
||||
|
||||
group.GET("/mail/outgoing", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListOutgoingMails(workspaceSlug, c.Query("mailboxId"))})
|
||||
})
|
||||
|
||||
group.POST("/mail/outgoing", func(c *gin.Context) {
|
||||
var input createOutgoingMailRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
mailbox, err := s.store.GetMailboxByID(input.MailboxID)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if mailbox.WorkspaceSlug != input.WorkspaceSlug {
|
||||
s.writeStatusError(c, http.StatusForbidden, "mailbox does not belong to workspace")
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.mail.QueueOutgoingMail(c.Request.Context(), mailruntime.QueueOutgoingMailInput{
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
MailboxID: input.MailboxID,
|
||||
To: input.To,
|
||||
Cc: input.Cc,
|
||||
Bcc: input.Bcc,
|
||||
Subject: input.Subject,
|
||||
TextBody: input.TextBody,
|
||||
HTMLBody: input.HTMLBody,
|
||||
ScheduledFor: input.ScheduledFor,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
})
|
||||
|
||||
group.POST("/mail/messages/:messageId/create-task", func(c *gin.Context) {
|
||||
message, err := s.store.GetMailMessageByID(c.Param("messageId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, message.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
if message.LinkedTaskID != nil {
|
||||
s.writeStatusError(c, http.StatusConflict, "message already linked to a task")
|
||||
return
|
||||
}
|
||||
|
||||
var input createTaskFromMailRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
description := mailTaskDescription(message)
|
||||
task := s.store.CreateTask(store.CreateTaskInput{
|
||||
WorkspaceSlug: message.WorkspaceSlug,
|
||||
BoardGroupID: input.BoardGroupID,
|
||||
Title: firstNonBlank(strings.TrimSpace(input.Title), strings.TrimSpace(message.Subject), "Follow up on email"),
|
||||
Description: description,
|
||||
DueAt: input.DueAt,
|
||||
Color: withFallback(input.Color, "blue"),
|
||||
})
|
||||
|
||||
if _, err := s.store.LinkMailMessageTask(message.ID, task.ID); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": task})
|
||||
})
|
||||
}
|
||||
|
||||
func mailTaskDescription(message store.MailMessage) string {
|
||||
var builder strings.Builder
|
||||
if message.From.Email != "" {
|
||||
builder.WriteString(fmt.Sprintf("From: %s <%s>\n\n", firstNonBlank(message.From.Name, "Sender"), message.From.Email))
|
||||
}
|
||||
body := firstNonBlank(strings.TrimSpace(message.TextBody), strings.TrimSpace(message.Snippet))
|
||||
builder.WriteString(body)
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withFallback(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type routeMetricSnapshot struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
Count uint64 `json:"count"`
|
||||
AvgLatencyMs float64 `json:"avgLatencyMs"`
|
||||
MaxLatencyMs float64 `json:"maxLatencyMs"`
|
||||
LastSeenAt string `json:"lastSeenAt"`
|
||||
}
|
||||
|
||||
type metricsSnapshot struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
RequestsTotal uint64 `json:"requestsTotal"`
|
||||
StatusClassTotals map[string]uint64 `json:"statusClassTotals"`
|
||||
Routes []routeMetricSnapshot `json:"routes"`
|
||||
}
|
||||
|
||||
type routeMetricBucket struct {
|
||||
Method string
|
||||
Path string
|
||||
Status int
|
||||
Count uint64
|
||||
TotalLatencyNanos float64
|
||||
MaxLatencyNanos float64
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type requestMetrics struct {
|
||||
startedAt time.Time
|
||||
requestsTotal uint64
|
||||
status2xxTotal uint64
|
||||
status3xxTotal uint64
|
||||
status4xxTotal uint64
|
||||
status5xxTotal uint64
|
||||
statusOther uint64
|
||||
|
||||
mu sync.RWMutex
|
||||
buckets map[string]*routeMetricBucket
|
||||
}
|
||||
|
||||
func newRequestMetrics() *requestMetrics {
|
||||
return &requestMetrics{
|
||||
startedAt: time.Now().UTC(),
|
||||
buckets: make(map[string]*routeMetricBucket),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *requestMetrics) observe(method, path string, status int, latency time.Duration) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if path == "" {
|
||||
path = "<unmatched>"
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
latencyNanos := float64(latency.Nanoseconds())
|
||||
key := method + " " + path + " " + itoa(status)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.requestsTotal++
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
m.status2xxTotal++
|
||||
case status >= 300 && status < 400:
|
||||
m.status3xxTotal++
|
||||
case status >= 400 && status < 500:
|
||||
m.status4xxTotal++
|
||||
case status >= 500 && status < 600:
|
||||
m.status5xxTotal++
|
||||
default:
|
||||
m.statusOther++
|
||||
}
|
||||
|
||||
bucket, exists := m.buckets[key]
|
||||
if !exists {
|
||||
bucket = &routeMetricBucket{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Status: status,
|
||||
Count: 1,
|
||||
TotalLatencyNanos: latencyNanos,
|
||||
MaxLatencyNanos: latencyNanos,
|
||||
LastSeenAt: now,
|
||||
}
|
||||
m.buckets[key] = bucket
|
||||
return
|
||||
}
|
||||
|
||||
bucket.Count++
|
||||
bucket.TotalLatencyNanos += latencyNanos
|
||||
if latencyNanos > bucket.MaxLatencyNanos {
|
||||
bucket.MaxLatencyNanos = latencyNanos
|
||||
}
|
||||
bucket.LastSeenAt = now
|
||||
}
|
||||
|
||||
func (m *requestMetrics) snapshot() metricsSnapshot {
|
||||
if m == nil {
|
||||
return metricsSnapshot{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
StatusClassTotals: map[string]uint64{},
|
||||
Routes: []routeMetricSnapshot{},
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
routes := make([]routeMetricSnapshot, 0, len(m.buckets))
|
||||
for _, bucket := range m.buckets {
|
||||
avgMs := 0.0
|
||||
if bucket.Count > 0 {
|
||||
avgMs = (bucket.TotalLatencyNanos / float64(bucket.Count)) / float64(time.Millisecond)
|
||||
}
|
||||
routes = append(routes, routeMetricSnapshot{
|
||||
Method: bucket.Method,
|
||||
Path: bucket.Path,
|
||||
Status: bucket.Status,
|
||||
Count: bucket.Count,
|
||||
AvgLatencyMs: avgMs,
|
||||
MaxLatencyMs: bucket.MaxLatencyNanos / float64(time.Millisecond),
|
||||
LastSeenAt: bucket.LastSeenAt.Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
if routes[i].Method != routes[j].Method {
|
||||
return routes[i].Method < routes[j].Method
|
||||
}
|
||||
if routes[i].Path != routes[j].Path {
|
||||
return routes[i].Path < routes[j].Path
|
||||
}
|
||||
return routes[i].Status < routes[j].Status
|
||||
})
|
||||
|
||||
return metricsSnapshot{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(time.Since(m.startedAt).Seconds()),
|
||||
RequestsTotal: m.requestsTotal,
|
||||
StatusClassTotals: map[string]uint64{
|
||||
"2xx": m.status2xxTotal,
|
||||
"3xx": m.status3xxTotal,
|
||||
"4xx": m.status4xxTotal,
|
||||
"5xx": m.status5xxTotal,
|
||||
"other": m.statusOther,
|
||||
},
|
||||
Routes: routes,
|
||||
}
|
||||
}
|
||||
|
||||
func requestMetricsMiddleware(metrics *requestMetrics) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
startedAt := time.Now()
|
||||
c.Next()
|
||||
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
if path == "/v1/metrics" || path == "/v1/metrics/prometheus" {
|
||||
return
|
||||
}
|
||||
metrics.observe(c.Request.Method, path, c.Writer.Status(), time.Since(startedAt))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *requestMetrics) snapshotPrometheus() string {
|
||||
snapshot := m.snapshot()
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString("# HELP productier_http_uptime_seconds Process uptime in seconds.\n")
|
||||
builder.WriteString("# TYPE productier_http_uptime_seconds gauge\n")
|
||||
builder.WriteString("productier_http_uptime_seconds ")
|
||||
builder.WriteString(strconv.FormatInt(snapshot.UptimeSeconds, 10))
|
||||
builder.WriteByte('\n')
|
||||
|
||||
builder.WriteString("# HELP productier_http_requests_total Total HTTP requests by status class.\n")
|
||||
builder.WriteString("# TYPE productier_http_requests_total counter\n")
|
||||
statusClasses := []string{"2xx", "3xx", "4xx", "5xx", "other"}
|
||||
for _, statusClass := range statusClasses {
|
||||
builder.WriteString(`productier_http_requests_total{status_class="`)
|
||||
builder.WriteString(escapePrometheusLabelValue(statusClass))
|
||||
builder.WriteString(`"} `)
|
||||
builder.WriteString(strconv.FormatUint(snapshot.StatusClassTotals[statusClass], 10))
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
|
||||
builder.WriteString("# HELP productier_http_requests_route_total Total HTTP requests by route and status code.\n")
|
||||
builder.WriteString("# TYPE productier_http_requests_route_total counter\n")
|
||||
builder.WriteString("# HELP productier_http_request_latency_avg_ms Average request latency in milliseconds by route and status code.\n")
|
||||
builder.WriteString("# TYPE productier_http_request_latency_avg_ms gauge\n")
|
||||
builder.WriteString("# HELP productier_http_request_latency_max_ms Max request latency in milliseconds by route and status code.\n")
|
||||
builder.WriteString("# TYPE productier_http_request_latency_max_ms gauge\n")
|
||||
for _, route := range snapshot.Routes {
|
||||
labels := `method="` + escapePrometheusLabelValue(route.Method) +
|
||||
`",path="` + escapePrometheusLabelValue(route.Path) +
|
||||
`",status="` + strconv.Itoa(route.Status) + `"`
|
||||
builder.WriteString("productier_http_requests_route_total{")
|
||||
builder.WriteString(labels)
|
||||
builder.WriteString("} ")
|
||||
builder.WriteString(strconv.FormatUint(route.Count, 10))
|
||||
builder.WriteByte('\n')
|
||||
|
||||
builder.WriteString("productier_http_request_latency_avg_ms{")
|
||||
builder.WriteString(labels)
|
||||
builder.WriteString("} ")
|
||||
builder.WriteString(strconv.FormatFloat(route.AvgLatencyMs, 'f', 3, 64))
|
||||
builder.WriteByte('\n')
|
||||
|
||||
builder.WriteString("productier_http_request_latency_max_ms{")
|
||||
builder.WriteString(labels)
|
||||
builder.WriteString("} ")
|
||||
builder.WriteString(strconv.FormatFloat(route.MaxLatencyMs, 'f', 3, 64))
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func escapePrometheusLabelValue(value string) string {
|
||||
escaped := strings.ReplaceAll(value, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
return escaped
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
isNegative := value < 0
|
||||
if isNegative {
|
||||
value = -value
|
||||
}
|
||||
var digits [20]byte
|
||||
index := len(digits)
|
||||
for value > 0 {
|
||||
index--
|
||||
digits[index] = byte('0' + (value % 10))
|
||||
value /= 10
|
||||
}
|
||||
if isNegative {
|
||||
index--
|
||||
digits[index] = '-'
|
||||
}
|
||||
return string(digits[index:])
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Server) authorizeMetricsRequest(c *gin.Context) bool {
|
||||
expectedToken := strings.TrimSpace(s.metricsToken)
|
||||
if expectedToken == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
providedToken := strings.TrimSpace(c.GetHeader("X-Metrics-Token"))
|
||||
if providedToken == "" {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
providedToken = strings.TrimSpace(authHeader[len("Bearer "):])
|
||||
}
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "valid metrics token required")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAuthorizeMetricsRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
createContext := func(headers map[string]string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/metrics", nil)
|
||||
for key, value := range headers {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
context.Request = request
|
||||
return context, recorder
|
||||
}
|
||||
|
||||
t.Run("allows when token unset", func(t *testing.T) {
|
||||
server := &Server{metricsToken: ""}
|
||||
context, _ := createContext(nil)
|
||||
if !server.authorizeMetricsRequest(context) {
|
||||
t.Fatal("expected request to pass when metrics token is unset")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts bearer token", func(t *testing.T) {
|
||||
server := &Server{metricsToken: "strong-metrics-token"}
|
||||
context, _ := createContext(map[string]string{
|
||||
"Authorization": "Bearer strong-metrics-token",
|
||||
})
|
||||
if !server.authorizeMetricsRequest(context) {
|
||||
t.Fatal("expected bearer token to authorize request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts x metrics token", func(t *testing.T) {
|
||||
server := &Server{metricsToken: "strong-metrics-token"}
|
||||
context, _ := createContext(map[string]string{
|
||||
"X-Metrics-Token": "strong-metrics-token",
|
||||
})
|
||||
if !server.authorizeMetricsRequest(context) {
|
||||
t.Fatal("expected X-Metrics-Token to authorize request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid token", func(t *testing.T) {
|
||||
server := &Server{metricsToken: "strong-metrics-token"}
|
||||
context, recorder := createContext(map[string]string{
|
||||
"Authorization": "Bearer wrong-token",
|
||||
})
|
||||
if server.authorizeMetricsRequest(context) {
|
||||
t.Fatal("expected invalid token to be rejected")
|
||||
}
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRequestMetricsObserveAndSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metrics := newRequestMetrics()
|
||||
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 100*time.Millisecond)
|
||||
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 200*time.Millisecond)
|
||||
metrics.observe(http.MethodPost, "/v1/tasks", http.StatusCreated, 40*time.Millisecond)
|
||||
|
||||
snapshot := metrics.snapshot()
|
||||
if snapshot.RequestsTotal != 3 {
|
||||
t.Fatalf("requestsTotal = %d, want 3", snapshot.RequestsTotal)
|
||||
}
|
||||
if snapshot.StatusClassTotals["2xx"] != 3 {
|
||||
t.Fatalf("2xx total = %d, want 3", snapshot.StatusClassTotals["2xx"])
|
||||
}
|
||||
if len(snapshot.Routes) != 2 {
|
||||
t.Fatalf("route bucket count = %d, want 2", len(snapshot.Routes))
|
||||
}
|
||||
if snapshot.UptimeSeconds < 0 {
|
||||
t.Fatalf("uptimeSeconds = %d, want >= 0", snapshot.UptimeSeconds)
|
||||
}
|
||||
|
||||
health := findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/health", http.StatusOK)
|
||||
if health == nil {
|
||||
t.Fatal("missing route metric for GET /v1/health 200")
|
||||
}
|
||||
if health.Count != 2 {
|
||||
t.Fatalf("health count = %d, want 2", health.Count)
|
||||
}
|
||||
if health.AvgLatencyMs != 150 {
|
||||
t.Fatalf("health avgLatencyMs = %.2f, want 150", health.AvgLatencyMs)
|
||||
}
|
||||
if health.MaxLatencyMs != 200 {
|
||||
t.Fatalf("health maxLatencyMs = %.2f, want 200", health.MaxLatencyMs)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, health.LastSeenAt); err != nil {
|
||||
t.Fatalf("health lastSeenAt parse error: %v", err)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, snapshot.GeneratedAt); err != nil {
|
||||
t.Fatalf("snapshot generatedAt parse error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestMetricsMiddlewareSkipsMetricsEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
metrics := newRequestMetrics()
|
||||
router := gin.New()
|
||||
router.Use(requestMetricsMiddleware(metrics))
|
||||
router.GET("/v1/health", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
router.GET("/v1/metrics", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
router.GET("/v1/metrics/prometheus", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
|
||||
healthResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(healthResponse, healthRequest)
|
||||
if healthResponse.Code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/health status = %d, want 200", healthResponse.Code)
|
||||
}
|
||||
|
||||
metricsRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics", nil)
|
||||
metricsResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(metricsResponse, metricsRequest)
|
||||
if metricsResponse.Code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/metrics status = %d, want 200", metricsResponse.Code)
|
||||
}
|
||||
|
||||
prometheusRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics/prometheus", nil)
|
||||
prometheusResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(prometheusResponse, prometheusRequest)
|
||||
if prometheusResponse.Code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/metrics/prometheus status = %d, want 200", prometheusResponse.Code)
|
||||
}
|
||||
|
||||
snapshot := metrics.snapshot()
|
||||
if snapshot.RequestsTotal != 1 {
|
||||
t.Fatalf("requestsTotal = %d, want 1", snapshot.RequestsTotal)
|
||||
}
|
||||
if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics", http.StatusOK) != nil {
|
||||
t.Fatal("metrics endpoint request should be excluded from tracking")
|
||||
}
|
||||
if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics/prometheus", http.StatusOK) != nil {
|
||||
t.Fatal("prometheus metrics endpoint request should be excluded from tracking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotPrometheus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metrics := newRequestMetrics()
|
||||
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 50*time.Millisecond)
|
||||
metrics.observe(http.MethodGet, "/v1/tasks", http.StatusNotFound, 25*time.Millisecond)
|
||||
metrics.observe(http.MethodGet, `/v1/quoted"path`, http.StatusOK, 35*time.Millisecond)
|
||||
|
||||
output := metrics.snapshotPrometheus()
|
||||
expectedFragments := []string{
|
||||
"productier_http_uptime_seconds",
|
||||
`productier_http_requests_total{status_class="2xx"} 2`,
|
||||
`productier_http_requests_total{status_class="4xx"} 1`,
|
||||
`productier_http_requests_route_total{method="GET",path="/v1/health",status="200"} 1`,
|
||||
`productier_http_request_latency_avg_ms{method="GET",path="/v1/health",status="200"} 50.000`,
|
||||
`productier_http_request_latency_max_ms{method="GET",path="/v1/tasks",status="404"} 25.000`,
|
||||
`path="/v1/quoted\"path"`,
|
||||
}
|
||||
for _, fragment := range expectedFragments {
|
||||
if !strings.Contains(output, fragment) {
|
||||
t.Fatalf("expected prometheus output to contain %q\noutput:\n%s", fragment, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItoa(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[int]string{
|
||||
0: "0",
|
||||
7: "7",
|
||||
42: "42",
|
||||
-10: "-10",
|
||||
2048: "2048",
|
||||
}
|
||||
|
||||
for input, want := range cases {
|
||||
if got := itoa(input); got != want {
|
||||
t.Fatalf("itoa(%d) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findRouteMetric(routes []routeMetricSnapshot, method, path string, status int) *routeMetricSnapshot {
|
||||
for _, route := range routes {
|
||||
if route.Method == method && route.Path == path && route.Status == status {
|
||||
result := route
|
||||
return &result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
// OAuth state for CSRF protection
|
||||
type oauthState struct {
|
||||
State string `json:"state"`
|
||||
Provider string `json:"provider"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
}
|
||||
|
||||
// In-memory state store (in production, use Redis or database)
|
||||
var oauthStates = make(map[string]oauthState)
|
||||
|
||||
func (s *Server) registerOAuthRoutes(group *gin.RouterGroup) {
|
||||
// Google Calendar OAuth
|
||||
group.GET("/oauth/google-calendar/connect", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
state := uuid.NewString()
|
||||
redirectURL := c.Query("redirect")
|
||||
if redirectURL == "" {
|
||||
redirectURL = fmt.Sprintf("/app/%s/integrations", workspaceSlug)
|
||||
}
|
||||
|
||||
oauthStates[state] = oauthState{
|
||||
State: state,
|
||||
Provider: "google_calendar",
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
RedirectURL: redirectURL,
|
||||
}
|
||||
|
||||
// Build Google OAuth URL
|
||||
// In production, use actual OAuth credentials from config
|
||||
authURL := fmt.Sprintf(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s&access_type=offline&prompt=consent",
|
||||
url.QueryEscape(s.config.GoogleClientID),
|
||||
url.QueryEscape(s.config.GoogleRedirectURI),
|
||||
url.QueryEscape("https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly"),
|
||||
state,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"authUrl": authURL})
|
||||
})
|
||||
|
||||
group.GET("/oauth/google-calendar/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
|
||||
oauthState, exists := oauthStates[state]
|
||||
if !exists || oauthState.Provider != "google_calendar" {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
delete(oauthStates, state)
|
||||
|
||||
// Exchange code for tokens
|
||||
// In production, make actual HTTP request to Google's token endpoint
|
||||
// For now, we'll create a placeholder integration
|
||||
integration := s.store.CreateIntegration(store.CreateIntegrationInput{
|
||||
WorkspaceSlug: oauthState.WorkspaceSlug,
|
||||
Provider: "google_calendar",
|
||||
Name: "Google Calendar",
|
||||
Config: `{"calendar_id": "primary"}`,
|
||||
Credentials: code, // In production, store actual tokens
|
||||
})
|
||||
|
||||
// Redirect back to the app
|
||||
c.Redirect(http.StatusTemporaryRedirect, oauthState.RedirectURL+"?connected=google_calendar")
|
||||
})
|
||||
|
||||
// Slack OAuth
|
||||
group.GET("/oauth/slack/connect", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
state := uuid.NewString()
|
||||
redirectURL := c.Query("redirect")
|
||||
if redirectURL == "" {
|
||||
redirectURL = fmt.Sprintf("/app/%s/integrations", workspaceSlug)
|
||||
}
|
||||
|
||||
oauthStates[state] = oauthState{
|
||||
State: state,
|
||||
Provider: "slack",
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
RedirectURL: redirectURL,
|
||||
}
|
||||
|
||||
// Build Slack OAuth URL
|
||||
scopes := "chat:write,channels:read,groups:read,im:read"
|
||||
authURL := fmt.Sprintf(
|
||||
"https://slack.com/oauth/v2/authorize?client_id=%s&scope=%s&redirect_uri=%s&state=%s",
|
||||
url.QueryEscape(s.config.SlackClientID),
|
||||
url.QueryEscape(scopes),
|
||||
url.QueryEscape(s.config.SlackRedirectURI),
|
||||
state,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"authUrl": authURL})
|
||||
})
|
||||
|
||||
group.GET("/oauth/slack/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
|
||||
oauthState, exists := oauthStates[state]
|
||||
if !exists || oauthState.Provider != "slack" {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
delete(oauthStates, state)
|
||||
|
||||
// Exchange code for tokens
|
||||
// In production, make actual HTTP request to Slack's token endpoint
|
||||
// For now, we'll create a placeholder integration
|
||||
integration := s.store.CreateIntegration(store.CreateIntegrationInput{
|
||||
WorkspaceSlug: oauthState.WorkspaceSlug,
|
||||
Provider: "slack",
|
||||
Name: "Slack",
|
||||
Config: `{"channel": "general"}`,
|
||||
Credentials: code, // In production, store actual tokens
|
||||
})
|
||||
|
||||
// Redirect back to the app
|
||||
c.Redirect(http.StatusTemporaryRedirect, oauthState.RedirectURL+"?connected=slack&integration_id="+integration.ID)
|
||||
})
|
||||
|
||||
// Disconnect integration
|
||||
group.POST("/integrations/:integrationId/disconnect", func(c *gin.Context) {
|
||||
integration, err := s.store.GetIntegrationByID(c.Param("integrationId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, integration.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// In production, revoke OAuth tokens with the provider
|
||||
// For Google: https://oauth2.googleapis.com/revoke?token=...
|
||||
// For Slack: https://slack.com/api/auth.revoke?token=...
|
||||
|
||||
if err := s.store.DeleteIntegration(integration.ID); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
|
||||
// SyncGoogleCalendar syncs events with Google Calendar
|
||||
func (s *Server) SyncGoogleCalendar(workspaceSlug string) error {
|
||||
integrations := s.store.ListIntegrations(workspaceSlug)
|
||||
for _, integration := range integrations {
|
||||
if integration.Provider == "google_calendar" && integration.Status == "active" {
|
||||
// In production, use the stored credentials to:
|
||||
// 1. Fetch events from Google Calendar
|
||||
// 2. Create/update events in our database
|
||||
// 3. Push local events to Google Calendar
|
||||
// This would be done via the Google Calendar API client
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendSlackNotification sends a notification to Slack
|
||||
func (s *Server) SendSlackNotification(workspaceSlug, channel, message string) error {
|
||||
integrations := s.store.ListIntegrations(workspaceSlug)
|
||||
for _, integration := range integrations {
|
||||
if integration.Provider == "slack" && integration.Status == "active" {
|
||||
// Parse config to get channel
|
||||
var config struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(integration.Config), &config); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// In production, use the stored credentials to:
|
||||
// 1. Post message to Slack channel via webhook or API
|
||||
// This would be done via the Slack API client
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to parse JSON config
|
||||
func parseConfig(configStr string) map[string]interface{} {
|
||||
var config map[string]interface{}
|
||||
if err := json.NewDecoder(strings.NewReader(configStr)).Decode(&config); err != nil {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerProductivityRoutes(group *gin.RouterGroup) {
|
||||
// Inbox
|
||||
group.GET("/inbox", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListInboxItems(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/inbox", func(c *gin.Context) {
|
||||
var input store.CreateInboxItemInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
item := s.store.CreateInboxItem(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
})
|
||||
|
||||
group.POST("/inbox/:itemId/process", func(c *gin.Context) {
|
||||
var input struct {
|
||||
EntityType string `json:"entityType" binding:"required"`
|
||||
EntityID string `json:"entityId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.store.ProcessInboxItem(c.Param("itemId"), input.EntityType, input.EntityID); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
group.DELETE("/inbox/:itemId", func(c *gin.Context) {
|
||||
if err := s.store.DeleteInboxItem(c.Param("itemId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Time entries
|
||||
group.GET("/time-entries", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListTimeEntries(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/time-entries", func(c *gin.Context) {
|
||||
var input store.CreateTimeEntryInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
entry := s.store.CreateTimeEntry(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": entry})
|
||||
})
|
||||
|
||||
group.PATCH("/time-entries/:entryId", func(c *gin.Context) {
|
||||
var input store.UpdateTimeEntryInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
updated, err := s.store.UpdateTimeEntry(c.Param("entryId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
group.DELETE("/time-entries/:entryId", func(c *gin.Context) {
|
||||
if err := s.store.DeleteTimeEntry(c.Param("entryId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Saved views
|
||||
group.GET("/saved-views", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
entityType := c.Query("entityType")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListSavedViews(workspaceSlug, entityType)})
|
||||
})
|
||||
|
||||
group.POST("/saved-views", func(c *gin.Context) {
|
||||
var input store.CreateSavedViewInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
view := s.store.CreateSavedView(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": view})
|
||||
})
|
||||
|
||||
group.DELETE("/saved-views/:viewId", func(c *gin.Context) {
|
||||
if err := s.store.DeleteSavedView(c.Param("viewId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/authsession"
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
const sessionContextKey = "sessionUser"
|
||||
|
||||
func (s *Server) registerRoutes() {
|
||||
v1 := s.engine.Group("/v1")
|
||||
{
|
||||
v1.GET("/health", func(c *gin.Context) {
|
||||
now := time.Now().UTC()
|
||||
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
storageStatus := gin.H{
|
||||
"provider": s.files.Provider(),
|
||||
"ok": true,
|
||||
}
|
||||
if err := s.files.Probe(probeCtx); err != nil {
|
||||
storageStatus["ok"] = false
|
||||
storageStatus["error"] = err.Error()
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"ok": false,
|
||||
"mode": s.mode,
|
||||
"timestamp": now,
|
||||
"storage": storageStatus,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"mode": s.mode,
|
||||
"timestamp": now,
|
||||
"storage": storageStatus,
|
||||
})
|
||||
})
|
||||
|
||||
v1.GET("/metrics", func(c *gin.Context) {
|
||||
if !s.authorizeMetricsRequest(c) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.metrics.snapshot())
|
||||
})
|
||||
|
||||
v1.GET("/metrics/prometheus", func(c *gin.Context) {
|
||||
if !s.authorizeMetricsRequest(c) {
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(s.metrics.snapshotPrometheus()))
|
||||
})
|
||||
|
||||
v1.GET("/invites/:token", func(c *gin.Context) {
|
||||
invite, err := s.store.GetInviteByToken(c.Param("token"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": invite})
|
||||
})
|
||||
}
|
||||
|
||||
authorized := v1.Group("/")
|
||||
authorized.Use(s.requireSession())
|
||||
{
|
||||
authorized.GET("/workspaces", func(c *gin.Context) {
|
||||
user := s.sessionUser(c)
|
||||
workspaces := s.store.ListWorkspaces()
|
||||
visible := make([]store.Workspace, 0, len(workspaces))
|
||||
for _, workspace := range workspaces {
|
||||
if _, ok := s.requireWorkspaceMemberByEmail(workspace.Slug, user.Email); ok {
|
||||
visible = append(visible, workspace)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": visible})
|
||||
})
|
||||
|
||||
authorized.GET("/members", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMembers(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/members/:memberId", func(c *gin.Context) {
|
||||
member, err := s.store.GetMemberByID(c.Param("memberId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
actingMember, ok := s.requireWorkspaceMember(c, member.WorkspaceSlug)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if actingMember.Role != "owner" && actingMember.Role != "admin" {
|
||||
s.writeStatusError(c, http.StatusForbidden, "member management permissions required")
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateMemberInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateMember(member.ID, input)
|
||||
if err != nil {
|
||||
switch err.Error() {
|
||||
case "invalid member role", "invalid member status":
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
case "workspace must have at least one active owner":
|
||||
s.writeStatusError(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/invites", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListInvites(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/invites", func(c *gin.Context) {
|
||||
var input store.CreateInviteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
member, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if member.Role != "owner" && member.Role != "admin" {
|
||||
s.writeStatusError(c, http.StatusForbidden, "invite permissions required")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateInvite(input)})
|
||||
})
|
||||
|
||||
authorized.POST("/invites/:token/revoke", func(c *gin.Context) {
|
||||
invite, err := s.store.GetInviteByID(c.Param("token"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
member, ok := s.requireWorkspaceMember(c, invite.WorkspaceSlug)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if member.Role != "owner" && member.Role != "admin" {
|
||||
s.writeStatusError(c, http.StatusForbidden, "invite permissions required")
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeInvite(invite.ID); err != nil {
|
||||
switch err.Error() {
|
||||
case "only pending invites can be revoked":
|
||||
s.writeStatusError(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
authorized.POST("/invites/:token/accept", func(c *gin.Context) {
|
||||
var input store.AcceptInviteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user := s.sessionUser(c)
|
||||
invite, err := s.store.AcceptInvite(c.Param("token"), store.AcceptInviteInput{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": invite})
|
||||
})
|
||||
|
||||
authorized.GET("/activity", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if strings.TrimSpace(workspaceSlug) == "" {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "workspaceSlug is required")
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
params, err := parseActivityListParams(c.Query("limit"), c.Query("type"), c.Query("q"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
activities := s.store.ListActivities(workspaceSlug)
|
||||
c.JSON(http.StatusOK, gin.H{"data": filterActivityEntries(activities, params)})
|
||||
})
|
||||
|
||||
authorized.GET("/board-groups", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListBoardGroups(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/board-groups", func(c *gin.Context) {
|
||||
var input store.CreateBoardGroupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateBoardGroup(input)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/board-groups/:groupId", func(c *gin.Context) {
|
||||
group, err := s.store.GetBoardGroupByID(c.Param("groupId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, group.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateBoardGroupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateBoardGroup(c.Param("groupId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/labels", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListLabels(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/labels", func(c *gin.Context) {
|
||||
var input store.CreateLabelInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateLabel(input)})
|
||||
})
|
||||
|
||||
authorized.GET("/tasks", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListTasks(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/tasks", func(c *gin.Context) {
|
||||
var input store.CreateTaskInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
task := s.store.CreateTask(input)
|
||||
|
||||
// Trigger webhooks for task creation
|
||||
s.store.TriggerWebhooks(input.WorkspaceSlug, "task.created", map[string]interface{}{
|
||||
"taskId": task.ID,
|
||||
"title": task.Title,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": task})
|
||||
})
|
||||
|
||||
authorized.PATCH("/tasks/:taskId", func(c *gin.Context) {
|
||||
task, err := s.store.GetTaskByID(c.Param("taskId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateTaskInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if assignee is being set (task assignment notification)
|
||||
if input.AssigneeID != nil && *input.AssigneeID != "" {
|
||||
// Get assignee email from member ID
|
||||
members := s.store.ListMembers(task.WorkspaceSlug)
|
||||
for _, member := range members {
|
||||
if member.ID == *input.AssigneeID && member.Status == "active" {
|
||||
// Create notification for the assignee
|
||||
s.store.CreateNotificationForTaskAssignment(
|
||||
task.WorkspaceSlug,
|
||||
member.Email,
|
||||
task.Title,
|
||||
task.ID,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if status is being changed to done (task completion notification)
|
||||
if input.Status != nil && *input.Status == "done" && task.Status != "done" && task.AssigneeID != nil {
|
||||
// Notify the task creator or workspace owner
|
||||
members := s.store.ListMembers(task.WorkspaceSlug)
|
||||
for _, member := range members {
|
||||
if member.Role == "owner" || member.Role == "admin" {
|
||||
s.store.CreateNotificationForTaskCompletion(
|
||||
task.WorkspaceSlug,
|
||||
member.Email,
|
||||
task.Title,
|
||||
task.ID,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateTask(c.Param("taskId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger webhooks for task updates
|
||||
s.store.TriggerWebhooks(task.WorkspaceSlug, "task.updated", map[string]interface{}{
|
||||
"taskId": task.ID,
|
||||
"title": task.Title,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/calendar/events", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListEvents(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/calendar/events", func(c *gin.Context) {
|
||||
var input store.CreateEventInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateEvent(input)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/calendar/events/:eventId", func(c *gin.Context) {
|
||||
event, err := s.store.GetEventByID(c.Param("eventId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, event.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateEventInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateEvent(c.Param("eventId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/notes", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListNotes(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/notes", func(c *gin.Context) {
|
||||
var input store.CreateNoteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateNote(input)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/notes/:noteId", func(c *gin.Context) {
|
||||
note, err := s.store.GetNoteByID(c.Param("noteId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, note.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateNoteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateNote(c.Param("noteId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/focus/sessions", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListFocusSessions(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/focus/sessions", func(c *gin.Context) {
|
||||
var input store.CreateFocusSessionInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateFocusSession(input)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/focus/sessions/:sessionId", func(c *gin.Context) {
|
||||
session, err := s.store.GetFocusSessionByID(c.Param("sessionId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, session.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateFocusSessionInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateFocusSession(c.Param("sessionId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
s.registerTaskAttachmentRoutes(authorized)
|
||||
s.registerMailRoutes(authorized)
|
||||
s.registerCRMRoutes(authorized)
|
||||
s.registerProductivityRoutes(authorized)
|
||||
s.registerIntegrationRoutes(authorized)
|
||||
s.registerOAuthRoutes(authorized)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) requireSession() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, err := s.authClient.GetUser(c.Request.Context(), c.GetHeader("Cookie"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "session lookup failed")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(sessionContextKey, user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sessionUser(c *gin.Context) *authsession.User {
|
||||
value, exists := c.Get(sessionContextKey)
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
user, _ := value.(*authsession.User)
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *Server) requireWorkspaceMember(c *gin.Context, workspaceSlug string) (store.Member, bool) {
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return store.Member{}, false
|
||||
}
|
||||
member, ok := s.requireWorkspaceMemberByEmail(workspaceSlug, user.Email)
|
||||
if !ok {
|
||||
s.writeStatusError(c, http.StatusForbidden, "workspace membership required")
|
||||
return store.Member{}, false
|
||||
}
|
||||
return member, true
|
||||
}
|
||||
|
||||
func (s *Server) requireWorkspaceMemberByEmail(workspaceSlug string, email string) (store.Member, bool) {
|
||||
for _, member := range s.store.ListMembers(workspaceSlug) {
|
||||
if strings.EqualFold(member.Email, email) && member.Status == "active" {
|
||||
return member, true
|
||||
}
|
||||
}
|
||||
return store.Member{}, false
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"productier/apps/backend/internal/authsession"
|
||||
"productier/apps/backend/internal/filestorage"
|
||||
"productier/apps/backend/internal/mailruntime"
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func getEnvOrDefault(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GoogleRedirectURI string
|
||||
SlackClientID string
|
||||
SlackClientSecret string
|
||||
SlackRedirectURI string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
engine *gin.Engine
|
||||
mode string
|
||||
store store.Store
|
||||
authClient *authsession.Client
|
||||
mail *mailruntime.Service
|
||||
files filestorage.Storage
|
||||
metrics *requestMetrics
|
||||
metricsToken string
|
||||
config OAuthConfig
|
||||
}
|
||||
|
||||
func NewServer(
|
||||
dataStore store.Store,
|
||||
authClient *authsession.Client,
|
||||
mailService *mailruntime.Service,
|
||||
fileStorage filestorage.Storage,
|
||||
mode string,
|
||||
corsAllowOrigins []string,
|
||||
metricsToken string,
|
||||
logger *zap.Logger,
|
||||
) *Server {
|
||||
engine := gin.New()
|
||||
metrics := newRequestMetrics()
|
||||
engine.Use(gin.Recovery())
|
||||
engine.Use(requestMetricsMiddleware(metrics))
|
||||
engine.Use(requestLogMiddleware(logger))
|
||||
engine.Use(requestIDMiddleware())
|
||||
engine.Use(cors.New(cors.Config{
|
||||
AllowOrigins: corsAllowOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodOptions},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "Cookie"},
|
||||
ExposeHeaders: []string{"Content-Length", "X-Request-Id"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
server := &Server{
|
||||
engine: engine,
|
||||
mode: mode,
|
||||
store: dataStore,
|
||||
authClient: authClient,
|
||||
mail: mailService,
|
||||
files: fileStorage,
|
||||
metrics: metrics,
|
||||
metricsToken: metricsToken,
|
||||
config: OAuthConfig{
|
||||
GoogleClientID: getEnvOrDefault("GOOGLE_CLIENT_ID", ""),
|
||||
GoogleClientSecret: getEnvOrDefault("GOOGLE_CLIENT_SECRET", ""),
|
||||
GoogleRedirectURI: getEnvOrDefault("GOOGLE_REDIRECT_URI", "http://localhost:8080/v1/oauth/google-calendar/callback"),
|
||||
SlackClientID: getEnvOrDefault("SLACK_CLIENT_ID", ""),
|
||||
SlackClientSecret: getEnvOrDefault("SLACK_CLIENT_SECRET", ""),
|
||||
SlackRedirectURI: getEnvOrDefault("SLACK_REDIRECT_URI", "http://localhost:8080/v1/oauth/slack/callback"),
|
||||
},
|
||||
}
|
||||
|
||||
server.registerRoutes()
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *Server) Engine() *gin.Engine {
|
||||
return s.engine
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"productier/apps/backend/internal/filestorage"
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
const maxTaskAttachmentBytes int64 = 20 << 20 // 20 MB
|
||||
|
||||
func (s *Server) registerTaskAttachmentRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/tasks/:taskId/attachments", func(c *gin.Context) {
|
||||
task, err := s.store.GetTaskByID(c.Param("taskId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
if file.Size <= 0 {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "file is empty")
|
||||
return
|
||||
}
|
||||
if file.Size > maxTaskAttachmentBytes {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "file exceeds 20MB limit")
|
||||
return
|
||||
}
|
||||
|
||||
attachmentID := uuid.NewString()
|
||||
objectKey := taskAttachmentObjectKey(task.ID, attachmentID)
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "unable to read uploaded file")
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
mimeType := file.Header.Get("Content-Type")
|
||||
if strings.TrimSpace(mimeType) == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
if err := s.files.Put(c.Request.Context(), objectKey, src, mimeType, file.Size); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "failed to store uploaded file")
|
||||
return
|
||||
}
|
||||
attachment := store.Attachment{
|
||||
ID: attachmentID,
|
||||
Name: cleanAttachmentName(file.Filename),
|
||||
MimeType: mimeType,
|
||||
Size: int(file.Size),
|
||||
DataURL: fmt.Sprintf("/v1/tasks/%s/attachments/%s/download", task.ID, attachmentID),
|
||||
}
|
||||
|
||||
attachments := append([]store.Attachment{attachment}, task.Attachments...)
|
||||
if _, err := s.store.UpdateTask(task.ID, store.UpdateTaskInput{Attachments: attachments}); err != nil {
|
||||
_ = s.files.Delete(c.Request.Context(), objectKey)
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "failed to save task attachment")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": attachment})
|
||||
})
|
||||
|
||||
group.GET("/tasks/:taskId/attachments/:attachmentId/download", func(c *gin.Context) {
|
||||
task, err := s.store.GetTaskByID(c.Param("taskId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
attachmentID := c.Param("attachmentId")
|
||||
var attachment *store.Attachment
|
||||
for index := range task.Attachments {
|
||||
if task.Attachments[index].ID == attachmentID {
|
||||
attachment = &task.Attachments[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if attachment == nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
|
||||
object, err := s.files.Get(c.Request.Context(), taskAttachmentObjectKey(task.ID, attachmentID))
|
||||
if err != nil {
|
||||
if err == filestorage.ErrNotFound {
|
||||
s.writeStatusError(c, http.StatusNotFound, "attachment file not found")
|
||||
return
|
||||
}
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "failed to read attachment file")
|
||||
return
|
||||
}
|
||||
defer object.Body.Close()
|
||||
|
||||
c.Header("Content-Type", firstNonBlank(object.ContentType, attachment.MimeType, "application/octet-stream"))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", cleanAttachmentName(attachment.Name)))
|
||||
c.Status(http.StatusOK)
|
||||
if _, err := io.Copy(c.Writer, object.Body); err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
group.DELETE("/tasks/:taskId/attachments/:attachmentId", func(c *gin.Context) {
|
||||
task, err := s.store.GetTaskByID(c.Param("taskId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
attachmentID := c.Param("attachmentId")
|
||||
index := -1
|
||||
for i := range task.Attachments {
|
||||
if task.Attachments[i].ID == attachmentID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
s.writeStatusError(c, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
|
||||
nextAttachments := make([]store.Attachment, 0, len(task.Attachments)-1)
|
||||
nextAttachments = append(nextAttachments, task.Attachments[:index]...)
|
||||
nextAttachments = append(nextAttachments, task.Attachments[index+1:]...)
|
||||
if _, err := s.store.UpdateTask(task.ID, store.UpdateTaskInput{Attachments: nextAttachments}); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "failed to update task attachments")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.files.Delete(c.Request.Context(), taskAttachmentObjectKey(task.ID, attachmentID)); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "attachment metadata updated but file cleanup failed")
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
func taskAttachmentObjectKey(taskID string, attachmentID string) string {
|
||||
return fmt.Sprintf("tasks/%s/%s", taskID, attachmentID)
|
||||
}
|
||||
|
||||
func cleanAttachmentName(name string) string {
|
||||
cleaned := strings.TrimSpace(filepath.Base(name))
|
||||
if cleaned == "" || cleaned == "." || cleaned == "/" {
|
||||
return "attachment"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
package mailruntime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
stdmail "net/mail"
|
||||
"net/smtp"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
imapclient "github.com/emersion/go-imap/client"
|
||||
"github.com/emersion/go-message"
|
||||
gomail "github.com/emersion/go-message/mail"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
var htmlTagPattern = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
type Service struct {
|
||||
store store.Store
|
||||
logger *zap.Logger
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
type ConnectMailboxInput struct {
|
||||
WorkspaceSlug string
|
||||
Label string
|
||||
Email string
|
||||
DisplayName string
|
||||
IMAPHost string
|
||||
IMAPPort int
|
||||
IMAPUsername string
|
||||
IMAPPassword string
|
||||
IMAPUseTLS bool
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPUseTLS bool
|
||||
}
|
||||
|
||||
type QueueOutgoingMailInput struct {
|
||||
WorkspaceSlug string
|
||||
MailboxID string
|
||||
To []store.MailAddress
|
||||
Cc []store.MailAddress
|
||||
Bcc []store.MailAddress
|
||||
Subject string
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
ScheduledFor *time.Time
|
||||
}
|
||||
|
||||
func New(dataStore store.Store, logger *zap.Logger, secretSeed string) (*Service, error) {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
aead, err := newAEAD(secretSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
store: dataStore,
|
||||
logger: logger,
|
||||
aead: aead,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context) {
|
||||
go s.runDueOutgoingLoop(ctx)
|
||||
go s.runMailboxSyncLoop(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) ConnectMailbox(ctx context.Context, input ConnectMailboxInput) (store.Mailbox, error) {
|
||||
input.normalize()
|
||||
if err := input.validate(); err != nil {
|
||||
return store.Mailbox{}, err
|
||||
}
|
||||
if err := s.verifyIMAP(ctx, input); err != nil {
|
||||
return store.Mailbox{}, fmt.Errorf("verify imap connection: %w", err)
|
||||
}
|
||||
if err := s.verifySMTP(input); err != nil {
|
||||
return store.Mailbox{}, fmt.Errorf("verify smtp connection: %w", err)
|
||||
}
|
||||
|
||||
imapCiphertext, err := s.encrypt(input.IMAPPassword)
|
||||
if err != nil {
|
||||
return store.Mailbox{}, err
|
||||
}
|
||||
smtpCiphertext, err := s.encrypt(input.SMTPPassword)
|
||||
if err != nil {
|
||||
return store.Mailbox{}, err
|
||||
}
|
||||
|
||||
mailbox, err := s.store.CreateMailbox(store.CreateMailboxRecordInput{
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Label: input.Label,
|
||||
Email: input.Email,
|
||||
DisplayName: input.DisplayName,
|
||||
IMAPHost: input.IMAPHost,
|
||||
IMAPPort: input.IMAPPort,
|
||||
IMAPUsername: input.IMAPUsername,
|
||||
IMAPPasswordCiphertext: imapCiphertext,
|
||||
IMAPUseTLS: input.IMAPUseTLS,
|
||||
SMTPHost: input.SMTPHost,
|
||||
SMTPPort: input.SMTPPort,
|
||||
SMTPUsername: input.SMTPUsername,
|
||||
SMTPPasswordCiphertext: smtpCiphertext,
|
||||
SMTPUseTLS: input.SMTPUseTLS,
|
||||
})
|
||||
if err != nil {
|
||||
return store.Mailbox{}, err
|
||||
}
|
||||
|
||||
if syncErr := s.SyncMailbox(ctx, mailbox.ID); syncErr != nil {
|
||||
s.logger.Warn("initial mailbox sync failed", zap.String("mailboxId", mailbox.ID), zap.Error(syncErr))
|
||||
}
|
||||
|
||||
return mailbox, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueueOutgoingMail(ctx context.Context, input QueueOutgoingMailInput) (store.OutgoingMail, error) {
|
||||
if input.WorkspaceSlug == "" || input.MailboxID == "" {
|
||||
return store.OutgoingMail{}, errors.New("workspace and mailbox are required")
|
||||
}
|
||||
if len(input.To) == 0 {
|
||||
return store.OutgoingMail{}, errors.New("at least one recipient is required")
|
||||
}
|
||||
|
||||
status := "queued"
|
||||
if input.ScheduledFor != nil && input.ScheduledFor.After(time.Now().UTC()) {
|
||||
status = "scheduled"
|
||||
}
|
||||
|
||||
item, err := s.store.CreateOutgoingMail(store.CreateOutgoingMailInput{
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
MailboxID: input.MailboxID,
|
||||
To: input.To,
|
||||
Cc: input.Cc,
|
||||
Bcc: input.Bcc,
|
||||
Subject: input.Subject,
|
||||
TextBody: input.TextBody,
|
||||
HTMLBody: input.HTMLBody,
|
||||
Status: status,
|
||||
ScheduledFor: input.ScheduledFor,
|
||||
})
|
||||
if err != nil {
|
||||
return store.OutgoingMail{}, err
|
||||
}
|
||||
|
||||
if status == "queued" {
|
||||
if err := s.SendOutgoingMail(ctx, item.ID); err != nil {
|
||||
updated, getErr := s.store.GetOutgoingMailByID(item.ID)
|
||||
if getErr == nil {
|
||||
return updated, err
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
return s.store.GetOutgoingMailByID(item.ID)
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) SendOutgoingMail(ctx context.Context, outgoingMailID string) error {
|
||||
item, err := s.store.GetOutgoingMailByID(outgoingMailID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
connection, err := s.mailboxConnection(item.MailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := sendSMTPMessage(connection, connection.SMTPPasswordCiphertext, item); err != nil {
|
||||
message := err.Error()
|
||||
_, _ = s.store.UpdateOutgoingMailStatus(outgoingMailID, store.UpdateOutgoingMailStatusInput{
|
||||
Status: "failed",
|
||||
Error: &message,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
empty := ""
|
||||
_, err = s.store.UpdateOutgoingMailStatus(outgoingMailID, store.UpdateOutgoingMailStatusInput{
|
||||
Status: "sent",
|
||||
SentAt: &now,
|
||||
Error: &empty,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) SyncMailbox(ctx context.Context, mailboxID string) error {
|
||||
if _, err := s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{SyncStatus: "syncing"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
connection, err := s.mailboxConnection(mailboxID)
|
||||
if err != nil {
|
||||
s.markMailboxError(mailboxID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
messages, err := fetchInboxMessages(connection, connection.IMAPPasswordCiphertext)
|
||||
if err != nil {
|
||||
s.markMailboxError(mailboxID, err)
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpsertMailMessages(mailboxID, messages); err != nil {
|
||||
s.markMailboxError(mailboxID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
empty := ""
|
||||
_, err = s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{
|
||||
SyncStatus: "ready",
|
||||
SyncError: &empty,
|
||||
LastSyncedAt: &now,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) runDueOutgoingLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
due := s.store.ListDueOutgoingMails(time.Now().UTC(), 20)
|
||||
for _, item := range due {
|
||||
if err := s.SendOutgoingMail(ctx, item.ID); err != nil {
|
||||
s.logger.Warn("send outgoing mail", zap.String("outgoingMailId", item.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) runMailboxSyncLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for _, mailbox := range s.store.ListAllMailboxes() {
|
||||
if err := s.SyncMailbox(ctx, mailbox.ID); err != nil {
|
||||
s.logger.Warn("sync mailbox", zap.String("mailboxId", mailbox.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) markMailboxError(mailboxID string, err error) {
|
||||
message := err.Error()
|
||||
_, updateErr := s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{
|
||||
SyncStatus: "error",
|
||||
SyncError: &message,
|
||||
})
|
||||
if updateErr != nil {
|
||||
s.logger.Warn("update mailbox sync error", zap.String("mailboxId", mailboxID), zap.Error(updateErr))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) mailboxConnection(mailboxID string) (store.MailboxConnection, error) {
|
||||
connection, err := s.store.GetMailboxConnection(mailboxID)
|
||||
if err != nil {
|
||||
return store.MailboxConnection{}, err
|
||||
}
|
||||
imapPassword, err := s.decrypt(connection.IMAPPasswordCiphertext)
|
||||
if err != nil {
|
||||
return store.MailboxConnection{}, err
|
||||
}
|
||||
smtpPassword, err := s.decrypt(connection.SMTPPasswordCiphertext)
|
||||
if err != nil {
|
||||
return store.MailboxConnection{}, err
|
||||
}
|
||||
connection.IMAPPasswordCiphertext = imapPassword
|
||||
connection.SMTPPasswordCiphertext = smtpPassword
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func newAEAD(secretSeed string) (cipher.AEAD, error) {
|
||||
if secretSeed == "" {
|
||||
secretSeed = "productier-local-mail-key"
|
||||
}
|
||||
|
||||
key, err := decodeSecretSeed(secretSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
func decodeSecretSeed(secretSeed string) ([]byte, error) {
|
||||
if raw, err := base64.StdEncoding.DecodeString(secretSeed); err == nil && len(raw) == 32 {
|
||||
return raw, nil
|
||||
}
|
||||
sum := sha256.Sum256([]byte(secretSeed))
|
||||
return sum[:], nil
|
||||
}
|
||||
|
||||
func (s *Service) encrypt(plain string) (string, error) {
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := s.aead.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func (s *Service) decrypt(ciphertext string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < s.aead.NonceSize() {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
nonce := raw[:s.aead.NonceSize()]
|
||||
payload := raw[s.aead.NonceSize():]
|
||||
plain, err := s.aead.Open(nil, nonce, payload, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func (input *ConnectMailboxInput) normalize() {
|
||||
input.WorkspaceSlug = strings.TrimSpace(input.WorkspaceSlug)
|
||||
input.Label = strings.TrimSpace(input.Label)
|
||||
input.Email = strings.TrimSpace(strings.ToLower(input.Email))
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.IMAPHost = strings.TrimSpace(input.IMAPHost)
|
||||
input.SMTPHost = strings.TrimSpace(input.SMTPHost)
|
||||
if input.IMAPPort == 0 {
|
||||
input.IMAPPort = 993
|
||||
}
|
||||
if input.SMTPPort == 0 {
|
||||
input.SMTPPort = 587
|
||||
}
|
||||
if input.IMAPUsername == "" {
|
||||
input.IMAPUsername = input.Email
|
||||
}
|
||||
if input.SMTPUsername == "" {
|
||||
input.SMTPUsername = input.IMAPUsername
|
||||
}
|
||||
if input.SMTPPassword == "" {
|
||||
input.SMTPPassword = input.IMAPPassword
|
||||
}
|
||||
if input.Label == "" {
|
||||
input.Label = input.Email
|
||||
}
|
||||
}
|
||||
|
||||
func (input ConnectMailboxInput) validate() error {
|
||||
if input.WorkspaceSlug == "" || input.Email == "" {
|
||||
return errors.New("workspace and email are required")
|
||||
}
|
||||
if input.IMAPHost == "" || input.SMTPHost == "" {
|
||||
return errors.New("imap and smtp hosts are required")
|
||||
}
|
||||
if input.IMAPUsername == "" || input.IMAPPassword == "" {
|
||||
return errors.New("imap credentials are required")
|
||||
}
|
||||
if input.SMTPUsername == "" || input.SMTPPassword == "" {
|
||||
return errors.New("smtp credentials are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) verifyIMAP(ctx context.Context, input ConnectMailboxInput) error {
|
||||
client, err := dialAndLoginIMAP(input.IMAPHost, input.IMAPPort, input.IMAPUseTLS, input.IMAPUsername, input.IMAPPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Logout()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
_, err = client.Select("INBOX", true)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) verifySMTP(input ConnectMailboxInput) error {
|
||||
client, err := dialSMTPClient(input.SMTPHost, input.SMTPPort, input.SMTPUseTLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = client.Quit()
|
||||
_ = client.Close()
|
||||
}()
|
||||
|
||||
return smtpAuthenticate(client, input.SMTPHost, input.SMTPUsername, input.SMTPPassword)
|
||||
}
|
||||
|
||||
func fetchInboxMessages(connection store.MailboxConnection, password string) ([]store.InboundMailMessage, error) {
|
||||
client, err := dialAndLoginIMAP(connection.IMAPHost, connection.IMAPPort, connection.IMAPUseTLS, connection.IMAPUsername, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Logout()
|
||||
|
||||
mbox, err := client.Select("INBOX", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mbox.Messages == 0 {
|
||||
return []store.InboundMailMessage{}, nil
|
||||
}
|
||||
|
||||
uids, err := client.UidSearch(&imap.SearchCriteria{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(uids) == 0 {
|
||||
return []store.InboundMailMessage{}, nil
|
||||
}
|
||||
sort.Slice(uids, func(i int, j int) bool { return uids[i] < uids[j] })
|
||||
if len(uids) > 50 {
|
||||
uids = uids[len(uids)-50:]
|
||||
}
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(uids...)
|
||||
|
||||
section := &imap.BodySectionName{}
|
||||
items := []imap.FetchItem{imap.FetchUid, imap.FetchFlags, imap.FetchEnvelope, section.FetchItem()}
|
||||
ch := make(chan *imap.Message, len(uids))
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- client.UidFetch(seqset, items, ch)
|
||||
}()
|
||||
|
||||
result := make([]store.InboundMailMessage, 0, len(uids))
|
||||
for msg := range ch {
|
||||
parsed, err := parseIMAPMessage(connection.WorkspaceSlug, connection.ID, section, msg)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, parsed)
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(result, func(i int, j int) bool { return result[i].ReceivedAt.After(result[j].ReceivedAt) })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseIMAPMessage(workspaceSlug string, mailboxID string, section *imap.BodySectionName, msg *imap.Message) (store.InboundMailMessage, error) {
|
||||
if msg == nil {
|
||||
return store.InboundMailMessage{}, errors.New("nil message")
|
||||
}
|
||||
|
||||
receivedAt := time.Now().UTC()
|
||||
if msg.Envelope != nil && !msg.Envelope.Date.IsZero() {
|
||||
receivedAt = msg.Envelope.Date.UTC()
|
||||
}
|
||||
|
||||
parsed := store.InboundMailMessage{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
MailboxID: mailboxID,
|
||||
RemoteUID: int64(msg.Uid),
|
||||
Folder: "INBOX",
|
||||
ReceivedAt: receivedAt,
|
||||
IsRead: hasFlag(msg.Flags, imap.SeenFlag),
|
||||
From: addressFromEnvelope(msg.Envelope),
|
||||
To: addressesFromEnvelope(msg.Envelope, "to"),
|
||||
Cc: addressesFromEnvelope(msg.Envelope, "cc"),
|
||||
}
|
||||
|
||||
if msg.Envelope != nil {
|
||||
parsed.Subject = msg.Envelope.Subject
|
||||
}
|
||||
|
||||
body := msg.GetBody(section)
|
||||
if body == nil {
|
||||
parsed.Snippet = truncatePlaintext(parsed.Subject, 180)
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
reader, err := gomail.CreateReader(body)
|
||||
if err != nil && !message.IsUnknownCharset(err) {
|
||||
return store.InboundMailMessage{}, err
|
||||
}
|
||||
|
||||
if headerMessageID, headerErr := reader.Header.MessageID(); headerErr == nil {
|
||||
parsed.MessageID = headerMessageID
|
||||
}
|
||||
if subject, headerErr := reader.Header.Subject(); headerErr == nil && subject != "" {
|
||||
parsed.Subject = subject
|
||||
}
|
||||
if from, headerErr := reader.Header.AddressList("From"); headerErr == nil && len(from) > 0 {
|
||||
parsed.From = mailAddress(from[0])
|
||||
}
|
||||
if to, headerErr := reader.Header.AddressList("To"); headerErr == nil {
|
||||
parsed.To = toStoreAddresses(to)
|
||||
}
|
||||
if cc, headerErr := reader.Header.AddressList("Cc"); headerErr == nil {
|
||||
parsed.Cc = toStoreAddresses(cc)
|
||||
}
|
||||
if date, headerErr := reader.Header.Date(); headerErr == nil && !date.IsZero() {
|
||||
parsed.ReceivedAt = date.UTC()
|
||||
}
|
||||
|
||||
for {
|
||||
part, partErr := reader.NextPart()
|
||||
if errors.Is(partErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if partErr != nil && !message.IsUnknownCharset(partErr) {
|
||||
return store.InboundMailMessage{}, partErr
|
||||
}
|
||||
if part == nil {
|
||||
break
|
||||
}
|
||||
|
||||
contentType := ""
|
||||
switch header := part.Header.(type) {
|
||||
case *gomail.InlineHeader:
|
||||
contentType = header.Get("Content-Type")
|
||||
default:
|
||||
contentType = part.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
payload, readErr := io.ReadAll(io.LimitReader(part.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return store.InboundMailMessage{}, readErr
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(strings.ToLower(contentType), "text/plain"):
|
||||
if parsed.TextBody == "" {
|
||||
parsed.TextBody = string(payload)
|
||||
}
|
||||
case strings.HasPrefix(strings.ToLower(contentType), "text/html"):
|
||||
if parsed.HTMLBody == "" {
|
||||
parsed.HTMLBody = string(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parsed.TextBody == "" && parsed.HTMLBody != "" {
|
||||
parsed.TextBody = htmlToText(parsed.HTMLBody)
|
||||
}
|
||||
parsed.Snippet = truncatePlaintext(firstNonEmpty(parsed.TextBody, parsed.Subject), 240)
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func sendSMTPMessage(connection store.MailboxConnection, password string, outgoing store.OutgoingMail) error {
|
||||
messageBytes, err := buildOutgoingMessage(connection, outgoing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := dialSMTPClient(connection.SMTPHost, connection.SMTPPort, connection.SMTPUseTLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = client.Quit()
|
||||
_ = client.Close()
|
||||
}()
|
||||
|
||||
if err := smtpAuthenticate(client, connection.SMTPHost, connection.SMTPUsername, password); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Mail(connection.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range uniqueRecipients(outgoing) {
|
||||
if err := client.Rcpt(recipient); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(messageBytes); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
return writer.Close()
|
||||
}
|
||||
|
||||
func buildOutgoingMessage(connection store.MailboxConnection, outgoing store.OutgoingMail) ([]byte, error) {
|
||||
var (
|
||||
header gomail.Header
|
||||
buffer bytes.Buffer
|
||||
)
|
||||
|
||||
fromName := strings.TrimSpace(connection.DisplayName)
|
||||
header.SetAddressList("From", []*gomail.Address{{Name: fromName, Address: connection.Email}})
|
||||
header.SetAddressList("To", toMailAddresses(outgoing.To))
|
||||
header.SetAddressList("Cc", toMailAddresses(outgoing.Cc))
|
||||
header.SetAddressList("Bcc", toMailAddresses(outgoing.Bcc))
|
||||
header.SetDate(time.Now().UTC())
|
||||
header.SetSubject(firstNonEmpty(outgoing.Subject, "(no subject)"))
|
||||
_ = header.GenerateMessageIDWithHostname(sanitizeHostname(connection.SMTPHost))
|
||||
|
||||
switch {
|
||||
case outgoing.TextBody != "" && outgoing.HTMLBody != "":
|
||||
writer, err := gomail.CreateInlineWriter(&buffer, header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var textHeader gomail.InlineHeader
|
||||
textHeader.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
|
||||
textPart, err := writer.CreatePart(textHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.WriteString(textPart, outgoing.TextBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := textPart.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var htmlHeader gomail.InlineHeader
|
||||
htmlHeader.SetContentType("text/html", map[string]string{"charset": "utf-8"})
|
||||
htmlPart, err := writer.CreatePart(htmlHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.WriteString(htmlPart, outgoing.HTMLBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := htmlPart.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case outgoing.HTMLBody != "":
|
||||
header.SetContentType("text/html", map[string]string{"charset": "utf-8"})
|
||||
writer, err := gomail.CreateSingleInlineWriter(&buffer, header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.WriteString(writer, outgoing.HTMLBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
header.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
|
||||
writer, err := gomail.CreateSingleInlineWriter(&buffer, header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.WriteString(writer, outgoing.TextBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func dialAndLoginIMAP(host string, port int, useTLS bool, username string, password string) (*imapclient.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
var (
|
||||
client *imapclient.Client
|
||||
err error
|
||||
)
|
||||
if useTLS {
|
||||
client, err = imapclient.DialTLS(addr, &tls.Config{ServerName: host})
|
||||
} else {
|
||||
client, err = imapclient.Dial(addr)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.Login(username, password); err != nil {
|
||||
_ = client.Logout()
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func dialSMTPClient(host string, port int, useTLS bool) (*smtp.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
if useTLS && port == 465 {
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return smtp.NewClient(conn, host)
|
||||
}
|
||||
|
||||
client, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if useTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func smtpAuthenticate(client *smtp.Client, host string, username string, password string) error {
|
||||
if username == "" || password == "" {
|
||||
return nil
|
||||
}
|
||||
if ok, _ := client.Extension("AUTH"); !ok {
|
||||
return nil
|
||||
}
|
||||
return client.Auth(smtp.PlainAuth("", username, password, host))
|
||||
}
|
||||
|
||||
func hasFlag(flags []string, target string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addressFromEnvelope(envelope *imap.Envelope) store.MailAddress {
|
||||
if envelope == nil || len(envelope.From) == 0 {
|
||||
return store.MailAddress{}
|
||||
}
|
||||
address := envelope.From[0]
|
||||
return store.MailAddress{
|
||||
Name: address.PersonalName,
|
||||
Email: strings.Trim(strings.Join([]string{address.MailboxName, address.HostName}, "@"), "@"),
|
||||
}
|
||||
}
|
||||
|
||||
func addressesFromEnvelope(envelope *imap.Envelope, field string) []store.MailAddress {
|
||||
if envelope == nil {
|
||||
return []store.MailAddress{}
|
||||
}
|
||||
var source []*imap.Address
|
||||
switch field {
|
||||
case "to":
|
||||
source = envelope.To
|
||||
case "cc":
|
||||
source = envelope.Cc
|
||||
}
|
||||
items := make([]store.MailAddress, 0, len(source))
|
||||
for _, address := range source {
|
||||
items = append(items, store.MailAddress{
|
||||
Name: address.PersonalName,
|
||||
Email: strings.Trim(strings.Join([]string{address.MailboxName, address.HostName}, "@"), "@"),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func toStoreAddresses(addrs []*gomail.Address) []store.MailAddress {
|
||||
items := make([]store.MailAddress, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
items = append(items, store.MailAddress{Name: addr.Name, Email: addr.Address})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func toMailAddresses(addrs []store.MailAddress) []*gomail.Address {
|
||||
items := make([]*gomail.Address, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
if addr.Email == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, &gomail.Address{Name: addr.Name, Address: addr.Email})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func uniqueRecipients(outgoing store.OutgoingMail) []string {
|
||||
set := make(map[string]struct{})
|
||||
items := make([]string, 0, len(outgoing.To)+len(outgoing.Cc)+len(outgoing.Bcc))
|
||||
for _, group := range [][]store.MailAddress{outgoing.To, outgoing.Cc, outgoing.Bcc} {
|
||||
for _, addr := range group {
|
||||
email := strings.TrimSpace(strings.ToLower(addr.Email))
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := set[email]; exists {
|
||||
continue
|
||||
}
|
||||
set[email] = struct{}{}
|
||||
items = append(items, email)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func htmlToText(value string) string {
|
||||
return strings.TrimSpace(htmlTagPattern.ReplaceAllString(value, " "))
|
||||
}
|
||||
|
||||
func truncatePlaintext(value string, limit int) string {
|
||||
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(value[:limit]) + "…"
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeHostname(host string) string {
|
||||
parsedHost := strings.TrimSpace(host)
|
||||
if parsedHost == "" {
|
||||
return "localhost"
|
||||
}
|
||||
if strings.Contains(parsedHost, ":") {
|
||||
if h, _, err := strings.Cut(parsedHost, ":"); err && h != "" {
|
||||
return h
|
||||
}
|
||||
}
|
||||
return parsedHost
|
||||
}
|
||||
|
||||
func mailAddress(addr *stdmail.Address) store.MailAddress {
|
||||
if addr == nil {
|
||||
return store.MailAddress{}
|
||||
}
|
||||
return store.MailAddress{Name: addr.Name, Email: addr.Address}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package mailruntime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
service, err := New(store.NewSeededState("test"), nil, "mailruntime-test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
ciphertext, err := service.encrypt("super-secret-password")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt() error = %v", err)
|
||||
}
|
||||
|
||||
plain, err := service.decrypt(ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt() error = %v", err)
|
||||
}
|
||||
|
||||
if plain != "super-secret-password" {
|
||||
t.Fatalf("decrypt() = %q, want %q", plain, "super-secret-password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutgoingMessageIncludesHeaders(t *testing.T) {
|
||||
messageBytes, err := buildOutgoingMessage(store.MailboxConnection{
|
||||
Mailbox: store.Mailbox{
|
||||
Email: "sender@example.com",
|
||||
DisplayName: "Sender",
|
||||
SMTPHost: "smtp.example.com",
|
||||
},
|
||||
}, store.OutgoingMail{
|
||||
To: []store.MailAddress{
|
||||
{Name: "Recipient", Email: "recipient@example.com"},
|
||||
},
|
||||
Subject: "Quarterly Update",
|
||||
TextBody: "Plain body",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildOutgoingMessage() error = %v", err)
|
||||
}
|
||||
|
||||
message := string(messageBytes)
|
||||
for _, fragment := range []string{
|
||||
"Subject: Quarterly Update",
|
||||
`From: "Sender" <sender@example.com>`,
|
||||
`To: "Recipient" <recipient@example.com>`,
|
||||
"Plain body",
|
||||
} {
|
||||
if !strings.Contains(message, fragment) {
|
||||
t.Fatalf("built message missing %q\n%s", fragment, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
// Contact represents a person in the CRM
|
||||
type Contact struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
CompanyID *string `json:"companyId,omitempty"`
|
||||
CompanyName string `json:"companyName,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Notes string `json:"notes"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Company represents an organization in the CRM
|
||||
type Company struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
Website string `json:"website"`
|
||||
Industry string `json:"industry"`
|
||||
Size string `json:"size"`
|
||||
Notes string `json:"notes"`
|
||||
LogoURL string `json:"logoUrl"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ContactTaskLink links a contact to a task
|
||||
type ContactTaskLink struct {
|
||||
ID string `json:"id"`
|
||||
ContactID string `json:"contactId"`
|
||||
TaskID string `json:"taskId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ContactEventLink links a contact to an event
|
||||
type ContactEventLink struct {
|
||||
ID string `json:"id"`
|
||||
ContactID string `json:"contactId"`
|
||||
EventID string `json:"eventId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// InboxItem represents a quick capture item
|
||||
type InboxItem struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source"`
|
||||
Processed bool `json:"processed"`
|
||||
ProcessedAt *time.Time `json:"processedAt,omitempty"`
|
||||
ProcessedEntityType *string `json:"processedEntityType,omitempty"`
|
||||
ProcessedEntityID *string `json:"processedEntityId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// TimeEntry represents logged time
|
||||
type TimeEntry struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
TaskID *string `json:"taskId,omitempty"`
|
||||
Description string `json:"description"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SavedView represents a user's saved filter/view
|
||||
type SavedView struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Name string `json:"name"`
|
||||
EntityType string `json:"entityType"`
|
||||
FilterJSON string `json:"filterJson"`
|
||||
SortJSON string `json:"sortJson"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// CreateContactInput for creating contacts
|
||||
type CreateContactInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
CompanyID *string `json:"companyId"`
|
||||
Title string `json:"title"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// UpdateContactInput for updating contacts
|
||||
type UpdateContactInput struct {
|
||||
FirstName *string `json:"firstName"`
|
||||
LastName *string `json:"lastName"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
CompanyID *string `json:"companyId"`
|
||||
Title *string `json:"title"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
// CreateCompanyInput for creating companies
|
||||
type CreateCompanyInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Domain string `json:"domain"`
|
||||
Website string `json:"website"`
|
||||
Industry string `json:"industry"`
|
||||
Size string `json:"size"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// UpdateCompanyInput for updating companies
|
||||
type UpdateCompanyInput struct {
|
||||
Name *string `json:"name"`
|
||||
Domain *string `json:"domain"`
|
||||
Website *string `json:"website"`
|
||||
Industry *string `json:"industry"`
|
||||
Size *string `json:"size"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
// CreateInboxItemInput for quick capture
|
||||
type CreateInboxItemInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// CreateTimeEntryInput for time tracking
|
||||
type CreateTimeEntryInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
TaskID *string `json:"taskId"`
|
||||
Description string `json:"description"`
|
||||
StartedAt time.Time `json:"startedAt" binding:"required"`
|
||||
EndedAt *time.Time `json:"endedAt"`
|
||||
}
|
||||
|
||||
// UpdateTimeEntryInput for updating time entries
|
||||
type UpdateTimeEntryInput struct {
|
||||
Description *string `json:"description"`
|
||||
EndedAt *time.Time `json:"endedAt"`
|
||||
}
|
||||
|
||||
// CreateSavedViewInput for saved views
|
||||
type CreateSavedViewInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
EntityType string `json:"entityType" binding:"required"`
|
||||
FilterJSON string `json:"filterJson"`
|
||||
SortJSON string `json:"sortJson"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Contact methods
|
||||
|
||||
func (s *PostgresStore) ListContacts(workspaceSlug string) []Contact {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
|
||||
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
|
||||
c.created_at, c.updated_at
|
||||
FROM contacts c
|
||||
LEFT JOIN companies co ON c.company_id = co.id
|
||||
WHERE c.workspace_slug = $1
|
||||
ORDER BY c.updated_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contacts []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
var companyID sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
|
||||
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
c.CompanyID = nullStringToPtr(companyID)
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetContactByID(contactID string) (Contact, error) {
|
||||
var c Contact
|
||||
var companyID sql.NullString
|
||||
err := s.db.QueryRow(`
|
||||
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
|
||||
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
|
||||
c.created_at, c.updated_at
|
||||
FROM contacts c
|
||||
LEFT JOIN companies co ON c.company_id = co.id
|
||||
WHERE c.id = $1
|
||||
`, contactID).Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
|
||||
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
|
||||
&c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.CompanyID = nullStringToPtr(companyID)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateContact(input CreateContactInput) Contact {
|
||||
now := time.Now().UTC()
|
||||
c := Contact{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
Email: input.Email,
|
||||
Phone: input.Phone,
|
||||
CompanyID: input.CompanyID,
|
||||
Title: input.Title,
|
||||
Notes: input.Notes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO contacts (id, workspace_slug, first_name, last_name, email, phone,
|
||||
company_id, title, notes, avatar_url, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, '', $10, $11)
|
||||
`, c.ID, c.WorkspaceSlug, c.FirstName, c.LastName, c.Email, c.Phone,
|
||||
ptrToNullString(c.CompanyID), c.Title, c.Notes, c.CreatedAt, c.UpdatedAt)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateContact(contactID string, input UpdateContactInput) (Contact, error) {
|
||||
c, err := s.GetContactByID(contactID)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
if input.FirstName != nil {
|
||||
c.FirstName = *input.FirstName
|
||||
}
|
||||
if input.LastName != nil {
|
||||
c.LastName = *input.LastName
|
||||
}
|
||||
if input.Email != nil {
|
||||
c.Email = *input.Email
|
||||
}
|
||||
if input.Phone != nil {
|
||||
c.Phone = *input.Phone
|
||||
}
|
||||
if input.CompanyID != nil {
|
||||
c.CompanyID = input.CompanyID
|
||||
}
|
||||
if input.Title != nil {
|
||||
c.Title = *input.Title
|
||||
}
|
||||
if input.Notes != nil {
|
||||
c.Notes = *input.Notes
|
||||
}
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
|
||||
s.db.Exec(`
|
||||
UPDATE contacts SET first_name = $1, last_name = $2, email = $3, phone = $4,
|
||||
company_id = $5, title = $6, notes = $7, updated_at = $8
|
||||
WHERE id = $9
|
||||
`, c.FirstName, c.LastName, c.Email, c.Phone, ptrToNullString(c.CompanyID),
|
||||
c.Title, c.Notes, c.UpdatedAt, c.ID)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteContact(contactID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM contacts WHERE id = $1`, contactID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Company methods
|
||||
|
||||
func (s *PostgresStore) ListCompanies(workspaceSlug string) []Company {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at
|
||||
FROM companies
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY name ASC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var companies []Company
|
||||
for rows.Next() {
|
||||
var c Company
|
||||
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.Name, &c.Domain, &c.Website,
|
||||
&c.Industry, &c.Size, &c.Notes, &c.LogoURL, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
companies = append(companies, c)
|
||||
}
|
||||
return companies
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetCompanyByID(companyID string) (Company, error) {
|
||||
var c Company
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at
|
||||
FROM companies
|
||||
WHERE id = $1
|
||||
`, companyID).Scan(&c.ID, &c.WorkspaceSlug, &c.Name, &c.Domain, &c.Website,
|
||||
&c.Industry, &c.Size, &c.Notes, &c.LogoURL, &c.CreatedAt, &c.UpdatedAt)
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateCompany(input CreateCompanyInput) Company {
|
||||
now := time.Now().UTC()
|
||||
c := Company{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Name: input.Name,
|
||||
Domain: input.Domain,
|
||||
Website: input.Website,
|
||||
Industry: input.Industry,
|
||||
Size: input.Size,
|
||||
Notes: input.Notes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO companies (id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', $9, $10)
|
||||
`, c.ID, c.WorkspaceSlug, c.Name, c.Domain, c.Website, c.Industry, c.Size, c.Notes, c.CreatedAt, c.UpdatedAt)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateCompany(companyID string, input UpdateCompanyInput) (Company, error) {
|
||||
c, err := s.GetCompanyByID(companyID)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
if input.Name != nil {
|
||||
c.Name = *input.Name
|
||||
}
|
||||
if input.Domain != nil {
|
||||
c.Domain = *input.Domain
|
||||
}
|
||||
if input.Website != nil {
|
||||
c.Website = *input.Website
|
||||
}
|
||||
if input.Industry != nil {
|
||||
c.Industry = *input.Industry
|
||||
}
|
||||
if input.Size != nil {
|
||||
c.Size = *input.Size
|
||||
}
|
||||
if input.Notes != nil {
|
||||
c.Notes = *input.Notes
|
||||
}
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
|
||||
s.db.Exec(`
|
||||
UPDATE companies SET name = $1, domain = $2, website = $3, industry = $4, size = $5, notes = $6, updated_at = $7
|
||||
WHERE id = $8
|
||||
`, c.Name, c.Domain, c.Website, c.Industry, c.Size, c.Notes, c.UpdatedAt, c.ID)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteCompany(companyID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM companies WHERE id = $1`, companyID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Contact-Task linking
|
||||
|
||||
func (s *PostgresStore) LinkContactToTask(contactID, taskID string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO contact_tasks (id, contact_id, task_id, created_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (contact_id, task_id) DO NOTHING
|
||||
`, uuid.NewString(), contactID, taskID, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UnlinkContactFromTask(contactID, taskID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM contact_tasks WHERE contact_id = $1 AND task_id = $2`, contactID, taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListContactsForTask(taskID string) []Contact {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
|
||||
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
|
||||
c.created_at, c.updated_at
|
||||
FROM contacts c
|
||||
JOIN contact_tasks ct ON c.id = ct.contact_id
|
||||
LEFT JOIN companies co ON c.company_id = co.id
|
||||
WHERE ct.task_id = $1
|
||||
`, taskID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contacts []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
var companyID sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
|
||||
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
c.CompanyID = nullStringToPtr(companyID)
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
// Contact-Event linking
|
||||
|
||||
func (s *PostgresStore) LinkContactToEvent(contactID, eventID string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO contact_events (id, contact_id, event_id, created_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (contact_id, event_id) DO NOTHING
|
||||
`, uuid.NewString(), contactID, eventID, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListContactsForEvent(eventID string) []Contact {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
|
||||
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
|
||||
c.created_at, c.updated_at
|
||||
FROM contacts c
|
||||
JOIN contact_events ce ON c.id = ce.contact_id
|
||||
LEFT JOIN companies co ON c.company_id = co.id
|
||||
WHERE ce.event_id = $1
|
||||
`, eventID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contacts []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
var companyID sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
|
||||
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
c.CompanyID = nullStringToPtr(companyID)
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func nullStringToPtr(ns sql.NullString) *string {
|
||||
if !ns.Valid {
|
||||
return nil
|
||||
}
|
||||
return &ns.String
|
||||
}
|
||||
|
||||
func ptrToNullString(s *string) sql.NullString {
|
||||
if s == nil {
|
||||
return sql.NullString{Valid: false}
|
||||
}
|
||||
return sql.NullString{String: *s, Valid: true}
|
||||
}
|
||||
|
||||
// Inbox methods
|
||||
|
||||
func (s *PostgresStore) ListInboxItems(workspaceSlug string) []InboxItem {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, content, source, processed, processed_at,
|
||||
processed_entity_type, processed_entity_id, created_at
|
||||
FROM inbox_items
|
||||
WHERE workspace_slug = $1 AND processed = false
|
||||
ORDER BY created_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []InboxItem
|
||||
for rows.Next() {
|
||||
var item InboxItem
|
||||
var processedAt sql.NullTime
|
||||
var processedEntityType, processedEntityID sql.NullString
|
||||
if err := rows.Scan(&item.ID, &item.WorkspaceSlug, &item.Content, &item.Source,
|
||||
&item.Processed, &processedAt, &processedEntityType, &processedEntityID,
|
||||
&item.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
item.ProcessedAt = nullTimeToPtr(processedAt)
|
||||
item.ProcessedEntityType = nullStringToPtr(processedEntityType)
|
||||
item.ProcessedEntityID = nullStringToPtr(processedEntityID)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateInboxItem(input CreateInboxItemInput) InboxItem {
|
||||
now := time.Now().UTC()
|
||||
item := InboxItem{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Content: input.Content,
|
||||
Source: input.Source,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if item.Source == "" {
|
||||
item.Source = "manual"
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO inbox_items (id, workspace_slug, content, source, processed, created_at)
|
||||
VALUES ($1, $2, $3, $4, false, $5)
|
||||
`, item.ID, item.WorkspaceSlug, item.Content, item.Source, item.CreatedAt)
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ProcessInboxItem(itemID string, entityType, entityID string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE inbox_items
|
||||
SET processed = true, processed_at = $1, processed_entity_type = $2, processed_entity_id = $3
|
||||
WHERE id = $4
|
||||
`, now, entityType, entityID, itemID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteInboxItem(itemID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM inbox_items WHERE id = $1`, itemID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Time entry methods
|
||||
|
||||
func (s *PostgresStore) ListTimeEntries(workspaceSlug string) []TimeEntry {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at
|
||||
FROM time_entries
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY started_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []TimeEntry
|
||||
for rows.Next() {
|
||||
var e TimeEntry
|
||||
var taskID sql.NullString
|
||||
var endedAt sql.NullTime
|
||||
if err := rows.Scan(&e.ID, &e.WorkspaceSlug, &taskID, &e.Description,
|
||||
&e.StartedAt, &endedAt, &e.DurationSeconds, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
e.TaskID = nullStringToPtr(taskID)
|
||||
e.EndedAt = nullTimeToPtr(endedAt)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateTimeEntry(input CreateTimeEntryInput) TimeEntry {
|
||||
now := time.Now().UTC()
|
||||
e := TimeEntry{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
TaskID: input.TaskID,
|
||||
Description: input.Description,
|
||||
StartedAt: input.StartedAt,
|
||||
EndedAt: input.EndedAt,
|
||||
DurationSeconds: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if input.EndedAt != nil {
|
||||
e.DurationSeconds = int(input.EndedAt.Sub(input.StartedAt).Seconds())
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO time_entries (id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, e.ID, e.WorkspaceSlug, ptrToNullString(e.TaskID), e.Description, e.StartedAt,
|
||||
ptrToNullTime(e.EndedAt), e.DurationSeconds, e.CreatedAt, e.UpdatedAt)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateTimeEntry(entryID string, input UpdateTimeEntryInput) (TimeEntry, error) {
|
||||
var e TimeEntry
|
||||
var taskID sql.NullString
|
||||
var endedAt sql.NullTime
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at
|
||||
FROM time_entries WHERE id = $1
|
||||
`, entryID).Scan(&e.ID, &e.WorkspaceSlug, &taskID, &e.Description, &e.StartedAt, &endedAt, &e.DurationSeconds, &e.CreatedAt, &e.UpdatedAt)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
e.TaskID = nullStringToPtr(taskID)
|
||||
e.EndedAt = nullTimeToPtr(endedAt)
|
||||
|
||||
if input.Description != nil {
|
||||
e.Description = *input.Description
|
||||
}
|
||||
if input.EndedAt != nil {
|
||||
e.EndedAt = input.EndedAt
|
||||
e.DurationSeconds = int(input.EndedAt.Sub(e.StartedAt).Seconds())
|
||||
}
|
||||
e.UpdatedAt = time.Now().UTC()
|
||||
|
||||
s.db.Exec(`
|
||||
UPDATE time_entries SET description = $1, ended_at = $2, duration_seconds = $3, updated_at = $4
|
||||
WHERE id = $5
|
||||
`, e.Description, ptrToNullTime(e.EndedAt), e.DurationSeconds, e.UpdatedAt, e.ID)
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteTimeEntry(entryID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM time_entries WHERE id = $1`, entryID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Saved view methods
|
||||
|
||||
func (s *PostgresStore) ListSavedViews(workspaceSlug, entityType string) []SavedView {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, name, entity_type, filter_json, sort_json, is_default, created_at, updated_at
|
||||
FROM saved_views
|
||||
WHERE workspace_slug = $1 AND entity_type = $2
|
||||
ORDER BY name ASC
|
||||
`, workspaceSlug, entityType)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var views []SavedView
|
||||
for rows.Next() {
|
||||
var v SavedView
|
||||
if err := rows.Scan(&v.ID, &v.WorkspaceSlug, &v.Name, &v.EntityType,
|
||||
&v.FilterJSON, &v.SortJSON, &v.IsDefault, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateSavedView(input CreateSavedViewInput) SavedView {
|
||||
now := time.Now().UTC()
|
||||
v := SavedView{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Name: input.Name,
|
||||
EntityType: input.EntityType,
|
||||
FilterJSON: input.FilterJSON,
|
||||
SortJSON: input.SortJSON,
|
||||
IsDefault: input.IsDefault,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO saved_views (id, workspace_slug, name, entity_type, filter_json, sort_json, is_default, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, v.ID, v.WorkspaceSlug, v.Name, v.EntityType, v.FilterJSON, v.SortJSON, v.IsDefault, v.CreatedAt, v.UpdatedAt)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteSavedView(viewID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM saved_views WHERE id = $1`, viewID)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullTimeToPtr(nt sql.NullTime) *time.Time {
|
||||
if !nt.Valid {
|
||||
return nil
|
||||
}
|
||||
return &nt.Time
|
||||
}
|
||||
|
||||
func ptrToNullTime(t *time.Time) sql.NullTime {
|
||||
if t == nil {
|
||||
return sql.NullTime{Valid: false}
|
||||
}
|
||||
return sql.NullTime{Time: *t, Valid: true}
|
||||
}
|
||||
|
||||
// Ensure PostgresStore implements Store interface for new methods
|
||||
var _ Store = (*PostgresStore)(nil)
|
||||
|
||||
// Add interface methods to store.go
|
||||
// These will be added to the Store interface in store.go
|
||||
@@ -0,0 +1,95 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Integration represents an external service connection
|
||||
type Integration struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
Config string `json:"config"`
|
||||
Status string `json:"status"`
|
||||
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Webhook represents an external webhook endpoint
|
||||
type Webhook struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Events string `json:"events"` // JSON array
|
||||
Active bool `json:"active"`
|
||||
LastTriggeredAt *time.Time `json:"lastTriggeredAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Notification represents a user notification
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
EntityType *string `json:"entityType,omitempty"`
|
||||
EntityID *string `json:"entityId,omitempty"`
|
||||
Read bool `json:"read"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Presence represents a user's real-time presence
|
||||
type Presence struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
UserName string `json:"userName"`
|
||||
EntityType *string `json:"entityType,omitempty"`
|
||||
EntityID *string `json:"entityId,omitempty"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CreateIntegrationInput for creating integrations
|
||||
type CreateIntegrationInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Config string `json:"config"`
|
||||
Credentials string `json:"credentials" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateWebhookInput for creating webhooks
|
||||
type CreateWebhookInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Events string `json:"events"` // JSON array
|
||||
}
|
||||
|
||||
// CreateNotificationInput for creating notifications
|
||||
type CreateNotificationInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
UserEmail string `json:"userEmail" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Body string `json:"body"`
|
||||
EntityType *string `json:"entityType"`
|
||||
EntityID *string `json:"entityId"`
|
||||
}
|
||||
|
||||
// UpdatePresenceInput for updating presence
|
||||
type UpdatePresenceInput struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
UserEmail string `json:"userEmail" binding:"required"`
|
||||
UserName string `json:"userName" binding:"required"`
|
||||
EntityType *string `json:"entityType"`
|
||||
EntityID *string `json:"entityId"`
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Integration methods
|
||||
|
||||
func (s *PostgresStore) ListIntegrations(workspaceSlug string) []Integration {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, provider, name, config, status, last_sync_at, created_at, updated_at
|
||||
FROM integrations
|
||||
WHERE workspace_slug = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var integrations []Integration
|
||||
for rows.Next() {
|
||||
var i Integration
|
||||
var lastSync sql.NullTime
|
||||
if err := rows.Scan(&i.ID, &i.WorkspaceSlug, &i.Provider, &i.Name, &i.Config,
|
||||
&i.Status, &lastSync, &i.CreatedAt, &i.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
i.LastSyncAt = nullTimeToPtr(lastSync)
|
||||
integrations = append(integrations, i)
|
||||
}
|
||||
return integrations
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetIntegrationByID(integrationID string) (Integration, error) {
|
||||
var i Integration
|
||||
var lastSync sql.NullTime
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, workspace_slug, provider, name, config, status, last_sync_at, created_at, updated_at
|
||||
FROM integrations WHERE id = $1
|
||||
`, integrationID).Scan(&i.ID, &i.WorkspaceSlug, &i.Provider, &i.Name, &i.Config,
|
||||
&i.Status, &lastSync, &i.CreatedAt, &i.UpdatedAt)
|
||||
if err != nil {
|
||||
return i, err
|
||||
}
|
||||
i.LastSyncAt = nullTimeToPtr(lastSync)
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateIntegration(input CreateIntegrationInput) Integration {
|
||||
now := time.Now().UTC()
|
||||
i := Integration{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Provider: input.Provider,
|
||||
Name: input.Name,
|
||||
Config: input.Config,
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO integrations (id, workspace_slug, provider, name, config, credentials_ciphertext, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8)
|
||||
`, i.ID, i.WorkspaceSlug, i.Provider, i.Name, i.Config, input.Credentials, i.CreatedAt, i.UpdatedAt)
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteIntegration(integrationID string) error {
|
||||
_, err := s.db.Exec(`UPDATE integrations SET status = 'deleted', updated_at = $1 WHERE id = $2`, time.Now().UTC(), integrationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Webhook methods
|
||||
|
||||
func (s *PostgresStore) ListWebhooks(workspaceSlug string) []Webhook {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, name, url, events, active, last_triggered_at, created_at, updated_at
|
||||
FROM webhooks
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY created_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var webhooks []Webhook
|
||||
for rows.Next() {
|
||||
var w Webhook
|
||||
var lastTriggered sql.NullTime
|
||||
if err := rows.Scan(&w.ID, &w.WorkspaceSlug, &w.Name, &w.URL, &w.Events,
|
||||
&w.Active, &lastTriggered, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
w.LastTriggeredAt = nullTimeToPtr(lastTriggered)
|
||||
webhooks = append(webhooks, w)
|
||||
}
|
||||
return webhooks
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateWebhook(input CreateWebhookInput) Webhook {
|
||||
now := time.Now().UTC()
|
||||
w := Webhook{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Name: input.Name,
|
||||
URL: input.URL,
|
||||
Events: input.Events,
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if w.Events == "" {
|
||||
w.Events = "[]"
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO webhooks (id, workspace_slug, name, url, secret, events, active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, w.ID, w.WorkspaceSlug, w.Name, w.URL, uuid.NewString(), w.Events, w.Active, w.CreatedAt, w.UpdatedAt)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteWebhook(webhookID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM webhooks WHERE id = $1`, webhookID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Notification methods
|
||||
|
||||
func (s *PostgresStore) ListNotifications(userEmail string, limit int) []Notification {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, user_email, type, title, body, entity_type, entity_id, read, created_at
|
||||
FROM notifications
|
||||
WHERE user_email = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`, userEmail, limit)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notifications []Notification
|
||||
for rows.Next() {
|
||||
var n Notification
|
||||
var entityType, entityID sql.NullString
|
||||
if err := rows.Scan(&n.ID, &n.WorkspaceSlug, &n.UserEmail, &n.Type, &n.Title,
|
||||
&n.Body, &entityType, &entityID, &n.Read, &n.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
n.EntityType = nullStringToPtr(entityType)
|
||||
n.EntityID = nullStringToPtr(entityID)
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
return notifications
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateNotification(input CreateNotificationInput) Notification {
|
||||
n := Notification{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
UserEmail: input.UserEmail,
|
||||
Type: input.Type,
|
||||
Title: input.Title,
|
||||
Body: input.Body,
|
||||
EntityType: input.EntityType,
|
||||
EntityID: input.EntityID,
|
||||
Read: false,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
s.db.Exec(`
|
||||
INSERT INTO notifications (id, workspace_slug, user_email, type, title, body, entity_type, entity_id, read, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, $9)
|
||||
`, n.ID, n.WorkspaceSlug, n.UserEmail, n.Type, n.Title, n.Body,
|
||||
ptrToNullString(n.EntityType), ptrToNullString(n.EntityID), n.CreatedAt)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *PostgresStore) MarkNotificationRead(notificationID string) error {
|
||||
_, err := s.db.Exec(`UPDATE notifications SET read = true WHERE id = $1`, notificationID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) MarkAllNotificationsRead(userEmail string) error {
|
||||
_, err := s.db.Exec(`UPDATE notifications SET read = true WHERE user_email = $1 AND read = false`, userEmail)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UnreadNotificationCount(userEmail string) int {
|
||||
var count int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE user_email = $1 AND read = false`, userEmail).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// Presence methods
|
||||
|
||||
func (s *PostgresStore) UpdatePresence(input UpdatePresenceInput) Presence {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Upsert presence
|
||||
s.db.Exec(`
|
||||
INSERT INTO presence (id, workspace_slug, user_email, user_name, entity_type, entity_id, last_seen_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (workspace_slug, user_email) DO UPDATE SET
|
||||
user_name = EXCLUDED.user_name,
|
||||
entity_type = EXCLUDED.entity_type,
|
||||
entity_id = EXCLUDED.entity_id,
|
||||
last_seen_at = EXCLUDED.last_seen_at
|
||||
`, uuid.NewString(), input.WorkspaceSlug, input.UserEmail, input.UserName,
|
||||
ptrToNullString(input.EntityType), ptrToNullString(input.EntityID), now, now)
|
||||
|
||||
return Presence{
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
UserEmail: input.UserEmail,
|
||||
UserName: input.UserName,
|
||||
EntityType: input.EntityType,
|
||||
EntityID: input.EntityID,
|
||||
LastSeenAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListPresence(workspaceSlug string, entityType, entityID string) []Presence {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, workspace_slug, user_email, user_name, entity_type, entity_id, last_seen_at, created_at
|
||||
FROM presence
|
||||
WHERE workspace_slug = $1
|
||||
AND last_seen_at > $2
|
||||
AND ($3 = '' OR entity_type = $3)
|
||||
AND ($4 = '' OR entity_id = $4)
|
||||
ORDER BY last_seen_at DESC
|
||||
`, workspaceSlug, time.Now().Add(-5*time.Minute), entityType, entityID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var presences []Presence
|
||||
for rows.Next() {
|
||||
var p Presence
|
||||
var entityType, entityID sql.NullString
|
||||
if err := rows.Scan(&p.ID, &p.WorkspaceSlug, &p.UserEmail, &p.UserName,
|
||||
&entityType, &entityID, &p.LastSeenAt, &p.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
p.EntityType = nullStringToPtr(entityType)
|
||||
p.EntityID = nullStringToPtr(entityID)
|
||||
presences = append(presences, p)
|
||||
}
|
||||
return presences
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ClearPresence(workspaceSlug, userEmail string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM presence WHERE workspace_slug = $1 AND user_email = $2`, workspaceSlug, userEmail)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Mailbox struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
Label string `json:"label"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IMAPHost string `json:"imapHost"`
|
||||
IMAPPort int `json:"imapPort"`
|
||||
IMAPUsername string `json:"imapUsername"`
|
||||
IMAPUseTLS bool `json:"imapUseTls"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort int `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPUseTLS bool `json:"smtpUseTls"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
SyncStatus string `json:"syncStatus"`
|
||||
SyncError string `json:"syncError,omitempty"`
|
||||
}
|
||||
|
||||
type MailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
RemoteUID int64 `json:"remoteUid"`
|
||||
MessageID string `json:"messageId"`
|
||||
Folder string `json:"folder"`
|
||||
From MailAddress `json:"from"`
|
||||
To []MailAddress `json:"to"`
|
||||
Cc []MailAddress `json:"cc"`
|
||||
Subject string `json:"subject"`
|
||||
Snippet string `json:"snippet"`
|
||||
TextBody string `json:"textBody"`
|
||||
HTMLBody string `json:"htmlBody"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
IsRead bool `json:"isRead"`
|
||||
LinkedTaskID *string `json:"linkedTaskId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type OutgoingMail struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
To []MailAddress `json:"to"`
|
||||
Cc []MailAddress `json:"cc"`
|
||||
Bcc []MailAddress `json:"bcc"`
|
||||
Subject string `json:"subject"`
|
||||
TextBody string `json:"textBody"`
|
||||
HTMLBody string `json:"htmlBody"`
|
||||
Status string `json:"status"`
|
||||
ScheduledFor *time.Time `json:"scheduledFor,omitempty"`
|
||||
SentAt *time.Time `json:"sentAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type MailboxConnection struct {
|
||||
Mailbox
|
||||
IMAPPasswordCiphertext string
|
||||
SMTPPasswordCiphertext string
|
||||
}
|
||||
|
||||
type CreateMailboxRecordInput struct {
|
||||
WorkspaceSlug string
|
||||
Label string
|
||||
Email string
|
||||
DisplayName string
|
||||
IMAPHost string
|
||||
IMAPPort int
|
||||
IMAPUsername string
|
||||
IMAPPasswordCiphertext string
|
||||
IMAPUseTLS bool
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPasswordCiphertext string
|
||||
SMTPUseTLS bool
|
||||
}
|
||||
|
||||
type UpdateMailboxSyncStatusInput struct {
|
||||
SyncStatus string
|
||||
SyncError *string
|
||||
LastSyncedAt *time.Time
|
||||
}
|
||||
|
||||
type InboundMailMessage struct {
|
||||
WorkspaceSlug string
|
||||
MailboxID string
|
||||
RemoteUID int64
|
||||
MessageID string
|
||||
Folder string
|
||||
From MailAddress
|
||||
To []MailAddress
|
||||
Cc []MailAddress
|
||||
Subject string
|
||||
Snippet string
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
ReceivedAt time.Time
|
||||
IsRead bool
|
||||
}
|
||||
|
||||
type CreateOutgoingMailInput struct {
|
||||
WorkspaceSlug string
|
||||
MailboxID string
|
||||
To []MailAddress
|
||||
Cc []MailAddress
|
||||
Bcc []MailAddress
|
||||
Subject string
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
Status string
|
||||
ScheduledFor *time.Time
|
||||
}
|
||||
|
||||
type UpdateOutgoingMailStatusInput struct {
|
||||
Status string
|
||||
SentAt *time.Time
|
||||
Error *string
|
||||
}
|
||||
|
||||
func (s *State) ListAllMailboxes() []Mailbox {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return append([]Mailbox(nil), s.Mailboxes...)
|
||||
}
|
||||
|
||||
func (s *State) ListMailboxes(workspaceSlug string) []Mailbox {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return filterByWorkspace(s.Mailboxes, workspaceSlug)
|
||||
}
|
||||
|
||||
func (s *State) GetMailboxByID(mailboxID string) (Mailbox, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, mailbox := range s.Mailboxes {
|
||||
if mailbox.ID == mailboxID {
|
||||
return mailbox, nil
|
||||
}
|
||||
}
|
||||
|
||||
return Mailbox{}, errors.New("mailbox not found")
|
||||
}
|
||||
|
||||
func (s *State) GetMailboxConnection(mailboxID string) (MailboxConnection, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
connection, ok := s.MailboxAuth[mailboxID]
|
||||
if !ok {
|
||||
return MailboxConnection{}, errors.New("mailbox connection not found")
|
||||
}
|
||||
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func (s *State) CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
mailbox := Mailbox{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Label: input.Label,
|
||||
Email: input.Email,
|
||||
DisplayName: input.DisplayName,
|
||||
IMAPHost: input.IMAPHost,
|
||||
IMAPPort: input.IMAPPort,
|
||||
IMAPUsername: input.IMAPUsername,
|
||||
IMAPUseTLS: input.IMAPUseTLS,
|
||||
SMTPHost: input.SMTPHost,
|
||||
SMTPPort: input.SMTPPort,
|
||||
SMTPUsername: input.SMTPUsername,
|
||||
SMTPUseTLS: input.SMTPUseTLS,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
SyncStatus: "idle",
|
||||
}
|
||||
s.Mailboxes = append([]Mailbox{mailbox}, s.Mailboxes...)
|
||||
s.MailboxAuth[mailbox.ID] = MailboxConnection{
|
||||
Mailbox: mailbox,
|
||||
IMAPPasswordCiphertext: input.IMAPPasswordCiphertext,
|
||||
SMTPPasswordCiphertext: input.SMTPPasswordCiphertext,
|
||||
}
|
||||
s.appendActivityLocked(input.WorkspaceSlug, "Mailbox connected", fmt.Sprintf("%s is ready for sync.", input.Email))
|
||||
return mailbox, nil
|
||||
}
|
||||
|
||||
func (s *State) UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for index, mailbox := range s.Mailboxes {
|
||||
if mailbox.ID != mailboxID {
|
||||
continue
|
||||
}
|
||||
|
||||
if input.SyncStatus != "" {
|
||||
mailbox.SyncStatus = input.SyncStatus
|
||||
}
|
||||
if input.SyncError != nil {
|
||||
mailbox.SyncError = *input.SyncError
|
||||
}
|
||||
if input.LastSyncedAt != nil {
|
||||
mailbox.LastSyncedAt = input.LastSyncedAt
|
||||
}
|
||||
mailbox.UpdatedAt = time.Now().UTC()
|
||||
s.Mailboxes[index] = mailbox
|
||||
if connection, ok := s.MailboxAuth[mailboxID]; ok {
|
||||
connection.Mailbox = mailbox
|
||||
s.MailboxAuth[mailboxID] = connection
|
||||
}
|
||||
return mailbox, nil
|
||||
}
|
||||
|
||||
return Mailbox{}, errors.New("mailbox not found")
|
||||
}
|
||||
|
||||
func (s *State) ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
items := filterByWorkspace(s.MailMessages, workspaceSlug)
|
||||
if mailboxID == "" {
|
||||
return items
|
||||
}
|
||||
|
||||
filtered := make([]MailMessage, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.MailboxID == mailboxID {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *State) GetMailMessageByID(messageID string) (MailMessage, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, message := range s.MailMessages {
|
||||
if message.ID == messageID {
|
||||
return message, nil
|
||||
}
|
||||
}
|
||||
|
||||
return MailMessage{}, errors.New("mail message not found")
|
||||
}
|
||||
|
||||
func (s *State) UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, input := range messages {
|
||||
found := false
|
||||
for index, message := range s.MailMessages {
|
||||
if message.MailboxID == mailboxID && message.Folder == input.Folder && message.RemoteUID == input.RemoteUID {
|
||||
linkedTaskID := message.LinkedTaskID
|
||||
s.MailMessages[index] = MailMessage{
|
||||
ID: message.ID,
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
MailboxID: mailboxID,
|
||||
RemoteUID: input.RemoteUID,
|
||||
MessageID: input.MessageID,
|
||||
Folder: input.Folder,
|
||||
From: input.From,
|
||||
To: input.To,
|
||||
Cc: input.Cc,
|
||||
Subject: input.Subject,
|
||||
Snippet: input.Snippet,
|
||||
TextBody: input.TextBody,
|
||||
HTMLBody: input.HTMLBody,
|
||||
ReceivedAt: input.ReceivedAt,
|
||||
IsRead: input.IsRead,
|
||||
LinkedTaskID: linkedTaskID,
|
||||
CreatedAt: message.CreatedAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
s.MailMessages = append([]MailMessage{{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
MailboxID: mailboxID,
|
||||
RemoteUID: input.RemoteUID,
|
||||
MessageID: input.MessageID,
|
||||
Folder: input.Folder,
|
||||
From: input.From,
|
||||
To: input.To,
|
||||
Cc: input.Cc,
|
||||
Subject: input.Subject,
|
||||
Snippet: input.Snippet,
|
||||
TextBody: input.TextBody,
|
||||
HTMLBody: input.HTMLBody,
|
||||
ReceivedAt: input.ReceivedAt,
|
||||
IsRead: input.IsRead,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}}, s.MailMessages...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) LinkMailMessageTask(messageID string, taskID string) (MailMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for index, message := range s.MailMessages {
|
||||
if message.ID != messageID {
|
||||
continue
|
||||
}
|
||||
message.LinkedTaskID = &taskID
|
||||
message.UpdatedAt = time.Now().UTC()
|
||||
s.MailMessages[index] = message
|
||||
return message, nil
|
||||
}
|
||||
|
||||
return MailMessage{}, errors.New("mail message not found")
|
||||
}
|
||||
|
||||
func (s *State) ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
items := filterByWorkspace(s.OutgoingMails, workspaceSlug)
|
||||
if mailboxID == "" {
|
||||
return items
|
||||
}
|
||||
|
||||
filtered := make([]OutgoingMail, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.MailboxID == mailboxID {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *State) ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
items := make([]OutgoingMail, 0, limit)
|
||||
for _, item := range s.OutgoingMails {
|
||||
if item.Status != "queued" && item.Status != "scheduled" {
|
||||
continue
|
||||
}
|
||||
if item.ScheduledFor != nil && item.ScheduledFor.After(now) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
if limit > 0 && len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *State) GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, item := range s.OutgoingMails {
|
||||
if item.ID == outgoingMailID {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
|
||||
return OutgoingMail{}, errors.New("outgoing mail not found")
|
||||
}
|
||||
|
||||
func (s *State) CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
item := OutgoingMail{
|
||||
ID: uuid.NewString(),
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
MailboxID: input.MailboxID,
|
||||
To: input.To,
|
||||
Cc: input.Cc,
|
||||
Bcc: input.Bcc,
|
||||
Subject: input.Subject,
|
||||
TextBody: input.TextBody,
|
||||
HTMLBody: input.HTMLBody,
|
||||
Status: input.Status,
|
||||
ScheduledFor: input.ScheduledFor,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.OutgoingMails = append([]OutgoingMail{item}, s.OutgoingMails...)
|
||||
s.appendActivityLocked(input.WorkspaceSlug, "Outgoing mail queued", input.Subject)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *State) UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for index, item := range s.OutgoingMails {
|
||||
if item.ID != outgoingMailID {
|
||||
continue
|
||||
}
|
||||
if input.Status != "" {
|
||||
item.Status = input.Status
|
||||
}
|
||||
if input.SentAt != nil {
|
||||
item.SentAt = input.SentAt
|
||||
}
|
||||
if input.Error != nil {
|
||||
item.Error = *input.Error
|
||||
}
|
||||
item.UpdatedAt = time.Now().UTC()
|
||||
s.OutgoingMails[index] = item
|
||||
return item, nil
|
||||
}
|
||||
|
||||
return OutgoingMail{}, errors.New("outgoing mail not found")
|
||||
}
|
||||
|
||||
func (item Mailbox) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
||||
func (item MailMessage) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
||||
func (item OutgoingMail) GetWorkspaceSlug() string { return item.WorkspaceSlug }
|
||||
@@ -0,0 +1,550 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *PostgresStore) ListAllMailboxes() []Mailbox {
|
||||
rows, err := s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
FROM mailboxes
|
||||
ORDER BY updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanMailboxes(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListMailboxes(workspaceSlug string) []Mailbox {
|
||||
rows, err := s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
FROM mailboxes
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY updated_at DESC
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanMailboxes(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetMailboxByID(mailboxID string) (Mailbox, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
FROM mailboxes
|
||||
WHERE id = $1
|
||||
`, mailboxID)
|
||||
return scanMailbox(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetMailboxConnection(mailboxID string) (MailboxConnection, error) {
|
||||
var (
|
||||
connection MailboxConnection
|
||||
lastSynced sql.NullTime
|
||||
syncError sql.NullString
|
||||
)
|
||||
|
||||
err := s.db.QueryRowContext(context.Background(), `
|
||||
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error,
|
||||
imap_password_ciphertext, smtp_password_ciphertext
|
||||
FROM mailboxes
|
||||
WHERE id = $1
|
||||
`, mailboxID).Scan(
|
||||
&connection.ID,
|
||||
&connection.WorkspaceSlug,
|
||||
&connection.Label,
|
||||
&connection.Email,
|
||||
&connection.DisplayName,
|
||||
&connection.IMAPHost,
|
||||
&connection.IMAPPort,
|
||||
&connection.IMAPUsername,
|
||||
&connection.IMAPUseTLS,
|
||||
&connection.SMTPHost,
|
||||
&connection.SMTPPort,
|
||||
&connection.SMTPUsername,
|
||||
&connection.SMTPUseTLS,
|
||||
&connection.CreatedAt,
|
||||
&connection.UpdatedAt,
|
||||
&lastSynced,
|
||||
&connection.SyncStatus,
|
||||
&syncError,
|
||||
&connection.IMAPPasswordCiphertext,
|
||||
&connection.SMTPPasswordCiphertext,
|
||||
)
|
||||
if err != nil {
|
||||
return MailboxConnection{}, err
|
||||
}
|
||||
|
||||
connection.LastSyncedAt = timePtr(lastSynced)
|
||||
connection.SyncError = syncError.String
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error) {
|
||||
now := time.Now().UTC()
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
INSERT INTO mailboxes (
|
||||
id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_password_ciphertext, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_password_ciphertext, smtp_use_tls, sync_status, sync_error, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'idle', '', $16, $16)
|
||||
RETURNING id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
`,
|
||||
uuid.NewString(),
|
||||
input.WorkspaceSlug,
|
||||
defaultString(input.Label, input.Email),
|
||||
input.Email,
|
||||
input.DisplayName,
|
||||
input.IMAPHost,
|
||||
input.IMAPPort,
|
||||
input.IMAPUsername,
|
||||
input.IMAPPasswordCiphertext,
|
||||
input.IMAPUseTLS,
|
||||
input.SMTPHost,
|
||||
input.SMTPPort,
|
||||
input.SMTPUsername,
|
||||
input.SMTPPasswordCiphertext,
|
||||
input.SMTPUseTLS,
|
||||
now,
|
||||
)
|
||||
|
||||
mailbox, err := scanMailbox(row)
|
||||
if err != nil {
|
||||
return Mailbox{}, err
|
||||
}
|
||||
|
||||
if err := appendActivity(context.Background(), s.queries, mailbox.WorkspaceSlug, "Mailbox connected", fmt.Sprintf("%s is ready for sync.", mailbox.Email)); err != nil {
|
||||
return Mailbox{}, err
|
||||
}
|
||||
|
||||
return mailbox, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
UPDATE mailboxes
|
||||
SET sync_status = COALESCE(NULLIF($2, ''), sync_status),
|
||||
sync_error = COALESCE($3, sync_error),
|
||||
last_synced_at = COALESCE($4, last_synced_at),
|
||||
updated_at = $5
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
|
||||
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
|
||||
`, mailboxID, input.SyncStatus, nullableString(input.SyncError), nullableTime(input.LastSyncedAt), time.Now().UTC())
|
||||
|
||||
return scanMailbox(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if mailboxID == "" {
|
||||
rows, err = s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
FROM mail_messages
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY received_at DESC
|
||||
LIMIT 120
|
||||
`, workspaceSlug)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
FROM mail_messages
|
||||
WHERE workspace_slug = $1 AND mailbox_id = $2
|
||||
ORDER BY received_at DESC
|
||||
LIMIT 120
|
||||
`, workspaceSlug, mailboxID)
|
||||
}
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanMailMessages(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetMailMessageByID(messageID string) (MailMessage, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
FROM mail_messages
|
||||
WHERE id = $1
|
||||
`, messageID)
|
||||
return scanMailMessage(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error {
|
||||
ctx := context.Background()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, message := range messages {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO mail_messages (
|
||||
id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL, $16, $16)
|
||||
ON CONFLICT (mailbox_id, folder, remote_uid)
|
||||
DO UPDATE SET
|
||||
message_id = EXCLUDED.message_id,
|
||||
from_address = EXCLUDED.from_address,
|
||||
to_recipients = EXCLUDED.to_recipients,
|
||||
cc_recipients = EXCLUDED.cc_recipients,
|
||||
subject = EXCLUDED.subject,
|
||||
snippet = EXCLUDED.snippet,
|
||||
text_body = EXCLUDED.text_body,
|
||||
html_body = EXCLUDED.html_body,
|
||||
received_at = EXCLUDED.received_at,
|
||||
is_read = EXCLUDED.is_read,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
uuid.NewString(),
|
||||
message.WorkspaceSlug,
|
||||
mailboxID,
|
||||
message.RemoteUID,
|
||||
message.MessageID,
|
||||
defaultString(message.Folder, "INBOX"),
|
||||
mustJSON(message.From),
|
||||
mustJSON(message.To),
|
||||
mustJSON(message.Cc),
|
||||
message.Subject,
|
||||
message.Snippet,
|
||||
message.TextBody,
|
||||
message.HTMLBody,
|
||||
message.ReceivedAt,
|
||||
message.IsRead,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *PostgresStore) LinkMailMessageTask(messageID string, taskID string) (MailMessage, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
UPDATE mail_messages
|
||||
SET linked_task_id = $2, updated_at = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
|
||||
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
|
||||
`, messageID, taskID, time.Now().UTC())
|
||||
return scanMailMessage(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if mailboxID == "" {
|
||||
rows, err = s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
FROM outgoing_mails
|
||||
WHERE workspace_slug = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 120
|
||||
`, workspaceSlug)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
FROM outgoing_mails
|
||||
WHERE workspace_slug = $1 AND mailbox_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 120
|
||||
`, workspaceSlug, mailboxID)
|
||||
}
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanOutgoingMails(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail {
|
||||
rows, err := s.db.QueryContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
FROM outgoing_mails
|
||||
WHERE status IN ('queued', 'scheduled')
|
||||
AND (scheduled_for IS NULL OR scheduled_for <= $1)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2
|
||||
`, now, limit)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanOutgoingMails(rows)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
FROM outgoing_mails
|
||||
WHERE id = $1
|
||||
`, outgoingMailID)
|
||||
return scanOutgoingMail(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error) {
|
||||
now := time.Now().UTC()
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
INSERT INTO outgoing_mails (
|
||||
id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULL, '', $12, $12)
|
||||
RETURNING id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
`,
|
||||
uuid.NewString(),
|
||||
input.WorkspaceSlug,
|
||||
input.MailboxID,
|
||||
mustJSON(input.To),
|
||||
mustJSON(input.Cc),
|
||||
mustJSON(input.Bcc),
|
||||
input.Subject,
|
||||
input.TextBody,
|
||||
input.HTMLBody,
|
||||
input.Status,
|
||||
nullableTime(input.ScheduledFor),
|
||||
now,
|
||||
)
|
||||
return scanOutgoingMail(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error) {
|
||||
row := s.db.QueryRowContext(context.Background(), `
|
||||
UPDATE outgoing_mails
|
||||
SET status = COALESCE(NULLIF($2, ''), status),
|
||||
sent_at = COALESCE($3, sent_at),
|
||||
error_message = COALESCE($4, error_message),
|
||||
updated_at = $5
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
|
||||
status, scheduled_for, sent_at, error_message, created_at, updated_at
|
||||
`, outgoingMailID, input.Status, nullableTime(input.SentAt), nullableString(input.Error), time.Now().UTC())
|
||||
return scanOutgoingMail(row)
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanMailboxes(rows *sql.Rows) []Mailbox {
|
||||
items := make([]Mailbox, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanMailbox(rows)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func scanMailbox(row rowScanner) (Mailbox, error) {
|
||||
var (
|
||||
item Mailbox
|
||||
lastSynced sql.NullTime
|
||||
syncError sql.NullString
|
||||
)
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceSlug,
|
||||
&item.Label,
|
||||
&item.Email,
|
||||
&item.DisplayName,
|
||||
&item.IMAPHost,
|
||||
&item.IMAPPort,
|
||||
&item.IMAPUsername,
|
||||
&item.IMAPUseTLS,
|
||||
&item.SMTPHost,
|
||||
&item.SMTPPort,
|
||||
&item.SMTPUsername,
|
||||
&item.SMTPUseTLS,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&lastSynced,
|
||||
&item.SyncStatus,
|
||||
&syncError,
|
||||
)
|
||||
if err != nil {
|
||||
return Mailbox{}, err
|
||||
}
|
||||
item.LastSyncedAt = timePtr(lastSynced)
|
||||
item.SyncError = syncError.String
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanMailMessages(rows *sql.Rows) []MailMessage {
|
||||
items := make([]MailMessage, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanMailMessage(rows)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func scanMailMessage(row rowScanner) (MailMessage, error) {
|
||||
var (
|
||||
item MailMessage
|
||||
fromJSON []byte
|
||||
toJSON []byte
|
||||
ccJSON []byte
|
||||
linkedTaskID sql.NullString
|
||||
)
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceSlug,
|
||||
&item.MailboxID,
|
||||
&item.RemoteUID,
|
||||
&item.MessageID,
|
||||
&item.Folder,
|
||||
&fromJSON,
|
||||
&toJSON,
|
||||
&ccJSON,
|
||||
&item.Subject,
|
||||
&item.Snippet,
|
||||
&item.TextBody,
|
||||
&item.HTMLBody,
|
||||
&item.ReceivedAt,
|
||||
&item.IsRead,
|
||||
&linkedTaskID,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return MailMessage{}, err
|
||||
}
|
||||
if err := decodeJSONValue(fromJSON, &item.From); err != nil {
|
||||
return MailMessage{}, err
|
||||
}
|
||||
to, err := decodeJSONSlice[MailAddress](toJSON)
|
||||
if err != nil {
|
||||
return MailMessage{}, err
|
||||
}
|
||||
cc, err := decodeJSONSlice[MailAddress](ccJSON)
|
||||
if err != nil {
|
||||
return MailMessage{}, err
|
||||
}
|
||||
item.To = to
|
||||
item.Cc = cc
|
||||
item.LinkedTaskID = stringPtr(linkedTaskID)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanOutgoingMails(rows *sql.Rows) []OutgoingMail {
|
||||
items := make([]OutgoingMail, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanOutgoingMail(rows)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func scanOutgoingMail(row rowScanner) (OutgoingMail, error) {
|
||||
var (
|
||||
item OutgoingMail
|
||||
toJSON []byte
|
||||
ccJSON []byte
|
||||
bccJSON []byte
|
||||
scheduledFor sql.NullTime
|
||||
sentAt sql.NullTime
|
||||
errorMessage sql.NullString
|
||||
)
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceSlug,
|
||||
&item.MailboxID,
|
||||
&toJSON,
|
||||
&ccJSON,
|
||||
&bccJSON,
|
||||
&item.Subject,
|
||||
&item.TextBody,
|
||||
&item.HTMLBody,
|
||||
&item.Status,
|
||||
&scheduledFor,
|
||||
&sentAt,
|
||||
&errorMessage,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return OutgoingMail{}, err
|
||||
}
|
||||
to, err := decodeJSONSlice[MailAddress](toJSON)
|
||||
if err != nil {
|
||||
return OutgoingMail{}, err
|
||||
}
|
||||
cc, err := decodeJSONSlice[MailAddress](ccJSON)
|
||||
if err != nil {
|
||||
return OutgoingMail{}, err
|
||||
}
|
||||
bcc, err := decodeJSONSlice[MailAddress](bccJSON)
|
||||
if err != nil {
|
||||
return OutgoingMail{}, err
|
||||
}
|
||||
item.To = to
|
||||
item.Cc = cc
|
||||
item.Bcc = bcc
|
||||
item.ScheduledFor = timePtr(scheduledFor)
|
||||
item.SentAt = timePtr(sentAt)
|
||||
item.Error = errorMessage.String
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func decodeJSONValue(raw []byte, target any) error {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CreateNotificationForTaskAssignment creates a notification when a task is assigned
|
||||
func (s *PostgresStore) CreateNotificationForTaskAssignment(workspaceSlug, assigneeEmail, taskTitle, taskID string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: assigneeEmail,
|
||||
Type: "task_assigned",
|
||||
Title: "Task assigned to you",
|
||||
Body: "You have been assigned to: " + taskTitle,
|
||||
EntityType: strPtr("task"),
|
||||
EntityID: strPtr(taskID),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNotificationForMention creates a notification when a user is mentioned
|
||||
func (s *PostgresStore) CreateNotificationForMention(workspaceSlug, mentionedEmail, mentionerName, entityType, entityID, context string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: mentionedEmail,
|
||||
Type: "mention",
|
||||
Title: mentionerName + " mentioned you",
|
||||
Body: context,
|
||||
EntityType: strPtr(entityType),
|
||||
EntityID: strPtr(entityID),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNotificationForComment creates a notification for a new comment on an entity
|
||||
func (s *PostgresStore) CreateNotificationForComment(workspaceSlug, ownerEmail, commenterName, entityType, entityID, entityTitle string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: ownerEmail,
|
||||
Type: "comment",
|
||||
Title: commenterName + " commented on " + entityTitle,
|
||||
Body: "New comment on " + entityType,
|
||||
EntityType: strPtr(entityType),
|
||||
EntityID: strPtr(entityID),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNotificationForTaskCompletion creates a notification when a task is completed
|
||||
func (s *PostgresStore) CreateNotificationForTaskCompletion(workspaceSlug, assignerEmail, taskTitle, taskID string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: assignerEmail,
|
||||
Type: "task_completed",
|
||||
Title: "Task completed: " + taskTitle,
|
||||
Body: "A task you assigned has been completed",
|
||||
EntityType: strPtr("task"),
|
||||
EntityID: strPtr(taskID),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNotificationForEventReminder creates a notification for an upcoming event
|
||||
func (s *PostgresStore) CreateNotificationForEventReminder(workspaceSlug, userEmail, eventTitle, eventID string) {
|
||||
s.CreateNotification(CreateNotificationInput{
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
UserEmail: userEmail,
|
||||
Type: "event_reminder",
|
||||
Title: "Upcoming event: " + eventTitle,
|
||||
Body: "Your event is starting soon",
|
||||
EntityType: strPtr("event"),
|
||||
EntityID: strPtr(eventID),
|
||||
})
|
||||
}
|
||||
|
||||
// TriggerWebhooks triggers all webhooks for a given event type
|
||||
func (s *PostgresStore) TriggerWebhooks(workspaceSlug, eventType string, payload map[string]interface{}) {
|
||||
// Get all active webhooks for this workspace
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, url, secret, events
|
||||
FROM webhooks
|
||||
WHERE workspace_slug = $1 AND active = true
|
||||
`, workspaceSlug)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id, url, secret, eventsJSON string
|
||||
if err := rows.Scan(&id, &url, &secret, &eventsJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this webhook subscribes to the event
|
||||
// Simple string contains check for the event type in the JSON array
|
||||
if !containsEvent(eventsJSON, eventType) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update last triggered timestamp
|
||||
s.db.Exec(`UPDATE webhooks SET last_triggered_at = $1 WHERE id = $2`, time.Now().UTC(), id)
|
||||
|
||||
// Webhook delivery would happen here in a goroutine
|
||||
// For now, we just mark it as triggered
|
||||
go deliverWebhook(url, secret, eventType, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func containsEvent(eventsJSON, eventType string) bool {
|
||||
// Simple check - in production would parse JSON properly
|
||||
return len(eventsJSON) > 2 &&
|
||||
(eventsJSON == "[]" ||
|
||||
eventsJSON == "[\""+eventType+"\"]" ||
|
||||
containsSubstring(eventsJSON, "\""+eventType+"\""))
|
||||
}
|
||||
|
||||
func containsSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deliverWebhook(url, secret, eventType string, payload map[string]interface{}) {
|
||||
// Webhook delivery implementation
|
||||
// In production, this would make an HTTP POST request
|
||||
// with proper signature using the secret
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrationsDirUsesEnvOverrideWhenValid(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
customDir := filepath.Join(tempDir, "migrations")
|
||||
if err := os.MkdirAll(customDir, 0o755); err != nil {
|
||||
t.Fatalf("create temp migrations dir: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("DB_MIGRATIONS_DIR", customDir)
|
||||
if got := migrationsDir(); got != customDir {
|
||||
t.Fatalf("migrationsDir() = %q, want %q", got, customDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsDirFallsBackWhenEnvOverrideIsInvalid(t *testing.T) {
|
||||
t.Setenv("DB_MIGRATIONS_DIR", filepath.Join(t.TempDir(), "missing"))
|
||||
got := migrationsDir()
|
||||
if got == "" {
|
||||
t.Fatal("migrationsDir() should never return empty path")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStateUpdateMemberRejectsLastActiveOwnerChange(t *testing.T) {
|
||||
state := NewSeededState("test")
|
||||
owner := findMemberByRole(t, state, "owner")
|
||||
|
||||
nextRole := "member"
|
||||
if _, err := state.UpdateMember(owner.ID, UpdateMemberInput{Role: &nextRole}); err == nil {
|
||||
t.Fatalf("expected last active owner demotion to fail")
|
||||
}
|
||||
|
||||
nextStatus := "removed"
|
||||
if _, err := state.UpdateMember(owner.ID, UpdateMemberInput{Status: &nextStatus}); err == nil {
|
||||
t.Fatalf("expected last active owner deactivation to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateUpdateMemberAllowsOwnerChangeWhenAnotherOwnerExists(t *testing.T) {
|
||||
state := NewSeededState("test")
|
||||
owner := findMemberByRole(t, state, "owner")
|
||||
|
||||
state.Members = append(state.Members, Member{
|
||||
ID: "member-owner-2",
|
||||
WorkspaceSlug: owner.WorkspaceSlug,
|
||||
Name: "Backup Owner",
|
||||
Email: "backup-owner@productier.app",
|
||||
Role: "owner",
|
||||
Status: "active",
|
||||
})
|
||||
|
||||
nextRole := "admin"
|
||||
updated, err := state.UpdateMember(owner.ID, UpdateMemberInput{Role: &nextRole})
|
||||
if err != nil {
|
||||
t.Fatalf("expected owner demotion to succeed with another active owner: %v", err)
|
||||
}
|
||||
if updated.Role != "admin" {
|
||||
t.Fatalf("expected updated role admin, got %s", updated.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRevokeInviteRules(t *testing.T) {
|
||||
state := NewSeededState("test")
|
||||
if len(state.Invites) == 0 {
|
||||
t.Fatalf("seed state has no invites")
|
||||
}
|
||||
invite := state.Invites[0]
|
||||
|
||||
if err := state.RevokeInvite(invite.ID); err != nil {
|
||||
t.Fatalf("expected pending invite revoke to succeed: %v", err)
|
||||
}
|
||||
if _, err := state.GetInviteByID(invite.ID); err == nil {
|
||||
t.Fatalf("expected revoked invite to be absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRevokeInviteRejectedWhenAccepted(t *testing.T) {
|
||||
state := NewSeededState("test")
|
||||
if len(state.Invites) == 0 {
|
||||
t.Fatalf("seed state has no invites")
|
||||
}
|
||||
invite := state.Invites[0]
|
||||
|
||||
if _, err := state.AcceptInvite(invite.Token, AcceptInviteInput{Name: "Taylor", Email: invite.Email}); err != nil {
|
||||
t.Fatalf("accept invite setup failed: %v", err)
|
||||
}
|
||||
if err := state.RevokeInvite(invite.ID); err == nil {
|
||||
t.Fatalf("expected revoke of accepted invite to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func findMemberByRole(t *testing.T, state *State, role string) Member {
|
||||
t.Helper()
|
||||
for _, member := range state.Members {
|
||||
if member.Role == role && member.Status == "active" {
|
||||
return member
|
||||
}
|
||||
}
|
||||
t.Fatalf("no active member with role %s", role)
|
||||
return Member{}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
type Store interface {
|
||||
ListWorkspaces() []Workspace
|
||||
ListMembers(workspaceSlug string) []Member
|
||||
GetMemberByID(memberID string) (Member, error)
|
||||
UpdateMember(memberID string, input UpdateMemberInput) (Member, error)
|
||||
ListInvites(workspaceSlug string) []Invite
|
||||
GetInviteByID(inviteID string) (Invite, error)
|
||||
GetInviteByToken(token string) (Invite, error)
|
||||
CreateInvite(input CreateInviteInput) Invite
|
||||
RevokeInvite(inviteID string) error
|
||||
AcceptInvite(token string, input AcceptInviteInput) (Invite, error)
|
||||
ListActivities(workspaceSlug string) []ActivityEntry
|
||||
ListBoardGroups(workspaceSlug string) []BoardGroup
|
||||
GetBoardGroupByID(groupID string) (BoardGroup, error)
|
||||
CreateBoardGroup(input CreateBoardGroupInput) BoardGroup
|
||||
UpdateBoardGroup(groupID string, input UpdateBoardGroupInput) (BoardGroup, error)
|
||||
ListLabels(workspaceSlug string) []Label
|
||||
CreateLabel(input CreateLabelInput) Label
|
||||
ListTasks(workspaceSlug string) []Task
|
||||
GetTaskByID(taskID string) (Task, error)
|
||||
CreateTask(input CreateTaskInput) Task
|
||||
UpdateTask(taskID string, input UpdateTaskInput) (Task, error)
|
||||
ListEvents(workspaceSlug string) []CalendarEvent
|
||||
GetEventByID(eventID string) (CalendarEvent, error)
|
||||
CreateEvent(input CreateEventInput) CalendarEvent
|
||||
UpdateEvent(eventID string, input UpdateEventInput) (CalendarEvent, error)
|
||||
ListNotes(workspaceSlug string) []Note
|
||||
GetNoteByID(noteID string) (Note, error)
|
||||
CreateNote(input CreateNoteInput) Note
|
||||
UpdateNote(noteID string, input UpdateNoteInput) (Note, error)
|
||||
ListFocusSessions(workspaceSlug string) []FocusSession
|
||||
GetFocusSessionByID(sessionID string) (FocusSession, error)
|
||||
CreateFocusSession(input CreateFocusSessionInput) FocusSession
|
||||
UpdateFocusSession(sessionID string, input UpdateFocusSessionInput) (FocusSession, error)
|
||||
ListAllMailboxes() []Mailbox
|
||||
ListMailboxes(workspaceSlug string) []Mailbox
|
||||
GetMailboxByID(mailboxID string) (Mailbox, error)
|
||||
GetMailboxConnection(mailboxID string) (MailboxConnection, error)
|
||||
CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error)
|
||||
UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error)
|
||||
ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage
|
||||
GetMailMessageByID(messageID string) (MailMessage, error)
|
||||
UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error
|
||||
LinkMailMessageTask(messageID string, taskID string) (MailMessage, error)
|
||||
ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail
|
||||
ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail
|
||||
GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error)
|
||||
CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error)
|
||||
UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error)
|
||||
// CRM
|
||||
ListContacts(workspaceSlug string) []Contact
|
||||
GetContactByID(contactID string) (Contact, error)
|
||||
CreateContact(input CreateContactInput) Contact
|
||||
UpdateContact(contactID string, input UpdateContactInput) (Contact, error)
|
||||
DeleteContact(contactID string) error
|
||||
ListCompanies(workspaceSlug string) []Company
|
||||
GetCompanyByID(companyID string) (Company, error)
|
||||
CreateCompany(input CreateCompanyInput) Company
|
||||
UpdateCompany(companyID string, input UpdateCompanyInput) (Company, error)
|
||||
DeleteCompany(companyID string) error
|
||||
LinkContactToTask(contactID, taskID string) error
|
||||
UnlinkContactFromTask(contactID, taskID string) error
|
||||
ListContactsForTask(taskID string) []Contact
|
||||
LinkContactToEvent(contactID, eventID string) error
|
||||
ListContactsForEvent(eventID string) []Contact
|
||||
// Inbox
|
||||
ListInboxItems(workspaceSlug string) []InboxItem
|
||||
CreateInboxItem(input CreateInboxItemInput) InboxItem
|
||||
ProcessInboxItem(itemID string, entityType, entityID string) error
|
||||
DeleteInboxItem(itemID string) error
|
||||
// Time tracking
|
||||
ListTimeEntries(workspaceSlug string) []TimeEntry
|
||||
CreateTimeEntry(input CreateTimeEntryInput) TimeEntry
|
||||
UpdateTimeEntry(entryID string, input UpdateTimeEntryInput) (TimeEntry, error)
|
||||
DeleteTimeEntry(entryID string) error
|
||||
// Saved views
|
||||
ListSavedViews(workspaceSlug, entityType string) []SavedView
|
||||
CreateSavedView(input CreateSavedViewInput) SavedView
|
||||
DeleteSavedView(viewID string) error
|
||||
// Integrations
|
||||
ListIntegrations(workspaceSlug string) []Integration
|
||||
GetIntegrationByID(integrationID string) (Integration, error)
|
||||
CreateIntegration(input CreateIntegrationInput) Integration
|
||||
DeleteIntegration(integrationID string) error
|
||||
// Webhooks
|
||||
ListWebhooks(workspaceSlug string) []Webhook
|
||||
CreateWebhook(input CreateWebhookInput) Webhook
|
||||
DeleteWebhook(webhookID string) error
|
||||
// Notifications
|
||||
ListNotifications(userEmail string, limit int) []Notification
|
||||
CreateNotification(input CreateNotificationInput) Notification
|
||||
MarkNotificationRead(notificationID string) error
|
||||
MarkAllNotificationsRead(userEmail string) error
|
||||
UnreadNotificationCount(userEmail string) int
|
||||
// Presence
|
||||
UpdatePresence(input UpdatePresenceInput) Presence
|
||||
ListPresence(workspaceSlug string, entityType, entityID string) []Presence
|
||||
ClearPresence(workspaceSlug, userEmail string) error
|
||||
}
|
||||
Reference in New Issue
Block a user