Files
dns-autoresolver/internal/api/api.go
T
vasyansk 1b367c4bda fix(api): manual check persists last_check_status (was stale unknown)
Manual domain checks (Recheck button / diff page load) never wrote
domains.last_check_status - only the scheduler did, leaving a
newly-templated domain stuck at "unknown" until the next scheduled run.

Extract status derivation into internal/service (single source of truth):
StatusUnknown/InSync/Drift/Error constants and DeriveStatus(diff.Changeset).
The scheduler now aliases these constants instead of duplicating them.
handleCheck persists the derived status (or StatusError on failure) via
TenantStore.SetDomainStatus after every manual check - status/history only,
no notification, which remains the scheduler's job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
2026-07-05 14:22:02 +07:00

168 lines
6.6 KiB
Go

package api
import (
"context"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
// CheckApplier is the service surface the API depends on.
type CheckApplier interface {
Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error)
Apply(ctx context.Context, projectID, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error)
// ZoneRecords reads a zone's current records straight from the provider,
// with no diff and no template required — backs read-only zone viewing
// and the template-from-zone snapshot.
ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error)
}
// TenantStore is the narrow persistence surface the CRUD handlers depend on.
// *store.Store satisfies it directly via its thin wrapper methods (see
// internal/store/tenant.go); tests can supply their own mock.
type TenantStore interface {
CreateAccount(ctx context.Context, projectID uuid.UUID, provider, secretEnc, comment string) (store.Account, error)
ListAccounts(ctx context.Context, projectID uuid.UUID) ([]store.Account, error)
GetAccount(ctx context.Context, id, projectID uuid.UUID) (store.Account, error)
DeleteAccount(ctx context.Context, id, projectID uuid.UUID) error
CreateTemplate(ctx context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error)
ListTemplates(ctx context.Context, projectID uuid.UUID) ([]store.Template, error)
UpdateTemplate(ctx context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error)
DeleteTemplate(ctx context.Context, id, projectID uuid.UUID) error
GetTemplate(ctx context.Context, id, projectID uuid.UUID) (store.Template, error)
CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error)
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
// GetDomain is used by the template-from-zone snapshot to read the
// domain's zone name (for the generated template's name) before creating it.
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error)
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
// SetDomainStatus persists the outcome of a manual check (handleCheck) so
// the domain's badge reflects reality immediately, instead of staying
// "unknown" until the scheduler's next tick.
SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error
}
// Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it.
type Cipher interface {
Encrypt(plaintext []byte) (string, error)
Decrypt(enc string) ([]byte, error)
}
// ProviderRegistry resolves a provider.Provider by name. *registry.Registry satisfies it.
type ProviderRegistry interface {
ByName(name string) (provider.Provider, error)
}
// AuthStore is the persistence surface the auth handlers depend on.
// *store.Store satisfies it directly (see internal/store/store.go); tests
// can supply their own mock.
type AuthStore interface {
RegisterUser(ctx context.Context, email, passwordHash string) (store.User, store.Project, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUserByID(ctx context.Context, userID uuid.UUID) (store.User, error)
GetUserProject(ctx context.Context, userID uuid.UUID) (store.Project, error)
// GetProjectOwned looks up projectID and returns it only if it's owned by
// userID — RequireProjectAccess uses this to reject foreign/nonexistent
// projects with 404 before any handler runs.
GetProjectOwned(ctx context.Context, projectID, userID uuid.UUID) (store.Project, error)
}
// SessionManager creates/validates/destroys login sessions. *auth.Sessions
// satisfies it directly (see internal/auth/session.go).
type SessionManager interface {
Create(ctx context.Context, userID uuid.UUID) (string, time.Time, error)
Validate(ctx context.Context, token string) (uuid.UUID, error)
Destroy(ctx context.Context, token string) error
}
// API holds handler dependencies.
type API struct {
Svc CheckApplier
Store TenantStore
Cipher Cipher
Reg ProviderRegistry
Auth AuthStore
Sessions SessionManager
Schedule ScheduleStore
Dispatch TestSender
}
func NewRouter(a *API) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Route("/api/v1/auth", func(r chi.Router) {
r.Post("/register", a.handleRegister)
r.Post("/login", a.handleLogin)
r.Group(func(r chi.Router) {
r.Use(a.RequireAuth)
r.Post("/logout", a.handleLogout)
r.Get("/me", a.handleMe)
})
})
r.Route("/api/v1/projects/{pid}", func(r chi.Router) {
r.Use(a.RequireAuth)
r.Use(a.RequireProjectAccess)
r.Route("/domains", func(r chi.Router) {
r.Post("/", a.handleCreateDomain)
r.Get("/", a.handleListDomains)
r.Route("/{did}", func(r chi.Router) {
r.Get("/check", a.handleCheck)
r.Post("/apply", a.handleApply)
r.Patch("/", a.handleSetDomainTemplate)
r.Delete("/", a.handleDeleteDomain)
r.Get("/history", a.handleDomainHistory)
r.Get("/records", a.handleZoneRecords)
r.Post("/template-from-zone", a.handleTemplateFromZone)
})
})
r.Get("/schedule", a.handleGetSchedule)
r.Put("/schedule", a.handlePutSchedule)
r.Route("/channels", func(r chi.Router) {
r.Post("/", a.handleCreateChannel)
r.Get("/", a.handleListChannels)
r.Route("/{cid}", func(r chi.Router) {
r.Delete("/", a.handleDeleteChannel)
r.Post("/test", a.handleTestChannel)
})
})
r.Route("/accounts", func(r chi.Router) {
r.Post("/", a.handleCreateAccount)
r.Get("/", a.handleListAccounts)
r.Route("/{aid}", func(r chi.Router) {
r.Delete("/", a.handleDeleteAccount)
r.Post("/import", a.handleImportZones)
})
})
r.Route("/templates", func(r chi.Router) {
r.Post("/", a.handleCreateTemplate)
r.Get("/", a.handleListTemplates)
r.Route("/{tid}", func(r chi.Router) {
r.Put("/", a.handleUpdateTemplate)
r.Delete("/", a.handleDeleteTemplate)
})
})
})
return r
}