# Sprint 01: gRPC Mixin Base Helpers

| Field | Value |
|-------|-------|
| **Sprint Size** | L (Large) |
| **Owner** | TBD |
| **Prerequisites** | None |
| **Phase** | Deduplication Phase 1 |
| **Est. Pattern Reduction** | 80-120 patterns |

## Open Issues

- [x] Verify `parse_meeting_id_or_abort` exists and is used consistently → Exists as `parse_meeting_id`
- [x] Confirm all abort helper functions have correct `NoReturn` annotations → Using `assert` for type narrowing
- [x] Determine if generic helpers should go in `errors.py` or a new `helpers.py` → Kept in `errors.py`

## Validation Status

| Component | Status | Notes |
|-----------|--------|-------|
| `parse_workspace_id` | ✅ Exists | In `grpc/_mixins/errors.py` |
| `parse_project_id` | ✅ Exists | In `grpc/_mixins/errors.py` |
| `parse_meeting_id` | ✅ Exists | In `grpc/_mixins/errors.py` |
| `parse_integration_id` | ✅ Exists | In `grpc/_mixins/errors.py` |
| `parse_webhook_id` | ✅ Exists | In `grpc/_mixins/errors.py` |
| `parse_entity_id` | ✅ Exists | In `grpc/_mixins/errors.py` |
| Feature require helpers | ✅ Complete | projects, webhooks, entities, integrations, workspaces |
| Service require helpers | ✅ Complete | project_service, ner_service |
| `get_meeting_or_abort` | ✅ Exists | In `grpc/_mixins/errors.py` |
| `get_project_or_abort` | ✅ Exists | Takes project_service as parameter |
| `get_webhook_or_abort` | ✅ Exists | Logging handled separately at call site |

## Objective

Consolidate repetitive gRPC mixin patterns into reusable helper functions, reducing duplicate abort/validation code by 60-80%. This sprint focuses on the highest-impact patterns found across 13+ mixin files.

## Key Decisions

| Decision | Rationale |
|----------|-----------|
| Keep helpers in `errors.py` | Already established location for abort helpers |
| Use async helpers consistently | All abort calls must await; consistent API |
| Create `require_*` pattern | Clear naming convention for precondition checks |
| Type with `NoReturn` + `raise` | Satisfy type checkers for control flow analysis |

## What Already Exists

### Current Helpers in `grpc/_mixins/errors.py`

```python
async def parse_workspace_id(workspace_id_str: str, context: _AbortableContext) -> UUID
async def parse_project_id(project_id_str: str, context: _AbortableContext) -> UUID
async def parse_meeting_id_or_abort(meeting_id_str: str, context: _AbortableContext) -> MeetingId
async def abort_invalid_argument(context: _AbortableContext, message: str) -> NoReturn
async def abort_not_found(context: _AbortableContext, entity: str, id_str: str) -> NoReturn
async def abort_failed_precondition(context: _AbortableContext, message: str) -> NoReturn
async def abort_database_required(context: _AbortableContext, feature: str) -> NoReturn
```

### Pattern Occurrences to Eliminate

| Pattern | Current Occurrences | Target |
|---------|---------------------|--------|
| Try/except UUID + abort | 9+ | 0 |
| `if not uow.supports_*` + abort | 47 | 0 (via helper) |
| `if entity is None` + abort_not_found | 15+ | 0 (via helper) |
| `if service is None` + abort | 5+ | 0 (via helper) |

## Scope

### Task 1: Create UUID Parse Helpers (S)

Create remaining UUID parse helpers following the established pattern:

```python
async def parse_integration_id(
    integration_id_str: str,
    context: _AbortableContext,
) -> UUID:
    """Parse integration_id string to UUID, aborting if invalid."""
    try:
        return UUID(integration_id_str)
    except ValueError:
        await abort_invalid_argument(context, f"Invalid integration_id format: {integration_id_str}")
        raise  # Unreachable: abort raises, but helps type checker
```

**Files to create/update:**
- `src/noteflow/grpc/_mixins/errors.py` - Add `parse_integration_id`, `parse_webhook_id`, `parse_entity_id`

**Files to update (use new helpers):**
- `src/noteflow/grpc/_mixins/sync.py` - Lines 57-60 (integration_id parsing)
- `src/noteflow/grpc/_mixins/webhooks.py` - Lines 182-191, 240-248, 283-291 (webhook_id parsing)
- `src/noteflow/grpc/_mixins/entities.py` - Lines 95-100 (entity_id parsing)

