feat: M11b — ownership tracking, agent groups, interactive renewal approval

Ownership: owners/teams GUI pages, notification email resolution via
resolveRecipient (owner_id → owner.email lookup). Agent groups: dynamic
device grouping by OS/arch/IP CIDR/version with manual include/exclude
membership, migration 000004, full CRUD stack (domain → repo → service →
handler → frontend). Interactive approval: AwaitingApproval job state,
approve/reject API endpoints with reason tracking. Tests: 12 agent group
handler tests, 8 approve/reject job handler tests, integration tests
updated for 13-param RegisterHandlers. Docs updated across architecture,
concepts, and seed data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-20 21:02:35 -04:00
parent 1ef16984eb
commit e445cbef22
27 changed files with 1774 additions and 21 deletions
+24 -4
View File
@@ -13,6 +13,7 @@ import (
// NotificationService provides business logic for managing notifications.
type NotificationService struct {
notifRepo repository.NotificationRepository
ownerRepo repository.OwnerRepository
notifierRegistry map[string]Notifier
}
@@ -35,6 +36,25 @@ func NewNotificationService(
}
}
// SetOwnerRepo sets the owner repository for email resolution.
// Called after construction to avoid circular dependency during initialization.
func (s *NotificationService) SetOwnerRepo(ownerRepo repository.OwnerRepository) {
s.ownerRepo = ownerRepo
}
// resolveRecipient resolves an owner ID to an email address.
// Falls back to the raw owner ID if the owner repo is not set or lookup fails.
func (s *NotificationService) resolveRecipient(ctx context.Context, ownerID string) string {
if s.ownerRepo == nil || ownerID == "" {
return ownerID
}
owner, err := s.ownerRepo.Get(ctx, ownerID)
if err != nil || owner == nil || owner.Email == "" {
return ownerID
}
return owner.Email
}
// SendExpirationWarning sends a certificate expiration warning for a specific threshold.
func (s *NotificationService) SendExpirationWarning(ctx context.Context, cert *domain.ManagedCertificate, daysUntilExpiry int) error {
return s.SendThresholdAlert(ctx, cert, daysUntilExpiry, daysUntilExpiry)
@@ -56,13 +76,13 @@ func (s *NotificationService) SendThresholdAlert(ctx context.Context, cert *doma
)
}
// Create notification record
// Create notification record — resolve owner email if possible
notif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: domain.NotificationTypeExpirationWarning,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Recipient: s.resolveRecipient(ctx, cert.OwnerID),
Message: body,
Status: "pending",
CreatedAt: time.Now(),
@@ -121,7 +141,7 @@ func (s *NotificationService) SendRenewalNotification(ctx context.Context, cert
CertificateID: &cert.ID,
Type: notifType,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Recipient: s.resolveRecipient(ctx, cert.OwnerID),
Message: body,
Status: "pending",
CreatedAt: time.Now(),
@@ -160,7 +180,7 @@ func (s *NotificationService) SendDeploymentNotification(ctx context.Context, ce
CertificateID: &cert.ID,
Type: notifType,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Recipient: s.resolveRecipient(ctx, cert.OwnerID),
Message: body,
Status: "pending",
CreatedAt: time.Now(),