bindizr-service 0.1.0-beta.4

Application services for bindizr zone, record, token, and notification workflows
Documentation
use bindizr_core::dns::name::{email_to_soa_mailbox, to_fqdn};
use chrono::Utc;

use super::{ZoneService, validation::normalize_zone_lookup_name};
use crate::{
    RepositoryTx,
    error::ServiceError,
    log_error, log_info, log_warn,
    model::{
        record::{Record, RecordType},
        zone::Zone,
        zone_change::ZoneChange,
    },
    repository::RepositoryService,
    serial::generate_serial,
    types::CreateZoneRequest,
    zone::{snapshot::save_zone_snapshot_tx, validation::validate_create_zone_request},
};

impl ZoneService {
    pub async fn update_tx(tx: &mut RepositoryTx<'_>, zone: Zone) -> Result<Zone, ServiceError> {
        RepositoryService::update_zone_tx(tx, zone).await
    }

    pub async fn create_change_tx(
        tx: &mut RepositoryTx<'_>,
        zone_change: ZoneChange,
    ) -> Result<ZoneChange, ServiceError> {
        RepositoryService::create_zone_change_tx(tx, zone_change).await
    }

    pub async fn update(
        zone_name: &str,
        update_zone_request: &CreateZoneRequest,
    ) -> Result<Zone, ServiceError> {
        let lookup_name = normalize_zone_lookup_name(zone_name)?;

        // Check if zone exists
        let existing_zone = match RepositoryService::get_zone_by_name(&lookup_name).await {
            Ok(Some(zone)) => zone,
            Ok(None) => {
                log_error!("Zone with name '{}' not found", zone_name);
                return Err(ServiceError::NotFound(format!(
                    "Zone with name '{}' not found",
                    zone_name
                )));
            }
            Err(e) => {
                log_error!("Failed to fetch zone: {}", e);
                return Err(ServiceError::Internal("Failed to update zone".to_string()));
            }
        };
        let zone_id = existing_zone.id;
        let validated = validate_create_zone_request(update_zone_request)?;

        // Check if zone with the new name already exists (if name is being changed)
        match RepositoryService::get_zone_by_name(&validated.name).await {
            Ok(Some(zone)) => {
                if zone.id != zone_id {
                    log_error!("Zone with name {} already exists", validated.name);
                    return Err(ServiceError::BadRequest(
                        "Zone name already exists".to_string(),
                    ));
                }
            }
            Ok(None) => {}
            Err(e) => {
                log_error!("Failed to check existing zone: {}", e);
                return Err(ServiceError::Internal("Failed to update zone".to_string()));
            }
        };

        // Auto-increment serial if not provided, or use existing if no change
        let new_serial = match update_zone_request.serial {
            Some(s) => s,
            None => generate_serial(Some(existing_zone.serial)),
        };

        let zone_records = RepositoryService::get_records_by_zone_id(zone_id)
            .await
            .map_err(|e| {
                log_error!("Failed to fetch zone records: {}", e);
                ServiceError::Internal("Failed to update zone".to_string())
            })?;

        // Update zone
        let mut tx = RepositoryService::begin_tx("Failed to update zone").await?;

        let apply_result = async {
            let updated_zone = RepositoryService::update_zone_tx(
                &mut tx,
                Zone {
                    id: zone_id,
                    name: validated.name.clone(),
                    primary_ns: validated.primary_ns.clone(),
                    admin_email: validated.admin_email.clone(),
                    ttl: validated.ttl,
                    serial: new_serial,
                    refresh: update_zone_request.refresh.unwrap_or(86400),
                    retry: update_zone_request.retry.unwrap_or(7200),
                    expire: update_zone_request.expire.unwrap_or(3_600_000),
                    minimum_ttl: update_zone_request.minimum_ttl.unwrap_or(86400),
                    created_at: existing_zone.created_at,
                },
            )
            .await
            .map_err(|e| {
                log_error!("Failed to update zone: {}", e);
                ServiceError::Internal("Failed to update zone".to_string())
            })?;

            // Record zone changes for IXFR
            let has_primary_ns = zone_records.iter().any(|r| {
                r.record_type == RecordType::NS
                    && r.name == "@"
                    && to_fqdn(&r.value).eq_ignore_ascii_case(&to_fqdn(&updated_zone.primary_ns))
            });

            if !has_primary_ns {
                let primary_ns_record = Record {
                    id: 0,
                    name: "@".to_string(),
                    record_type: RecordType::NS,
                    value: updated_zone.primary_ns.clone(),
                    ttl: Some(updated_zone.ttl),
                    priority: None,
                    zone_id,
                    created_at: Utc::now(),
                };

                RepositoryService::create_record_tx(&mut tx, primary_ns_record)
                    .await
                    .map_err(|e| {
                        log_error!("Failed to create primary NS record during update: {}", e);
                        ServiceError::Internal("Failed to keep primary NS consistency".to_string())
                    })?;

                RepositoryService::create_zone_change_tx(
                    &mut tx,
                    ZoneChange {
                        id: 0,
                        zone_id,
                        serial: new_serial,
                        operation: "ADD".to_string(),
                        record_name: "@".to_string(),
                        record_type: "NS".to_string(),
                        record_value: updated_zone.primary_ns.clone(),
                        record_ttl: Some(updated_zone.ttl),
                        record_priority: None,
                    },
                )
                .await
                .map_err(|e| {
                    log_error!("Failed to create zone change (ADD NS): {}", e);
                    ServiceError::Internal("Failed to create zone change".to_string())
                })?;
            }

            let format_soa = |zone: &Zone| -> Result<String, ServiceError> {
                Ok(format!(
                    "{} {} {} {} {} {} {}",
                    zone.primary_ns,
                    email_to_soa_mailbox(&zone.admin_email)
                        .map_err(|e| ServiceError::BadRequest(e.to_string()))?,
                    zone.serial,
                    zone.refresh,
                    zone.retry,
                    zone.expire,
                    zone.minimum_ttl
                ))
            };

            // Delete old SOA record
            RepositoryService::create_zone_change_tx(
                &mut tx,
                ZoneChange {
                    id: 0,
                    zone_id,
                    serial: new_serial,
                    operation: "DEL".to_string(),
                    record_name: "@".to_string(),
                    record_type: "SOA".to_string(),
                    record_value: format_soa(&existing_zone)?,
                    record_ttl: Some(existing_zone.ttl),
                    record_priority: None,
                },
            )
            .await
            .map_err(|e| {
                log_error!("Failed to create zone change (DEL SOA): {}", e);
                ServiceError::Internal("Failed to create zone change".to_string())
            })?;

            // Add new SOA record
            RepositoryService::create_zone_change_tx(
                &mut tx,
                ZoneChange {
                    id: 0,
                    zone_id,
                    serial: new_serial,
                    operation: "ADD".to_string(),
                    record_name: "@".to_string(),
                    record_type: "SOA".to_string(),
                    record_value: format_soa(&updated_zone)?,
                    record_ttl: Some(updated_zone.ttl),
                    record_priority: None,
                },
            )
            .await
            .map_err(|e| {
                log_error!("Failed to create zone change (ADD SOA): {}", e);
                ServiceError::Internal("Failed to create zone change".to_string())
            })?;

            save_zone_snapshot_tx(&mut tx, &updated_zone, new_serial).await?;

            Ok::<Zone, ServiceError>(updated_zone)
        }
        .await;

        let updated_zone =
            RepositoryService::finish_tx(tx, apply_result, "Failed to update zone").await?;

        // Log zone update after commit (structured logging)
        log_info!(
            "event=zone_update zone={} previous_name={} new_serial={} zone_id={}",
            updated_zone.name,
            zone_name,
            new_serial,
            updated_zone.id
        );

        // Send NOTIFY to secondary servers
        if let Err(e) = crate::notify::send_notify_after_update(Some(&updated_zone.name)).await {
            log_warn!(
                "Failed to send NOTIFY for zone {}: {}",
                updated_zone.name,
                e
            );
        }

        if existing_zone.name != updated_zone.name
            && let Err(e) = crate::notify::send_notify_after_update(Some("catalog.bind")).await
        {
            log_warn!("Failed to send NOTIFY for catalog.bind: {}", e);
        }

        Ok(updated_zone)
    }
}