diff --git a/internal/bootstrap/container.go b/internal/bootstrap/container.go index 5547c92..92a5314 100644 --- a/internal/bootstrap/container.go +++ b/internal/bootstrap/container.go @@ -23,10 +23,14 @@ import ( deploymentshttp "github.com/kleffio/platform/internal/core/deployments/adapters/http" deploymentspersistence "github.com/kleffio/platform/internal/core/deployments/adapters/persistence" deploymentscommands "github.com/kleffio/platform/internal/core/deployments/application/commands" + notificationshttp "github.com/kleffio/platform/internal/core/notifications/adapters/http" + notificationspersistence "github.com/kleffio/platform/internal/core/notifications/adapters/persistence" + notificationsapp "github.com/kleffio/platform/internal/core/notifications/application" nodeshttp "github.com/kleffio/platform/internal/core/nodes/adapters/http" nodespersistence "github.com/kleffio/platform/internal/core/nodes/adapters/persistence" nodesapp "github.com/kleffio/platform/internal/core/nodes/application" organizationshttp "github.com/kleffio/platform/internal/core/organizations/adapters/http" + organizationspersistence "github.com/kleffio/platform/internal/core/organizations/adapters/persistence" pluginhttp "github.com/kleffio/platform/internal/core/plugins/adapters/http" pluginpersistence "github.com/kleffio/platform/internal/core/plugins/adapters/persistence" pluginregistry "github.com/kleffio/platform/internal/core/plugins/adapters/registry" @@ -58,19 +62,22 @@ type Container struct { PluginManager *pluginapplication.Manager // HTTP handler groups per domain module - AuthHandler *pluginhttp.AuthHandler - SetupHandler *pluginhttp.SetupHandler - CatalogHandler *cataloghttp.Handler - OrganizationsHandler *organizationshttp.Handler - ProjectsHandler *projectshttp.Handler - WorkloadsHandler *workloadshttp.Handler - DeploymentsHandler *deploymentshttp.Handler - NodesHandler *nodeshttp.Handler - BillingHandler *billinghttp.Handler - UsageHandler *usagehttp.Handler - AuditHandler *audithttp.Handler - AdminHandler *adminhttp.Handler - PluginsHandler *pluginhttp.Handler + AuthHandler *pluginhttp.AuthHandler + SetupHandler *pluginhttp.SetupHandler + CatalogHandler *cataloghttp.Handler + OrganizationsHandler *organizationshttp.Handler + ProjectsHandler *projectshttp.Handler + WorkloadsHandler *workloadshttp.Handler + DeploymentsHandler *deploymentshttp.Handler + NodesHandler *nodeshttp.Handler + BillingHandler *billinghttp.Handler + UsageHandler *usagehttp.Handler + AuditHandler *audithttp.Handler + AdminHandler *adminhttp.Handler + PluginsHandler *pluginhttp.Handler + NotificationsHandler *notificationshttp.Handler + NotificationService *notificationsapp.Service + NotificationHub *notificationsapp.Hub } // NewContainer wires all dependencies and returns the composition root. @@ -129,6 +136,7 @@ func NewContainer(cfg *Config, logger *slog.Logger) (*Container, error) { nodeStore := nodespersistence.NewPostgresNodeStore(db) nodeVerifier := nodesapp.NewTokenVerifier(nodeStore) + orgStore := organizationspersistence.NewPostgresOrgStore(db) projectsStore := projectspersistence.NewPostgresProjectStore(db) workloadsStore := workloadspersistence.NewPostgresStore(db) @@ -141,6 +149,11 @@ func NewContainer(cfg *Config, logger *slog.Logger) (*Container, error) { } bus := events.New() + + notificationStore := notificationspersistence.NewPostgresNotificationStore(db) + notificationHub := notificationsapp.NewHub() + notificationSvc := notificationsapp.NewService(notificationStore, notificationHub, logger) + provisionHandler := workloadcmd.NewProvisionWorkloadHandler(workloadsStore, projectsStore, queuePublisher, catalogStore, logger) workloadAction := workloadcmd.NewWorkloadActionHandler(workloadsStore, projectsStore, queuePublisher, logger) @@ -156,16 +169,19 @@ func NewContainer(cfg *Config, logger *slog.Logger) (*Container, error) { AuthHandler: pluginhttp.NewAuthHandler(pluginMgr, logger), SetupHandler: pluginhttp.NewSetupHandler(pluginMgr, catalogRegistry, logger), CatalogHandler: cataloghttp.NewHandler(catalogStore, logger), - OrganizationsHandler: organizationshttp.NewHandler(logger), + OrganizationsHandler: organizationshttp.NewHandler(orgStore, notificationSvc, logger), DeploymentsHandler: deploymentshttp.NewHandler(createDeployment, serverAction, deploymentStore, cfg.SecretKey, logger), - ProjectsHandler: projectshttp.NewHandler(projectsStore, logger), - WorkloadsHandler: workloadshttp.NewHandler(projectsStore, workloadsStore, provisionHandler, workloadAction, bus, logger), + ProjectsHandler: projectshttp.NewHandler(projectsStore, orgStore, logger), + WorkloadsHandler: workloadshttp.NewHandler(projectsStore, orgStore, workloadsStore, provisionHandler, workloadAction, bus, logger), NodesHandler: nodeshttp.NewHandler(nodeStore, logger), BillingHandler: billinghttp.NewHandler(logger), UsageHandler: usagehttp.NewHandler(logger), AuditHandler: audithttp.NewHandler(logger), AdminHandler: adminhttp.NewHandler(logger), PluginsHandler: pluginhttp.NewHandler(pluginMgr, catalogRegistry, logger), + NotificationsHandler: notificationshttp.NewHandler(notificationSvc, notificationHub, logger), + NotificationService: notificationSvc, + NotificationHub: notificationHub, }, nil } diff --git a/internal/bootstrap/http.go b/internal/bootstrap/http.go index 0c88b7c..4c36372 100644 --- a/internal/bootstrap/http.go +++ b/internal/bootstrap/http.go @@ -69,6 +69,7 @@ func buildRouter(c *Container) http.Handler { c.BillingHandler.RegisterRoutes(r) c.UsageHandler.RegisterRoutes(r) c.AuditHandler.RegisterRoutes(r) + c.NotificationsHandler.RegisterRoutes(r) // Admin routes — additionally require the "admin" role. r.Group(func(r chi.Router) { diff --git a/internal/core/notifications/adapters/http/handler.go b/internal/core/notifications/adapters/http/handler.go new file mode 100644 index 0000000..afeff77 --- /dev/null +++ b/internal/core/notifications/adapters/http/handler.go @@ -0,0 +1,213 @@ +// Package http exposes REST endpoints and an SSE stream for the notifications module. +package http + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/kleffio/platform/internal/core/notifications/application" + "github.com/kleffio/platform/internal/core/notifications/domain" + "github.com/kleffio/platform/internal/shared/middleware" +) + +const basePath = "/api/v1/notifications" + +// Handler groups all HTTP endpoints for the notifications module. +type Handler struct { + svc *application.Service + hub *application.Hub + logger *slog.Logger +} + +// NewHandler creates a Handler. +func NewHandler(svc *application.Service, hub *application.Hub, logger *slog.Logger) *Handler { + return &Handler{svc: svc, hub: hub, logger: logger} +} + +// RegisterRoutes attaches all notification routes to the provided router. +// All routes require a valid JWT (caller must already be wrapped in RequireAuth). +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get(basePath, h.list) + r.Get(basePath+"/unread-count", h.unreadCount) + r.Get(basePath+"/stream", h.stream) + r.Post(basePath+"/read-all", h.markAllRead) + r.Patch(basePath+"/{id}/read", h.markRead) + r.Delete(basePath+"/{id}", h.delete) +} + +// ── List ────────────────────────────────────────────────────────────────────── + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + q := r.URL.Query() + unreadOnly := q.Get("unread") == "true" + limit, _ := strconv.Atoi(q.Get("limit")) + offset, _ := strconv.Atoi(q.Get("offset")) + + notifications, err := h.svc.List(r.Context(), claims.Subject, domain.ListFilter{ + UnreadOnly: unreadOnly, + Limit: limit, + Offset: offset, + }) + if err != nil { + h.logger.Error("list notifications", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to list notifications")) + return + } + if notifications == nil { + notifications = []*domain.Notification{} + } + writeJSON(w, http.StatusOK, map[string]any{"notifications": notifications}) +} + +// ── Unread count ────────────────────────────────────────────────────────────── + +func (h *Handler) unreadCount(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + count, err := h.svc.CountUnread(r.Context(), claims.Subject) + if err != nil { + h.logger.Error("count unread notifications", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to count unread notifications")) + return + } + writeJSON(w, http.StatusOK, map[string]int{"unread_count": count}) +} + +// ── Mark single read ────────────────────────────────────────────────────────── + +func (h *Handler) markRead(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + id := chi.URLParam(r, "id") + if err := h.svc.MarkRead(r.Context(), id, claims.Subject); err != nil { + h.logger.Error("mark notification read", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to mark notification as read")) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── Mark all read ───────────────────────────────────────────────────────────── + +func (h *Handler) markAllRead(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + if err := h.svc.MarkAllRead(r.Context(), claims.Subject); err != nil { + h.logger.Error("mark all notifications read", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to mark all notifications as read")) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── Delete ──────────────────────────────────────────────────────────────────── + +func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + id := chi.URLParam(r, "id") + if err := h.svc.Delete(r.Context(), id, claims.Subject); err != nil { + h.logger.Error("delete notification", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to delete notification")) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── SSE stream ──────────────────────────────────────────────────────────────── + +// stream opens a Server-Sent Events connection for the authenticated user. +// On connect it sends the current unread count, then streams new notifications +// as they arrive. A heartbeat comment is sent every 30 s to keep the connection +// alive through proxies. +func (h *Handler) stream(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering + + // Send initial connected event with the current unread count. + count, _ := h.svc.CountUnread(r.Context(), claims.Subject) + fmt.Fprintf(w, "event: connected\ndata: {\"unread_count\":%d}\n\n", count) + flusher.Flush() + + ch := h.hub.Subscribe(claims.Subject) + defer h.hub.Unsubscribe(claims.Subject, ch) + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + + case <-ticker.C: + // Heartbeat — keeps the connection alive through idle-timeout proxies. + fmt.Fprintf(w, ": heartbeat\n\n") + flusher.Flush() + + case n, open := <-ch: + if !open { + return + } + data, err := json.Marshal(n) + if err != nil { + continue + } + fmt.Fprintf(w, "event: notification\ndata: %s\n\n", data) + flusher.Flush() + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func errBody(msg string) map[string]string { + return map[string]string{"error": msg} +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/internal/core/notifications/adapters/persistence/store.go b/internal/core/notifications/adapters/persistence/store.go new file mode 100644 index 0000000..38a469c --- /dev/null +++ b/internal/core/notifications/adapters/persistence/store.go @@ -0,0 +1,167 @@ +// Package persistence provides the PostgreSQL implementation of NotificationRepository. +package persistence + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/kleffio/platform/internal/core/notifications/domain" + "github.com/kleffio/platform/internal/core/notifications/ports" +) + +// PostgresNotificationStore implements ports.NotificationRepository. +type PostgresNotificationStore struct { + db *sql.DB +} + +// NewPostgresNotificationStore returns a store backed by db. +func NewPostgresNotificationStore(db *sql.DB) ports.NotificationRepository { + return &PostgresNotificationStore{db: db} +} + +// ── Write operations ────────────────────────────────────────────────────────── + +func (s *PostgresNotificationStore) Save(ctx context.Context, n *domain.Notification) error { + data, err := json.Marshal(n.Data) + if err != nil { + return fmt.Errorf("marshal notification data: %w", err) + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO notifications (id, user_id, type, title, body, data, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + n.ID, n.UserID, string(n.Type), n.Title, n.Body, data, n.CreatedAt) + if err != nil { + return fmt.Errorf("save notification: %w", err) + } + return nil +} + +func (s *PostgresNotificationStore) MarkRead(ctx context.Context, id, userID string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE notifications SET read_at = $1 + WHERE id = $2 AND user_id = $3 AND read_at IS NULL`, + time.Now().UTC(), id, userID) + if err != nil { + return fmt.Errorf("mark notification read: %w", err) + } + return nil +} + +func (s *PostgresNotificationStore) MarkAllRead(ctx context.Context, userID string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE notifications SET read_at = $1 + WHERE user_id = $2 AND read_at IS NULL`, + time.Now().UTC(), userID) + if err != nil { + return fmt.Errorf("mark all notifications read: %w", err) + } + return nil +} + +func (s *PostgresNotificationStore) Delete(ctx context.Context, id, userID string) error { + _, err := s.db.ExecContext(ctx, ` + DELETE FROM notifications WHERE id = $1 AND user_id = $2`, + id, userID) + if err != nil { + return fmt.Errorf("delete notification: %w", err) + } + return nil +} + +// ── Read operations ─────────────────────────────────────────────────────────── + +func (s *PostgresNotificationStore) FindByID(ctx context.Context, id string) (*domain.Notification, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, user_id, type, title, body, data, read_at, created_at + FROM notifications WHERE id = $1`, id) + return scanNotification(row) +} + +func (s *PostgresNotificationStore) List(ctx context.Context, userID string, f domain.ListFilter) ([]*domain.Notification, error) { + limit := f.Limit + if limit <= 0 { + limit = 50 + } + offset := f.Offset + if offset < 0 { + offset = 0 + } + + var rows *sql.Rows + var err error + + if f.UnreadOnly { + rows, err = s.db.QueryContext(ctx, ` + SELECT id, user_id, type, title, body, data, read_at, created_at + FROM notifications + WHERE user_id = $1 AND read_at IS NULL + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + userID, limit, offset) + } else { + rows, err = s.db.QueryContext(ctx, ` + SELECT id, user_id, type, title, body, data, read_at, created_at + FROM notifications + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + userID, limit, offset) + } + if err != nil { + return nil, fmt.Errorf("list notifications: %w", err) + } + defer rows.Close() + return scanNotifications(rows) +} + +func (s *PostgresNotificationStore) CountUnread(ctx context.Context, userID string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, + userID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count unread: %w", err) + } + return count, nil +} + +// ── Scanners ────────────────────────────────────────────────────────────────── + +func scanNotification(row *sql.Row) (*domain.Notification, error) { + var n domain.Notification + var readAt sql.NullTime + var rawData []byte + if err := row.Scan(&n.ID, &n.UserID, &n.Type, &n.Title, &n.Body, &rawData, &readAt, &n.CreatedAt); err != nil { + return nil, err + } + if readAt.Valid { + n.ReadAt = &readAt.Time + } + if len(rawData) > 0 { + _ = json.Unmarshal(rawData, &n.Data) + } + return &n, nil +} + +func scanNotifications(rows *sql.Rows) ([]*domain.Notification, error) { + var out []*domain.Notification + for rows.Next() { + var n domain.Notification + var readAt sql.NullTime + var rawData []byte + if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.Title, &n.Body, &rawData, &readAt, &n.CreatedAt); err != nil { + return nil, err + } + if readAt.Valid { + n.ReadAt = &readAt.Time + } + if len(rawData) > 0 { + _ = json.Unmarshal(rawData, &n.Data) + } + out = append(out, &n) + } + return out, rows.Err() +} diff --git a/internal/core/notifications/application/hub.go b/internal/core/notifications/application/hub.go new file mode 100644 index 0000000..0557b84 --- /dev/null +++ b/internal/core/notifications/application/hub.go @@ -0,0 +1,61 @@ +package application + +import ( + "sync" + + "github.com/kleffio/platform/internal/core/notifications/domain" +) + +// Hub manages open SSE connections keyed by user ID. +// Each connected browser tab gets its own channel; when a notification is +// created the Hub fans it out to all open tabs for that user. +type Hub struct { + mu sync.RWMutex + clients map[string][]chan *domain.Notification +} + +// NewHub returns an initialised Hub. +func NewHub() *Hub { + return &Hub{clients: make(map[string][]chan *domain.Notification)} +} + +// Subscribe registers a new channel for userID and returns it. +// The caller must call Unsubscribe when the SSE connection closes. +func (h *Hub) Subscribe(userID string) chan *domain.Notification { + ch := make(chan *domain.Notification, 16) + h.mu.Lock() + h.clients[userID] = append(h.clients[userID], ch) + h.mu.Unlock() + return ch +} + +// Unsubscribe removes the channel from the hub and closes it. +func (h *Hub) Unsubscribe(userID string, ch chan *domain.Notification) { + h.mu.Lock() + defer h.mu.Unlock() + + channels := h.clients[userID] + for i, c := range channels { + if c == ch { + h.clients[userID] = append(channels[:i], channels[i+1:]...) + close(ch) + return + } + } +} + +// Push delivers a notification to all open SSE connections for the user. +// Non-blocking: a slow consumer misses the event (it will catch up via polling). +func (h *Hub) Push(userID string, n *domain.Notification) { + h.mu.RLock() + channels := make([]chan *domain.Notification, len(h.clients[userID])) + copy(channels, h.clients[userID]) + h.mu.RUnlock() + + for _, ch := range channels { + select { + case ch <- n: + default: + } + } +} diff --git a/internal/core/notifications/application/service.go b/internal/core/notifications/application/service.go new file mode 100644 index 0000000..7e61d8b --- /dev/null +++ b/internal/core/notifications/application/service.go @@ -0,0 +1,82 @@ +// Package application contains the use-case logic for the notifications module. +package application + +import ( + "context" + "log/slog" + "time" + + "github.com/kleffio/platform/internal/core/notifications/domain" + "github.com/kleffio/platform/internal/core/notifications/ports" + "github.com/kleffio/platform/internal/shared/ids" +) + +// Service orchestrates notification operations and delivers real-time events +// to connected SSE clients via the Hub. +type Service struct { + repo ports.NotificationRepository + hub *Hub + logger *slog.Logger +} + +// NewService creates a Service. +func NewService(repo ports.NotificationRepository, hub *Hub, logger *slog.Logger) *Service { + return &Service{repo: repo, hub: hub, logger: logger} +} + +// CreateInput holds the fields needed to create a new notification. +type CreateInput struct { + UserID string + Type domain.Type + Title string + Body string + Data map[string]any +} + +// Create persists a new notification and pushes it to any live SSE connections +// the user currently has open. +func (s *Service) Create(ctx context.Context, in CreateInput) (*domain.Notification, error) { + n := &domain.Notification{ + ID: ids.New(), + UserID: in.UserID, + Type: in.Type, + Title: in.Title, + Body: in.Body, + Data: in.Data, + CreatedAt: time.Now().UTC(), + } + + if err := s.repo.Save(ctx, n); err != nil { + return nil, err + } + + // Best-effort push to live SSE connections; never fails the caller. + s.hub.Push(in.UserID, n) + + return n, nil +} + +// List returns notifications for userID according to f. +func (s *Service) List(ctx context.Context, userID string, f domain.ListFilter) ([]*domain.Notification, error) { + return s.repo.List(ctx, userID, f) +} + +// CountUnread returns the number of unread notifications for userID. +func (s *Service) CountUnread(ctx context.Context, userID string) (int, error) { + return s.repo.CountUnread(ctx, userID) +} + +// MarkRead marks a single notification as read. The notification must belong to userID. +func (s *Service) MarkRead(ctx context.Context, id, userID string) error { + return s.repo.MarkRead(ctx, id, userID) +} + +// MarkAllRead marks every unread notification for userID as read. +func (s *Service) MarkAllRead(ctx context.Context, userID string) error { + return s.repo.MarkAllRead(ctx, userID) +} + +// Delete removes a notification. The notification must belong to userID. +func (s *Service) Delete(ctx context.Context, id, userID string) error { + return s.repo.Delete(ctx, id, userID) +} diff --git a/internal/core/notifications/domain/notification.go b/internal/core/notifications/domain/notification.go new file mode 100644 index 0000000..f60e7fb --- /dev/null +++ b/internal/core/notifications/domain/notification.go @@ -0,0 +1,41 @@ +// Package domain contains the pure business types for the notifications module. +package domain + +import "time" + +// Type classifies what kind of event a notification represents. +type Type string + +const ( + TypeSystem Type = "system" + TypeBilling Type = "billing" + TypeOrgInvitation Type = "org_invitation" + TypeProjectInvitation Type = "project_invitation" + TypeDeployment Type = "deployment" + TypeWorkload Type = "workload" + TypeSecurity Type = "security" +) + +// Notification is a single inbox item for a user. +type Notification struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Type Type `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + Data map[string]any `json:"data,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// IsRead returns true when the user has already read this notification. +func (n *Notification) IsRead() bool { + return n.ReadAt != nil +} + +// ListFilter controls which notifications are returned. +type ListFilter struct { + UnreadOnly bool + Limit int + Offset int +} diff --git a/internal/core/notifications/ports/repository.go b/internal/core/notifications/ports/repository.go new file mode 100644 index 0000000..91f88b4 --- /dev/null +++ b/internal/core/notifications/ports/repository.go @@ -0,0 +1,32 @@ +// Package ports defines the outbound interfaces for the notifications module. +package ports + +import ( + "context" + + "github.com/kleffio/platform/internal/core/notifications/domain" +) + +// NotificationRepository is the persistence contract for notifications. +type NotificationRepository interface { + // Save persists a new notification. + Save(ctx context.Context, n *domain.Notification) error + + // FindByID returns a single notification. Returns sql.ErrNoRows when not found. + FindByID(ctx context.Context, id string) (*domain.Notification, error) + + // List returns notifications for a user according to the supplied filter. + List(ctx context.Context, userID string, f domain.ListFilter) ([]*domain.Notification, error) + + // CountUnread returns the number of unread notifications for a user. + CountUnread(ctx context.Context, userID string) (int, error) + + // MarkRead sets read_at to now for a single notification owned by userID. + MarkRead(ctx context.Context, id, userID string) error + + // MarkAllRead sets read_at to now for every unread notification owned by userID. + MarkAllRead(ctx context.Context, userID string) error + + // Delete removes a notification owned by userID. + Delete(ctx context.Context, id, userID string) error +} diff --git a/internal/core/organizations/adapters/http/handler.go b/internal/core/organizations/adapters/http/handler.go index 4d956e1..86935ca 100644 --- a/internal/core/organizations/adapters/http/handler.go +++ b/internal/core/organizations/adapters/http/handler.go @@ -1,21 +1,37 @@ package http import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" "log/slog" "net/http" + "strings" + "time" "github.com/go-chi/chi/v5" + "github.com/kleffio/platform/internal/core/notifications/application" + notificationsdomain "github.com/kleffio/platform/internal/core/notifications/domain" + "github.com/kleffio/platform/internal/core/organizations/adapters/persistence" + "github.com/kleffio/platform/internal/core/organizations/domain" + "github.com/kleffio/platform/internal/core/organizations/ports" + "github.com/kleffio/platform/internal/shared/ids" + "github.com/kleffio/platform/internal/shared/middleware" ) const basePath = "/api/v1/organizations" // Handler groups all HTTP endpoints for the organizations module. type Handler struct { - logger *slog.Logger + repo ports.OrganizationRepository + notifications *application.Service + logger *slog.Logger } -func NewHandler(logger *slog.Logger) *Handler { - return &Handler{logger: logger} +func NewHandler(repo ports.OrganizationRepository, notifications *application.Service, logger *slog.Logger) *Handler { + return &Handler{repo: repo, notifications: notifications, logger: logger} } // RegisterRoutes attaches all organizations routes to the provided router. @@ -29,20 +45,631 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // Members sub-resource r.Get(basePath+"/{id}/members", h.listMembers) r.Post(basePath+"/{id}/members", h.addMember) + r.Patch(basePath+"/{id}/members/{userId}", h.updateMemberRole) r.Delete(basePath+"/{id}/members/{userId}", h.removeMember) + + // Invites sub-resource + r.Get(basePath+"/{id}/invites", h.listInvites) + r.Post(basePath+"/{id}/invites", h.createInvite) + r.Delete(basePath+"/{id}/invites/{inviteId}", h.revokeInvite) + + // Public invite resolution + accept + r.Get("/api/v1/invites/{token}", h.resolveInvite) + r.Post("/api/v1/invites/{token}/accept", h.acceptInvite) +} + +// ── List orgs the caller belongs to ────────────────────────────────────────── + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + orgs, err := h.repo.ListByUserID(r.Context(), claims.Subject) + if err != nil { + h.logger.Error("list orgs", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to list organizations")) + return + } + if orgs == nil { + orgs = []*domain.Organization{} + } + writeJSON(w, http.StatusOK, map[string]any{"organizations": orgs}) +} + +// ── Create org ──────────────────────────────────────────────────────────────── + +func (h *Handler) create(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + if strings.TrimSpace(req.Name) == "" { + writeJSON(w, http.StatusBadRequest, errBody("name is required")) + return + } + + now := time.Now().UTC() + org := &domain.Organization{ + ID: ids.New(), + Name: strings.TrimSpace(req.Name), + CreatedAt: now, + UpdatedAt: now, + } + + if err := h.repo.Save(r.Context(), org); err != nil { + h.logger.Error("create organization", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to create organization")) + return + } + + // Caller is automatically the first owner. + member := &domain.Member{ + OrgID: org.ID, + UserID: claims.Subject, + Email: claims.Email, + DisplayName: claims.Username, + Role: domain.RoleOwner, + CreatedAt: now, + } + if err := h.repo.AddMember(r.Context(), member); err != nil { + h.logger.Error("add owner after create", "error", err) + // Non-fatal: org was created, membership may be retried. + } + + writeJSON(w, http.StatusCreated, org) +} + +// ── Get org ─────────────────────────────────────────────────────────────────── + +func (h *Handler) get(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + org, err := h.authorizedOrg(r, id, "") + if err != nil { + writeOrgError(w, err) + return + } + writeJSON(w, http.StatusOK, org) +} + +// ── Update org name ─────────────────────────────────────────────────────────── + +func (h *Handler) update(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + if strings.TrimSpace(req.Name) == "" { + writeJSON(w, http.StatusBadRequest, errBody("name is required")) + return + } + + org := &domain.Organization{ + ID: id, + Name: strings.TrimSpace(req.Name), + UpdatedAt: time.Now().UTC(), + } + if err := h.repo.Update(r.Context(), org); err != nil { + h.logger.Error("update organization", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to update organization")) + return + } + writeJSON(w, http.StatusOK, org) +} + +// ── Delete org ──────────────────────────────────────────────────────────────── + +func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, domain.RoleOwner); err != nil { + writeOrgError(w, err) + return + } + + if err := h.repo.Delete(r.Context(), id); err != nil { + h.logger.Error("delete organization", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to delete organization")) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── Members ─────────────────────────────────────────────────────────────────── + +func (h *Handler) listMembers(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, ""); err != nil { + writeOrgError(w, err) + return + } + + members, err := h.repo.ListMembers(r.Context(), id) + if err != nil { + h.logger.Error("list members", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to list members")) + return + } + if members == nil { + members = []*domain.Member{} + } + writeJSON(w, http.StatusOK, map[string]any{"members": members}) +} + +func (h *Handler) addMember(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + var req struct { + UserID string `json:"user_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + if req.UserID == "" { + writeJSON(w, http.StatusBadRequest, errBody("user_id is required")) + return + } + role := normalizeRole(req.Role) + + member := &domain.Member{ + OrgID: id, + UserID: req.UserID, + Email: req.Email, + DisplayName: req.DisplayName, + Role: role, + CreatedAt: time.Now().UTC(), + } + if err := h.repo.AddMember(r.Context(), member); err != nil { + h.logger.Error("add member", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to add member")) + return + } + writeJSON(w, http.StatusCreated, member) +} + +func (h *Handler) updateMemberRole(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + userID := chi.URLParam(r, "userId") + + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + var req struct { + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + role := normalizeRole(req.Role) + + if err := h.repo.UpdateMemberRole(r.Context(), id, userID, role); err != nil { + h.logger.Error("update member role", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to update role")) + return + } + writeJSON(w, http.StatusOK, map[string]string{"role": role}) +} + +func (h *Handler) removeMember(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + userID := chi.URLParam(r, "userId") + + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + // Members may remove themselves; removing others requires admin/owner. + if claims.Subject != userID { + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + } else { + // Self-removal: still need to be a member. + if _, err := h.authorizedOrg(r, id, ""); err != nil { + writeOrgError(w, err) + return + } + } + + // Prevent removing the last owner. + target, err := h.repo.GetMember(r.Context(), id, userID) + if err != nil { + writeJSON(w, http.StatusNotFound, errBody("member not found")) + return + } + if target.Role == domain.RoleOwner { + count, err := h.repo.CountOwners(r.Context(), id) + if err != nil || count <= 1 { + writeJSON(w, http.StatusConflict, errBody("cannot remove the last owner")) + return + } + } + + if err := h.repo.RemoveMember(r.Context(), id, userID); err != nil { + h.logger.Error("remove member", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to remove member")) + return + } + w.WriteHeader(http.StatusNoContent) } -func notImplemented(w http.ResponseWriter) { +// ── Invites ─────────────────────────────────────────────────────────────────── + +func (h *Handler) listInvites(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + invites, err := h.repo.ListInvites(r.Context(), id) + if err != nil { + h.logger.Error("list invites", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to list invites")) + return + } + if invites == nil { + invites = []*domain.Invite{} + } + // Strip token hashes from the list response. + type safeInvite struct { + ID string `json:"id"` + OrgID string `json:"org_id"` + InvitedEmail string `json:"invited_email"` + Role string `json:"role"` + InvitedBy string `json:"invited_by"` + ExpiresAt time.Time `json:"expires_at"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + } + out := make([]safeInvite, len(invites)) + for i, inv := range invites { + out[i] = safeInvite{ + ID: inv.ID, + OrgID: inv.OrgID, + InvitedEmail: inv.InvitedEmail, + Role: inv.Role, + InvitedBy: inv.InvitedBy, + ExpiresAt: inv.ExpiresAt, + AcceptedAt: inv.AcceptedAt, + CreatedAt: inv.CreatedAt, + } + } + writeJSON(w, http.StatusOK, map[string]any{"invites": out}) +} + +func (h *Handler) createInvite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + var req struct { + Email string `json:"email"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + if strings.TrimSpace(req.Email) == "" { + writeJSON(w, http.StatusBadRequest, errBody("email is required")) + return + } + + token, err := generateToken() + if err != nil { + h.logger.Error("generate invite token", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to generate invite")) + return + } + + now := time.Now().UTC() + inv := &domain.Invite{ + ID: ids.New(), + OrgID: id, + InvitedEmail: strings.ToLower(strings.TrimSpace(req.Email)), + Role: normalizeRole(req.Role), + Token: token, + TokenHash: persistence.HashToken(token), + InvitedBy: claims.Subject, + ExpiresAt: now.Add(7 * 24 * time.Hour), + CreatedAt: now, + } + + if err := h.repo.CreateInvite(r.Context(), inv); err != nil { + h.logger.Error("create invite", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to create invite")) + return + } + + // Notify the inviting admin that the invite was dispatched. + if h.notifications != nil { + _, _ = h.notifications.Create(r.Context(), application.CreateInput{ + UserID: claims.Subject, + Type: notificationsdomain.TypeOrgInvitation, + Title: "Invitation sent", + Body: fmt.Sprintf("An invitation was sent to %s to join the organization.", inv.InvitedEmail), + Data: map[string]any{"org_id": id, "invite_id": inv.ID, "invited_email": inv.InvitedEmail}, + }) + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "id": inv.ID, + "org_id": inv.OrgID, + "invited_email": inv.InvitedEmail, + "role": inv.Role, + "token": inv.Token, // raw token returned once; client builds the accept URL + "expires_at": inv.ExpiresAt, + "created_at": inv.CreatedAt, + }) +} + +func (h *Handler) revokeInvite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + inviteID := chi.URLParam(r, "inviteId") + + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + if err := h.repo.RevokeInvite(r.Context(), inviteID); err != nil { + h.logger.Error("revoke invite", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to revoke invite")) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── Public invite resolution ────────────────────────────────────────────────── + +func (h *Handler) resolveInvite(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + tokenHash := persistence.HashToken(token) + + inv, err := h.repo.FindInviteByToken(r.Context(), tokenHash) + if err == sql.ErrNoRows || err != nil { + writeJSON(w, http.StatusNotFound, errBody("invite not found or expired")) + return + } + if inv.AcceptedAt != nil { + writeJSON(w, http.StatusGone, errBody("invite already accepted")) + return + } + if time.Now().After(inv.ExpiresAt) { + writeJSON(w, http.StatusGone, errBody("invite has expired")) + return + } + + org, err := h.repo.FindByID(r.Context(), inv.OrgID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errBody("failed to load organization")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "id": inv.ID, + "org_id": inv.OrgID, + "org_name": org.Name, + "invited_email": inv.InvitedEmail, + "role": inv.Role, + "invited_by": inv.InvitedBy, + "expires_at": inv.ExpiresAt, + }) +} + +func (h *Handler) acceptInvite(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + tokenHash := persistence.HashToken(token) + + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + inv, err := h.repo.FindInviteByToken(r.Context(), tokenHash) + if err == sql.ErrNoRows || err != nil { + writeJSON(w, http.StatusNotFound, errBody("invite not found or expired")) + return + } + if inv.AcceptedAt != nil { + writeJSON(w, http.StatusGone, errBody("invite already accepted")) + return + } + if time.Now().After(inv.ExpiresAt) { + writeJSON(w, http.StatusGone, errBody("invite has expired")) + return + } + + if err := h.repo.AcceptInvite(r.Context(), inv.ID, claims.Subject, claims.Email, claims.Username); err != nil { + h.logger.Error("accept invite", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to accept invite")) + return + } + + // Notify the new member that they joined the organization. + if h.notifications != nil { + org, orgErr := h.repo.FindByID(r.Context(), inv.OrgID) + orgName := inv.OrgID + if orgErr == nil { + orgName = org.Name + } + _, _ = h.notifications.Create(r.Context(), application.CreateInput{ + UserID: claims.Subject, + Type: notificationsdomain.TypeOrgInvitation, + Title: "You joined an organization", + Body: fmt.Sprintf("You have successfully joined %s.", orgName), + Data: map[string]any{"org_id": inv.OrgID}, + }) + } + + writeJSON(w, http.StatusOK, map[string]string{"org_id": inv.OrgID}) +} + +// ── Access guard ────────────────────────────────────────────────────────────── + +// authorizedOrg loads the org and verifies the caller is a member. +// If minRole is non-empty, it also enforces the minimum role (owner > admin > member). +// +// When the caller has no membership row for their personal org (org-), +// we bootstrap it automatically so first-time access works without a separate +// project-list round-trip. +func (h *Handler) authorizedOrg(r *http.Request, orgID, minRole string) (*domain.Organization, error) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + org, err := h.repo.FindByID(r.Context(), orgID) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("not found") + } + if err != nil { + return nil, fmt.Errorf("internal") + } + + member, err := h.repo.GetMember(r.Context(), orgID, claims.Subject) + if err == sql.ErrNoRows { + // Bootstrap the personal org membership row if this is the caller's own org. + if personalOrgID(claims.Subject) == orgID { + orgName := "My Organization" + if claims.Username != "" { + orgName = claims.Username + "'s Organization" + } + if bootstrapErr := h.repo.EnsureOrgWithOwner(r.Context(), orgID, orgName, + claims.Subject, claims.Email, claims.Username); bootstrapErr != nil { + h.logger.Error("bootstrap org membership", "error", bootstrapErr) + return nil, fmt.Errorf("internal") + } + member, err = h.repo.GetMember(r.Context(), orgID, claims.Subject) + if err != nil { + return nil, fmt.Errorf("internal") + } + } else { + return nil, fmt.Errorf("forbidden") + } + } else if err != nil { + return nil, fmt.Errorf("internal") + } + + if minRole != "" && !roleAtLeast(member.Role, minRole) { + return nil, fmt.Errorf("forbidden") + } + return org, nil +} + +// personalOrgID returns the personal org ID for a given JWT subject, +// matching the derivation used in the projects handler. +func personalOrgID(subject string) string { + s := strings.ToLower(strings.TrimSpace(subject)) + s = strings.ReplaceAll(s, "_", "-") + s = strings.ReplaceAll(s, " ", "-") + // Keep only lowercase alphanum and hyphens. + var b strings.Builder + for _, c := range s { + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { + b.WriteRune(c) + } + } + slug := strings.Trim(b.String(), "-") + if len(slug) > 40 { + slug = slug[:40] + } + return "org-" + slug +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func roleAtLeast(have, need string) bool { + rank := map[string]int{ + domain.RoleMember: 1, + domain.RoleAdmin: 2, + domain.RoleOwner: 3, + } + return rank[have] >= rank[need] +} + +func normalizeRole(r string) string { + switch strings.ToLower(strings.TrimSpace(r)) { + case domain.RoleOwner: + return domain.RoleOwner + case domain.RoleAdmin: + return domain.RoleAdmin + default: + return domain.RoleMember + } +} + +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func writeOrgError(w http.ResponseWriter, err error) { + msg := err.Error() + switch msg { + case "unauthorized": + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + case "forbidden": + writeJSON(w, http.StatusForbidden, errBody("forbidden")) + case "not found": + writeJSON(w, http.StatusNotFound, errBody("organization not found")) + default: + writeJSON(w, http.StatusInternalServerError, errBody("internal error")) + } +} + +func errBody(msg string) map[string]string { + return map[string]string{"error": msg} +} + +func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotImplemented) - _, _ = w.Write([]byte(`{"error":"not implemented"}`)) -} - -func (h *Handler) list(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) create(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) get(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) update(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) delete(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) listMembers(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) addMember(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) removeMember(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/internal/core/organizations/adapters/persistence/store.go b/internal/core/organizations/adapters/persistence/store.go new file mode 100644 index 0000000..d9a958d --- /dev/null +++ b/internal/core/organizations/adapters/persistence/store.go @@ -0,0 +1,346 @@ +package persistence + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "time" + + "github.com/kleffio/platform/internal/core/organizations/domain" + "github.com/kleffio/platform/internal/core/organizations/ports" +) + +type PostgresOrgStore struct { + db *sql.DB +} + +func NewPostgresOrgStore(db *sql.DB) ports.OrganizationRepository { + return &PostgresOrgStore{db: db} +} + +// ── Organizations ───────────────────────────────────────────────────────────── + +func (s *PostgresOrgStore) FindByID(ctx context.Context, id string) (*domain.Organization, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, name, COALESCE(slug, ''), created_at, updated_at + FROM organizations WHERE id = $1`, id) + return scanOrg(row) +} + +func (s *PostgresOrgStore) FindBySlug(ctx context.Context, slug string) (*domain.Organization, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, name, COALESCE(slug, ''), created_at, updated_at + FROM organizations WHERE slug = $1`, slug) + return scanOrg(row) +} + +func (s *PostgresOrgStore) Save(ctx context.Context, org *domain.Organization) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO organizations (id, name, created_at, updated_at) + VALUES ($1, $2, $3, $4)`, + org.ID, org.Name, org.CreatedAt, org.UpdatedAt) + if err != nil { + return fmt.Errorf("save organization: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) Update(ctx context.Context, org *domain.Organization) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE organizations SET name = $1, updated_at = $2 WHERE id = $3`, + org.Name, org.UpdatedAt, org.ID) + if err != nil { + return fmt.Errorf("update organization: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) Delete(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM organizations WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete organization: %w", err) + } + return nil +} + +// ── Membership ──────────────────────────────────────────────────────────────── + +func (s *PostgresOrgStore) ListByUserID(ctx context.Context, userID string) ([]*domain.Organization, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT o.id, o.name, COALESCE(o.slug, ''), o.created_at, o.updated_at + FROM organizations o + JOIN organization_members m ON m.org_id = o.id + WHERE m.user_id = $1 + ORDER BY o.created_at ASC`, userID) + if err != nil { + return nil, fmt.Errorf("list orgs by user: %w", err) + } + defer rows.Close() + return scanOrgs(rows) +} + +func (s *PostgresOrgStore) ListMembers(ctx context.Context, orgID string) ([]*domain.Member, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT org_id, user_id, email, display_name, role, created_at + FROM organization_members + WHERE org_id = $1 + ORDER BY created_at ASC`, orgID) + if err != nil { + return nil, fmt.Errorf("list members: %w", err) + } + defer rows.Close() + return scanMembers(rows) +} + +func (s *PostgresOrgStore) GetMember(ctx context.Context, orgID, userID string) (*domain.Member, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT org_id, user_id, email, display_name, role, created_at + FROM organization_members + WHERE org_id = $1 AND user_id = $2`, orgID, userID) + return scanMember(row) +} + +func (s *PostgresOrgStore) AddMember(ctx context.Context, m *domain.Member) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO organization_members (org_id, user_id, email, display_name, role, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (org_id, user_id) DO NOTHING`, + m.OrgID, m.UserID, m.Email, m.DisplayName, m.Role, m.CreatedAt) + if err != nil { + return fmt.Errorf("add member: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) UpdateMemberRole(ctx context.Context, orgID, userID, role string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE organization_members SET role = $1 + WHERE org_id = $2 AND user_id = $3`, + role, orgID, userID) + if err != nil { + return fmt.Errorf("update member role: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) RemoveMember(ctx context.Context, orgID, userID string) error { + _, err := s.db.ExecContext(ctx, ` + DELETE FROM organization_members WHERE org_id = $1 AND user_id = $2`, + orgID, userID) + if err != nil { + return fmt.Errorf("remove member: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) CountOwners(ctx context.Context, orgID string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM organization_members + WHERE org_id = $1 AND role = 'owner'`, orgID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count owners: %w", err) + } + return count, nil +} + +// EnsureOrgWithOwner upserts the organization row and adds the caller as owner +// if they are not already a member. Handles the personal-org bootstrap path. +func (s *PostgresOrgStore) EnsureOrgWithOwner(ctx context.Context, orgID, orgName, userID, email, displayName string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + now := time.Now().UTC() + + // Upsert the org row. + _, err = tx.ExecContext(ctx, ` + INSERT INTO organizations (id, name, created_at, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET updated_at = EXCLUDED.updated_at`, + orgID, orgName, now, now) + if err != nil { + return fmt.Errorf("upsert organization: %w", err) + } + + // Add the caller as owner only if they have no membership row yet. + _, err = tx.ExecContext(ctx, ` + INSERT INTO organization_members (org_id, user_id, email, display_name, role, created_at) + VALUES ($1, $2, $3, $4, 'owner', $5) + ON CONFLICT (org_id, user_id) DO NOTHING`, + orgID, userID, email, displayName, now) + if err != nil { + return fmt.Errorf("ensure owner membership: %w", err) + } + + return tx.Commit() +} + +// ── Invites ─────────────────────────────────────────────────────────────────── + +func (s *PostgresOrgStore) CreateInvite(ctx context.Context, inv *domain.Invite) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO org_invites (id, org_id, invited_email, role, token_hash, invited_by, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + inv.ID, inv.OrgID, inv.InvitedEmail, inv.Role, inv.TokenHash, + inv.InvitedBy, inv.ExpiresAt, inv.CreatedAt) + if err != nil { + return fmt.Errorf("create invite: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) FindInviteByToken(ctx context.Context, tokenHash string) (*domain.Invite, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, org_id, invited_email, role, token_hash, invited_by, + expires_at, accepted_at, created_at + FROM org_invites WHERE token_hash = $1`, tokenHash) + return scanInvite(row) +} + +func (s *PostgresOrgStore) ListInvites(ctx context.Context, orgID string) ([]*domain.Invite, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, org_id, invited_email, role, token_hash, invited_by, + expires_at, accepted_at, created_at + FROM org_invites + WHERE org_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, orgID) + if err != nil { + return nil, fmt.Errorf("list invites: %w", err) + } + defer rows.Close() + return scanInvites(rows) +} + +// AcceptInvite marks the invite as accepted and adds the user as a member. +func (s *PostgresOrgStore) AcceptInvite(ctx context.Context, inviteID, userID, email, displayName string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + var inv struct { + OrgID string + Role string + } + err = tx.QueryRowContext(ctx, ` + SELECT org_id, role FROM org_invites + WHERE id = $1 AND accepted_at IS NULL AND expires_at > NOW()`, inviteID). + Scan(&inv.OrgID, &inv.Role) + if err == sql.ErrNoRows { + return fmt.Errorf("invite not found or already used") + } + if err != nil { + return fmt.Errorf("find invite: %w", err) + } + + now := time.Now().UTC() + + _, err = tx.ExecContext(ctx, ` + UPDATE org_invites SET accepted_at = $1 WHERE id = $2`, now, inviteID) + if err != nil { + return fmt.Errorf("mark invite accepted: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO organization_members (org_id, user_id, email, display_name, role, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role`, + inv.OrgID, userID, email, displayName, inv.Role, now) + if err != nil { + return fmt.Errorf("add member from invite: %w", err) + } + + return tx.Commit() +} + +func (s *PostgresOrgStore) RevokeInvite(ctx context.Context, inviteID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM org_invites WHERE id = $1`, inviteID) + if err != nil { + return fmt.Errorf("revoke invite: %w", err) + } + return nil +} + +// ── Scanners ────────────────────────────────────────────────────────────────── + +func scanOrg(row *sql.Row) (*domain.Organization, error) { + var o domain.Organization + if err := row.Scan(&o.ID, &o.Name, &o.Slug, &o.CreatedAt, &o.UpdatedAt); err != nil { + return nil, err + } + return &o, nil +} + +func scanOrgs(rows *sql.Rows) ([]*domain.Organization, error) { + var out []*domain.Organization + for rows.Next() { + var o domain.Organization + if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.CreatedAt, &o.UpdatedAt); err != nil { + return nil, err + } + out = append(out, &o) + } + return out, rows.Err() +} + +func scanMember(row *sql.Row) (*domain.Member, error) { + var m domain.Member + if err := row.Scan(&m.OrgID, &m.UserID, &m.Email, &m.DisplayName, &m.Role, &m.CreatedAt); err != nil { + return nil, err + } + return &m, nil +} + +func scanMembers(rows *sql.Rows) ([]*domain.Member, error) { + var out []*domain.Member + for rows.Next() { + var m domain.Member + if err := rows.Scan(&m.OrgID, &m.UserID, &m.Email, &m.DisplayName, &m.Role, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, &m) + } + return out, rows.Err() +} + +func scanInvite(row *sql.Row) (*domain.Invite, error) { + var inv domain.Invite + var acceptedAt sql.NullTime + if err := row.Scan(&inv.ID, &inv.OrgID, &inv.InvitedEmail, &inv.Role, + &inv.TokenHash, &inv.InvitedBy, &inv.ExpiresAt, &acceptedAt, &inv.CreatedAt); err != nil { + return nil, err + } + if acceptedAt.Valid { + inv.AcceptedAt = &acceptedAt.Time + } + return &inv, nil +} + +func scanInvites(rows *sql.Rows) ([]*domain.Invite, error) { + var out []*domain.Invite + for rows.Next() { + var inv domain.Invite + var acceptedAt sql.NullTime + if err := rows.Scan(&inv.ID, &inv.OrgID, &inv.InvitedEmail, &inv.Role, + &inv.TokenHash, &inv.InvitedBy, &inv.ExpiresAt, &acceptedAt, &inv.CreatedAt); err != nil { + return nil, err + } + if acceptedAt.Valid { + inv.AcceptedAt = &acceptedAt.Time + } + out = append(out, &inv) + } + return out, rows.Err() +} + +// HashToken returns the SHA-256 hex of a raw token for safe DB storage. +func HashToken(raw string) string { + h := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/core/organizations/application/commands/create_organization.go b/internal/core/organizations/application/commands/create_organization.go index 9519c82..4238a86 100644 --- a/internal/core/organizations/application/commands/create_organization.go +++ b/internal/core/organizations/application/commands/create_organization.go @@ -53,6 +53,16 @@ func (h *CreateOrganizationHandler) Handle(ctx context.Context, cmd CreateOrgani return nil, fmt.Errorf("save organization: %w", err) } + // The caller becomes the first owner. + if cmd.CreatedBy != "" { + _ = h.orgs.AddMember(ctx, &domain.Member{ + OrgID: org.ID, + UserID: cmd.CreatedBy, + Role: domain.RoleOwner, + CreatedAt: now, + }) + } + return &CreateOrganizationResult{OrganizationID: org.ID, Slug: org.Slug}, nil } diff --git a/internal/core/organizations/domain/organization.go b/internal/core/organizations/domain/organization.go index 9dff660..bb3686a 100644 --- a/internal/core/organizations/domain/organization.go +++ b/internal/core/organizations/domain/organization.go @@ -12,3 +12,34 @@ type Organization struct { CreatedAt time.Time UpdatedAt time.Time } + +// Role constants for organization membership. +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMember = "member" +) + +// Member represents a user's membership in an organization. +type Member struct { + OrgID string + UserID string + Email string + DisplayName string + Role string + CreatedAt time.Time +} + +// Invite is a pending email invitation to join an organization. +type Invite struct { + ID string + OrgID string + InvitedEmail string + Role string + Token string // raw token (only available at creation time) + TokenHash string + InvitedBy string + ExpiresAt time.Time + AcceptedAt *time.Time + CreatedAt time.Time +} diff --git a/internal/core/organizations/ports/repository.go b/internal/core/organizations/ports/repository.go index db9a005..9b17df0 100644 --- a/internal/core/organizations/ports/repository.go +++ b/internal/core/organizations/ports/repository.go @@ -8,9 +8,30 @@ import ( // OrganizationRepository is the persistence port for Organization aggregates. type OrganizationRepository interface { + // Org CRUD FindByID(ctx context.Context, id string) (*domain.Organization, error) FindBySlug(ctx context.Context, slug string) (*domain.Organization, error) - ListByUserID(ctx context.Context, userID string) ([]*domain.Organization, error) Save(ctx context.Context, org *domain.Organization) error + Update(ctx context.Context, org *domain.Organization) error Delete(ctx context.Context, id string) error + + // Membership + ListByUserID(ctx context.Context, userID string) ([]*domain.Organization, error) + ListMembers(ctx context.Context, orgID string) ([]*domain.Member, error) + GetMember(ctx context.Context, orgID, userID string) (*domain.Member, error) + AddMember(ctx context.Context, member *domain.Member) error + UpdateMemberRole(ctx context.Context, orgID, userID, role string) error + RemoveMember(ctx context.Context, orgID, userID string) error + CountOwners(ctx context.Context, orgID string) (int, error) + + // Bootstrap: upsert org row + add caller as owner if not already a member. + // Used to migrate existing "org-" orgs on first login. + EnsureOrgWithOwner(ctx context.Context, orgID, orgName, userID, email, displayName string) error + + // Invites + CreateInvite(ctx context.Context, invite *domain.Invite) error + FindInviteByToken(ctx context.Context, tokenHash string) (*domain.Invite, error) + ListInvites(ctx context.Context, orgID string) ([]*domain.Invite, error) + AcceptInvite(ctx context.Context, inviteID, userID, email, displayName string) error + RevokeInvite(ctx context.Context, inviteID string) error } diff --git a/internal/core/projects/adapters/http/handler.go b/internal/core/projects/adapters/http/handler.go index 86b5797..252dcb8 100644 --- a/internal/core/projects/adapters/http/handler.go +++ b/internal/core/projects/adapters/http/handler.go @@ -12,6 +12,7 @@ import ( "time" "github.com/go-chi/chi/v5" + orgports "github.com/kleffio/platform/internal/core/organizations/ports" "github.com/kleffio/platform/internal/core/projects/domain" "github.com/kleffio/platform/internal/core/projects/ports" "github.com/kleffio/platform/internal/shared/ids" @@ -24,11 +25,12 @@ var slugCleaner = regexp.MustCompile(`[^a-z0-9-]+`) type Handler struct { repo ports.ProjectRepository + orgs orgports.OrganizationRepository logger *slog.Logger } -func NewHandler(repo ports.ProjectRepository, logger *slog.Logger) *Handler { - return &Handler{repo: repo, logger: logger} +func NewHandler(repo ports.ProjectRepository, orgs orgports.OrganizationRepository, logger *slog.Logger) *Handler { + return &Handler{repo: repo, orgs: orgs, logger: logger} } func (h *Handler) RegisterRoutes(r chi.Router) { @@ -50,16 +52,11 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // ── Project CRUD ───────────────────────────────────────────────────────────── func (h *Handler) list(w http.ResponseWriter, r *http.Request) { - orgID, err := organizationIDFromRequest(r) + orgID, err := h.resolveOrganizationID(r) if err != nil { writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) return } - if err := h.repo.EnsureOrganization(r.Context(), orgID, "Organization "+orgID); err != nil { - h.logger.Error("ensure organization", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to ensure organization"}) - return - } projects, err := h.repo.ListByOrganization(r.Context(), orgID) if err != nil { @@ -111,7 +108,7 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } - orgID, err := organizationIDFromRequest(r) + orgID, err := h.resolveOrganizationID(r) if err != nil { writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) return @@ -120,11 +117,6 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden: organization mismatch"}) return } - if err := h.repo.EnsureOrganization(r.Context(), orgID, "Organization "+orgID); err != nil { - h.logger.Error("ensure organization", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to ensure organization"}) - return - } slug := normalizeSlug(req.Slug) if slug == "" { @@ -351,32 +343,58 @@ func (h *Handler) upsertGraphNode(w http.ResponseWriter, r *http.Request) { // ── Helpers ─────────────────────────────────────────────────────────────────── -func organizationIDFromRequest(r *http.Request) (string, error) { +// resolveOrganizationID determines the caller's active organization. +// +// If X-Organization-ID / organization_id is present: +// - verify the caller is a member of that org (membership table) +// +// Otherwise fall back to the caller's personal org (derived from JWT sub), +// bootstrapping the org row + owner membership on first use. +func (h *Handler) resolveOrganizationID(r *http.Request) (string, error) { queryOrgID := strings.TrimSpace(r.URL.Query().Get("organization_id")) headerOrgID := strings.TrimSpace(r.Header.Get("X-Organization-ID")) if queryOrgID != "" && headerOrgID != "" && queryOrgID != headerOrgID { return "", fmt.Errorf("forbidden: conflicting organization context") } - requestedOrgID := queryOrgID if requestedOrgID == "" { requestedOrgID = headerOrgID } - if claims, ok := middleware.ClaimsFromContext(r.Context()); ok { - if claims.Subject != "" { - callerOrgID := "org-" + normalizeSlug(claims.Subject) - if requestedOrgID != "" && requestedOrgID != callerOrgID { - return "", fmt.Errorf("forbidden: organization mismatch") + claims, hasClaims := middleware.ClaimsFromContext(r.Context()) + + // Explicit org in request — verify membership. + if requestedOrgID != "" { + if hasClaims && claims.Subject != "" && h.orgs != nil { + if _, err := h.orgs.GetMember(r.Context(), requestedOrgID, claims.Subject); err != nil { + return "", fmt.Errorf("forbidden: not a member of this organization") } - return callerOrgID, nil } + return requestedOrgID, nil } - if requestedOrgID != "" { - return requestedOrgID, nil + // No explicit org — use personal org derived from JWT sub. + if hasClaims && claims.Subject != "" { + personalOrgID := "org-" + normalizeSlug(claims.Subject) + if h.orgs != nil { + orgName := "My Organization" + if claims.Username != "" { + orgName = claims.Username + "'s Organization" + } + if err := h.orgs.EnsureOrgWithOwner(r.Context(), personalOrgID, orgName, + claims.Subject, claims.Email, claims.Username); err != nil { + return "", fmt.Errorf("failed to bootstrap organization") + } + } else { + // Fallback when org repo not available (tests/legacy). + if err := h.repo.EnsureOrganization(r.Context(), personalOrgID, "Organization "+personalOrgID); err != nil { + return "", fmt.Errorf("failed to ensure organization") + } + } + return personalOrgID, nil } + return "org-default", nil } @@ -386,7 +404,7 @@ func (h *Handler) authorizedProject(r *http.Request, projectID string) (*domain. return nil, err } - orgID, err := organizationIDFromRequest(r) + orgID, err := h.resolveOrganizationID(r) if err != nil { return nil, err } diff --git a/internal/core/workloads/adapters/http/handler.go b/internal/core/workloads/adapters/http/handler.go index 0c567ea..a7b77a4 100644 --- a/internal/core/workloads/adapters/http/handler.go +++ b/internal/core/workloads/adapters/http/handler.go @@ -13,6 +13,7 @@ import ( "time" "github.com/go-chi/chi/v5" + orgports "github.com/kleffio/platform/internal/core/organizations/ports" projectports "github.com/kleffio/platform/internal/core/projects/ports" "github.com/kleffio/platform/internal/core/workloads/application/commands" "github.com/kleffio/platform/internal/core/workloads/domain" @@ -30,6 +31,7 @@ const ( type Handler struct { projects projectports.ProjectRepository + orgs orgports.OrganizationRepository repo ports.Repository provision *commands.ProvisionWorkloadHandler action *commands.WorkloadActionHandler @@ -39,8 +41,8 @@ type Handler struct { var orgSlugCleaner = regexp.MustCompile(`[^a-z0-9-]+`) -func NewHandler(projects projectports.ProjectRepository, repo ports.Repository, provision *commands.ProvisionWorkloadHandler, action *commands.WorkloadActionHandler, bus *events.Bus, logger *slog.Logger) *Handler { - return &Handler{projects: projects, repo: repo, provision: provision, action: action, bus: bus, logger: logger} +func NewHandler(projects projectports.ProjectRepository, orgs orgports.OrganizationRepository, repo ports.Repository, provision *commands.ProvisionWorkloadHandler, action *commands.WorkloadActionHandler, bus *events.Bus, logger *slog.Logger) *Handler { + return &Handler{projects: projects, orgs: orgs, repo: repo, provision: provision, action: action, bus: bus, logger: logger} } func (h *Handler) RegisterRoutes(r chi.Router) { @@ -59,7 +61,7 @@ func (h *Handler) RegisterInternalRoutes(r chi.Router) { func (h *Handler) provisionWorkload(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") - orgID := callerOrganizationID(r.Context()) + orgID := h.callerOrganizationID(r) if err := h.ensureProjectAccess(r.Context(), projectID, orgID); err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) @@ -134,7 +136,7 @@ func (h *Handler) provisionWorkload(w http.ResponseWriter, r *http.Request) { func (h *Handler) list(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") - orgID := callerOrganizationID(r.Context()) + orgID := h.callerOrganizationID(r) if err := h.ensureProjectAccess(r.Context(), projectID, orgID); err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) @@ -159,7 +161,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "workload not found"}) return } - orgID := callerOrganizationID(r.Context()) + orgID := h.callerOrganizationID(r) if orgID != "" && workload.OrganizationID != orgID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden: workload does not belong to caller organization"}) return @@ -267,7 +269,7 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { func (h *Handler) runAction(w http.ResponseWriter, r *http.Request, action queue.JobType) { projectID := chi.URLParam(r, "projectID") workloadID := chi.URLParam(r, "id") - orgID := callerOrganizationID(r.Context()) + orgID := h.callerOrganizationID(r) if err := h.ensureProjectAccess(r.Context(), projectID, orgID); err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) @@ -309,11 +311,30 @@ func writeJSON(w http.ResponseWriter, status int, body any) { _ = json.NewEncoder(w).Encode(body) } -func callerOrganizationID(ctx context.Context) string { - claims, ok := middleware.ClaimsFromContext(ctx) +// callerOrganizationID resolves the active org from X-Organization-ID header, +// verifying membership. Falls back to the personal org derived from JWT sub. +func (h *Handler) callerOrganizationID(r *http.Request) string { + headerOrgID := strings.TrimSpace(r.Header.Get("X-Organization-ID")) + if headerOrgID == "" { + headerOrgID = strings.TrimSpace(r.URL.Query().Get("organization_id")) + } + + claims, ok := middleware.ClaimsFromContext(r.Context()) if !ok || claims.Subject == "" { - return "" + return headerOrgID + } + + if headerOrgID != "" { + // Verify membership when an explicit org is requested. + if h.orgs != nil { + if _, err := h.orgs.GetMember(r.Context(), headerOrgID, claims.Subject); err != nil { + return "" // not a member — access denied at ensureProjectAccess + } + } + return headerOrgID } + + // Personal org fallback. return "org-" + normalizeOrgSlug(claims.Subject) } diff --git a/internal/database/migrations/008_org_members.sql b/internal/database/migrations/008_org_members.sql new file mode 100644 index 0000000..bada4bd --- /dev/null +++ b/internal/database/migrations/008_org_members.sql @@ -0,0 +1,35 @@ +-- Tracks which users belong to which organizations and their role. +-- Roles: owner | admin | member +-- Multiple owners are allowed per org. + +CREATE TABLE IF NOT EXISTS organization_members ( + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (org_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_org_members_user_id ON organization_members(user_id); + +-- Back-fill: for any organization that was auto-created from a user ID +-- (pattern: "org-"), we cannot reconstruct the original user_id here, +-- so bootstrap happens at runtime on first login (see EnsureOrganization). + +-- Pending email invitations into an organization. +CREATE TABLE IF NOT EXISTS org_invites ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + invited_email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + token_hash TEXT NOT NULL UNIQUE, + invited_by TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_org_invites_org_id ON org_invites(org_id); +CREATE INDEX IF NOT EXISTS idx_org_invites_token ON org_invites(token_hash); diff --git a/internal/database/migrations/009_notifications.sql b/internal/database/migrations/009_notifications.sql new file mode 100644 index 0000000..83cd6cc --- /dev/null +++ b/internal/database/migrations/009_notifications.sql @@ -0,0 +1,23 @@ +-- System-wide notification inbox. +-- Each row is a notification for a specific user (identified by their IDP subject / user_id). +-- Notifications are soft-readable: read_at is NULL until the user marks it read. + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + data JSONB NOT NULL DEFAULT '{}', + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Most queries filter by user_id and sort by created_at desc. +CREATE INDEX IF NOT EXISTS idx_notifications_user_id + ON notifications(user_id, created_at DESC); + +-- Fast unread count per user. +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread + ON notifications(user_id) + WHERE read_at IS NULL; diff --git a/internal/shared/middleware/auth.go b/internal/shared/middleware/auth.go index 4b99525..7325734 100644 --- a/internal/shared/middleware/auth.go +++ b/internal/shared/middleware/auth.go @@ -92,9 +92,13 @@ func RequireRole(roles ...string) func(http.Handler) http.Handler { } func extractBearer(r *http.Request) string { - v := r.Header.Get("Authorization") - if after, ok := strings.CutPrefix(v, "Bearer "); ok { - return after + // Standard Authorization header (used by all regular API calls). + if v := r.Header.Get("Authorization"); v != "" { + if after, ok := strings.CutPrefix(v, "Bearer "); ok { + return after + } } - return "" + // Fallback: ?token= query parameter for EventSource / SSE connections, + // which cannot set custom headers in the browser. + return r.URL.Query().Get("token") }