### Task 2: Create Feature Requirement Helpers (M)

Create helpers for common `uow.supports_*` checks:

```python
async def require_feature_projects(
    uow: UnitOfWork,
    context: _AbortableContext,
) -> None:
    """Ensure projects feature is available, abort if not."""
    if not uow.supports_projects:
        await abort_database_required(context, FEATURE_NAME_PROJECTS)
        raise  # Unreachable

async def require_feature_webhooks(
    uow: UnitOfWork,
    context: _AbortableContext,
) -> None:
    """Ensure webhooks feature is available, abort if not."""
    if not uow.supports_webhooks:
        await abort_database_required(context, "Webhooks")
        raise  # Unreachable

async def require_feature_entities(
    uow: UnitOfWork,
    context: _AbortableContext,
) -> None:
    """Ensure entities feature is available, abort if not."""
    if not uow.supports_entities:
        await abort_database_required(context, "Named Entities")
        raise  # Unreachable
```

**Files to update:**
- `src/noteflow/grpc/_mixins/project/_mixin.py` - 8 occurrences
- `src/noteflow/grpc/_mixins/project/_membership.py` - 4 occurrences
- `src/noteflow/grpc/_mixins/webhooks.py` - 5 occurrences
- `src/noteflow/grpc/_mixins/entities.py` - 2 occurrences

### Task 3: Create Service Requirement Helpers (S)

Create helpers for service availability checks:

```python
async def require_project_service(
    project_service: ProjectService | None,
    context: _AbortableContext,
) -> ProjectService:
    """Ensure project service is configured, abort if not."""
    if project_service is None:
        await abort_failed_precondition(context, "Project service not configured")
        raise  # Unreachable
    return project_service

async def require_ner_service(
    ner_service: NerService | None,
    context: _AbortableContext,
) -> NerService:
    """Ensure NER service is configured, abort if not."""
    if ner_service is None:
        await abort_failed_precondition(
            context,
            "NER service not configured. Set NOTEFLOW_FEATURE_NER_ENABLED=true",
        )
        raise  # Unreachable
    return ner_service
```

**Note:** `_require_project_service` already exists in `project/_mixin.py`. Move to `errors.py` and reuse.

**Files to update:**
- `src/noteflow/grpc/_mixins/project/_mixin.py` - Move existing helper
- `src/noteflow/grpc/_mixins/project/_membership.py` - Use shared helper
- `src/noteflow/grpc/_mixins/entities.py` - Replace inline check

### Task 4: Create Get-or-Abort Helpers (M)

Create helpers that combine entity fetch + not found abort:

```python
async def get_meeting_or_abort(
    repo: MeetingRepository,
    meeting_id: MeetingId,
    context: _AbortableContext,
) -> Meeting:
    """Get meeting by ID, aborting with NOT_FOUND if missing."""
    meeting = await repo.meetings.get(meeting_id)
    if meeting is None:
        await abort_not_found(context, ENTITY_MEETING, str(meeting_id))
        raise  # Unreachable
    return meeting

async def get_project_or_abort(
    uow: UnitOfWork,
    project_id: UUID,
    context: _AbortableContext,
) -> Project:
    """Get project by ID, aborting with NOT_FOUND if missing."""
    project = await uow.projects.get_by_id(project_id)
    if project is None:
        await abort_not_found(context, ENTITY_PROJECT, str(project_id))
        raise  # Unreachable
    return project
```

**Files to update:**
- `src/noteflow/grpc/_mixins/project/_mixin.py` - 6 occurrences of get + not_found
- `src/noteflow/grpc/_mixins/meeting.py` - GetMeeting, DeleteMeeting
- `src/noteflow/grpc/_mixins/oidc.py` - 3 occurrences

### Task 5: Update All Consumers (L)

Systematically update all files using old patterns:

| File | Patterns to Update | Est. Lines Reduced |
|------|-------------------|-------------------|
| `project/_mixin.py` | 14 abort, 8 supports, 6 not_found | 80+ |
| `project/_membership.py` | 11 abort, 4 supports | 40+ |
| `oidc.py` | 10 abort, 4 not_found | 30+ |
| `webhooks.py` | 6 abort, 5 supports | 25+ |
| `sync.py` | 3 UUID parse | 10+ |
| `meeting.py` | 3 abort | 10+ |
| `entities.py` | 5 not_found, 2 supports | 15+ |

