# Task Recon: Implement assign command with user resolution, location validation, and dry-run preview

**Task ID:** 23.1
**Status:** pending
**Generated:** 2026-01-29
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Add the `assign` command to `src/teams_phone/cli/numbers.py` that resolves a user via UserService, validates the emergency location (required for Calling Plan numbers), supports dry-run preview mode, and calls `NumberService.assign_number`.

### Parent Task Context

**Parent Task:** Implement Numbers Assign/Unassign CLI Commands (ID: 23)
**Parent Goal:** Add assign and unassign commands to the numbers CLI with progress display and confirmation prompts. This completes the write operations for phone number management.
**This Subtask's Role:** Implements the `assign` command - the primary write operation for phone number assignment. This establishes the CLI patterns (user resolution, location validation, dry-run, --no-wait) that will be reused by subtask 23.2 (unassign) and subtask 23.3 (progress display).

### Dependencies
- Depends on: Task 17 (UserService - done), Task 21 (NumberService assign/unassign - done), Task 22 (Numbers CLI read operations - done), Task 40 (Validation checkpoint - done)

### Acceptance Criteria
From task details:
1. Command signature with all required parameters (user, number, --location, --type, --dry-run, --no-wait)
2. User resolution via UserService.resolve_user()
3. Location validation for Calling Plan numbers (check --location flag or tenant default_location)
4. Dry-run preview panel showing user, phone number, type, and location
5. Assignment execution via NumberService.assign_number()
6. Success/failure output with remediation guidance
7. JSON mode support (--json output)

---

## Insights from Previous Tasks

### Dependency Task Notes

| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 17 | UserService | Circular import handling - must import UserService directly from `teams_phone.services.user_service`, not from `teams_phone.services`. resolve_user() handles UPN/name/ID detection automatically. |
| 21 | Number Assign/Unassign Operations | GraphClient uses `graph_client` attribute (public), NOT `_graph_client`. Location header uses OData format `operations('id')` with single quotes. AsyncOperation requires camelCase keys when using model_validate. |
| 22 | Numbers CLI Read Operations | Use `model_validate()` with dict for Pydantic models that have field aliases. Phone numbers in output may have ANSI escape codes from Rich. |
| 40 | Validation Checkpoint 2 | assigned filter is client-side (Graph API limitation). All 845 unit tests pass. |

### Reusable Patterns from Dependencies

- **_run_async pattern**: Implemented in `numbers.py:36-64` - creates GraphClient/Service within async context. Need to extend for multiple services (UserService + NumberService).
- **User resolution**: `UserService.resolve_user(identifier)` handles all identifier types (see `user_service.py:181-255`)
- **Location validation**: `LocationService.validate_location(identifier)` returns EmergencyLocation (see `location_service.py:101-127`)
- **Confirmation prompt pattern**: See `tenants.py:240-245` - use `typer.confirm()` before destructive actions

### Warnings and Gotchas

- **UserService import**: Import from `teams_phone.services.user_service` directly to avoid circular import (from task 17)
- **E911 validation already in NumberService**: `assign_number()` validates location_id for Calling Plan, raises ValidationError if missing (see `number_service.py:309-314`). CLI should validate earlier to provide better UX with --location hint.
- **TenantProfile.default_location**: Get via `ConfigManager.get_current_tenant()` for fallback location

### Open Items Inherited

- [ ] The UserService is NOT exported from `teams_phone.services` due to circular import - import directly from `teams_phone.services.user_service`

---

## Architecture Context

### Relevant Design Decisions

- **Layered architecture**: CLI -> Services -> Infrastructure (Graph API)
- **CLI commands are thin**: Resolve inputs, call services, format output
- **Async patterns**: Use `asyncio.run()` wrapper for async service calls
- **JSON mode**: All commands support `--json` for automation
- **Dry-run**: Preview changes without execution

### Module Structure

```
src/teams_phone/cli/numbers.py  <- Extend with assign command
src/teams_phone/services/
  - user_service.py    <- resolve_user(identifier)
  - number_service.py  <- assign_number(user_id, phone_number, number_type, location_id, wait, timeout)
  - location_service.py <- validate_location(identifier)
src/teams_phone/infrastructure/
  - config_manager.py  <- get_current_tenant() for default_location
```

### Conventions to Follow

- Use `typer.Argument(...)` for required positional args
- Use `typer.Option(...)` for optional flags
- Return exit code via `raise typer.Exit(code)`
- Error handling: catch `TeamsPhoneError`, display via `formatter.error()`, exit with `e.exit_code`
- Format output: use `formatter.success()`, `formatter.info()`, `formatter.table()`, `formatter.json()`

