# Task ID: 21

**Title:** Implement Number Assign/Unassign Operations

**Status:** pending

**Dependencies:** 15, 20

**Priority:** high

**Description:** Add assign and unassign methods to NumberService with E911 location enforcement and async polling.

**Details:**

Extend `src/teams_phone/services/number_service.py`:
```python
async def assign_number(
    self,
    user_id: str,
    phone_number: str,
    number_type: NumberType,
    location_id: str | None = None,
    wait: bool = True,
    timeout: int = 60,
) -> AsyncOperation:
    """Assign phone number to user."""
    # Validate location for Calling Plan (E911 requirement)
    if number_type == NumberType.CALLING_PLAN and not location_id:
        raise ValidationError(
            f"Emergency location required for Calling Plan number {phone_number}",
            remediation="Use --location flag or set default_location in tenant config"
        )
    
    body = {
        "telephoneNumber": format_for_assign(phone_number),
        "numberType": number_type.value,
        "userId": user_id,
    }
    if location_id:
        body["locationId"] = location_id
    
    response = await self._graph_client.post("/assignNumber", body)
    # Returns 202, extract operation_id from Location header
    if wait:
        return await self.poll_operation(operation_id, timeout)
    return AsyncOperation(id=operation_id, status="notStarted")

async def unassign_number(
    self,
    phone_number: str,
    number_type: NumberType,
    wait: bool = True,
    timeout: int = 60,
) -> AsyncOperation:
    ...
```

**Test Strategy:**

Unit tests: E911 validation for Calling Plan, successful assign/unassign flows, polling integration, error handling for failed operations.

---

## Implementation Notes

### Session: 2026-01-29 - Agent: claude-sonnet-4-5-20250929

**Subtask:** 21.1 - Implement assign_number method with E911 location enforcement

**Status:** Implemented

#### What Was Done
- Added `post_with_response()` method to GraphClient at `src/teams_phone/infrastructure/graph_client.py:428-455`
  - Returns raw `httpx.Response` for access to response headers
  - Required for extracting Location header from 202 Accepted responses
- Implemented `assign_number()` method in NumberService at `src/teams_phone/services/number_service.py:286-349`
  - E911 validation: CallingPlan numbers require location_id, raises ValidationError with remediation hint
  - Uses `format_for_assign()` for phone number normalization (digits only)
  - Builds request body with telephoneNumber, numberType, assignmentTargetId, and optional locationId
  - Calls POST `/admin/teams/telephoneNumberManagement/numberAssignments/assignNumber`
  - Extracts operation_id from Location header using existing `_extract_operation_id()`
  - If `wait=True`: polls using `poll_operation()` and returns completed AsyncOperation
  - If `wait=False`: returns AsyncOperation with status=NOT_STARTED immediately
- Added `format_for_assign` import to number_service.py
- Added `datetime` import for timezone-aware timestamp in immediate return
- Added 14 unit tests in `tests/unit/test_number_service.py` (TestNumberServiceAssignNumber class)

#### Key Decisions Made
- **Used `model_validate()` for AsyncOperation construction**: Direct constructor caused mypy error due to Pydantic alias handling. Using `model_validate()` with a dict is consistent with poll_operation and avoids type issues.
- **Request body field name is `assignmentTargetId`**: Matches Graph API contract, not `userId` as shown in task description template.
- **POST uses `post_with_response()`**: New method added to GraphClient rather than modifying existing `post()` to maintain backward compatibility.

#### Patterns Discovered
- **Response header access pattern**: `response.headers.get("location", "")` with fallback to empty string - `_extract_operation_id()` then raises ValidationError for invalid format.
- **AsyncOperation construction via model_validate**: See `number_service.py:342-349` - use dict with camelCase keys and enum `.value` for status.

#### Open Items for Next Agent
- [x] Implement `unassign_number` method (Task 21.2) - DONE

#### Gotchas and Warnings
- **GraphClient uses public `graph_client` attribute**, NOT `_graph_client` (private)
- **Location header uses OData format**: `operations('id')` with single quotes and parentheses
- **Polling endpoint uses REST format**: `operations/{id}` - different from Location header
- **AsyncOperation requires camelCase keys when using model_validate**: Use `createdDateTime`, not `created_date_time`

---

### Session: 2026-01-29 - Agent: claude-sonnet-4-5-20250929

**Subtask:** 21.2 - Implement unassign_number method and integrate both methods with poll_operation

**Status:** Implemented

#### What Was Done
- Implemented `unassign_number()` method in NumberService at `src/teams_phone/services/number_service.py:350-400`
  - Simpler than assign_number - no E911 validation needed (unassign doesn't require location)
  - Parameters: `phone_number`, `number_type`, `wait=True`, `timeout=60`
  - Request body contains only `telephoneNumber` and `numberType` (no userId, no locationId)
  - Calls POST `/admin/teams/telephoneNumberManagement/numberAssignments/unassignNumber`
  - Extracts operation_id from Location header using `_extract_operation_id()`
  - If `wait=True`: polls using `poll_operation()` and returns completed AsyncOperation
  - If `wait=False`: returns AsyncOperation with status=NOT_STARTED immediately
- Added 11 unit tests in `tests/unit/test_number_service.py` (TestNumberServiceUnassignNumber class)
  - test_successful_unassign_with_wait
  - test_request_body_contains_only_required_fields
  - test_phone_number_normalized_via_format_for_assign
  - test_wait_true_polls_for_completion
  - test_wait_false_returns_immediately
  - test_wait_false_includes_created_datetime
  - test_timeout_passed_to_poll_operation
  - test_invalid_location_header_raises_validation_error
  - test_missing_location_header_raises_validation_error
  - test_operation_failure_returns_failed_status
  - test_all_number_types_supported

#### Key Decisions Made
- **Followed exact pattern from assign_number**: Copy-adapted the structure for consistency across both methods
- **No E911 validation for unassign**: Unlike assign, unassign doesn't require location_id since we're just removing the assignment
- **Request body is minimal**: Only `telephoneNumber` and `numberType` - simpler than assign which also needs `assignmentTargetId` and optional `locationId`

#### Patterns Discovered
- **Unassign API contract is simpler**: The Graph API unassignNumber endpoint only needs the phone number and type to identify what to unassign
- **Error handling identical to assign**: Same ValidationError for invalid/missing Location header, same TimeoutError handling in poll_operation

#### Open Items for Next Agent
- [ ] None - Parent task 21 (Number Assign/Unassign Operations) is now complete
- [ ] CLI commands for assign/unassign can now be built on top of these service methods

#### Gotchas and Warnings
- **All warnings from subtask 21.1 still apply** - see above section
- **Unassign doesn't verify number is assigned**: The service method doesn't check if the number is currently assigned - the Graph API will return an appropriate error if unassigning an already-unassigned number

---
