ai-lls-lib 2.0.0rc2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ai_lls_lib/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ AI LLS Library - Core business logic for Landline Scrubber.
3
+
4
+ This library provides phone verification and DNC checking capabilities.
5
+
6
+ Version 2.0.0 establishes clean semantic versioning baseline.
7
+ All version management now controlled by Python Semantic Release.
8
+ """
9
+ from ai_lls_lib.core.models import (
10
+ PhoneVerification,
11
+ BulkJob,
12
+ BulkJobStatus,
13
+ LineType,
14
+ VerificationSource,
15
+ JobStatus
16
+ )
17
+ from ai_lls_lib.core.verifier import PhoneVerifier
18
+ from ai_lls_lib.core.processor import BulkProcessor
19
+ from ai_lls_lib.core.cache import DynamoDBCache
20
+
21
+ __version__ = "2.0.0-rc.2"
22
+ __all__ = [
23
+ "PhoneVerification",
24
+ "BulkJob",
25
+ "BulkJobStatus",
26
+ "LineType",
27
+ "VerificationSource",
28
+ "JobStatus",
29
+ "PhoneVerifier",
30
+ "BulkProcessor",
31
+ "DynamoDBCache",
32
+ ]
@@ -0,0 +1,4 @@
1
+ """Auth module for handling authentication and authorization."""
2
+ from .context_parser import get_email_from_event, get_user_from_event
3
+
4
+ __all__ = ["get_user_from_event", "get_email_from_event"]
@@ -0,0 +1,68 @@
1
+ """Auth context parser for HTTP API v2.0 events."""
2
+ from typing import Any, Dict, Optional
3
+
4
+
5
+ def get_user_from_event(event: Dict[str, Any]) -> Optional[str]:
6
+ """
7
+ Extract user ID from HTTP API v2.0 event with all possible paths.
8
+ Handles both JWT and API key authentication contexts.
9
+
10
+ This function handles the complexities of AWS API Gateway authorizer contexts,
11
+ especially when EnableSimpleResponses is set to false, which wraps the
12
+ context in a 'lambda' key.
13
+
14
+ Args:
15
+ event: The Lambda event from API Gateway HTTP API v2.0
16
+
17
+ Returns:
18
+ User ID string if found, None otherwise
19
+ """
20
+ request_context = event.get("requestContext", {})
21
+ auth = request_context.get("authorizer", {})
22
+
23
+ # Handle lambda-wrapped context (EnableSimpleResponses: false)
24
+ # When EnableSimpleResponses is false, the authorizer context is wrapped
25
+ lam_ctx = auth.get("lambda", auth) if isinstance(auth.get("lambda"), dict) else auth
26
+
27
+ # Try all possible paths for user_id in priority order
28
+ user_id = (
29
+ # Lambda authorizer paths (most common with current setup)
30
+ lam_ctx.get("principal_id") or
31
+ lam_ctx.get("principalId") or
32
+ lam_ctx.get("sub") or
33
+ lam_ctx.get("user_id") or
34
+ # JWT paths (when using JWT authorizer directly)
35
+ auth.get("jwt", {}).get("claims", {}).get("sub") or
36
+ # Direct auth paths (fallback)
37
+ auth.get("principal_id") or
38
+ auth.get("principalId") or
39
+ auth.get("sub")
40
+ )
41
+
42
+ return user_id
43
+
44
+
45
+ def get_email_from_event(event: Dict[str, Any]) -> Optional[str]:
46
+ """
47
+ Extract email from HTTP API v2.0 event.
48
+
49
+ Args:
50
+ event: The Lambda event from API Gateway HTTP API v2.0
51
+
52
+ Returns:
53
+ Email string if found, None otherwise
54
+ """
55
+ request_context = event.get("requestContext", {})
56
+ auth = request_context.get("authorizer", {})
57
+
58
+ # Handle lambda-wrapped context
59
+ lam_ctx = auth.get("lambda", auth) if isinstance(auth.get("lambda"), dict) else auth
60
+
61
+ # Try to get email from various locations
62
+ email = (
63
+ lam_ctx.get("email") or
64
+ auth.get("jwt", {}).get("claims", {}).get("email") or
65
+ auth.get("email")
66
+ )
67
+
68
+ return email
@@ -0,0 +1,3 @@
1
+ """
2
+ Landline Scrubber CLI - Infrastructure-aware administrative tools
3
+ """
@@ -0,0 +1,63 @@
1
+ """
2
+ Landline Scrubber CLI entry point
3
+ """
4
+ import click
5
+ import sys
6
+ import os
7
+ from pathlib import Path
8
+ from ai_lls_lib.cli.commands import verify, cache, admin, test_stack, stripe, monitor
9
+ from ai_lls_lib.cli.env_loader import load_env_file
10
+
11
+ def load_env_files(verbose=False):
12
+ """Load .env files from ~/.lls and current directory"""
13
+ # Load from ~/.lls/.env
14
+ home_env = Path.home() / '.lls' / '.env'
15
+ if home_env.exists():
16
+ env_vars = load_env_file(home_env)
17
+ for key, value in env_vars.items():
18
+ if key not in os.environ: # Don't override existing env vars
19
+ os.environ[key] = value
20
+ if env_vars and verbose:
21
+ click.echo(f"Loaded {len(env_vars)} variables from {home_env}", err=True)
22
+
23
+ # Load from current directory .env
24
+ local_env = Path('.env')
25
+ if local_env.exists():
26
+ env_vars = load_env_file(local_env)
27
+ for key, value in env_vars.items():
28
+ if key not in os.environ: # Don't override existing env vars
29
+ os.environ[key] = value
30
+ if env_vars and verbose:
31
+ click.echo(f"Loaded {len(env_vars)} variables from {local_env}", err=True)
32
+
33
+ # Set default environment to 'prod' if not set
34
+ if 'ENVIRONMENT' not in os.environ:
35
+ os.environ['ENVIRONMENT'] = 'prod'
36
+ if verbose:
37
+ click.echo(f"Set default ENVIRONMENT=prod", err=True)
38
+
39
+ @click.group()
40
+ @click.version_option(version="0.1.0", prog_name="ai-lls")
41
+ def cli():
42
+ """Landline Scrubber CLI - Administrative and debugging tools"""
43
+ # Load environment files at startup
44
+ load_env_files()
45
+
46
+ # Register command groups
47
+ cli.add_command(verify.verify_group)
48
+ cli.add_command(cache.cache_group)
49
+ cli.add_command(admin.admin_group)
50
+ cli.add_command(test_stack.test_stack_group)
51
+ cli.add_command(stripe.stripe_group)
52
+ cli.add_command(monitor.monitor_group)
53
+
54
+ def main():
55
+ """Main entry point"""
56
+ try:
57
+ cli()
58
+ except Exception as e:
59
+ click.echo(f"Error: {e}", err=True)
60
+ sys.exit(1)
61
+
62
+ if __name__ == "__main__":
63
+ main()
@@ -0,0 +1,123 @@
1
+ """
2
+ AWS client utilities for CLI operations
3
+ """
4
+ import os
5
+ import boto3
6
+ from typing import Optional, Any, Dict
7
+ from botocore.exceptions import ClientError
8
+ import click
9
+
10
+ class AWSClient:
11
+ """Wrapper for AWS operations with proper error handling"""
12
+
13
+ def __init__(self, region: Optional[str] = None, profile: Optional[str] = None):
14
+ """Initialize AWS clients"""
15
+ self.region = region or os.environ.get("AWS_REGION", "us-east-1")
16
+ self.profile = profile
17
+
18
+ # Create session
19
+ if profile:
20
+ self.session = boto3.Session(profile_name=profile, region_name=self.region)
21
+ else:
22
+ self.session = boto3.Session(region_name=self.region)
23
+
24
+ # Lazy-load clients
25
+ self._dynamodb = None
26
+ self._s3 = None
27
+ self._sqs = None
28
+ self._secretsmanager = None
29
+ self._cloudformation = None
30
+ self._logs = None
31
+
32
+ @property
33
+ def dynamodb(self):
34
+ """Get DynamoDB client"""
35
+ if not self._dynamodb:
36
+ self._dynamodb = self.session.resource('dynamodb')
37
+ return self._dynamodb
38
+
39
+ @property
40
+ def s3(self):
41
+ """Get S3 client"""
42
+ if not self._s3:
43
+ self._s3 = self.session.client('s3')
44
+ return self._s3
45
+
46
+ @property
47
+ def sqs(self):
48
+ """Get SQS client"""
49
+ if not self._sqs:
50
+ self._sqs = self.session.client('sqs')
51
+ return self._sqs
52
+
53
+ @property
54
+ def secretsmanager(self):
55
+ """Get Secrets Manager client"""
56
+ if not self._secretsmanager:
57
+ self._secretsmanager = self.session.client('secretsmanager')
58
+ return self._secretsmanager
59
+
60
+ @property
61
+ def cloudformation(self):
62
+ """Get CloudFormation client"""
63
+ if not self._cloudformation:
64
+ self._cloudformation = self.session.client('cloudformation')
65
+ return self._cloudformation
66
+
67
+ @property
68
+ def logs(self):
69
+ """Get CloudWatch Logs client"""
70
+ if not self._logs:
71
+ self._logs = self.session.client('logs')
72
+ return self._logs
73
+
74
+ def get_stack_outputs(self, stack_name: str) -> Dict[str, str]:
75
+ """Get CloudFormation stack outputs"""
76
+ try:
77
+ response = self.cloudformation.describe_stacks(StackName=stack_name)
78
+ stack = response['Stacks'][0]
79
+ outputs = {}
80
+ for output in stack.get('Outputs', []):
81
+ outputs[output['OutputKey']] = output['OutputValue']
82
+ return outputs
83
+ except ClientError as e:
84
+ if e.response['Error']['Code'] == 'ValidationError':
85
+ raise click.ClickException(f"Stack '{stack_name}' not found")
86
+ raise
87
+
88
+ def get_table_name(self, stack_name: str, logical_name: str) -> str:
89
+ """Get actual table name from stack"""
90
+ try:
91
+ response = self.cloudformation.describe_stack_resource(
92
+ StackName=stack_name,
93
+ LogicalResourceId=logical_name
94
+ )
95
+ return response['StackResourceDetail']['PhysicalResourceId']
96
+ except ClientError:
97
+ # Fallback to conventional naming
98
+ return f"{stack_name}-{logical_name.lower()}"
99
+
100
+ def scan_table(self, table_name: str, limit: int = 100) -> list:
101
+ """Scan DynamoDB table"""
102
+ try:
103
+ table = self.dynamodb.Table(table_name)
104
+ response = table.scan(Limit=limit)
105
+ return response.get('Items', [])
106
+ except ClientError as e:
107
+ raise click.ClickException(f"Error scanning table: {e}")
108
+
109
+ def put_item(self, table_name: str, item: dict) -> None:
110
+ """Put item to DynamoDB"""
111
+ try:
112
+ table = self.dynamodb.Table(table_name)
113
+ table.put_item(Item=item)
114
+ except ClientError as e:
115
+ raise click.ClickException(f"Error putting item: {e}")
116
+
117
+ def delete_item(self, table_name: str, key: dict) -> None:
118
+ """Delete item from DynamoDB"""
119
+ try:
120
+ table = self.dynamodb.Table(table_name)
121
+ table.delete_item(Key=key)
122
+ except ClientError as e:
123
+ raise click.ClickException(f"Error deleting item: {e}")
@@ -0,0 +1,3 @@
1
+ """
2
+ CLI command modules
3
+ """
@@ -0,0 +1,174 @@
1
+ """
2
+ Administrative commands for user and credit management
3
+ """
4
+ import click
5
+ import json
6
+ from datetime import datetime
7
+ from ai_lls_lib.cli.aws_client import AWSClient
8
+
9
+ @click.group(name="admin")
10
+ def admin_group():
11
+ """Administrative commands"""
12
+ pass
13
+
14
+ @admin_group.command(name="user-credits")
15
+ @click.argument("user_id")
16
+ @click.option("--add", type=int, help="Add credits")
17
+ @click.option("--set", "set_credits", type=int, help="Set credits to specific value")
18
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
19
+ @click.option("--profile", help="AWS profile")
20
+ @click.option("--region", help="AWS region")
21
+ def user_credits(user_id, add, set_credits, stack, profile, region):
22
+ """Manage user credits"""
23
+ aws = AWSClient(region=region, profile=profile)
24
+ credits_table = aws.get_table_name(stack, "CreditsTable")
25
+
26
+ try:
27
+ table = aws.dynamodb.Table(credits_table)
28
+
29
+ # Get current credits
30
+ response = table.get_item(Key={"user_id": user_id})
31
+ current = response.get('Item', {}).get('credits', 0)
32
+
33
+ click.echo(f"User {user_id} - Current credits: {current}")
34
+
35
+ if add:
36
+ new_credits = current + add
37
+ table.update_item(
38
+ Key={"user_id": user_id},
39
+ UpdateExpression="SET credits = :val, updated_at = :now",
40
+ ExpressionAttributeValues={
41
+ ":val": new_credits,
42
+ ":now": datetime.utcnow().isoformat()
43
+ }
44
+ )
45
+ click.echo(f"Added {add} credits. New balance: {new_credits}")
46
+
47
+ elif set_credits is not None:
48
+ table.update_item(
49
+ Key={"user_id": user_id},
50
+ UpdateExpression="SET credits = :val, updated_at = :now",
51
+ ExpressionAttributeValues={
52
+ ":val": set_credits,
53
+ ":now": datetime.utcnow().isoformat()
54
+ }
55
+ )
56
+ click.echo(f"Set credits to {set_credits}")
57
+
58
+ except Exception as e:
59
+ click.echo(f"Error managing credits: {e}", err=True)
60
+
61
+ @admin_group.command(name="api-keys")
62
+ @click.option("--user", help="Filter by user ID")
63
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
64
+ @click.option("--profile", help="AWS profile")
65
+ @click.option("--region", help="AWS region")
66
+ def list_api_keys(user, stack, profile, region):
67
+ """List API keys"""
68
+ aws = AWSClient(region=region, profile=profile)
69
+ keys_table = aws.get_table_name(stack, "ApiKeysTable")
70
+
71
+ try:
72
+ if user:
73
+ # Query by user using GSI
74
+ table = aws.dynamodb.Table(keys_table)
75
+ response = table.query(
76
+ IndexName='gsi_user',
77
+ KeyConditionExpression='user_id = :uid',
78
+ ExpressionAttributeValues={':uid': user}
79
+ )
80
+ items = response.get('Items', [])
81
+ else:
82
+ # Scan all
83
+ items = aws.scan_table(keys_table)
84
+
85
+ if not items:
86
+ click.echo("No API keys found")
87
+ return
88
+
89
+ click.echo(f"\nAPI Keys ({len(items)} total):")
90
+ click.echo("=" * 60)
91
+
92
+ for item in items:
93
+ click.echo(f"Key ID: {item.get('api_key_id', 'N/A')}")
94
+ click.echo(f"User: {item.get('user_id', 'N/A')}")
95
+ click.echo(f"Created: {item.get('created_at', 'N/A')}")
96
+ click.echo(f"Last Used: {item.get('last_used', 'Never')}")
97
+ click.echo("-" * 60)
98
+
99
+ except Exception as e:
100
+ click.echo(f"Error listing API keys: {e}", err=True)
101
+
102
+ @admin_group.command(name="queue-stats")
103
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
104
+ @click.option("--profile", help="AWS profile")
105
+ @click.option("--region", help="AWS region")
106
+ def queue_stats(stack, profile, region):
107
+ """Show SQS queue statistics"""
108
+ aws = AWSClient(region=region, profile=profile)
109
+
110
+ try:
111
+ # Get queue URLs from stack
112
+ outputs = aws.get_stack_outputs(stack)
113
+
114
+ # Find queue URLs or construct them
115
+ queue_name = f"{stack}-bulk-processing"
116
+ dlq_name = f"{stack}-bulk-processing-dlq"
117
+
118
+ # Get queue attributes
119
+ queues = [
120
+ ("Processing Queue", queue_name),
121
+ ("Dead Letter Queue", dlq_name)
122
+ ]
123
+
124
+ for display_name, queue in queues:
125
+ try:
126
+ response = aws.sqs.get_queue_url(QueueName=queue)
127
+ queue_url = response['QueueUrl']
128
+
129
+ attrs = aws.sqs.get_queue_attributes(
130
+ QueueUrl=queue_url,
131
+ AttributeNames=['All']
132
+ )['Attributes']
133
+
134
+ click.echo(f"\n{display_name}:")
135
+ click.echo(f" Messages Available: {attrs.get('ApproximateNumberOfMessages', 0)}")
136
+ click.echo(f" Messages In Flight: {attrs.get('ApproximateNumberOfMessagesNotVisible', 0)}")
137
+ click.echo(f" Messages Delayed: {attrs.get('ApproximateNumberOfMessagesDelayed', 0)}")
138
+
139
+ except aws.sqs.exceptions.QueueDoesNotExist:
140
+ click.echo(f"\n{display_name}: Not found")
141
+
142
+ except Exception as e:
143
+ click.echo(f"Error getting queue stats: {e}", err=True)
144
+
145
+ @admin_group.command(name="secrets")
146
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
147
+ @click.option("--show", is_flag=True, help="Show secret values (CAREFUL!)")
148
+ @click.option("--profile", help="AWS profile")
149
+ @click.option("--region", help="AWS region")
150
+ def manage_secrets(stack, show, profile, region):
151
+ """Manage secrets"""
152
+ aws = AWSClient(region=region, profile=profile)
153
+ secret_name = f"{stack}-secrets"
154
+
155
+ try:
156
+ response = aws.secretsmanager.describe_secret(SecretId=secret_name)
157
+ click.echo(f"\nSecret: {secret_name}")
158
+ click.echo(f"ARN: {response['ARN']}")
159
+ click.echo(f"Last Updated: {response.get('LastChangedDate', 'N/A')}")
160
+
161
+ if show and click.confirm("Show secret values? This will display sensitive data!"):
162
+ secret_value = aws.secretsmanager.get_secret_value(SecretId=secret_name)
163
+ secrets = json.loads(secret_value['SecretString'])
164
+
165
+ click.echo("\nSecret Values:")
166
+ for key, value in secrets.items():
167
+ # Mask most of the value
168
+ masked = value[:4] + "*" * (len(value) - 8) + value[-4:] if len(value) > 8 else "*" * len(value)
169
+ click.echo(f" {key}: {masked}")
170
+
171
+ except aws.secretsmanager.exceptions.ResourceNotFoundException:
172
+ click.echo(f"Secret '{secret_name}' not found")
173
+ except Exception as e:
174
+ click.echo(f"Error managing secrets: {e}", err=True)
@@ -0,0 +1,142 @@
1
+ """
2
+ Cache management commands
3
+ """
4
+ import click
5
+ import json
6
+ from datetime import datetime, timedelta
7
+ from ai_lls_lib.cli.aws_client import AWSClient
8
+
9
+ @click.group(name="cache")
10
+ def cache_group():
11
+ """Cache management commands"""
12
+ pass
13
+
14
+ @cache_group.command(name="stats")
15
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
16
+ @click.option("--profile", help="AWS profile")
17
+ @click.option("--region", help="AWS region")
18
+ def cache_stats(stack, profile, region):
19
+ """Show cache statistics"""
20
+ aws = AWSClient(region=region, profile=profile)
21
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
22
+
23
+ try:
24
+ # Get table description for item count
25
+ table = aws.dynamodb.Table(cache_table)
26
+ desc = table.meta.client.describe_table(TableName=cache_table)
27
+
28
+ click.echo(f"\nCache Table: {cache_table}")
29
+ click.echo("=" * 50)
30
+ click.echo(f"Item Count: {desc['Table']['ItemCount']:,}")
31
+ click.echo(f"Table Size: {desc['Table']['TableSizeBytes']:,} bytes")
32
+ click.echo(f"Status: {desc['Table']['TableStatus']}")
33
+
34
+ # Sample some items to show age distribution
35
+ items = aws.scan_table(cache_table, limit=100)
36
+ if items:
37
+ now = datetime.utcnow()
38
+ ages = []
39
+ for item in items:
40
+ if 'verified_at' in item:
41
+ verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
42
+ age = (now - verified.replace(tzinfo=None)).days
43
+ ages.append(age)
44
+
45
+ if ages:
46
+ click.echo(f"\nCache Age (sample of {len(ages)} items):")
47
+ click.echo(f" Newest: {min(ages)} days")
48
+ click.echo(f" Oldest: {max(ages)} days")
49
+ click.echo(f" Average: {sum(ages)/len(ages):.1f} days")
50
+
51
+ except Exception as e:
52
+ click.echo(f"Error getting cache stats: {e}", err=True)
53
+
54
+ @cache_group.command(name="get")
55
+ @click.argument("phone_number")
56
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
57
+ @click.option("--profile", help="AWS profile")
58
+ @click.option("--region", help="AWS region")
59
+ def cache_get(phone_number, stack, profile, region):
60
+ """Get cached entry for a phone number"""
61
+ from ai_lls_lib.core.verifier import PhoneVerifier
62
+ from ai_lls_lib.core.cache import DynamoDBCache
63
+
64
+ aws = AWSClient(region=region, profile=profile)
65
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
66
+
67
+ # Normalize phone first
68
+ verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
69
+ try:
70
+ normalized = verifier.normalize_phone(phone_number)
71
+ except ValueError as e:
72
+ click.echo(f"Invalid phone: {e}", err=True)
73
+ return
74
+
75
+ # Get from cache
76
+ cache = DynamoDBCache(table_name=cache_table)
77
+ result = cache.get(normalized)
78
+
79
+ if result:
80
+ click.echo(f"\nCached entry for {normalized}:")
81
+ click.echo(json.dumps(result.dict() if hasattr(result, 'dict') else result, indent=2, default=str))
82
+ else:
83
+ click.echo(f"No cache entry found for {normalized}")
84
+
85
+ @cache_group.command(name="invalidate")
86
+ @click.argument("phone_number")
87
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
88
+ @click.option("--profile", help="AWS profile")
89
+ @click.option("--region", help="AWS region")
90
+ @click.confirmation_option(prompt="Are you sure you want to invalidate this cache entry?")
91
+ def cache_invalidate(phone_number, stack, profile, region):
92
+ """Invalidate cached entry for a phone number"""
93
+ from ai_lls_lib.core.verifier import PhoneVerifier
94
+ from ai_lls_lib.core.cache import DynamoDBCache
95
+
96
+ aws = AWSClient(region=region, profile=profile)
97
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
98
+
99
+ # Normalize phone
100
+ verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
101
+ try:
102
+ normalized = verifier.normalize_phone(phone_number)
103
+ except ValueError as e:
104
+ click.echo(f"Invalid phone: {e}", err=True)
105
+ return
106
+
107
+ # Delete from cache
108
+ try:
109
+ aws.delete_item(cache_table, {"phone_number": normalized})
110
+ click.echo(f"Cache entry invalidated for {normalized}")
111
+ except Exception as e:
112
+ click.echo(f"Error invalidating cache: {e}", err=True)
113
+
114
+ @cache_group.command(name="clear")
115
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
116
+ @click.option("--older-than", type=int, help="Clear entries older than N days")
117
+ @click.option("--profile", help="AWS profile")
118
+ @click.option("--region", help="AWS region")
119
+ @click.confirmation_option(prompt="This will clear cache entries. Continue?")
120
+ def cache_clear(stack, older_than, profile, region):
121
+ """Clear cache entries"""
122
+ aws = AWSClient(region=region, profile=profile)
123
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
124
+
125
+ if older_than:
126
+ click.echo(f"Clearing entries older than {older_than} days...")
127
+ cutoff = datetime.utcnow() - timedelta(days=older_than)
128
+
129
+ # Scan and delete old entries
130
+ items = aws.scan_table(cache_table, limit=1000)
131
+ deleted = 0
132
+
133
+ for item in items:
134
+ if 'verified_at' in item:
135
+ verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
136
+ if verified.replace(tzinfo=None) < cutoff:
137
+ aws.delete_item(cache_table, {"phone_number": item['phone_number']})
138
+ deleted += 1
139
+
140
+ click.echo(f"Deleted {deleted} entries older than {older_than} days")
141
+ else:
142
+ click.echo("Full cache clear not implemented for safety. Use --older-than option.")