# Task Recon: Implement LocationService

**Task ID:** 15
**Status:** pending
**Generated:** 2026-01-29T18:45:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Create the LocationService for reading emergency locations from CSV cache with search, validation, and staleness detection. The service provides a business logic layer on top of CacheManager's CSV reading capabilities, converting raw CSV dictionaries to typed EmergencyLocation models and providing search, validation, and cache health methods.

### Dependencies
- Task 3: Pydantic Models for Configuration (done)
- Task 5: Implement CacheManager (done)
- Task 9: Pydantic Models for Graph API Responses (done)
- Task 39: Validation Checkpoint 1 (done)

### Acceptance Criteria
From task details:
- Create `src/teams_phone/services/location_service.py` with LocationService class
- Methods required: `list_locations()`, `get_location()`, `search_locations()`, `validate_location()`, `get_cache_age()`, `is_cache_stale()`, `get_staleness_warning()`
- Graceful degradation: return empty list if CSV missing, log warning
- Search should match description, city, address fields (case-insensitive)
- `validate_location()` accepts ID or name and raises NotFoundError if not found
- Cache staleness threshold: 30 days (CSV_STALE_DAYS constant)

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 3 | Pydantic Models for Configuration | Use `ConfigDict(extra="forbid")` for all models; `(str, Enum)` pattern for string enums |
| 5 | CacheManager | Returns `list[dict[str, str]]` from CSV - all values are strings; uses `utf-8-sig` for BOM handling; graceful degradation returns empty list |
| 9 | Graph API Response Models | EmergencyLocation model already created with camelCase aliases for CSV fields; uses `populate_by_name=True` |

### Reusable Patterns from Dependencies
- **CSV reading**: `CacheManager.read_locations_csv()` returns `list[dict[str, str]]` - see `cache_manager.py:176-184`
- **Staleness detection**: `CacheManager.is_cache_stale()` and `get_cache_age_days()` already implemented - see `cache_manager.py:262-274`
- **Model validation from dict**: Use `EmergencyLocation.model_validate(dict)` pattern - see `user_service.py:96`
- **Service initialization**: Take infrastructure dependencies in constructor - see `user_service.py:29-35`

### Warnings and Gotchas
- **CSV values are strings**: Boolean values like `isDefault` in CSV are strings ("True"/"False") - must convert when creating model
- **Pydantic alias handling**: EmergencyLocation uses camelCase aliases (`locationId`, `civicAddressId`, etc.) which match CSV column headers from PowerShell export
- **BOM in CSV**: CacheManager handles UTF-8 BOM transparently with `utf-8-sig` encoding
- **Empty CSV graceful degradation**: Return empty list when CSV doesn't exist (don't raise errors)

### Open Items Inherited
- None directly affecting LocationService

---

## Architecture Context

### Relevant Design Decisions
- **Services abstract CSV cache access**: LocationService reads from CacheManager, not directly from filesystem
- **Repository pattern**: Services provide typed domain objects over raw infrastructure data
- **Graceful degradation**: Return empty results when cache missing rather than failing
- **Staleness warnings**: Services detect stale cache and can provide warning messages to CLI layer

### Module Structure
```
src/teams_phone/
├── services/
│   ├── __init__.py          # Export services
│   ├── location_service.py  # NEW - LocationService
│   ├── user_service.py      # Pattern reference (Graph API service)
│   ├── tenant_service.py    # Pattern reference (sync service)
│   └── auth_service.py      # Auth operations
├── infrastructure/
│   └── cache_manager.py     # CSV reading, staleness detection
└── models/
    └── location.py          # EmergencyLocation model
```