---

## Related Code References

### Primary References (Must Read)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/numbers.py` | 1-368 | Current numbers CLI implementation - extend with assign command |
| `src/teams_phone/cli/numbers.py` | 36-64 | `_run_async` helper pattern to extend for multiple services |
| `src/teams_phone/services/number_service.py` | 282-347 | `assign_number()` method signature and behavior |
| `src/teams_phone/services/user_service.py` | 181-255 | `resolve_user()` for user identifier resolution |
| `src/teams_phone/services/location_service.py` | 101-127 | `validate_location()` for location validation |

### Secondary References (Helpful Context)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/tenants.py` | 240-245 | typer.confirm() pattern for confirmation prompts |
| `src/teams_phone/cli/users.py` | 40-68 | Alternative `_run_async` pattern with UserService |
| `src/teams_phone/models/tenant.py` | 46 | `default_location` field on TenantProfile |
| `src/teams_phone/models/number.py` | 14-26 | NumberType enum values |
| `src/teams_phone/models/number.py` | 76-94 | OperationStatus enum for result handling |
| `src/teams_phone/infrastructure/output_formatter.py` | 98-143 | OutputFormatter methods for success/error/info |

### Code Patterns to Follow

```python
# Example from tenants.py - confirmation prompt pattern
# (src/teams_phone/cli/tenants.py:240-245)
if not force:
    confirm = typer.confirm(f"Are you sure you want to remove tenant '{name}'?")
    if not confirm:
        formatter.info("Removal cancelled")
        raise typer.Exit(0)
```

```python
# Example from numbers.py - error handling pattern
# (src/teams_phone/cli/numbers.py:231-233)
except TeamsPhoneError as e:
    formatter.error(e.message, remediation=e.remediation)
    raise typer.Exit(e.exit_code)
```

```python
# Example from users.py - _run_async pattern
# (src/teams_phone/cli/users.py:40-68)
def _run_async(ctx: typer.Context, coro_factory: Any) -> tuple[Any, "OutputFormatter"]:
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()
    config_manager = cli_ctx.get_config_manager()
    cache_manager = CacheManager()
    auth_service = AuthService(config_manager, cache_manager)

    async def _execute() -> Any:
        async with GraphClient(auth_service) as graph_client:
            service = UserService(graph_client)
            return await coro_factory(service)

    result = asyncio.run(_execute())
    return result, formatter
```

---

## Dependencies

### Internal Modules

- `teams_phone.cli.context` - get_context(), CLIContext for global options
- `teams_phone.services.user_service` - UserService.resolve_user()
- `teams_phone.services.number_service` - NumberService.assign_number()
- `teams_phone.services.location_service` - LocationService.validate_location()
- `teams_phone.infrastructure` - GraphClient, CacheManager, ConfigManager
- `teams_phone.models` - NumberType, AsyncOperation, OperationStatus, UserConfiguration
- `teams_phone.exceptions` - TeamsPhoneError, ValidationError, NotFoundError

### External Packages

- `typer` - CLI framework (already in use)
- `asyncio` - For running async operations (already in use)

### New Dependencies Needed

None required - all dependencies already imported or available

---

## Testing Guidance

### Test File Location

`tests/unit/test_cli_numbers.py` - Extend with assign command tests

### Test Patterns in Use

```python
# Example test fixture from test_cli_numbers.py:28-52
def _make_number(
    number_id: str = "num-123",
    telephone_number: str = "+14255551234",
    number_type: NumberType = NumberType.DIRECT_ROUTING,
    assignment_status: AssignmentStatus = AssignmentStatus.UNASSIGNED,
    ...
) -> NumberAssignment:
    return NumberAssignment.model_validate({...})
```

```python
# Example test structure from test_cli_numbers.py:66-86
@pytest.mark.unit
def test_list_empty(self) -> None:
    """list shows empty message when no numbers found."""
    with (
        patch("teams_phone.cli.numbers.CacheManager"),
        patch("teams_phone.cli.numbers.AuthService"),
        patch("teams_phone.cli.numbers.GraphClient") as mock_gc_class,
        patch("teams_phone.cli.numbers.NumberService") as mock_ns_class,
    ):
        mock_gc = AsyncMock()
        mock_gc.__aenter__.return_value = mock_gc
        mock_gc.__aexit__.return_value = None
        mock_gc_class.return_value = mock_gc

        mock_service = MagicMock()
        mock_service.list_numbers = AsyncMock(return_value=[])
        mock_ns_class.return_value = mock_service

        result = runner.invoke(app, ["numbers", "list"])
        assert result.exit_code == 0
```

