package store import ( "context" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/store/db" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) // Account/Template/Domain are provider-neutral domain structs returned by the // thin wrappers below, so callers (internal/api) never need to import // internal/store/db directly. type Account struct { ID uuid.UUID ProjectID uuid.UUID Provider string SecretEnc string Comment string } func accountFromDB(a db.ProviderAccount) Account { return Account{ID: a.ID, ProjectID: a.ProjectID, Provider: a.Provider, SecretEnc: a.SecretEnc, Comment: a.Comment} } func (s *Store) CreateAccount(ctx context.Context, projectID uuid.UUID, provider, secretEnc, comment string) (Account, error) { a, err := s.q.CreateAccount(ctx, db.CreateAccountParams{ ID: uuid.New(), ProjectID: projectID, Provider: provider, SecretEnc: secretEnc, Comment: comment, }) if err != nil { return Account{}, err } return accountFromDB(a), nil } func (s *Store) ListAccounts(ctx context.Context, projectID uuid.UUID) ([]Account, error) { rows, err := s.q.ListAccounts(ctx, projectID) if err != nil { return nil, err } out := make([]Account, 0, len(rows)) for _, r := range rows { out = append(out, accountFromDB(r)) } return out, nil } func (s *Store) GetAccount(ctx context.Context, id, projectID uuid.UUID) (Account, error) { a, err := s.q.GetAccount(ctx, db.GetAccountParams{ID: id, ProjectID: projectID}) if err != nil { return Account{}, err } return accountFromDB(a), nil } func (s *Store) DeleteAccount(ctx context.Context, id, projectID uuid.UUID) error { return s.q.DeleteAccount(ctx, db.DeleteAccountParams{ID: id, ProjectID: projectID}) } type Template struct { ID uuid.UUID ProjectID uuid.UUID Name string Doc dto.TemplateDoc Version int32 } func templateFromDB(t db.Template) Template { var doc dto.TemplateDoc if t.Doc != nil { doc = *t.Doc } return Template{ID: t.ID, ProjectID: t.ProjectID, Name: t.Name, Doc: doc, Version: t.Version} } func (s *Store) CreateTemplate(ctx context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (Template, error) { d := doc t, err := s.q.CreateTemplate(ctx, db.CreateTemplateParams{ID: uuid.New(), ProjectID: projectID, Name: name, Doc: &d}) if err != nil { return Template{}, err } return templateFromDB(t), nil } func (s *Store) ListTemplates(ctx context.Context, projectID uuid.UUID) ([]Template, error) { rows, err := s.q.ListTemplates(ctx, projectID) if err != nil { return nil, err } out := make([]Template, 0, len(rows)) for _, r := range rows { out = append(out, templateFromDB(r)) } return out, nil } func (s *Store) UpdateTemplate(ctx context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (Template, error) { d := doc t, err := s.q.UpdateTemplate(ctx, db.UpdateTemplateParams{ID: id, ProjectID: projectID, Name: name, Doc: &d}) if err != nil { return Template{}, err } return templateFromDB(t), nil } func (s *Store) DeleteTemplate(ctx context.Context, id, projectID uuid.UUID) error { return s.q.DeleteTemplate(ctx, db.DeleteTemplateParams{ID: id, ProjectID: projectID}) } // GetTemplate is a scoped lookup used to verify a template belongs to // projectID before it is referenced elsewhere (e.g. CreateDomain). func (s *Store) GetTemplate(ctx context.Context, id, projectID uuid.UUID) (Template, error) { t, err := s.q.GetTemplate(ctx, db.GetTemplateParams{ID: id, ProjectID: projectID}) if err != nil { return Template{}, err } return templateFromDB(t), nil } type Domain struct { ID uuid.UUID ProjectID uuid.UUID ProviderAccountID uuid.UUID ZoneName string ZoneID string TemplateID *uuid.UUID } func domainFromDB(d db.Domain) Domain { return Domain{ ID: d.ID, ProjectID: d.ProjectID, ProviderAccountID: d.ProviderAccountID, ZoneName: d.ZoneName, ZoneID: d.ZoneID, TemplateID: d.TemplateID, } } func (s *Store) CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (Domain, error) { d, err := s.q.CreateDomain(ctx, db.CreateDomainParams{ ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: zoneName, ZoneID: zoneID, TemplateID: templateID, }) if err != nil { return Domain{}, err } return domainFromDB(d), nil } func (s *Store) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) { rows, err := s.q.ListDomains(ctx, projectID) if err != nil { return nil, err } out := make([]Domain, 0, len(rows)) for _, r := range rows { out = append(out, domainFromDB(r)) } return out, nil } func (s *Store) DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error { return s.q.DeleteDomain(ctx, db.DeleteDomainParams{ID: id, ProjectID: projectID}) } // ImportDomains creates one domain per zone inside a single transaction: if // any zone fails to be created, the whole batch is rolled back so callers // never observe a partially-imported set of domains. // // Import is idempotent: zones that already have a domain for this project // (enforced by the domains_project_zone_uniq constraint) are silently // skipped via ON CONFLICT DO NOTHING rather than erroring or duplicating — // so a repeated POST .../import never creates duplicate domains. Only the // zones that were actually newly created are returned. func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) // no-op once Commit has succeeded q := s.q.WithTx(tx) out := make([]Domain, 0, len(zones)) for _, z := range zones { d, err := q.ImportDomain(ctx, db.ImportDomainParams{ ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // ON CONFLICT DO NOTHING: this zone was already imported // for this project — skip it rather than fail the batch. continue } return nil, err } out = append(out, domainFromDB(d)) } if err := tx.Commit(ctx); err != nil { return nil, err } return out, nil } // SetDomainTemplate attaches (or clears, when templateID is nil) the DNS // template used to check/apply a domain. When templateID is non-nil it must // belong to the same project — verified via scoped GetTemplate — otherwise // a caller could bind a domain to another tenant's template. func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (Domain, error) { if templateID != nil { if _, err := s.GetTemplate(ctx, *templateID, projectID); err != nil { return Domain{}, err } } d, err := s.q.UpdateDomainTemplate(ctx, db.UpdateDomainTemplateParams{ ID: domainID, ProjectID: projectID, TemplateID: templateID, }) if err != nil { return Domain{}, err } return domainFromDB(d), nil } // User and Project are provider-neutral domain structs for the auth/tenant // layer (Фаза 2), mirroring the Account/Template/Domain wrappers above so // callers never need to import internal/store/db directly. type User struct { ID uuid.UUID Email string PasswordHash string } type Project struct { ID uuid.UUID UserID uuid.UUID Name string } // ptr is a small helper for passing a Go string into a nullable text column // (password_hash) via sqlc's generated *string param type. func ptr(s string) *string { return &s } // strFromPtr converts a nullable text column back into a plain string; a // nil password_hash never happens on the real registration flow (an argon2 // hash is always supplied), but is handled defensively here. func strFromPtr(p *string) string { if p == nil { return "" } return *p } func toUser(u db.User) User { return User{ID: u.ID, Email: u.Email, PasswordHash: strFromPtr(u.PasswordHash)} } func toProject(p db.Project) Project { return Project{ID: p.ID, UserID: p.UserID, Name: p.Name} } func (s *Store) CreateUser(ctx context.Context, email, passwordHash string) (User, error) { u, err := s.q.CreateUser(ctx, db.CreateUserParams{ID: uuid.New(), Email: email, PasswordHash: ptr(passwordHash)}) if err != nil { return User{}, err } return toUser(u), nil } func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) { u, err := s.q.GetUserByEmail(ctx, email) if err != nil { return User{}, err } return toUser(u), nil } func (s *Store) CreateProjectForUser(ctx context.Context, userID uuid.UUID, name string) (Project, error) { p, err := s.q.CreateProject(ctx, db.CreateProjectParams{ID: uuid.New(), UserID: userID, Name: name}) if err != nil { return Project{}, err } return toProject(p), nil } func (s *Store) GetProjectOwned(ctx context.Context, projectID, userID uuid.UUID) (Project, error) { p, err := s.q.GetProjectOwned(ctx, db.GetProjectOwnedParams{ID: projectID, UserID: userID}) if err != nil { return Project{}, err } return toProject(p), nil } func (s *Store) GetUserProject(ctx context.Context, userID uuid.UUID) (Project, error) { p, err := s.q.GetUserProject(ctx, userID) if err != nil { return Project{}, err } return toProject(p), nil } func (s *Store) CreateSession(ctx context.Context, userID uuid.UUID, tokenHash string, expiresAt time.Time) error { return s.q.CreateSession(ctx, db.CreateSessionParams{ ID: uuid.New(), UserID: userID, TokenHash: tokenHash, ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}, }) } // GetSessionUser returns the owning user ID for a non-expired session token; // expired sessions are excluded by the query itself (expires_at > now()). func (s *Store) GetSessionUser(ctx context.Context, tokenHash string) (uuid.UUID, error) { return s.q.GetSessionUser(ctx, tokenHash) } func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error { return s.q.DeleteSession(ctx, tokenHash) } // RegisterUser creates a user and their default project in one transaction, // mirroring the ImportDomains pattern above: if project creation fails, the // user insert is rolled back too, so a caller never observes a user without // a default project. func (s *Store) RegisterUser(ctx context.Context, email, passwordHash string) (User, Project, error) { tx, err := s.pool.Begin(ctx) if err != nil { return User{}, Project{}, err } defer tx.Rollback(ctx) // no-op once Commit has succeeded q := s.q.WithTx(tx) uid := uuid.New() dbu, err := q.CreateUser(ctx, db.CreateUserParams{ID: uid, Email: email, PasswordHash: ptr(passwordHash)}) if err != nil { return User{}, Project{}, err } dbp, err := q.CreateProject(ctx, db.CreateProjectParams{ID: uuid.New(), UserID: uid, Name: "default"}) if err != nil { return User{}, Project{}, err } if err := tx.Commit(ctx); err != nil { return User{}, Project{}, err } return toUser(dbu), toProject(dbp), nil }