### Conventions to Follow
- Use `from __future__ import annotations` for forward references
- Use `TYPE_CHECKING` guard for type-only imports to avoid circular imports
- Mark all exceptions with specific types from `teams_phone.exceptions`
- Follow docstring format: description, Args, Returns, Raises sections
- Use `@pytest.mark.unit` marker on all unit tests
- Class names: `LocationService`, test classes: `TestLocationServiceInit`, `TestLocationServiceList`, etc.

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/infrastructure/cache_manager.py` | 176-194 | CSV reading methods (`read_locations_csv`, `read_policies_csv`) |
| `src/teams_phone/infrastructure/cache_manager.py` | 246-274 | Staleness detection (`get_cache_age_days`, `is_cache_stale`) |
| `src/teams_phone/models/location.py` | 1-37 | EmergencyLocation model definition |
| `src/teams_phone/services/user_service.py` | 19-35 | Service class pattern with GraphClient dependency |
| `tests/unit/test_cache_manager.py` | 337-370 | CSV test fixtures (LOCATIONS_CSV, POLICIES_CSV) |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/exceptions.py` | 72-83 | NotFoundError for validation failures |
| `src/teams_phone/constants.py` | 44-45 | CSV_STALE_DAYS = 30 |
| `src/teams_phone/services/tenant_service.py` | 19-50 | Sync service pattern with ConfigManager dependency |
| `src/teams_phone/services/__init__.py` | 1-9 | Service exports pattern |

### Code Patterns to Follow

```python
# Service initialization pattern (user_service.py:19-35)
class LocationService:
    """Service for emergency location operations.

    Provides methods for listing, searching, and validating emergency
    locations from the CSV cache.

    Attributes:
        cache_manager: CacheManager instance for CSV access.
    """

    def __init__(self, cache_manager: CacheManager) -> None:
        """Initialize the LocationService.

        Args:
            cache_manager: CacheManager instance for CSV reading.
        """
        self.cache_manager = cache_manager
```

```python
# Model validation from dict pattern (user_service.py:96)
def list_locations(self) -> list[EmergencyLocation]:
    raw_data = self.cache_manager.read_locations_csv()
    locations: list[EmergencyLocation] = []
    for row in raw_data:
        # Convert string booleans to actual booleans
        row["isDefault"] = row.get("isDefault", "").lower() == "true"
        locations.append(EmergencyLocation.model_validate(row))
    return locations
```

```python
# NotFoundError pattern (user_service.py:209-215)
from teams_phone.exceptions import NotFoundError

def validate_location(self, identifier: str) -> EmergencyLocation:
    location = self._find_location(identifier)
    if location is None:
        raise NotFoundError(
            f"Location '{identifier}' not found",
            remediation=(
                "Verify the location ID or description is correct.\n"
                "Use 'teams-phone locations list' to see available locations."
            ),
        )
    return location
```

---

## Dependencies

### Internal Modules
- `teams_phone.infrastructure.CacheManager` - CSV file reading and staleness detection
- `teams_phone.models.EmergencyLocation` - Pydantic model for location data
- `teams_phone.exceptions.NotFoundError` - Exception for validation failures
- `teams_phone.constants.CSV_STALE_DAYS` - Staleness threshold (30 days)

### External Packages
- `pydantic` - Model validation (`model_validate` method)
- `datetime` - For `timedelta` return type on `get_cache_age()`

### New Dependencies Needed
- None required - all dependencies already in project

---

## Testing Guidance

### Test File Location
`tests/unit/test_location_service.py`

### Test Patterns in Use
```python
# Test fixture pattern (test_cache_manager.py:337-345)
LOCATIONS_CSV = """locationId,civicAddressId,description,companyName,address,city,stateOrProvince,postalCode,countryOrRegion,isDefault
93cb8a70-b4af-41df-9928-d07607e21776,a1b2c3d4-0000-0000-0000-000000000000,Main Office - Floor 3,Contoso Ltd,123 Main St,Seattle,WA,98101,US,True
f47ac10b-58cc-4372-a567-0e02b2c3d479,a1b2c3d4-0000-0000-0000-000000000000,Branch Office,Contoso Ltd,456 Oak Ave,Portland,OR,97201,US,False"""
```