### Fixtures Available

- `runner = CliRunner()` - Typer test runner
- `_make_number()` - Create NumberAssignment test fixtures
- Mock patterns for GraphClient, NumberService, AuthService, CacheManager

### Test Utilities

- `tests/conftest.py` - Shared pytest fixtures
- `from typer.testing import CliRunner` - CLI test runner

### Test Cases to Implement

From task test strategy:
1. Test assign with valid user/number
2. Test assign with --location flag
3. Test E911 validation error when Calling Plan without location
4. Test dry-run preview output
5. Test --no-wait returns immediately
6. Test JSON output format
7. Test user resolution errors (not found, ambiguous)
8. Test location validation errors

---

## Risks and Blockers

### Technical Risks

- [x] **Multiple service initialization**: Need to extend `_run_async` pattern to support both UserService and NumberService in same async context. Mitigation: Create new helper or modify existing pattern.
- [x] **Location fallback logic**: Must check --location flag, then tenant default_location, then fail with helpful message for Calling Plan. This validation is in addition to NumberService validation.

### Dependency Risks

- [x] **None** - All dependency tasks (17, 21, 22, 40) are marked as done

### Open Questions

- [ ] Should dry-run preview use Rich panel/table or simple formatter.info() calls? (Recommendation: Use formatter.info() for consistency with show command)
- [ ] Should --type default to callingPlan as specified in task, or require explicit selection? (Recommendation: Default to callingPlan per task description)

---

## Implementation Recommendations

### Suggested Approach

1. **Create extended async helper**: Modify `_run_async` or create `_run_assign_async` that initializes UserService, NumberService, and LocationService
2. **Implement command signature**: Add all parameters per task details
3. **User resolution**: Call `user_service.resolve_user(user)` to get UserConfiguration, extract `user.id`
4. **Location handling**:
   - If --location provided: validate via LocationService
   - If not provided and number_type is CallingPlan: check tenant default_location
   - If still missing: raise early with helpful error message
5. **Dry-run mode**: Display preview and exit without calling assign_number
6. **Execute assignment**: Call `number_service.assign_number(...)` with `wait=not no_wait`
7. **Handle results**: Display success message or error with remediation

### Files to Create/Modify

| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/cli/numbers.py` | Add assign command and helper function |
| Modify | `tests/unit/test_cli_numbers.py` | Add assign command unit tests |

### Quick Wins

- Start with command signature and basic flow without location validation
- Add dry-run mode early to test output formatting
- Location validation can be added incrementally

---

## Cheat Sheet

### Key Imports

```python
from teams_phone.services.user_service import UserService  # Direct import!
from teams_phone.services.number_service import NumberService
from teams_phone.services.location_service import LocationService
from teams_phone.models import NumberType, OperationStatus, AsyncOperation
from teams_phone.exceptions import TeamsPhoneError, ValidationError
```

### Key Functions to Use

- `UserService.resolve_user(identifier: str) -> UserConfiguration` - Resolves UPN/name/ID
- `LocationService.validate_location(identifier: str) -> EmergencyLocation` - Validates location
- `NumberService.assign_number(user_id, phone_number, number_type, location_id, wait, timeout) -> AsyncOperation` - Assigns number
- `ConfigManager.get_current_tenant() -> TenantProfile` - Access default_location

### Common Patterns

- Error handling: See `numbers.py:231-233`
- Confirmation prompt: See `tenants.py:240-245`
- Async helper: See `numbers.py:36-64`

### NumberType Enum Values

- `NumberType.DIRECT_ROUTING` = "directRouting"
- `NumberType.CALLING_PLAN` = "callingPlan"
- `NumberType.OPERATOR_CONNECT` = "operatorConnect"

### OperationStatus Enum Values

- `OperationStatus.NOT_STARTED` = "notStarted"
- `OperationStatus.RUNNING` = "running"
- `OperationStatus.SUCCEEDED` = "succeeded"
- `OperationStatus.FAILED` = "failed"
- `OperationStatus.SKIPPED` = "skipped"

### Exit Codes

- 0 = Success
- 4 = NotFoundError (user/number/location not found)
- 5 = ValidationError (E911 location required, invalid input)

---

*Generated by task-recon (claude-opus-4-5-20251101) | Ready for implementation*
