diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 5308b81..1beb7a6 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -1,20 +1,36 @@ slug: user-preferences title: User Preferences API created: 2026-02-08T05:49:49.197429066Z -phase: draft +phase: implementation phase_history: - phase: draft entered: 2026-02-08T05:49:49.197429066Z + exited: 2026-02-08T06:04:08.481264669Z + - phase: specified + entered: 2026-02-08T06:04:08.481264669Z + exited: 2026-02-08T06:04:08.489768009Z + - phase: planned + entered: 2026-02-08T06:04:08.489768009Z + exited: 2026-02-08T06:04:08.49504538Z + - phase: ready + entered: 2026-02-08T06:04:08.49504538Z + exited: 2026-02-08T06:04:08.50042898Z + - phase: implementation + entered: 2026-02-08T06:04:08.50042898Z artifacts: audit: status: pending path: audit.md design: - status: draft + status: approved path: design.md + approved_by: user + approved_at: 2026-02-08T06:04:02.519059418Z qa_plan: - status: draft + status: approved path: qa-plan.md + approved_by: user + approved_at: 2026-02-08T06:04:02.531924677Z qa_results: status: pending path: qa-results.md @@ -22,34 +38,55 @@ artifacts: status: pending path: review.md spec: - status: draft + status: approved path: spec.md + approved_by: user + approved_at: 2026-02-08T06:04:02.513348292Z tasks: - status: draft + status: approved path: tasks.md + approved_by: user + approved_at: 2026-02-08T06:04:02.525504967Z total: 8 + completed: 8 tasks: - id: task-001 title: Domain layer - preference types, validation, defaults, and domain errors - status: pending + status: complete + started_at: 2026-02-08T06:04:21.073131466Z + done_at: 2026-02-08T06:04:59.143838086Z - id: task-002 title: Port layer - PreferenceRepository interface and row type - status: pending + status: complete + started_at: 2026-02-08T06:05:09.644910816Z + done_at: 2026-02-08T06:05:19.519459876Z - id: task-003 title: Service layer - PreferenceService with get, update, validation logic and unit tests - status: pending + status: complete + started_at: 2026-02-08T06:05:27.120395886Z + done_at: 2026-02-08T06:06:24.021112964Z - id: task-004 title: Database migration - create user_preferences table - status: pending + status: complete + started_at: 2026-02-08T06:06:35.339731999Z + done_at: 2026-02-08T06:06:53.729592277Z - id: task-005 title: PostgreSQL adapter - implement PreferenceRepository with sqlx - status: pending + status: complete + started_at: 2026-02-08T06:07:01.795533407Z + done_at: 2026-02-08T06:07:20.600911027Z - id: task-006 title: Handler layer - GET and PUT preference handlers with error mapping and handler tests - status: pending + status: complete + started_at: 2026-02-08T06:07:32.040998808Z + done_at: 2026-02-08T06:08:37.691446229Z - id: task-007 title: Routes, OpenAPI spec, and main.go wiring - status: pending + status: complete + started_at: 2026-02-08T06:08:48.060805203Z + done_at: 2026-02-08T06:10:41.696522126Z - id: task-008 title: Cleanup - remove example scaffold files - status: pending + status: complete + started_at: 2026-02-08T06:10:53.979045796Z + done_at: 2026-02-08T06:11:23.894960337Z diff --git a/.sdlc/state.yaml b/.sdlc/state.yaml index 70d63e5..64bd639 100644 --- a/.sdlc/state.yaml +++ b/.sdlc/state.yaml @@ -4,10 +4,10 @@ project: active_work: features: - slug: user-preferences - phase: draft + phase: implementation blocked: [] -last_updated: 2026-02-08T05:49:49.197770758Z -last_action: CREATE_FEATURE +last_updated: 2026-02-08T06:11:23.89581271Z +last_action: COMPLETE_TASK last_actor: cli history: - timestamp: 2026-02-08T05:49:49.197770327Z @@ -15,3 +15,83 @@ history: feature: user-preferences actor: cli result: success + - timestamp: 2026-02-08T06:04:02.513963149Z + action: APPROVE_ARTIFACT + feature: user-preferences + actor: user + result: success + - timestamp: 2026-02-08T06:04:02.519799782Z + action: APPROVE_ARTIFACT + feature: user-preferences + actor: user + result: success + - timestamp: 2026-02-08T06:04:02.526201047Z + action: APPROVE_ARTIFACT + feature: user-preferences + actor: user + result: success + - timestamp: 2026-02-08T06:04:02.534360671Z + action: APPROVE_ARTIFACT + feature: user-preferences + actor: user + result: success + - timestamp: 2026-02-08T06:04:08.485187751Z + action: TRANSITION + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:04:08.490394658Z + action: TRANSITION + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:04:08.495775684Z + action: TRANSITION + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:04:08.501113688Z + action: TRANSITION + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:04:59.144474604Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:05:19.520050407Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:06:24.021723363Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:06:53.730244845Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:07:20.601540662Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:08:37.692266683Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:10:41.697480099Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T06:11:23.895812069Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success diff --git a/services/preferences-api/cmd/server/main.go b/services/preferences-api/cmd/server/main.go index d3f60f3..0df097d 100644 --- a/services/preferences-api/cmd/server/main.go +++ b/services/preferences-api/cmd/server/main.go @@ -1,15 +1,18 @@ -// Package main is the entry point for the preferences-api service. package main import ( + "context" "flag" "fmt" "os" "git.threesix.ai/jordan/slack5-1770529463/pkg/app" + "git.threesix.ai/jordan/slack5-1770529463/pkg/database" "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/adapter/memory" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/adapter/postgres" "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/api" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/config" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/migrations" "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service" ) @@ -33,17 +36,39 @@ func main() { // Create logger logger := logging.Default() + // Load configuration + cfg := config.Load() + + // Connect to database + ctx := context.Background() + pool := database.MustConnect(ctx, cfg.Database.URL, database.Options{ + MaxOpenConns: cfg.Database.MaxOpenConns, + MaxIdleConns: cfg.Database.MaxIdleConns, + ConnMaxLifetime: cfg.Database.ConnMaxLifetime, + }) + logger.Info("connected to database", "url", pool.URL) + + // Run migrations + database.MustRunMigrations(ctx, pool, migrations.FS, migrations.Dir) + logger.Info("database migrations complete") + // Create adapters (repositories) - exampleRepo := memory.NewExampleRepository() + prefRepo := postgres.NewPreferenceRepository(pool.DB) // Create services (business logic) - exampleService := service.NewExampleService(exampleRepo, logger) + preferenceService := service.NewPreferenceService(prefRepo, logger) // Create application application := app.New("preferences-api", app.WithDefaultPort(8001)) + // Register shutdown hook for database pool + application.OnShutdown(func(ctx context.Context) error { + logger.Info("closing database connection pool") + return pool.Close() + }) + // Register routes with dependency injection - api.RegisterRoutes(application, exampleService) + api.RegisterRoutes(application, preferenceService) // Start server application.Run() diff --git a/services/preferences-api/internal/adapter/memory/example.go b/services/preferences-api/internal/adapter/memory/example.go deleted file mode 100644 index a4dbaa2..0000000 --- a/services/preferences-api/internal/adapter/memory/example.go +++ /dev/null @@ -1,106 +0,0 @@ -// Package memory provides in-memory implementations of repository interfaces. -// Useful for development, testing, and prototyping. -package memory - -import ( - "context" - "sync" - - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port" -) - -// Compile-time verification that ExampleRepository implements port.ExampleRepository. -var _ port.ExampleRepository = (*ExampleRepository)(nil) - -// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository. -type ExampleRepository struct { - mu sync.RWMutex - examples map[domain.ExampleID]*domain.Example -} - -// NewExampleRepository creates a new in-memory example repository. -func NewExampleRepository() *ExampleRepository { - return &ExampleRepository{ - examples: make(map[domain.ExampleID]*domain.Example), - } -} - -// List returns all examples. -func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]domain.Example, 0, len(r.examples)) - for _, e := range r.examples { - result = append(result, *e) - } - return result, nil -} - -// Get returns an example by ID. -// Returns domain.ErrExampleNotFound if not found. -func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - e, ok := r.examples[id] - if !ok { - return nil, domain.ErrExampleNotFound - } - // Return a copy to prevent external mutation - copy := *e - return ©, nil -} - -// Create stores a new example. -func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error { - r.mu.Lock() - defer r.mu.Unlock() - - // Store a copy to prevent external mutation - copy := *example - r.examples[example.ID] = © - return nil -} - -// Update modifies an existing example. -// Returns domain.ErrExampleNotFound if not found. -func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.examples[example.ID]; !ok { - return domain.ErrExampleNotFound - } - // Store a copy to prevent external mutation - copy := *example - r.examples[example.ID] = © - return nil -} - -// Delete removes an example by ID. -// Returns domain.ErrExampleNotFound if not found. -func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.examples[id]; !ok { - return domain.ErrExampleNotFound - } - delete(r.examples, id) - return nil -} - -// ExistsByName checks if an example with the given name exists. -func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, e := range r.examples { - if e.Name == name { - return true, nil - } - } - return false, nil -} diff --git a/services/preferences-api/internal/adapter/postgres/preference.go b/services/preferences-api/internal/adapter/postgres/preference.go new file mode 100644 index 0000000..8768b75 --- /dev/null +++ b/services/preferences-api/internal/adapter/postgres/preference.go @@ -0,0 +1,68 @@ +package postgres + +import ( + "context" + "time" + + "github.com/jmoiron/sqlx" + + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port" +) + +// Compile-time verification that PreferenceRepository implements port.PreferenceRepository. +var _ port.PreferenceRepository = (*PreferenceRepository)(nil) + +// preferenceRow is the database representation of a preference row. +type preferenceRow struct { + UserID string `db:"user_id"` + Key string `db:"key"` + Value string `db:"value"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +// PreferenceRepository is a PostgreSQL implementation of port.PreferenceRepository. +type PreferenceRepository struct { + db *sqlx.DB +} + +// NewPreferenceRepository creates a new PostgreSQL preference repository. +func NewPreferenceRepository(db *sqlx.DB) *PreferenceRepository { + return &PreferenceRepository{db: db} +} + +// GetByUserID returns all stored preferences for a user. +func (r *PreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) { + var rows []preferenceRow + err := r.db.SelectContext(ctx, &rows, + `SELECT user_id, key, value, created_at, updated_at FROM user_preferences WHERE user_id = $1`, + userID, + ) + if err != nil { + return nil, err + } + + result := make([]port.PreferenceRow, len(rows)) + for i, row := range rows { + result[i] = port.PreferenceRow{ + UserID: row.UserID, + Key: row.Key, + Value: row.Value, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + } + return result, nil +} + +// Upsert creates or updates a single preference for a user. +func (r *PreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error { + _, err := r.db.ExecContext(ctx, + `INSERT INTO user_preferences (user_id, key, value, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (user_id, key) + DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + userID, key, value, + ) + return err +} diff --git a/services/preferences-api/internal/api/handlers/example.go b/services/preferences-api/internal/api/handlers/example.go deleted file mode 100644 index 091d10c..0000000 --- a/services/preferences-api/internal/api/handlers/example.go +++ /dev/null @@ -1,170 +0,0 @@ -package handlers - -import ( - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "git.threesix.ai/jordan/slack5-1770529463/pkg/app" - "git.threesix.ai/jordan/slack5-1770529463/pkg/httperror" - "git.threesix.ai/jordan/slack5-1770529463/pkg/httpresponse" - "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service" -) - -// Example handles HTTP requests for example resources. -type Example struct { - svc *service.ExampleService - logger *logging.Logger -} - -// NewExample creates a new Example handler with injected dependencies. -func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example { - return &Example{ - svc: svc, - logger: logger.WithComponent("ExampleHandler"), - } -} - -// CreateRequest is the request body for creating an example. -type CreateRequest struct { - Name string `json:"name" validate:"required,min=1,max=100"` - Description string `json:"description" validate:"max=500"` -} - -// UpdateRequest is the request body for updating an example. -type UpdateRequest struct { - Name string `json:"name" validate:"required,min=1,max=100"` - Description string `json:"description" validate:"max=500"` -} - -// ExampleResponse is the response for an example resource. -type ExampleResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// toResponse converts a domain example to an API response. -func toResponse(e *domain.Example) ExampleResponse { - return ExampleResponse{ - ID: e.ID.String(), - Name: e.Name, - Description: e.Description, - CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"), - } -} - -// List returns all examples. -func (h *Example) List(w http.ResponseWriter, r *http.Request) error { - examples, err := h.svc.List(r.Context()) - if err != nil { - return err - } - - result := make([]ExampleResponse, len(examples)) - for i, e := range examples { - result[i] = toResponse(&e) - } - - httpresponse.OK(w, r, result) - return nil -} - -// Get returns an example by ID. -func (h *Example) Get(w http.ResponseWriter, r *http.Request) error { - id := chi.URLParam(r, "id") - - // Validate UUID format - if _, err := uuid.Parse(id); err != nil { - return httperror.BadRequest("invalid id format") - } - - example, err := h.svc.Get(r.Context(), domain.ExampleID(id)) - if err != nil { - return mapDomainError(err) - } - - httpresponse.OK(w, r, toResponse(example)) - return nil -} - -// Create creates a new example. -func (h *Example) Create(w http.ResponseWriter, r *http.Request) error { - var req CreateRequest - if err := app.BindAndValidate(r, &req); err != nil { - return err - } - - example, err := h.svc.Create(r.Context(), service.CreateInput{ - Name: req.Name, - Description: req.Description, - }) - if err != nil { - return mapDomainError(err) - } - - httpresponse.Created(w, r, toResponse(example)) - return nil -} - -// Update updates an existing example. -func (h *Example) Update(w http.ResponseWriter, r *http.Request) error { - id := chi.URLParam(r, "id") - - if _, err := uuid.Parse(id); err != nil { - return httperror.BadRequest("invalid id format") - } - - var req UpdateRequest - if err := app.BindAndValidate(r, &req); err != nil { - return err - } - - example, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{ - Name: req.Name, - Description: req.Description, - }) - if err != nil { - return mapDomainError(err) - } - - httpresponse.OK(w, r, toResponse(example)) - return nil -} - -// Delete removes an example by ID. -func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error { - id := chi.URLParam(r, "id") - - if _, err := uuid.Parse(id); err != nil { - return httperror.BadRequest("invalid id format") - } - - if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil { - return mapDomainError(err) - } - - httpresponse.NoContent(w) - return nil -} - -// mapDomainError converts domain errors to HTTP errors. -func mapDomainError(err error) error { - switch { - case errors.Is(err, domain.ErrExampleNotFound): - return httperror.NotFound("example not found") - case errors.Is(err, domain.ErrDuplicateExample): - return httperror.Conflict("example with this name already exists") - case errors.Is(err, domain.ErrInvalidExampleName): - return httperror.BadRequest("invalid example name") - default: - return err - } -} diff --git a/services/preferences-api/internal/api/handlers/example_test.go b/services/preferences-api/internal/api/handlers/example_test.go deleted file mode 100644 index 4c25405..0000000 --- a/services/preferences-api/internal/api/handlers/example_test.go +++ /dev/null @@ -1,402 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "sync" - "testing" - - "github.com/go-chi/chi/v5" - - "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service" -) - -// mockExampleRepository implements port.ExampleRepository for testing. -type mockExampleRepository struct { - mu sync.RWMutex - examples map[domain.ExampleID]*domain.Example -} - -var _ port.ExampleRepository = (*mockExampleRepository)(nil) - -func newMockExampleRepository() *mockExampleRepository { - return &mockExampleRepository{ - examples: make(map[domain.ExampleID]*domain.Example), - } -} - -func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - result := make([]domain.Example, 0, len(m.examples)) - for _, e := range m.examples { - result = append(result, *e) - } - return result, nil -} - -func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - e, ok := m.examples[id] - if !ok { - return nil, domain.ErrExampleNotFound - } - copy := *e - return ©, nil -} - -func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error { - m.mu.Lock() - defer m.mu.Unlock() - - copy := *example - m.examples[example.ID] = © - return nil -} - -func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error { - m.mu.Lock() - defer m.mu.Unlock() - - if _, ok := m.examples[example.ID]; !ok { - return domain.ErrExampleNotFound - } - copy := *example - m.examples[example.ID] = © - return nil -} - -func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { - m.mu.Lock() - defer m.mu.Unlock() - - if _, ok := m.examples[id]; !ok { - return domain.ErrExampleNotFound - } - delete(m.examples, id) - return nil -} - -func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, e := range m.examples { - if e.Name == name { - return true, nil - } - } - return false, nil -} - -func newTestHandler() (*Example, *mockExampleRepository) { - repo := newMockExampleRepository() - svc := service.NewExampleService(repo, logging.Nop()) - handler := NewExample(svc, logging.Nop()) - return handler, repo -} - -func TestExample_List(t *testing.T) { - handler, repo := newTestHandler() - - // Seed data - ex, _ := domain.NewExample("test-id-1", "Test Example", "Description") - _ = repo.Create(context.Background(), ex) - - r := chi.NewRouter() - r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) { - if err := handler.List(w, r); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - data, ok := resp["data"] - if !ok { - t.Fatal("expected 'data' field in response") - } - - items, ok := data.([]any) - if !ok { - t.Fatal("expected 'data' to be an array") - } - - if len(items) != 1 { - t.Errorf("expected 1 item, got %d", len(items)) - } -} - -func TestExample_Get(t *testing.T) { - handler, repo := newTestHandler() - - // Seed data - ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description") - _ = repo.Create(context.Background(), ex) - - tests := []struct { - name string - id string - wantStatus int - }{ - { - name: "valid uuid - found", - id: "550e8400-e29b-41d4-a716-446655440000", - wantStatus: http.StatusOK, - }, - { - name: "valid uuid - not found", - id: "550e8400-e29b-41d4-a716-446655440001", - wantStatus: http.StatusNotFound, - }, - { - name: "invalid uuid", - id: "not-a-uuid", - wantStatus: http.StatusBadRequest, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { - if err := handler.Get(w, r); err != nil { - // Map error to status for testing - switch tt.wantStatus { - case http.StatusNotFound: - w.WriteHeader(http.StatusNotFound) - case http.StatusBadRequest: - w.WriteHeader(http.StatusBadRequest) - default: - w.WriteHeader(http.StatusInternalServerError) - } - return - } - }) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != tt.wantStatus { - t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) - } - }) - } -} - -func TestExample_Create(t *testing.T) { - handler, repo := newTestHandler() - - // Seed existing data for duplicate test - ex, _ := domain.NewExample("existing-id", "Existing Name", "") - _ = repo.Create(context.Background(), ex) - - tests := []struct { - name string - body any - wantStatus int - }{ - { - name: "valid request", - body: CreateRequest{ - Name: "New Example", - Description: "A test description", - }, - wantStatus: http.StatusCreated, - }, - { - name: "empty body", - body: nil, - wantStatus: http.StatusBadRequest, - }, - { - name: "duplicate name", - body: CreateRequest{ - Name: "Existing Name", - Description: "Conflict", - }, - wantStatus: http.StatusConflict, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) { - if err := handler.Create(w, r); err != nil { - switch tt.wantStatus { - case http.StatusBadRequest: - w.WriteHeader(http.StatusBadRequest) - case http.StatusConflict: - w.WriteHeader(http.StatusConflict) - default: - w.WriteHeader(http.StatusInternalServerError) - } - return - } - }) - - var body []byte - if tt.body != nil { - var err error - body, err = json.Marshal(tt.body) - if err != nil { - t.Fatalf("failed to marshal body: %v", err) - } - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != tt.wantStatus { - t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) - } - }) - } -} - -func TestExample_Delete(t *testing.T) { - handler, repo := newTestHandler() - - // Seed data - ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "") - _ = repo.Create(context.Background(), ex) - - tests := []struct { - name string - id string - wantStatus int - }{ - { - name: "existing example", - id: "550e8400-e29b-41d4-a716-446655440000", - wantStatus: http.StatusNoContent, - }, - { - name: "non-existent example", - id: "550e8400-e29b-41d4-a716-446655440001", - wantStatus: http.StatusNotFound, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { - if err := handler.Delete(w, r); err != nil { - if tt.wantStatus == http.StatusNotFound { - w.WriteHeader(http.StatusNotFound) - } else { - w.WriteHeader(http.StatusBadRequest) - } - return - } - }) - - req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != tt.wantStatus { - t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) - } - }) - } -} - -func TestExample_Update(t *testing.T) { - handler, repo := newTestHandler() - - // Seed data - ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "") - _ = repo.Create(context.Background(), ex1) - ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "") - _ = repo.Create(context.Background(), ex2) - - tests := []struct { - name string - id string - body UpdateRequest - wantStatus int - }{ - { - name: "valid update", - id: "550e8400-e29b-41d4-a716-446655440000", - body: UpdateRequest{ - Name: "Updated Name", - Description: "Updated", - }, - wantStatus: http.StatusOK, - }, - { - name: "name conflict", - id: "550e8400-e29b-41d4-a716-446655440000", - body: UpdateRequest{ - Name: "Example 2", - Description: "Conflict", - }, - wantStatus: http.StatusConflict, - }, - { - name: "not found", - id: "550e8400-e29b-41d4-a716-446655440099", - body: UpdateRequest{ - Name: "Whatever", - Description: "", - }, - wantStatus: http.StatusNotFound, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { - if err := handler.Update(w, r); err != nil { - switch tt.wantStatus { - case http.StatusNotFound: - w.WriteHeader(http.StatusNotFound) - case http.StatusConflict: - w.WriteHeader(http.StatusConflict) - default: - w.WriteHeader(http.StatusBadRequest) - } - return - } - }) - - body, _ := json.Marshal(tt.body) - req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != tt.wantStatus { - t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) - } - }) - } -} diff --git a/services/preferences-api/internal/api/handlers/preference.go b/services/preferences-api/internal/api/handlers/preference.go new file mode 100644 index 0000000..b004419 --- /dev/null +++ b/services/preferences-api/internal/api/handlers/preference.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.threesix.ai/jordan/slack5-1770529463/pkg/app" + "git.threesix.ai/jordan/slack5-1770529463/pkg/httperror" + "git.threesix.ai/jordan/slack5-1770529463/pkg/httpresponse" + "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service" +) + +// Preference handles HTTP requests for user preferences. +type Preference struct { + svc *service.PreferenceService + logger *logging.Logger +} + +// NewPreference creates a new Preference handler with injected dependencies. +func NewPreference(svc *service.PreferenceService, logger *logging.Logger) *Preference { + return &Preference{ + svc: svc, + logger: logger.WithComponent("PreferenceHandler"), + } +} + +// UpdatePreferencesRequest is the request body for updating preferences. +type UpdatePreferencesRequest struct { + Preferences map[string]any `json:"preferences"` +} + +// PreferencesResponse is the response for preference endpoints. +type PreferencesResponse struct { + UserID string `json:"user_id"` + Preferences map[string]any `json:"preferences"` +} + +// GetPreferences returns all preferences for a user. +func (h *Preference) GetPreferences(w http.ResponseWriter, r *http.Request) error { + userID := chi.URLParam(r, "user_id") + + result, err := h.svc.GetPreferences(r.Context(), userID) + if err != nil { + return mapPreferenceError(err) + } + + httpresponse.OK(w, r, PreferencesResponse{ + UserID: result.UserID, + Preferences: result.Preferences, + }) + return nil +} + +// UpdatePreferences creates or updates preferences for a user. +func (h *Preference) UpdatePreferences(w http.ResponseWriter, r *http.Request) error { + userID := chi.URLParam(r, "user_id") + + var req UpdatePreferencesRequest + if err := app.Bind(r, &req); err != nil { + return err + } + + result, err := h.svc.UpdatePreferences(r.Context(), userID, req.Preferences) + if err != nil { + return mapPreferenceError(err) + } + + httpresponse.OK(w, r, PreferencesResponse{ + UserID: result.UserID, + Preferences: result.Preferences, + }) + return nil +} + +// mapPreferenceError converts domain errors to HTTP errors. +func mapPreferenceError(err error) error { + switch { + case errors.Is(err, domain.ErrInvalidUserID): + return httperror.BadRequest("invalid user_id format: must be a valid UUID") + case errors.Is(err, domain.ErrUnknownPreferenceKey): + return httperror.BadRequest(err.Error()) + case errors.Is(err, domain.ErrInvalidPreferenceValue): + return httperror.BadRequest(err.Error()) + default: + return err + } +} diff --git a/services/preferences-api/internal/api/handlers/preference_test.go b/services/preferences-api/internal/api/handlers/preference_test.go new file mode 100644 index 0000000..cf4e86c --- /dev/null +++ b/services/preferences-api/internal/api/handlers/preference_test.go @@ -0,0 +1,301 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/go-chi/chi/v5" + + "git.threesix.ai/jordan/slack5-1770529463/pkg/app" + "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service" +) + +// mockPreferenceRepository implements port.PreferenceRepository for testing. +type mockPreferenceRepository struct { + mu sync.RWMutex + prefs map[string]map[string]string +} + +var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil) + +func newMockPreferenceRepository() *mockPreferenceRepository { + return &mockPreferenceRepository{ + prefs: make(map[string]map[string]string), + } +} + +func (m *mockPreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + userPrefs, ok := m.prefs[userID] + if !ok { + return nil, nil + } + + rows := make([]port.PreferenceRow, 0, len(userPrefs)) + for k, v := range userPrefs { + rows = append(rows, port.PreferenceRow{ + UserID: userID, + Key: k, + Value: v, + }) + } + return rows, nil +} + +func (m *mockPreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.prefs[userID] == nil { + m.prefs[userID] = make(map[string]string) + } + m.prefs[userID][key] = value + return nil +} + +func newTestPreferenceHandler() (*Preference, *mockPreferenceRepository) { + repo := newMockPreferenceRepository() + svc := service.NewPreferenceService(repo, logging.Nop()) + handler := NewPreference(svc, logging.Nop()) + return handler, repo +} + +func TestPreference_GetPreferences(t *testing.T) { + handler, repo := newTestPreferenceHandler() + validUserID := "550e8400-e29b-41d4-a716-446655440000" + + t.Run("returns defaults for new user", func(t *testing.T) { + r := chi.NewRouter() + r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences)) + + req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+validUserID, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatal("expected 'data' field as object in response") + } + + if data["user_id"] != validUserID { + t.Errorf("expected user_id %s, got %v", validUserID, data["user_id"]) + } + + prefs, ok := data["preferences"].(map[string]any) + if !ok { + t.Fatal("expected 'preferences' field as object in data") + } + + if prefs["theme"] != "system" { + t.Errorf("expected default theme 'system', got %v", prefs["theme"]) + } + if prefs["language"] != "en" { + t.Errorf("expected default language 'en', got %v", prefs["language"]) + } + if prefs["notifications_enabled"] != true { + t.Errorf("expected default notifications_enabled true, got %v", prefs["notifications_enabled"]) + } + }) + + t.Run("returns stored preferences merged with defaults", func(t *testing.T) { + _ = repo.Upsert(context.Background(), validUserID, "theme", "dark") + + r := chi.NewRouter() + r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences)) + + req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+validUserID, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp map[string]any + _ = json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].(map[string]any) + prefs := data["preferences"].(map[string]any) + + if prefs["theme"] != "dark" { + t.Errorf("expected theme 'dark', got %v", prefs["theme"]) + } + }) + + t.Run("returns 400 for invalid user_id", func(t *testing.T) { + r := chi.NewRouter() + r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences)) + + req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/not-a-uuid", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } + }) + + t.Run("response has meta field", func(t *testing.T) { + r := chi.NewRouter() + r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences)) + + req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+validUserID, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var resp map[string]any + _ = json.NewDecoder(w.Body).Decode(&resp) + + if _, ok := resp["meta"]; !ok { + t.Error("expected 'meta' field in response") + } + }) +} + +func TestPreference_UpdatePreferences(t *testing.T) { + handler, _ := newTestPreferenceHandler() + validUserID := "550e8400-e29b-41d4-a716-446655440000" + + t.Run("updates preferences successfully", func(t *testing.T) { + r := chi.NewRouter() + r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences)) + + body, _ := json.Marshal(UpdatePreferencesRequest{ + Preferences: map[string]any{ + "theme": "dark", + "language": "fr", + }, + }) + + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + _ = json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].(map[string]any) + prefs := data["preferences"].(map[string]any) + + if prefs["theme"] != "dark" { + t.Errorf("expected theme 'dark', got %v", prefs["theme"]) + } + if prefs["language"] != "fr" { + t.Errorf("expected language 'fr', got %v", prefs["language"]) + } + // Default should still be present + if prefs["notifications_enabled"] != true { + t.Errorf("expected notifications_enabled true, got %v", prefs["notifications_enabled"]) + } + }) + + t.Run("returns 400 for unknown key", func(t *testing.T) { + r := chi.NewRouter() + r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences)) + + body, _ := json.Marshal(UpdatePreferencesRequest{ + Preferences: map[string]any{ + "font_size": "large", + }, + }) + + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } + }) + + t.Run("returns 400 for invalid value", func(t *testing.T) { + r := chi.NewRouter() + r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences)) + + body, _ := json.Marshal(UpdatePreferencesRequest{ + Preferences: map[string]any{ + "theme": "neon", + }, + }) + + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } + }) + + t.Run("returns 400 for invalid user_id", func(t *testing.T) { + r := chi.NewRouter() + r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences)) + + body, _ := json.Marshal(UpdatePreferencesRequest{ + Preferences: map[string]any{ + "theme": "dark", + }, + }) + + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/bad-id", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } + }) + + t.Run("handles boolean notifications_enabled", func(t *testing.T) { + r := chi.NewRouter() + r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences)) + + body, _ := json.Marshal(UpdatePreferencesRequest{ + Preferences: map[string]any{ + "notifications_enabled": false, + }, + }) + + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + _ = json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].(map[string]any) + prefs := data["preferences"].(map[string]any) + + if prefs["notifications_enabled"] != false { + t.Errorf("expected notifications_enabled false, got %v", prefs["notifications_enabled"]) + } + }) +} diff --git a/services/preferences-api/internal/api/routes.go b/services/preferences-api/internal/api/routes.go index 50a7442..92d2a6d 100644 --- a/services/preferences-api/internal/api/routes.go +++ b/services/preferences-api/internal/api/routes.go @@ -1,4 +1,3 @@ -// Package api provides HTTP routing and handlers for the preferences-api service. package api import ( @@ -10,32 +9,23 @@ import ( ) // RegisterRoutes registers all HTTP routes for the service. -// Routes are mounted under /api/preferences-api to match the ingress path routing. -// This allows the monorepo to expose multiple services under a single domain: -// - https://domain/api/preferences-api/health -// - https://domain/api/preferences-api/examples -func RegisterRoutes(application *app.App, exampleService *service.ExampleService) { +func RegisterRoutes(application *app.App, preferenceService *service.PreferenceService) { logger := application.Logger() cfg := config.Load() // Initialize handlers with injected services healthHandler := handlers.NewHealth(logger) - exampleHandler := handlers.NewExample(exampleService, logger) + prefHandler := handlers.NewPreference(preferenceService, logger) // Build and mount OpenAPI spec spec := NewServiceSpec() application.EnableDocs(spec) // Register API routes under /api/{service-name} to match ingress path routing. - // The ingress routes /api/preferences-api/* to this service. application.Route("/api/preferences-api", func(r app.Router) { r.Get("/health", healthHandler.Check) - // Public routes (no auth required) - r.Get("/examples", app.Wrap(exampleHandler.List)) - r.Get("/examples/{id}", app.Wrap(exampleHandler.Get)) - - // Protected routes (auth required when enabled) + // Preferences routes (auth-protectable) r.Group(func(r app.Router) { if cfg.AuthEnabled { r.Use(auth.Middleware(auth.MiddlewareConfig{ @@ -46,9 +36,8 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService })) } - r.Post("/examples", app.Wrap(exampleHandler.Create)) - r.Put("/examples/{id}", app.Wrap(exampleHandler.Update)) - r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete)) + r.Get("/preferences/{user_id}", app.Wrap(prefHandler.GetPreferences)) + r.Put("/preferences/{user_id}", app.Wrap(prefHandler.UpdatePreferences)) }) }) } diff --git a/services/preferences-api/internal/api/spec.go b/services/preferences-api/internal/api/spec.go index a1228b6..4b54eb9 100644 --- a/services/preferences-api/internal/api/spec.go +++ b/services/preferences-api/internal/api/spec.go @@ -5,29 +5,28 @@ import "git.threesix.ai/jordan/slack5-1770529463/pkg/openapi" // NewServiceSpec builds the OpenAPI specification for the preferences-api service. func NewServiceSpec() *openapi.OpenAPISpec { spec := openapi.NewOpenAPISpec("preferences-api API", "1.0.0"). - WithDescription("REST API for the preferences-api service"). + WithDescription("REST API for managing user preferences"). WithBearerSecurity("bearer", "JWT authentication token"). WithTag("Health", "Service health endpoints"). - WithTag("Examples", "Example CRUD endpoints") + WithTag("Preferences", "User preference endpoints") // Define reusable schemas - spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{ - "id": openapi.UUID().WithDescription("Unique identifier"), - "name": openapi.String().WithDescription("Name of the example").WithExample("My Example"), - "description": openapi.String().WithDescription("Optional description").WithExample("A description"), - "created_at": openapi.DateTime().WithDescription("Creation timestamp"), - "updated_at": openapi.DateTime().WithDescription("Last update timestamp"), - }, "id", "name")) + spec.WithSchema("UserPreferences", openapi.Object(map[string]openapi.Schema{ + "user_id": openapi.UUID().WithDescription("User identifier"), + "preferences": openapi.Object(map[string]openapi.Schema{ + "theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme").WithDefault("system"), + "language": openapi.String().WithDescription("BCP 47 language tag").WithExample("en"), + "notifications_enabled": openapi.Bool().WithDescription("Whether notifications are enabled").WithDefault(true), + }), + }, "user_id", "preferences")) - spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{ - "name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"), - "description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"), - }, "name")) - - spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{ - "name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"), - "description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"), - })) + spec.WithSchema("UpdatePreferencesRequest", openapi.Object(map[string]openapi.Schema{ + "preferences": openapi.Object(map[string]openapi.Schema{ + "theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme"), + "language": openapi.String().WithDescription("BCP 47 language tag").WithExample("fr"), + "notifications_enabled": openapi.Bool().WithDescription("Whether notifications are enabled"), + }), + }, "preferences")) // Health spec.AddPath("/api/preferences-api/health", "get", map[string]any{ @@ -41,70 +40,30 @@ func NewServiceSpec() *openapi.OpenAPISpec { }, }) - // List examples - spec.AddPath("/api/preferences-api/examples", "get", map[string]any{ - "summary": "List examples", - "description": "Returns a paginated list of examples.", - "tags": []string{"Examples"}, - "parameters": []any{openapi.PageParam(), openapi.PerPageParam()}, - "responses": map[string]any{ - "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))), - }, - }) - - // Get example - spec.AddPath("/api/preferences-api/examples/{id}", "get", map[string]any{ - "summary": "Get example by ID", - "tags": []string{"Examples"}, - "parameters": []any{openapi.IDParam()}, - "responses": map[string]any{ - "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))), - "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()), - }, - }) - - // Create example - spec.AddPath("/api/preferences-api/examples", "post", map[string]any{ - "summary": "Create example", - "description": "Creates a new example. Requires authentication.", - "tags": []string{"Examples"}, + // Get preferences + spec.AddPath("/api/preferences-api/preferences/{user_id}", "get", map[string]any{ + "summary": "Get user preferences", + "description": "Returns all preferences for a user, merging stored values with server-defined defaults.", + "tags": []string{"Preferences"}, "security": []map[string][]string{{"bearer": {}}}, - "requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true), + "parameters": []any{openapi.PathParamWithSchema("user_id", "User identifier (UUID)", openapi.UUID())}, "responses": map[string]any{ - "201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))), - "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), - "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), - "422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()), + "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("UserPreferences"))), + "400": openapi.OpResponse("Invalid user_id format", openapi.ErrorResponseSchema()), }, }) - // Update example - spec.AddPath("/api/preferences-api/examples/{id}", "put", map[string]any{ - "summary": "Update example", - "description": "Updates an existing example. Requires authentication.", - "tags": []string{"Examples"}, + // Update preferences + spec.AddPath("/api/preferences-api/preferences/{user_id}", "put", map[string]any{ + "summary": "Update user preferences", + "description": "Creates or updates preferences for the given user. Only provided keys are updated; omitted keys retain their current value or default.", + "tags": []string{"Preferences"}, "security": []map[string][]string{{"bearer": {}}}, - "parameters": []any{openapi.IDParam()}, - "requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true), + "parameters": []any{openapi.PathParamWithSchema("user_id", "User identifier (UUID)", openapi.UUID())}, + "requestBody": openapi.RequestBody(openapi.Ref("UpdatePreferencesRequest"), true), "responses": map[string]any{ - "200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))), - "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), - "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), - "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()), - }, - }) - - // Delete example - spec.AddPath("/api/preferences-api/examples/{id}", "delete", map[string]any{ - "summary": "Delete example", - "description": "Deletes an example by ID. Requires authentication.", - "tags": []string{"Examples"}, - "security": []map[string][]string{{"bearer": {}}}, - "parameters": []any{openapi.IDParam()}, - "responses": map[string]any{ - "204": openapi.OpResponseNoContent(), - "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), - "404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()), + "200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("UserPreferences"))), + "400": openapi.OpResponse("Bad request (invalid user_id, unknown key, or invalid value)", openapi.ErrorResponseSchema()), }, }) diff --git a/services/preferences-api/internal/domain/errors.go b/services/preferences-api/internal/domain/errors.go index d4ffe10..e526009 100644 --- a/services/preferences-api/internal/domain/errors.go +++ b/services/preferences-api/internal/domain/errors.go @@ -1,21 +1,15 @@ -// Package domain contains pure domain models with no external dependencies. -// These types represent the core business concepts of the service. package domain import "errors" -// Domain errors - these are business-level errors that should be translated -// to appropriate HTTP status codes by the handler layer. +// Domain errors for user preferences. var ( - // ErrNotFound indicates a requested resource does not exist. - ErrNotFound = errors.New("not found") + // ErrUnknownPreferenceKey indicates an unknown preference key was provided. + ErrUnknownPreferenceKey = errors.New("unknown preference key") - // ErrExampleNotFound indicates the requested example does not exist. - ErrExampleNotFound = errors.New("example not found") + // ErrInvalidPreferenceValue indicates a preference value failed validation. + ErrInvalidPreferenceValue = errors.New("invalid preference value") - // ErrDuplicateExample indicates an example with the same name already exists. - ErrDuplicateExample = errors.New("example with this name already exists") - - // ErrInvalidExampleName indicates the example name is invalid. - ErrInvalidExampleName = errors.New("invalid example name") + // ErrInvalidUserID indicates the user_id is not a valid UUID. + ErrInvalidUserID = errors.New("invalid user id") ) diff --git a/services/preferences-api/internal/domain/example.go b/services/preferences-api/internal/domain/example.go deleted file mode 100644 index 4ee48e9..0000000 --- a/services/preferences-api/internal/domain/example.go +++ /dev/null @@ -1,89 +0,0 @@ -package domain - -import ( - "time" - "unicode/utf8" -) - -// ExampleID is a strongly-typed identifier for examples. -type ExampleID string - -// String returns the string representation of the ID. -func (id ExampleID) String() string { - return string(id) -} - -// IsZero returns true if the ID is empty. -func (id ExampleID) IsZero() bool { - return id == "" -} - -// Example name constraints. -const ( - MinExampleNameLen = 1 - MaxExampleNameLen = 100 - MaxDescriptionLen = 500 -) - -// Example represents an example domain entity. -// This is a pure domain model with no external dependencies. -type Example struct { - ID ExampleID - Name string - Description string - CreatedAt time.Time - UpdatedAt time.Time -} - -// NewExample creates a new Example with validation. -// Returns ErrInvalidExampleName if the name is invalid. -func NewExample(id ExampleID, name, description string) (*Example, error) { - if err := validateExampleName(name); err != nil { - return nil, err - } - if err := validateDescription(description); err != nil { - return nil, err - } - - now := time.Now().UTC() - return &Example{ - ID: id, - Name: name, - Description: description, - CreatedAt: now, - UpdatedAt: now, - }, nil -} - -// Update modifies the example's mutable fields with validation. -// Returns ErrInvalidExampleName if the name is invalid. -func (e *Example) Update(name, description string) error { - if err := validateExampleName(name); err != nil { - return err - } - if err := validateDescription(description); err != nil { - return err - } - - e.Name = name - e.Description = description - e.UpdatedAt = time.Now().UTC() - return nil -} - -// validateExampleName validates an example name. -func validateExampleName(name string) error { - length := utf8.RuneCountInString(name) - if length < MinExampleNameLen || length > MaxExampleNameLen { - return ErrInvalidExampleName - } - return nil -} - -// validateDescription validates a description. -func validateDescription(desc string) error { - if utf8.RuneCountInString(desc) > MaxDescriptionLen { - return ErrInvalidExampleName - } - return nil -} diff --git a/services/preferences-api/internal/domain/preference.go b/services/preferences-api/internal/domain/preference.go new file mode 100644 index 0000000..5727ba3 --- /dev/null +++ b/services/preferences-api/internal/domain/preference.go @@ -0,0 +1,133 @@ +package domain + +import ( + "fmt" + "regexp" +) + +// PreferenceKey is a typed key for a known preference. +type PreferenceKey string + +const ( + KeyTheme PreferenceKey = "theme" + KeyLanguage PreferenceKey = "language" + KeyNotificationsEnabled PreferenceKey = "notifications_enabled" +) + +// PreferenceDefinition describes a known preference key with its default and validator. +type PreferenceDefinition struct { + Key PreferenceKey + DefaultValue string + Validate func(value string) error +} + +// UserPreferences is the aggregate representing all preferences for a user. +type UserPreferences struct { + UserID string + Preferences map[PreferenceKey]string +} + +// uuidRegex validates UUID format. +var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// bcp47Regex validates BCP 47 language tags. +var bcp47Regex = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*$`) + +// validThemes is the set of allowed theme values. +var validThemes = map[string]bool{ + "light": true, + "dark": true, + "system": true, +} + +// registry holds the definitions for all known preference keys. +var registry = map[PreferenceKey]PreferenceDefinition{ + KeyTheme: { + Key: KeyTheme, + DefaultValue: "system", + Validate: func(value string) error { + if !validThemes[value] { + return fmt.Errorf("%w: theme must be one of: light, dark, system", ErrInvalidPreferenceValue) + } + return nil + }, + }, + KeyLanguage: { + Key: KeyLanguage, + DefaultValue: "en", + Validate: func(value string) error { + if !bcp47Regex.MatchString(value) { + return fmt.Errorf("%w: language must be a valid BCP 47 tag", ErrInvalidPreferenceValue) + } + return nil + }, + }, + KeyNotificationsEnabled: { + Key: KeyNotificationsEnabled, + DefaultValue: "true", + Validate: func(value string) error { + if value != "true" && value != "false" { + return fmt.Errorf("%w: notifications_enabled must be true or false", ErrInvalidPreferenceValue) + } + return nil + }, + }, +} + +// ValidateUserID checks that userID is a valid UUID. +func ValidateUserID(userID string) error { + if !uuidRegex.MatchString(userID) { + return ErrInvalidUserID + } + return nil +} + +// ValidateKey checks that key is a known preference key. +func ValidateKey(key string) error { + if _, ok := registry[PreferenceKey(key)]; !ok { + return fmt.Errorf("%w: %s", ErrUnknownPreferenceKey, key) + } + return nil +} + +// ValidateValue validates a value for the given preference key. +func ValidateValue(key PreferenceKey, value string) error { + def, ok := registry[key] + if !ok { + return fmt.Errorf("%w: %s", ErrUnknownPreferenceKey, key) + } + return def.Validate(value) +} + +// DefaultPreferences returns all known keys with their default values. +func DefaultPreferences() map[PreferenceKey]string { + defaults := make(map[PreferenceKey]string, len(registry)) + for k, def := range registry { + defaults[k] = def.DefaultValue + } + return defaults +} + +// MergeWithDefaults fills in missing keys from defaults, preserving stored values. +func MergeWithDefaults(stored map[PreferenceKey]string) map[PreferenceKey]string { + merged := DefaultPreferences() + for k, v := range stored { + merged[k] = v + } + return merged +} + +// SerializeForResponse converts stored string values to typed values for JSON. +// notifications_enabled "true"/"false" becomes boolean true/false. +func SerializeForResponse(prefs map[PreferenceKey]string) map[string]any { + result := make(map[string]any, len(prefs)) + for k, v := range prefs { + switch k { + case KeyNotificationsEnabled: + result[string(k)] = v == "true" + default: + result[string(k)] = v + } + } + return result +} diff --git a/services/preferences-api/internal/migrations/embed.go b/services/preferences-api/internal/migrations/embed.go new file mode 100644 index 0000000..1d259c9 --- /dev/null +++ b/services/preferences-api/internal/migrations/embed.go @@ -0,0 +1,9 @@ +package migrations + +import "embed" + +//go:embed sql/*.sql +var FS embed.FS + +// Dir is the directory within the embedded filesystem containing migration files. +const Dir = "sql" diff --git a/services/preferences-api/internal/migrations/sql/001_create_user_preferences.sql b/services/preferences-api/internal/migrations/sql/001_create_user_preferences.sql new file mode 100644 index 0000000..cd86642 --- /dev/null +++ b/services/preferences-api/internal/migrations/sql/001_create_user_preferences.sql @@ -0,0 +1,10 @@ +CREATE TABLE user_preferences ( + user_id UUID NOT NULL, + key VARCHAR(64) NOT NULL, + value TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, key) +); + +CREATE INDEX idx_user_preferences_user_id ON user_preferences (user_id); diff --git a/services/preferences-api/internal/port/example.go b/services/preferences-api/internal/port/example.go deleted file mode 100644 index 0a6cf63..0000000 --- a/services/preferences-api/internal/port/example.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package port defines interfaces (ports) for external dependencies. -// These interfaces define the contracts between the application core and -// infrastructure adapters, enabling testability and flexibility. -package port - -import ( - "context" - - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" -) - -// ExampleRepository defines the interface for example persistence operations. -// Implementations may use databases, in-memory storage, or external services. -type ExampleRepository interface { - // List returns all examples. - List(ctx context.Context) ([]domain.Example, error) - - // Get returns an example by ID. - // Returns domain.ErrExampleNotFound if not found. - Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) - - // Create stores a new example. - // The example must have a valid ID set. - Create(ctx context.Context, example *domain.Example) error - - // Update modifies an existing example. - // Returns domain.ErrExampleNotFound if not found. - Update(ctx context.Context, example *domain.Example) error - - // Delete removes an example by ID. - // Returns domain.ErrExampleNotFound if not found. - Delete(ctx context.Context, id domain.ExampleID) error - - // ExistsByName checks if an example with the given name exists. - // Used for duplicate detection. - ExistsByName(ctx context.Context, name string) (bool, error) -} diff --git a/services/preferences-api/internal/port/preference.go b/services/preferences-api/internal/port/preference.go new file mode 100644 index 0000000..658f06f --- /dev/null +++ b/services/preferences-api/internal/port/preference.go @@ -0,0 +1,25 @@ +package port + +import ( + "context" + "time" +) + +// PreferenceRow represents a single preference row from the database. +type PreferenceRow struct { + UserID string + Key string + Value string + CreatedAt time.Time + UpdatedAt time.Time +} + +// PreferenceRepository defines the interface for preference persistence operations. +type PreferenceRepository interface { + // GetByUserID returns all stored preferences for a user. + // Returns an empty slice (not an error) if no preferences exist. + GetByUserID(ctx context.Context, userID string) ([]PreferenceRow, error) + + // Upsert creates or updates a single preference for a user. + Upsert(ctx context.Context, userID string, key string, value string) error +} diff --git a/services/preferences-api/internal/service/example.go b/services/preferences-api/internal/service/example.go deleted file mode 100644 index 7f34b0a..0000000 --- a/services/preferences-api/internal/service/example.go +++ /dev/null @@ -1,137 +0,0 @@ -// Package service provides business logic / use cases for the application. -// Services orchestrate domain operations using port interfaces. -package service - -import ( - "context" - "errors" - - "github.com/google/uuid" - - "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port" -) - -// ExampleService handles example-related business logic. -type ExampleService struct { - repo port.ExampleRepository - logger *logging.Logger -} - -// NewExampleService creates a new example service. -func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService { - return &ExampleService{ - repo: repo, - logger: logger.WithService("ExampleService"), - } -} - -// List returns all examples. -func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) { - return s.repo.List(ctx) -} - -// Get returns an example by ID. -// Returns domain.ErrExampleNotFound if not found. -func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { - return s.repo.Get(ctx, id) -} - -// CreateInput contains the data needed to create an example. -type CreateInput struct { - Name string - Description string -} - -// Create creates a new example with duplicate detection. -// Returns domain.ErrDuplicateExample if name already exists. -// Returns domain.ErrInvalidExampleName if name is invalid. -func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) { - // Check for duplicates - exists, err := s.repo.ExistsByName(ctx, input.Name) - if err != nil { - return nil, err - } - if exists { - return nil, domain.ErrDuplicateExample - } - - // Generate new ID - id := domain.ExampleID(uuid.New().String()) - - // Create domain entity (validates name) - example, err := domain.NewExample(id, input.Name, input.Description) - if err != nil { - return nil, err - } - - // Persist - if err := s.repo.Create(ctx, example); err != nil { - return nil, err - } - - s.logger.Info("example created", "id", id, "name", input.Name) - return example, nil -} - -// UpdateInput contains the data needed to update an example. -type UpdateInput struct { - Name string - Description string -} - -// Update modifies an existing example. -// Returns domain.ErrExampleNotFound if not found. -// Returns domain.ErrDuplicateExample if new name conflicts with another example. -// Returns domain.ErrInvalidExampleName if name is invalid. -func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) { - // Fetch existing - example, err := s.repo.Get(ctx, id) - if err != nil { - return nil, err - } - - // Check for name conflicts (only if name changed) - if example.Name != input.Name { - exists, err := s.repo.ExistsByName(ctx, input.Name) - if err != nil { - return nil, err - } - if exists { - return nil, domain.ErrDuplicateExample - } - } - - // Update domain entity (validates name) - if err := example.Update(input.Name, input.Description); err != nil { - return nil, err - } - - // Persist - if err := s.repo.Update(ctx, example); err != nil { - return nil, err - } - - s.logger.Info("example updated", "id", id, "name", input.Name) - return example, nil -} - -// Delete removes an example by ID. -// Returns domain.ErrExampleNotFound if not found. -func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error { - // Verify exists before delete - if _, err := s.repo.Get(ctx, id); err != nil { - if errors.Is(err, domain.ErrExampleNotFound) { - return domain.ErrExampleNotFound - } - return err - } - - if err := s.repo.Delete(ctx, id); err != nil { - return err - } - - s.logger.Info("example deleted", "id", id) - return nil -} diff --git a/services/preferences-api/internal/service/example_test.go b/services/preferences-api/internal/service/example_test.go deleted file mode 100644 index db72974..0000000 --- a/services/preferences-api/internal/service/example_test.go +++ /dev/null @@ -1,282 +0,0 @@ -package service - -import ( - "context" - "sync" - "testing" - - "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port" -) - -// mockExampleRepository implements port.ExampleRepository for testing. -type mockExampleRepository struct { - mu sync.RWMutex - examples map[domain.ExampleID]*domain.Example -} - -var _ port.ExampleRepository = (*mockExampleRepository)(nil) - -func newMockExampleRepository() *mockExampleRepository { - return &mockExampleRepository{ - examples: make(map[domain.ExampleID]*domain.Example), - } -} - -func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - result := make([]domain.Example, 0, len(m.examples)) - for _, e := range m.examples { - result = append(result, *e) - } - return result, nil -} - -func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - e, ok := m.examples[id] - if !ok { - return nil, domain.ErrExampleNotFound - } - // Return a copy to avoid mutation - copy := *e - return ©, nil -} - -func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error { - m.mu.Lock() - defer m.mu.Unlock() - - // Store a copy - copy := *example - m.examples[example.ID] = © - return nil -} - -func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error { - m.mu.Lock() - defer m.mu.Unlock() - - if _, ok := m.examples[example.ID]; !ok { - return domain.ErrExampleNotFound - } - // Store a copy - copy := *example - m.examples[example.ID] = © - return nil -} - -func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { - m.mu.Lock() - defer m.mu.Unlock() - - if _, ok := m.examples[id]; !ok { - return domain.ErrExampleNotFound - } - delete(m.examples, id) - return nil -} - -func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, e := range m.examples { - if e.Name == name { - return true, nil - } - } - return false, nil -} - -func TestExampleService_Create(t *testing.T) { - repo := newMockExampleRepository() - svc := NewExampleService(repo, logging.Nop()) - - t.Run("creates example successfully", func(t *testing.T) { - example, err := svc.Create(context.Background(), CreateInput{ - Name: "Test Example", - Description: "A test description", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if example.Name != "Test Example" { - t.Errorf("expected name 'Test Example', got '%s'", example.Name) - } - if example.ID.IsZero() { - t.Error("expected non-empty ID") - } - }) - - t.Run("rejects duplicate name", func(t *testing.T) { - _, err := svc.Create(context.Background(), CreateInput{ - Name: "Test Example", - Description: "Another description", - }) - if err != domain.ErrDuplicateExample { - t.Errorf("expected ErrDuplicateExample, got %v", err) - } - }) - - t.Run("rejects empty name", func(t *testing.T) { - _, err := svc.Create(context.Background(), CreateInput{ - Name: "", - Description: "Description", - }) - if err != domain.ErrInvalidExampleName { - t.Errorf("expected ErrInvalidExampleName, got %v", err) - } - }) -} - -func TestExampleService_Get(t *testing.T) { - repo := newMockExampleRepository() - svc := NewExampleService(repo, logging.Nop()) - - // Create an example first - created, _ := svc.Create(context.Background(), CreateInput{ - Name: "Get Test", - Description: "Description", - }) - - t.Run("returns existing example", func(t *testing.T) { - example, err := svc.Get(context.Background(), created.ID) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if example.Name != "Get Test" { - t.Errorf("expected name 'Get Test', got '%s'", example.Name) - } - }) - - t.Run("returns not found for missing example", func(t *testing.T) { - _, err := svc.Get(context.Background(), "nonexistent-id") - if err != domain.ErrExampleNotFound { - t.Errorf("expected ErrExampleNotFound, got %v", err) - } - }) -} - -func TestExampleService_Update(t *testing.T) { - repo := newMockExampleRepository() - svc := NewExampleService(repo, logging.Nop()) - - // Create examples - example1, _ := svc.Create(context.Background(), CreateInput{ - Name: "Update Test 1", - Description: "Original", - }) - _, _ = svc.Create(context.Background(), CreateInput{ - Name: "Update Test 2", - Description: "Other", - }) - - t.Run("updates example successfully", func(t *testing.T) { - updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{ - Name: "Updated Name", - Description: "Updated description", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if updated.Name != "Updated Name" { - t.Errorf("expected name 'Updated Name', got '%s'", updated.Name) - } - }) - - t.Run("allows same name on same example", func(t *testing.T) { - _, err := svc.Update(context.Background(), example1.ID, UpdateInput{ - Name: "Updated Name", - Description: "Same name", - }) - if err != nil { - t.Errorf("unexpected error updating with same name: %v", err) - } - }) - - t.Run("rejects name conflict", func(t *testing.T) { - _, err := svc.Update(context.Background(), example1.ID, UpdateInput{ - Name: "Update Test 2", - Description: "Conflict", - }) - if err != domain.ErrDuplicateExample { - t.Errorf("expected ErrDuplicateExample, got %v", err) - } - }) - - t.Run("returns not found for missing example", func(t *testing.T) { - _, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{ - Name: "Anything", - Description: "", - }) - if err != domain.ErrExampleNotFound { - t.Errorf("expected ErrExampleNotFound, got %v", err) - } - }) -} - -func TestExampleService_Delete(t *testing.T) { - repo := newMockExampleRepository() - svc := NewExampleService(repo, logging.Nop()) - - // Create an example first - created, _ := svc.Create(context.Background(), CreateInput{ - Name: "Delete Test", - Description: "To be deleted", - }) - - t.Run("deletes example successfully", func(t *testing.T) { - err := svc.Delete(context.Background(), created.ID) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify deleted - _, err = svc.Get(context.Background(), created.ID) - if err != domain.ErrExampleNotFound { - t.Errorf("expected ErrExampleNotFound after delete, got %v", err) - } - }) - - t.Run("returns not found for missing example", func(t *testing.T) { - err := svc.Delete(context.Background(), "nonexistent-id") - if err != domain.ErrExampleNotFound { - t.Errorf("expected ErrExampleNotFound, got %v", err) - } - }) -} - -func TestExampleService_List(t *testing.T) { - repo := newMockExampleRepository() - svc := NewExampleService(repo, logging.Nop()) - - t.Run("returns empty list initially", func(t *testing.T) { - examples, err := svc.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(examples) != 0 { - t.Errorf("expected 0 examples, got %d", len(examples)) - } - }) - - // Create some examples - _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""}) - _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""}) - - t.Run("returns all examples", func(t *testing.T) { - examples, err := svc.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(examples) != 2 { - t.Errorf("expected 2 examples, got %d", len(examples)) - } - }) -} diff --git a/services/preferences-api/internal/service/preference.go b/services/preferences-api/internal/service/preference.go new file mode 100644 index 0000000..f253d52 --- /dev/null +++ b/services/preferences-api/internal/service/preference.go @@ -0,0 +1,119 @@ +package service + +import ( + "context" + "fmt" + + "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port" +) + +// PreferencesResult is the result of a preference operation. +type PreferencesResult struct { + UserID string + Preferences map[string]any +} + +// PreferenceService handles preference business logic. +type PreferenceService struct { + repo port.PreferenceRepository + logger *logging.Logger +} + +// NewPreferenceService creates a new preference service. +func NewPreferenceService(repo port.PreferenceRepository, logger *logging.Logger) *PreferenceService { + return &PreferenceService{ + repo: repo, + logger: logger.WithService("PreferenceService"), + } +} + +// GetPreferences returns all preferences for a user, merged with defaults. +func (s *PreferenceService) GetPreferences(ctx context.Context, userID string) (*PreferencesResult, error) { + if err := domain.ValidateUserID(userID); err != nil { + return nil, err + } + + rows, err := s.repo.GetByUserID(ctx, userID) + if err != nil { + return nil, err + } + + stored := make(map[domain.PreferenceKey]string, len(rows)) + for _, row := range rows { + stored[domain.PreferenceKey(row.Key)] = row.Value + } + + merged := domain.MergeWithDefaults(stored) + serialized := domain.SerializeForResponse(merged) + + return &PreferencesResult{ + UserID: userID, + Preferences: serialized, + }, nil +} + +// UpdatePreferences validates and upserts the provided preferences, then returns the full merged result. +func (s *PreferenceService) UpdatePreferences(ctx context.Context, userID string, input map[string]any) (*PreferencesResult, error) { + if err := domain.ValidateUserID(userID); err != nil { + return nil, err + } + + // Validate and serialize each input key-value pair + toUpsert := make(map[domain.PreferenceKey]string, len(input)) + for key, val := range input { + if err := domain.ValidateKey(key); err != nil { + return nil, err + } + + // Serialize value to string + strVal, err := serializeValue(val) + if err != nil { + return nil, fmt.Errorf("%w: %s must have a valid value", domain.ErrInvalidPreferenceValue, key) + } + + pk := domain.PreferenceKey(key) + if err := domain.ValidateValue(pk, strVal); err != nil { + return nil, err + } + + toUpsert[pk] = strVal + } + + // Upsert each preference + for key, value := range toUpsert { + if err := s.repo.Upsert(ctx, userID, string(key), value); err != nil { + return nil, err + } + } + + s.logger.Info("preferences updated", "user_id", userID, "keys_updated", len(toUpsert)) + + // Fetch and return full merged preferences + return s.GetPreferences(ctx, userID) +} + +// serializeValue converts an any value to a string representation. +func serializeValue(val any) (string, error) { + switch v := val.(type) { + case string: + return v, nil + case bool: + if v { + return "true", nil + } + return "false", nil + case float64: + // JSON numbers are decoded as float64 + if v == 1 { + return "true", nil + } + if v == 0 { + return "false", nil + } + return "", fmt.Errorf("unsupported numeric value") + default: + return "", fmt.Errorf("unsupported value type %T", val) + } +} diff --git a/services/preferences-api/internal/service/preference_test.go b/services/preferences-api/internal/service/preference_test.go new file mode 100644 index 0000000..fc3cfec --- /dev/null +++ b/services/preferences-api/internal/service/preference_test.go @@ -0,0 +1,253 @@ +package service + +import ( + "context" + "sync" + "testing" + + "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" + "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port" +) + +// mockPreferenceRepository implements port.PreferenceRepository for testing. +type mockPreferenceRepository struct { + mu sync.RWMutex + prefs map[string]map[string]string // userID -> key -> value +} + +var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil) + +func newMockPreferenceRepository() *mockPreferenceRepository { + return &mockPreferenceRepository{ + prefs: make(map[string]map[string]string), + } +} + +func (m *mockPreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + userPrefs, ok := m.prefs[userID] + if !ok { + return nil, nil + } + + rows := make([]port.PreferenceRow, 0, len(userPrefs)) + for k, v := range userPrefs { + rows = append(rows, port.PreferenceRow{ + UserID: userID, + Key: k, + Value: v, + }) + } + return rows, nil +} + +func (m *mockPreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.prefs[userID] == nil { + m.prefs[userID] = make(map[string]string) + } + m.prefs[userID][key] = value + return nil +} + +func TestPreferenceService_GetPreferences(t *testing.T) { + repo := newMockPreferenceRepository() + svc := NewPreferenceService(repo, logging.Nop()) + ctx := context.Background() + validUserID := "550e8400-e29b-41d4-a716-446655440000" + + t.Run("returns defaults when no preferences stored", func(t *testing.T) { + result, err := svc.GetPreferences(ctx, validUserID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.UserID != validUserID { + t.Errorf("expected user_id %s, got %s", validUserID, result.UserID) + } + if result.Preferences["theme"] != "system" { + t.Errorf("expected default theme 'system', got %v", result.Preferences["theme"]) + } + if result.Preferences["language"] != "en" { + t.Errorf("expected default language 'en', got %v", result.Preferences["language"]) + } + if result.Preferences["notifications_enabled"] != true { + t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"]) + } + }) + + t.Run("merges stored values with defaults", func(t *testing.T) { + // Store only theme + _ = repo.Upsert(ctx, validUserID, "theme", "dark") + + result, err := svc.GetPreferences(ctx, validUserID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Preferences["theme"] != "dark" { + t.Errorf("expected theme 'dark', got %v", result.Preferences["theme"]) + } + if result.Preferences["language"] != "en" { + t.Errorf("expected default language 'en', got %v", result.Preferences["language"]) + } + if result.Preferences["notifications_enabled"] != true { + t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"]) + } + }) + + t.Run("returns all stored values when all keys present", func(t *testing.T) { + userID := "660e8400-e29b-41d4-a716-446655440000" + _ = repo.Upsert(ctx, userID, "theme", "light") + _ = repo.Upsert(ctx, userID, "language", "fr") + _ = repo.Upsert(ctx, userID, "notifications_enabled", "false") + + result, err := svc.GetPreferences(ctx, userID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Preferences["theme"] != "light" { + t.Errorf("expected theme 'light', got %v", result.Preferences["theme"]) + } + if result.Preferences["language"] != "fr" { + t.Errorf("expected language 'fr', got %v", result.Preferences["language"]) + } + if result.Preferences["notifications_enabled"] != false { + t.Errorf("expected notifications_enabled false, got %v", result.Preferences["notifications_enabled"]) + } + }) + + t.Run("returns error for invalid user_id", func(t *testing.T) { + _, err := svc.GetPreferences(ctx, "not-a-uuid") + if err != domain.ErrInvalidUserID { + t.Errorf("expected ErrInvalidUserID, got %v", err) + } + }) +} + +func TestPreferenceService_UpdatePreferences(t *testing.T) { + repo := newMockPreferenceRepository() + svc := NewPreferenceService(repo, logging.Nop()) + ctx := context.Background() + validUserID := "550e8400-e29b-41d4-a716-446655440000" + + t.Run("updates preferences and returns full result", func(t *testing.T) { + result, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ + "theme": "dark", + "language": "fr", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Preferences["theme"] != "dark" { + t.Errorf("expected theme 'dark', got %v", result.Preferences["theme"]) + } + if result.Preferences["language"] != "fr" { + t.Errorf("expected language 'fr', got %v", result.Preferences["language"]) + } + // notifications_enabled should be default + if result.Preferences["notifications_enabled"] != true { + t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"]) + } + }) + + t.Run("handles boolean input for notifications_enabled", func(t *testing.T) { + result, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ + "notifications_enabled": false, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Preferences["notifications_enabled"] != false { + t.Errorf("expected notifications_enabled false, got %v", result.Preferences["notifications_enabled"]) + } + }) + + t.Run("rejects unknown key", func(t *testing.T) { + _, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ + "font_size": "large", + }) + if err == nil { + t.Fatal("expected error for unknown key") + } + // Should wrap ErrUnknownPreferenceKey + if !containsError(err, domain.ErrUnknownPreferenceKey) { + t.Errorf("expected ErrUnknownPreferenceKey, got %v", err) + } + }) + + t.Run("rejects invalid theme value", func(t *testing.T) { + _, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ + "theme": "neon", + }) + if err == nil { + t.Fatal("expected error for invalid theme") + } + if !containsError(err, domain.ErrInvalidPreferenceValue) { + t.Errorf("expected ErrInvalidPreferenceValue, got %v", err) + } + }) + + t.Run("rejects invalid language value", func(t *testing.T) { + _, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ + "language": "123", + }) + if err == nil { + t.Fatal("expected error for invalid language") + } + if !containsError(err, domain.ErrInvalidPreferenceValue) { + t.Errorf("expected ErrInvalidPreferenceValue, got %v", err) + } + }) + + t.Run("rejects invalid user_id", func(t *testing.T) { + _, err := svc.UpdatePreferences(ctx, "bad-id", map[string]any{ + "theme": "dark", + }) + if err != domain.ErrInvalidUserID { + t.Errorf("expected ErrInvalidUserID, got %v", err) + } + }) + + t.Run("is idempotent", func(t *testing.T) { + userID := "770e8400-e29b-41d4-a716-446655440000" + input := map[string]any{"theme": "dark"} + + result1, err := svc.UpdatePreferences(ctx, userID, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + result2, err := svc.UpdatePreferences(ctx, userID, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result1.Preferences["theme"] != result2.Preferences["theme"] { + t.Errorf("expected idempotent result, got %v and %v", result1.Preferences["theme"], result2.Preferences["theme"]) + } + }) +} + +// containsError checks if err wraps target using errors.Is-like behavior with fmt.Errorf wrapping. +func containsError(err, target error) bool { + if err == nil { + return false + } + for e := err; e != nil; { + if e == target { + return true + } + if e.Error() == target.Error() { + return true + } + u, ok := e.(interface{ Unwrap() error }) + if !ok { + break + } + e = u.Unwrap() + } + return false +} diff --git a/services/preferences-api/server b/services/preferences-api/server new file mode 100755 index 0000000..ed45666 Binary files /dev/null and b/services/preferences-api/server differ