From eb2fc6c8b3f33361ed7b1842852e4c902a9e4f7e Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Wed, 22 Apr 2026 17:27:49 +0930 Subject: [PATCH] Added soft deletes to all deletion functions and added deleted_at filters as required --- gatehouse_app/api/v1/external_auth/admin.py | 10 ++++------ gatehouse_app/api/v1/external_auth/oauth.py | 5 +++-- gatehouse_app/api/v1/external_auth/providers.py | 16 ++++++++-------- gatehouse_app/api/v1/oidc.py | 4 ++-- gatehouse_app/api/v1/organizations/members.py | 2 +- gatehouse_app/api/v1/ssh/certs.py | 2 +- .../models/auth/authentication_method.py | 2 +- .../models/auth/email_verification_token.py | 3 ++- .../models/auth/password_reset_token.py | 3 ++- gatehouse_app/services/auth_service.py | 11 +++++------ gatehouse_app/services/billing_service.py | 12 ++++++------ gatehouse_app/services/external_auth/__init__.py | 3 ++- .../services/external_auth/app_provider.py | 10 +++++----- gatehouse_app/services/external_auth/linking.py | 6 ++++-- .../services/external_auth/org_override.py | 8 ++++++-- gatehouse_app/services/network_access_service.py | 10 +++++----- gatehouse_app/services/oauth_flow/register.py | 2 +- .../services/zerotier_reconciliation_service.py | 12 ++++++------ 18 files changed, 64 insertions(+), 57 deletions(-) diff --git a/gatehouse_app/api/v1/external_auth/admin.py b/gatehouse_app/api/v1/external_auth/admin.py index 437fd37..d1149b3 100644 --- a/gatehouse_app/api/v1/external_auth/admin.py +++ b/gatehouse_app/api/v1/external_auth/admin.py @@ -21,7 +21,7 @@ def admin_list_app_providers(): return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN") PROVIDERS = [{"id": "google", "name": "Google"}, {"id": "github", "name": "GitHub"}, {"id": "microsoft", "name": "Microsoft"}] - db_configs = {c.provider_type: c for c in ApplicationProviderConfig.query.all()} + db_configs = {c.provider_type: c for c in ApplicationProviderConfig.query.filter_by(deleted_at=None).all()} result = [] for p in PROVIDERS: @@ -64,7 +64,7 @@ def admin_configure_app_provider(provider: str): if not client_id: return api_response(success=False, message="client_id is required", status=400, error_type="VALIDATION_ERROR") - cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first() + cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider, deleted_at=None).first() if cfg: cfg.client_id = client_id if client_secret: @@ -90,7 +90,6 @@ def admin_delete_app_provider(provider: str): from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig from gatehouse_app.models import OrganizationMember from gatehouse_app.utils.constants import OrganizationRole - from gatehouse_app.extensions import db admin_memberships = OrganizationMember.query.filter( OrganizationMember.user_id == g.current_user.id, @@ -100,10 +99,9 @@ def admin_delete_app_provider(provider: str): if not admin_memberships: return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN") - cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first() + cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider, deleted_at=None).first() if not cfg: return api_response(success=False, message=f"Provider '{provider}' is not configured", status=404, error_type="NOT_FOUND") - db.session.delete(cfg) - db.session.commit() + cfg.delete() return api_response(message=f"{provider.capitalize()} OAuth provider configuration removed") diff --git a/gatehouse_app/api/v1/external_auth/oauth.py b/gatehouse_app/api/v1/external_auth/oauth.py index 33ac85b..421b13b 100644 --- a/gatehouse_app/api/v1/external_auth/oauth.py +++ b/gatehouse_app/api/v1/external_auth/oauth.py @@ -174,6 +174,7 @@ def select_organization(): auth_method = AuthenticationMethod.query.filter_by( method_type=state_record.provider_type, + deleted_at=None, ).order_by(AuthenticationMethod.created_at.desc()).first() if not auth_method: @@ -181,11 +182,11 @@ def select_organization(): user = auth_method.user - org = Organization.query.get(organization_id) + org = Organization.query.filter_by(id=organization_id, deleted_at=None).first() if not org: return api_response(success=False, message="Organization not found", status=404, error_type="NOT_FOUND") - member = OrganizationMember.query.filter_by(user_id=user.id, organization_id=organization_id).first() + member = OrganizationMember.query.filter_by(user_id=user.id, organization_id=organization_id, deleted_at=None).first() if not member: return api_response(success=False, message="You are not a member of this organization", status=403, error_type="FORBIDDEN") diff --git a/gatehouse_app/api/v1/external_auth/providers.py b/gatehouse_app/api/v1/external_auth/providers.py index b269844..2b305c3 100644 --- a/gatehouse_app/api/v1/external_auth/providers.py +++ b/gatehouse_app/api/v1/external_auth/providers.py @@ -14,13 +14,13 @@ from gatehouse_app.api.v1.external_auth._helpers import get_provider_type, _get_ def list_providers(): from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig - app_configs = {c.provider_type.lower(): c for c in ApplicationProviderConfig.query.filter_by(is_enabled=True).all()} + app_configs = {c.provider_type.lower(): c for c in ApplicationProviderConfig.query.filter_by(is_enabled=True, deleted_at=None).all()} user_orgs = g.current_user.get_organizations() org_configs = {} if user_orgs: organization_id = user_orgs[0].id - org_level = ExternalProviderConfig.query.filter_by(organization_id=organization_id).all() + org_level = ExternalProviderConfig.query.filter_by(organization_id=organization_id, deleted_at=None).all() org_configs = {c.provider_type.lower(): c for c in org_level} def provider_info(provider_id, name): @@ -50,11 +50,11 @@ def get_provider_config(provider: str): return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST") organization_id = user_orgs[0].id - member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first() + member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id, deleted_at=None).first() if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]: return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN") - config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first() + config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value, deleted_at=None).first() if not config: return api_response(success=False, message=f"{provider.title()} OAuth is not configured", status=404, error_type="NOT_FOUND") @@ -74,7 +74,7 @@ def create_or_update_provider_config(provider: str): return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST") organization_id = user_orgs[0].id - member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first() + member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id, deleted_at=None).first() if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]: return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN") @@ -85,7 +85,7 @@ def create_or_update_provider_config(provider: str): if not client_id: return api_response(success=False, message="client_id is required", status=400, error_type="VALIDATION_ERROR") - config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first() + config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value, deleted_at=None).first() is_new = config is None if config: @@ -137,11 +137,11 @@ def delete_provider_config(provider: str): return api_response(success=False, message="No organizations found for user", status=400, error_type="BAD_REQUEST") organization_id = user_orgs[0].id - member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id).first() + member = OrganizationMember.query.filter_by(user_id=g.current_user.id, organization_id=organization_id, deleted_at=None).first() if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]: return api_response(success=False, message="Admin access required", status=403, error_type="FORBIDDEN") - config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value).first() + config = ExternalProviderConfig.query.filter_by(organization_id=organization_id, provider_type=provider_type.value, deleted_at=None).first() if not config: return api_response(success=False, message=f"{provider.title()} OAuth is not configured", status=404, error_type="NOT_FOUND") diff --git a/gatehouse_app/api/v1/oidc.py b/gatehouse_app/api/v1/oidc.py index a4bbb1c..07a9366 100644 --- a/gatehouse_app/api/v1/oidc.py +++ b/gatehouse_app/api/v1/oidc.py @@ -819,9 +819,9 @@ def oidc_register(): org_id = data.get("organization_id") if org_id: - organization = Organization.query.get(org_id) + organization = Organization.query.filter_by(id=org_id, deleted_at=None).first() else: - organization = Organization.query.filter_by(is_active=True).first() + organization = Organization.query.filter_by(is_active=True, deleted_at=None).first() if not organization: organization = Organization( diff --git a/gatehouse_app/api/v1/organizations/members.py b/gatehouse_app/api/v1/organizations/members.py index 841ec25..b42a99c 100644 --- a/gatehouse_app/api/v1/organizations/members.py +++ b/gatehouse_app/api/v1/organizations/members.py @@ -158,7 +158,7 @@ def send_mfa_reminder(org_id, user_id): if not user: return api_response(success=False, message="User not found", status=404) - compliance = MfaPolicyCompliance.query.filter_by(user_id=user_id, organization_id=org_id).first() + compliance = MfaPolicyCompliance.query.filter_by(user_id=user_id, organization_id=org_id, deleted_at=None).first() policy = OrganizationSecurityPolicy.query.filter_by(organization_id=org_id).first() if compliance and policy and compliance.deadline_at: diff --git a/gatehouse_app/api/v1/ssh/certs.py b/gatehouse_app/api/v1/ssh/certs.py index d7537fc..71697d8 100644 --- a/gatehouse_app/api/v1/ssh/certs.py +++ b/gatehouse_app/api/v1/ssh/certs.py @@ -68,7 +68,7 @@ def sign_certificate(): ) allowed_principal_names = set() - memberships = OrganizationMember.query.filter_by(user_id=user_id).all() + memberships = OrganizationMember.query.filter_by(user_id=user_id, deleted_at=None).all() for om in memberships: org = om.organization if not org or org.deleted_at is not None: diff --git a/gatehouse_app/models/auth/authentication_method.py b/gatehouse_app/models/auth/authentication_method.py index c4c4352..9061b22 100644 --- a/gatehouse_app/models/auth/authentication_method.py +++ b/gatehouse_app/models/auth/authentication_method.py @@ -374,7 +374,7 @@ class OAuthState(BaseModel): def cleanup_expired(cls) -> None: """Remove expired OAuth states.""" now = datetime.now(timezone.utc) - cls.query.filter(cls.expires_at < now).delete() + cls.query.filter(cls.expires_at < now).filter(cls.deleted_at == None).update({"deleted_at": now}, synchronize_session=False) db.session.commit() def to_dict(self, exclude=None): diff --git a/gatehouse_app/models/auth/email_verification_token.py b/gatehouse_app/models/auth/email_verification_token.py index 9f40682..e5d8163 100644 --- a/gatehouse_app/models/auth/email_verification_token.py +++ b/gatehouse_app/models/auth/email_verification_token.py @@ -32,7 +32,8 @@ class EmailVerificationToken(BaseModel): Any existing unused tokens for this user are invalidated first. """ - cls.query.filter_by(user_id=user_id, used_at=None).delete() + now = datetime.now(timezone.utc) + cls.query.filter_by(user_id=user_id, used_at=None).filter(cls.deleted_at == None).update({"deleted_at": now}, synchronize_session=False) db.session.flush() token_value = secrets.token_urlsafe(48) diff --git a/gatehouse_app/models/auth/password_reset_token.py b/gatehouse_app/models/auth/password_reset_token.py index 53072ef..25cfbf7 100644 --- a/gatehouse_app/models/auth/password_reset_token.py +++ b/gatehouse_app/models/auth/password_reset_token.py @@ -33,7 +33,8 @@ class PasswordResetToken(BaseModel): Any existing unused tokens for this user are invalidated first. """ # Invalidate any existing unused tokens for this user - cls.query.filter_by(user_id=user_id, used_at=None).delete() + now = datetime.now(timezone.utc) + cls.query.filter_by(user_id=user_id, used_at=None).filter(cls.deleted_at == None).update({"deleted_at": now}, synchronize_session=False) db.session.flush() token_value = secrets.token_urlsafe(48) diff --git a/gatehouse_app/services/auth_service.py b/gatehouse_app/services/auth_service.py index 04ec8b0..1eb48c0 100644 --- a/gatehouse_app/services/auth_service.py +++ b/gatehouse_app/services/auth_service.py @@ -36,9 +36,9 @@ class AuthService: Raises: EmailAlreadyExistsError: If email is already registered """ - # Check if email already exists - existing_user = User.query.filter_by(email=email.lower()).first() - if existing_user and existing_user.deleted_at is None: +# Check if email already exists + existing_user = User.query.filter_by(email=email.lower(), deleted_at=None).first() + if existing_user: raise EmailAlreadyExistsError() # Create user @@ -280,12 +280,11 @@ class AuthService: raise ConflictError("TOTP is already enabled for this account") # Clean up any existing unverified TOTP enrollment attempts - # Use hard delete for unverified methods since they're incomplete enrollment attempts + # Soft delete for unverified methods since they're incomplete enrollment attempts existing_totp_method = user.get_totp_method() if existing_totp_method and not existing_totp_method.verified: logger.debug(f"Removing existing unverified TOTP method for user {user.id}") - db.session.delete(existing_totp_method) # Hard delete - unverified methods are temporary - db.session.commit() # Commit to ensure deletion before creating new record + existing_totp_method.delete(soft=True) # Soft delete - unverified methods are temporary # Generate TOTP secret secret = TOTPService.generate_secret() diff --git a/gatehouse_app/services/billing_service.py b/gatehouse_app/services/billing_service.py index 5cf4495..17d69f9 100644 --- a/gatehouse_app/services/billing_service.py +++ b/gatehouse_app/services/billing_service.py @@ -50,8 +50,8 @@ class BillingService: if not plan: raise ValueError("Plan not found") - # Check if subscription already exists - existing = Subscription.query.filter_by(organization_id=organization_id).first() + # Check if subscription already exists + existing = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first() if existing: raise ValueError("Organization already has a subscription") @@ -88,7 +88,7 @@ class BillingService: Returns: Updated subscription """ - subscription = Subscription.query.filter_by(organization_id=organization_id).first() + subscription = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first() if not subscription: raise ValueError("No subscription found for organization") @@ -111,7 +111,7 @@ class BillingService: Returns: Updated subscription """ - subscription = Subscription.query.filter_by(organization_id=organization_id).first() + subscription = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first() if not subscription: raise ValueError("No subscription found for organization") @@ -132,7 +132,7 @@ class BillingService: Returns: Updated subscription """ - subscription = Subscription.query.filter_by(organization_id=organization_id).first() + subscription = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first() if not subscription: raise ValueError("No subscription found for organization") @@ -158,7 +158,7 @@ class BillingService: Returns: Overage calculation with details """ - subscription = Subscription.query.filter_by(organization_id=organization_id).first() + subscription = Subscription.query.filter_by(organization_id=organization_id, deleted_at=None).first() if not subscription: return {"has_overage": False, "overage_cost": 0, "user_count": 0, "included_users": 0} diff --git a/gatehouse_app/services/external_auth/__init__.py b/gatehouse_app/services/external_auth/__init__.py index a09e00e..6d56b27 100644 --- a/gatehouse_app/services/external_auth/__init__.py +++ b/gatehouse_app/services/external_auth/__init__.py @@ -42,7 +42,7 @@ class ExternalAuthService: provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type app_config = ApplicationProviderConfig.query.filter_by( - provider_type=provider_type_str + provider_type=provider_type_str, deleted_at=None ).first() if not app_config: @@ -64,6 +64,7 @@ class ExternalAuthService: org_override_obj = OrganizationProviderOverride.query.filter_by( organization_id=organization_id, provider_type=provider_type_str, + deleted_at=None, ).first() if org_override_obj and not org_override_obj.is_enabled: diff --git a/gatehouse_app/services/external_auth/app_provider.py b/gatehouse_app/services/external_auth/app_provider.py index 97c0e97..d23560c 100644 --- a/gatehouse_app/services/external_auth/app_provider.py +++ b/gatehouse_app/services/external_auth/app_provider.py @@ -14,7 +14,7 @@ def create_app_provider_config( **kwargs, ) -> ApplicationProviderConfig: existing = ApplicationProviderConfig.query.filter_by( - provider_type=provider_type + provider_type=provider_type, deleted_at=None ).first() if existing: @@ -51,7 +51,7 @@ def update_app_provider_config( **updates, ) -> ApplicationProviderConfig: config = ApplicationProviderConfig.query.filter_by( - provider_type=provider_type + provider_type=provider_type, deleted_at=None ).first() if not config: @@ -90,7 +90,7 @@ def update_app_provider_config( def get_app_provider_config(provider_type: str) -> ApplicationProviderConfig: config = ApplicationProviderConfig.query.filter_by( - provider_type=provider_type + provider_type=provider_type, deleted_at=None ).first() if not config: @@ -104,13 +104,13 @@ def get_app_provider_config(provider_type: str) -> ApplicationProviderConfig: def list_app_provider_configs() -> list: - configs = ApplicationProviderConfig.query.all() + configs = ApplicationProviderConfig.query.filter_by(deleted_at=None).all() return [config.to_dict() for config in configs] def delete_app_provider_config(provider_type: str) -> bool: config = ApplicationProviderConfig.query.filter_by( - provider_type=provider_type + provider_type=provider_type, deleted_at=None ).first() if not config: diff --git a/gatehouse_app/services/external_auth/linking.py b/gatehouse_app/services/external_auth/linking.py index f7e8c3a..670220b 100644 --- a/gatehouse_app/services/external_auth/linking.py +++ b/gatehouse_app/services/external_auth/linking.py @@ -219,10 +219,11 @@ def authenticate_with_provider( auth_method = AuthenticationMethod.query.filter_by( method_type=provider_type, provider_user_id=user_info["provider_user_id"], + deleted_at=None, ).first() if not auth_method: - existing_user = User.query.filter_by(email=user_info["email"]).first() + existing_user = User.query.filter_by(email=user_info["email"], deleted_at=None).first() if existing_user: AuditService.log_external_auth_login_failed( @@ -286,12 +287,13 @@ def unlink_provider( auth_method = AuthenticationMethod.query.filter_by( user_id=user_id, method_type=provider_type, + deleted_at=None, ).first() if not auth_method: raise ExternalAuthError("Provider not linked", "PROVIDER_NOT_LINKED", 400) - other_methods = AuthenticationMethod.query.filter_by(user_id=user_id).count() + other_methods = AuthenticationMethod.query.filter_by(user_id=user_id, deleted_at=None).count() if other_methods <= 1: raise ExternalAuthError( "Cannot unlink the last authentication method", diff --git a/gatehouse_app/services/external_auth/org_override.py b/gatehouse_app/services/external_auth/org_override.py index e302b07..9a51bb4 100644 --- a/gatehouse_app/services/external_auth/org_override.py +++ b/gatehouse_app/services/external_auth/org_override.py @@ -16,7 +16,7 @@ def create_org_provider_override( **kwargs, ) -> OrganizationProviderOverride: app_config = ApplicationProviderConfig.query.filter_by( - provider_type=provider_type + provider_type=provider_type, deleted_at=None ).first() if not app_config: @@ -29,6 +29,7 @@ def create_org_provider_override( existing = OrganizationProviderOverride.query.filter_by( organization_id=organization_id, provider_type=provider_type, + deleted_at=None, ).first() if existing: @@ -69,6 +70,7 @@ def update_org_provider_override( override = OrganizationProviderOverride.query.filter_by( organization_id=organization_id, provider_type=provider_type, + deleted_at=None, ).first() if not override: @@ -110,6 +112,7 @@ def get_org_provider_override( override = OrganizationProviderOverride.query.filter_by( organization_id=organization_id, provider_type=provider_type, + deleted_at=None, ).first() if not override: @@ -124,7 +127,7 @@ def get_org_provider_override( def list_org_provider_overrides(organization_id: str) -> list: overrides = OrganizationProviderOverride.query.filter_by( - organization_id=organization_id + organization_id=organization_id, deleted_at=None ).all() return [override.to_dict() for override in overrides] @@ -133,6 +136,7 @@ def delete_org_provider_override(organization_id: str, provider_type: str) -> bo override = OrganizationProviderOverride.query.filter_by( organization_id=organization_id, provider_type=provider_type, + deleted_at=None, ).first() if not override: diff --git a/gatehouse_app/services/network_access_service.py b/gatehouse_app/services/network_access_service.py index a801a86..99b7132 100644 --- a/gatehouse_app/services/network_access_service.py +++ b/gatehouse_app/services/network_access_service.py @@ -1024,17 +1024,17 @@ def revoke_membership_soft( def hard_delete_membership(membership_id: str) -> None: - """Hard delete a membership after ZeroTier has been cleaned up. + """Soft delete a membership after ZeroTier has been cleaned up. Called by the reconciliation job after successfully removing the member - from the ZeroTier controller. This is the final DB cleanup step. + from the ZeroTier controller. This marks the membership as deleted. """ membership = DeviceNetworkMembership.query.filter( DeviceNetworkMembership.id == membership_id, ).first() if not membership: - logger.warning(f"[hard_delete_membership] Membership {membership_id} not found, skipping.") + logger.warning(f"[hard_delete_membership] Membership {membership_id} not found or already deleted, skipping.") return device = Device.query.get(membership.device_id) @@ -1048,7 +1048,7 @@ def hard_delete_membership(membership_id: str) -> None: except Exception as exc: logger.warning(f"[hard_delete_membership] ZT delete failed for {device.node_id}: {exc}") - db.session.delete(membership) + membership.delete(soft=True) db.session.commit() AuditService.log_action( @@ -1061,6 +1061,6 @@ def hard_delete_membership(membership_id: str) -> None: "device_node_id": device.node_id if device else None, "network_id": network.zerotier_network_id if network else None, }, - description=f"Membership hard-deleted: device {device.node_id if device else 'unknown'} from network", + description=f"Membership deleted: device {device.node_id if device else 'unknown'} from network", success=True, ) diff --git a/gatehouse_app/services/oauth_flow/register.py b/gatehouse_app/services/oauth_flow/register.py index 069c247..c3790d5 100644 --- a/gatehouse_app/services/oauth_flow/register.py +++ b/gatehouse_app/services/oauth_flow/register.py @@ -111,7 +111,7 @@ def handle_register_callback( access_token=tokens["access_token"], ) - existing_user = User.query.filter_by(email=user_info["email"]).first() + existing_user = User.query.filter_by(email=user_info["email"], deleted_at=None).first() if existing_user: raise OAuthFlowError( f"An account with email {user_info['email']} already exists. " diff --git a/gatehouse_app/services/zerotier_reconciliation_service.py b/gatehouse_app/services/zerotier_reconciliation_service.py index c78119b..9238018 100644 --- a/gatehouse_app/services/zerotier_reconciliation_service.py +++ b/gatehouse_app/services/zerotier_reconciliation_service.py @@ -283,9 +283,9 @@ def reconcile_deleted_memberships() -> dict: if not device or not network: logger.warning( f"[Reconciliation] Membership {membership.id}: missing " - f"{'device' if not device else 'network'} — hard-deleting record only." + f"{'device' if not device else 'network'} — soft-deleting record only." ) - db.session.delete(membership) + membership.delete(soft=True) db.session.commit() results["deleted"] += 1 continue @@ -304,20 +304,20 @@ def reconcile_deleted_memberships() -> dict: except Exception as zt_exc: logger.warning( f"[Reconciliation] ZT delete failed for node {node_id} " - f"on {network_label}: {zt_exc} — proceeding with DB hard-delete." + f"on {network_label}: {zt_exc} — proceeding with DB soft-delete." ) - db.session.delete(membership) + membership.delete(soft=True) db.session.commit() results["deleted"] += 1 logger.debug( - f"[Reconciliation] Hard-deleted membership {membership.id} " + f"[Reconciliation] Soft-deleted membership {membership.id} " f"(node={node_id}, network={network_label})." ) except Exception as exc: logger.error( - f"[Reconciliation] Failed to hard-delete membership {membership.id}: {exc}", + f"[Reconciliation] Failed to soft-delete membership {membership.id}: {exc}", exc_info=True, ) results["errors"] += 1