```python
# Service test with mocked CacheManager pattern
class TestLocationServiceList:
    @pytest.mark.unit
    def test_list_locations_returns_empty_list_when_no_csv(
        self, tmp_path: Path
    ) -> None:
        """list_locations returns empty list when CSV doesn't exist."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        service = LocationService(cache_manager)

        result = service.list_locations()

        assert result == []

    @pytest.mark.unit
    def test_list_locations_returns_emergency_location_objects(
        self, tmp_path: Path
    ) -> None:
        """list_locations returns list of EmergencyLocation objects."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "locations.csv").write_text(LOCATIONS_CSV, encoding="utf-8")
        service = LocationService(cache_manager)

        result = service.list_locations()

        assert len(result) == 2
        assert all(isinstance(loc, EmergencyLocation) for loc in result)
        assert result[0].location_id == "93cb8a70-b4af-41df-9928-d07607e21776"
```

### Fixtures Available
- `tmp_path` - pytest built-in for temporary directories
- `LOCATIONS_CSV` / `POLICIES_CSV` - sample CSV content from `test_cache_manager.py`

### Test Utilities
- `tests/conftest.py` - Custom markers (`@pytest.mark.unit`)
- Use `Path.write_text()` for creating test CSV files

---

## Risks and Blockers

### Technical Risks
- [x] **Boolean conversion from CSV**: CSV values are strings; need to convert `isDefault` field from "True"/"False" to bool before model validation. Mitigation: Pre-process row dict before `model_validate()`.

### Dependency Risks
- None - all dependencies (Task 3, 5, 9, 39) are already completed

### Open Questions
- [ ] Should `get_staleness_warning()` return a specific message format or is it flexible? (Assume flexible based on task spec)
- [ ] Should search be case-insensitive across all fields? (Task says yes - description, city, address)

---

## Implementation Recommendations

### Suggested Approach
1. Create `src/teams_phone/services/location_service.py` with class skeleton
2. Implement `list_locations()` - read CSV, convert booleans, validate to models
3. Implement `get_location(location_id)` - filter by ID from list
4. Implement `search_locations(query)` - case-insensitive search on description, city, address
5. Implement `validate_location(identifier)` - lookup by ID or description, raise NotFoundError
6. Implement `get_cache_age()`, `is_cache_stale()`, `get_staleness_warning()` - delegate to CacheManager
7. Update `src/teams_phone/services/__init__.py` to export LocationService
8. Create `tests/unit/test_location_service.py` with comprehensive unit tests

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/services/location_service.py` | Main LocationService implementation |
| Create | `tests/unit/test_location_service.py` | Unit tests for LocationService |
| Modify | `src/teams_phone/services/__init__.py` | Add LocationService export |

### Quick Wins
- Start with `list_locations()` to validate the CSV-to-model conversion pattern
- `get_cache_age()` and `is_cache_stale()` are direct delegations to CacheManager

---

## Cheat Sheet

### Key Imports
```python
from __future__ import annotations

from datetime import timedelta
from typing import TYPE_CHECKING

from teams_phone.exceptions import NotFoundError
from teams_phone.models import EmergencyLocation

if TYPE_CHECKING:
    from teams_phone.infrastructure import CacheManager
```

### Key Functions to Use
- `CacheManager.read_locations_csv() -> list[dict[str, str]]` - reads CSV data
- `CacheManager.get_cache_age_days() -> int | None` - days since last update
- `CacheManager.is_cache_stale() -> bool` - True if cache > 30 days old
- `EmergencyLocation.model_validate(dict)` - validate dict to model
- `str.lower()` - for case-insensitive search comparison

### Common Patterns
- Boolean conversion: `row["isDefault"] = row.get("isDefault", "").lower() == "true"`
- Search pattern: `query.lower() in (field or "").lower()`
- Staleness warning: Check `is_cache_stale()`, return formatted message with age

### EmergencyLocation Model Fields (with CSV aliases)
```python
location_id: str        # alias="locationId"
civic_address_id: str   # alias="civicAddressId"
description: str        # alias="description"
company_name: str | None  # alias="companyName"
address: str | None     # alias="address"
city: str | None        # alias="city"
state_or_province: str | None  # alias="stateOrProvince"
postal_code: str | None  # alias="postalCode"
country_or_region: str | None  # alias="countryOrRegion"
is_default: bool        # alias="isDefault"
```

---

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