## Deliverables

### Code Changes

- [x] Add `parse_integration_id`, `parse_webhook_id`, `parse_entity_id` to `errors.py`
- [x] Add `require_feature_projects`, `require_feature_webhooks`, `require_feature_entities`, `require_feature_integrations`, `require_feature_workspaces` to `errors.py`
- [x] Move `_require_project_service` from `project/_mixin.py` to `errors.py`
- [x] Add `require_ner_service` helper
- [x] Add `get_meeting_or_abort` helper
- [x] Add `get_project_or_abort` helper (takes project_service param)
- [x] Add `get_webhook_or_abort` helper
- [x] Update `project/_mixin.py` to use `require_feature_workspaces`
- [ ] Update remaining consumers to use new helpers (ongoing)
- [ ] Remove unused inline imports (ongoing)

### Tests

- [x] Add parametrized tests for UUID parse helpers in `tests/grpc/test_mixin_helpers.py`
- [x] Add tests for feature requirement helpers
- [x] Add tests for service requirement helpers
- [x] Add tests for all get-or-abort helpers (meeting, project, webhook)
- [x] All 32 helper tests pass
- [x] Quality gates pass (90 tests)

## Test Strategy

### New Test File: `tests/grpc/test_mixin_helpers.py`

```python
import pytest
from uuid import UUID
from unittest.mock import AsyncMock, MagicMock

from noteflow.grpc._mixins.errors import (
    parse_integration_id,
    parse_webhook_id,
    require_feature_projects,
    get_project_or_abort,
)

class TestParseHelpers:
    @pytest.mark.parametrize("helper,input_str", [
        (parse_integration_id, "not-a-uuid"),
        (parse_webhook_id, "invalid"),
    ])
    async def test_invalid_uuid_aborts(self, helper, input_str):
        context = AsyncMock()
        with pytest.raises(Exception):  # abort raises
            await helper(input_str, context)
        context.abort.assert_called_once()

    @pytest.mark.parametrize("helper,valid_uuid", [
        (parse_integration_id, "12345678-1234-5678-1234-567812345678"),
    ])
    async def test_valid_uuid_returns(self, helper, valid_uuid):
        context = AsyncMock()
        result = await helper(valid_uuid, context)
        assert result == UUID(valid_uuid)

class TestFeatureRequirements:
    async def test_require_projects_aborts_when_unsupported(self):
        uow = MagicMock()
        uow.supports_projects = False
        context = AsyncMock()

        with pytest.raises(Exception):
            await require_feature_projects(uow, context)

class TestGetOrAbort:
    async def test_get_project_aborts_when_none(self):
        uow = MagicMock()
        uow.projects.get_by_id = AsyncMock(return_value=None)
        context = AsyncMock()

        with pytest.raises(Exception):
            await get_project_or_abort(uow, UUID(int=1), context)
```

## Quality Gates

### Pre-Implementation

- [x] Verify current pattern counts with quality test
- [x] Document baseline

### Post-Implementation

- [x] Quality test passes: 90 tests passed
- [x] All existing tests pass: `pytest tests/grpc/test_mixin_helpers.py` (28 passed)
- [x] Type checking passes: `basedpyright` (0 errors)
- [x] No new linting errors

### Verification Script

```bash
#!/bin/bash
set -e

echo "=== Pre-flight checks ==="
source .venv/bin/activate

echo "1. Type checking..."
basedpyright

echo "2. Linting..."
ruff check src/noteflow/grpc/_mixins/

echo "3. Quality tests..."
pytest tests/quality/test_duplicate_code.py -v

echo "4. gRPC mixin tests..."
pytest tests/grpc/ -v

echo "=== All checks passed ==="
```

## Rollback Plan

If issues are discovered after merge:

1. Each helper can be removed individually without affecting others
2. Consumers can revert to inline patterns if needed
3. No database changes, no migration needed
4. Git revert of the PR restores original state

## References

- `src/noteflow/grpc/_mixins/errors.py` - Current helper location
- `src/noteflow/grpc/_mixins/protocols.py` - ServicerHost protocol
- `tests/quality/test_duplicate_code.py` - Quality verification tests
