feat: add OpenSpec proposal for GraphQL member API

Create comprehensive OpenSpec proposal for implementing a minimal GraphQL API
for member management with flexible validation requirements.

Key features:
- Only firstName required for member creation
- Conditional validation (email/phone format validated only when provided)
- Full CRUD operations via GraphQL
- SQLite database with Alembic migrations
- FastAPI + Strawberry GraphQL integration

Includes 4 capability specs:
- project-setup: Python project structure with uv dependency management
- database-layer: SQLAlchemy async models with nullable fields
- graphql-api: Strawberry schema with minimal required fields
- member-crud: Business logic with conditional validation

Implementation broken down into 20 discrete tasks across 5 phases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 11:29:06 +01:00
co-authored by Claude
parent 88a82b1e4e
commit f3f5e0282c
7 changed files with 2404 additions and 0 deletions
@@ -0,0 +1,455 @@
# Design Document: GraphQL Member API
**Change ID:** `add-graphql-member-api`
**Status:** Draft
**Created:** 2025-11-20
## Architecture Overview
This change establishes a layered architecture following the pattern documented in project.md:
```
┌─────────────────────────────────────────┐
│ API Layer (FastAPI) │
│ GraphQL Endpoint (/graphql) │
│ Strawberry Schema │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ GraphQL Resolvers │
│ Query: getMember, listMembers │
│ Mutation: create, update, delete │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ Service Layer (Optional) │
│ Business logic & validation │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ Data Layer (SQLAlchemy) │
│ Member model, database sessions │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ Database (SQLite) │
│ members table, alembic_version │
└─────────────────────────────────────────┘
```
## Key Design Decisions
### 1. Project Structure
**Decision:** Use src/ layout with separate modules for each layer
```
src/
├── __init__.py
├── main.py # FastAPI app, startup logic
├── config.py # Configuration management
├── database.py # Database connection, session factory
├── models/
│ ├── __init__.py
│ └── member.py # SQLAlchemy Member model
├── schemas/
│ ├── __init__.py
│ └── member.py # Strawberry GraphQL types
└── resolvers/
├── __init__.py
└── member.py # GraphQL query/mutation resolvers
```
**Rationale:**
- Clear separation of concerns
- Easy to navigate and understand
- Supports future growth (add events, payments, etc.)
- Aligns with Python community best practices
**Alternatives considered:**
- Flat structure: Rejected, doesn't scale beyond ~5 files
- Feature-based modules (members/, events/): Deferred until we have multiple features
### 2. Database Schema
**Decision:** Single `members` table with embedded address fields
```sql
CREATE TABLE members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100),
street VARCHAR(200),
apartment_number VARCHAR(20),
zip VARCHAR(20),
city VARCHAR(100),
country VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Rationale:**
- Simple query performance (no joins needed)
- Address is not a reusable entity in this domain
- Satisfies all current requirements
- Easy to migrate to separate table later if needed
**Alternatives considered:**
- Separate `addresses` table: Over-engineering for current needs
- JSON column for address: Loses type safety and query capability
- No apartment_number field: Addresses in many countries need this
### 3. GraphQL Schema Design
**Decision:** Input types separate from output types
```graphql
type Member {
id: ID!
firstName: String!
lastName: String
street: String
apartmentNumber: String
zip: String
city: String
country: String
email: String
phone: String
createdAt: DateTime!
updatedAt: DateTime!
}
input CreateMemberInput {
firstName: String!
lastName: String
street: String
apartmentNumber: String
zip: String
city: String
country: String
email: String
phone: String
}
input UpdateMemberInput {
id: ID!
firstName: String
lastName: String
street: String
apartmentNumber: String
zip: String
city: String
country: String
email: String
phone: String
}
```
**Rationale:**
- Strawberry best practice
- Input validation separate from output shape
- Only firstName required in CreateMemberInput (enables minimal member creation)
- Optional fields in both inputs enable partial/incremental data entry
- Clear contract for API consumers
**Alternatives considered:**
- Single type for input/output: Doesn't work in GraphQL (different field requirements)
- Nested Address input type: Adds complexity without clear benefit
### 4. Validation Strategy
**Decision:** Conditional validation with minimal required fields
1. **GraphQL layer:** Type system enforces only firstName as required
2. **Python layer:** Conditional format validation (email/phone only when provided)
3. **Database layer:** SQLAlchemy constraints (only firstName NOT NULL, length limits on all)
**Rationale:**
- Minimal friction for initial member creation (only firstName required)
- Validation still protects data quality when fields are provided
- Supports incremental data entry workflows
- GraphQL type system prevents invalid types
- Python validation gives clear error messages for bad formats
**Conditional validation logic:**
```python
def validate_email(email: Optional[str]) -> None:
"""Only validate format if email is provided."""
if email is not None and email != "":
if not EMAIL_REGEX.match(email):
raise ValidationError(f"Invalid email format: {email}")
def validate_phone(phone: Optional[str]) -> None:
"""Only validate format if phone is provided."""
if phone is not None and phone != "":
if not PHONE_REGEX.match(phone):
raise ValidationError(f"Invalid phone format: {phone}")
```
**Email validation pattern:**
```python
EMAIL_REGEX = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
```
**Phone validation pattern:**
```python
PHONE_REGEX = r'^\+?[1-9]\d{1,14}$' # E.164 format (international standard)
```
**Alternatives considered:**
- All fields required: Rejected, too restrictive for real-world data entry
- No validation: Rejected, leads to bad data quality
- Email/phone uniqueness: Explicitly out of scope (business decision)
- Third-party validation library: Overkill for simple formats
### 5. Database Session Management
**Decision:** Async SQLAlchemy sessions with dependency injection
```python
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
```
**Rationale:**
- FastAPI's dependency injection handles lifecycle
- Async/await throughout the stack (no blocking I/O)
- Automatic commit/rollback
- Easy to test (inject mock sessions)
**Alternatives considered:**
- Sync SQLAlchemy: Rejected, blocks event loop
- Manual session management in resolvers: Error-prone, boilerplate
- Context managers in each resolver: Doesn't compose well with FastAPI
### 6. Error Handling
**Decision:** Raise GraphQL-friendly exceptions with clear messages
```python
class MemberNotFoundError(Exception):
"""Raised when member ID doesn't exist"""
pass
# In resolver:
if not member:
raise MemberNotFoundError(f"Member with ID {id} not found")
```
**Rationale:**
- Strawberry automatically converts to GraphQL errors
- Clear error messages help API consumers
- Exceptions are idiomatic Python
**Alternatives considered:**
- Return None for not found: Ambiguous (missing vs. error)
- Custom error types in schema: Over-engineering for simple CRUD
### 7. Seed Data Strategy
**Decision:** Idempotent seed script that checks before inserting
```python
# scripts/seed.py
async def seed_database():
async with async_session_maker() as session:
# Check if sample member exists
result = await session.execute(
select(Member).where(Member.email == "jane.doe@example.com")
)
if result.scalar_one_or_none():
print("Sample member already exists")
return
# Create sample member with minimal required data
# Demonstrates that only firstName is required
member = Member(
first_name="Jane",
last_name="Doe",
email="jane.doe@example.com",
# All other fields (street, zip, city, country, phone) are None
)
session.add(member)
await session.commit()
```
**Rationale:**
- Safe to run multiple times (idempotent)
- Separate script (not in application startup)
- Easy to extend with more sample data
**Alternatives considered:**
- Fixture file (JSON/YAML): More complex to maintain
- Migration with data: Mixing schema and data is anti-pattern
- Application startup seed: Slows every startup unnecessarily
### 8. Testing Strategy
**Decision:** Pytest with async support, focus on resolver tests
```python
# tests/test_member_resolvers.py
@pytest.mark.asyncio
async def test_create_member(db_session):
input_data = CreateMemberInput(...)
member = await create_member(input_data, db_session)
assert member.first_name == "John"
assert member.email == "john@example.com"
```
**Rationale:**
- Pytest is Python standard, excellent async support
- Integration tests via resolver layer (end-to-end within backend)
- Database fixtures for isolated tests
- No need for GraphQL client in tests (test resolvers directly)
**Test coverage targets:**
- Resolvers: 100% (critical business logic)
- Models: Covered by resolver tests
- Validation: 100% (security-relevant)
**Alternatives considered:**
- GraphQL client tests: Slower, more brittle
- Unit test every layer: Diminishing returns for CRUD
- No tests: Rejected, testing is essential
## Data Flow Example: Create Member
1. **Client:** POST to `/graphql` with mutation
```graphql
mutation {
createMember(input: {
firstName: "Jane"
lastName: "Smith"
# ... rest of fields
}) {
id
firstName
email
}
}
```
2. **FastAPI:** Routes to Strawberry schema
3. **Strawberry:** Validates input against CreateMemberInput type
4. **Resolver:** `create_member(input, db_session)`
- Validates email format with regex
- Validates phone format with regex
- Creates SQLAlchemy Member instance
- Adds to session
5. **SQLAlchemy:** Generates INSERT statement
```sql
INSERT INTO members (first_name, last_name, ...)
VALUES (?, ?, ...)
```
6. **Database:** Executes insert, returns generated ID
7. **Resolver:** Returns Member instance
8. **Strawberry:** Serializes Member to GraphQL response
9. **Client:** Receives JSON response
```json
{
"data": {
"createMember": {
"id": "1",
"firstName": "Jane",
"email": "jane@example.com"
}
}
}
```
## Configuration Management
**Decision:** Environment-based configuration with sensible defaults
```python
# src/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./clubber.db"
debug: bool = False
class Config:
env_file = ".env"
settings = Settings()
```
**Rationale:**
- 12-factor app principles
- Easy to override for testing (TEST_DATABASE_URL)
- Pydantic validation for config values
- Sensible defaults for development
## Security Considerations
**Current scope (v1):**
- Input validation (SQL injection via ORM protection)
- Email/phone format validation
- No sensitive data exposure (all fields are readable)
**Deferred to future changes:**
- Authentication/authorization
- Rate limiting
- CORS configuration (localhost only)
- GDPR data handling (right to erasure, data export)
## Performance Considerations
**Current scope:**
- Single-threaded SQLite (acceptable for <1000 members)
- No pagination (listMembers returns all)
- No caching layer
- No query optimization
**Future optimizations (when needed):**
- Add pagination (offset/limit or cursor-based)
- Migrate to PostgreSQL for concurrent writes
- Add Redis caching for read-heavy queries
- Optimize N+1 queries with DataLoader
## Migration Path (Future PostgreSQL)
**Design ensures future migration:**
- SQLAlchemy dialect-agnostic code
- No SQLite-specific SQL
- Async sessions work with asyncpg
- Alembic migrations portable
**Migration steps (future):**
1. Change DATABASE_URL to PostgreSQL
2. Run `alembic upgrade head` on PostgreSQL
3. Export SQLite data with `sqlite3 .dump`
4. Import to PostgreSQL with `psql`
5. Update deployment configuration
## Open Questions
None at this stage. All design decisions are sufficient for v1 implementation.
## References
- [FastAPI Dependency Injection](https://fastapi.tiangolo.com/tutorial/dependencies/)
- [Strawberry GraphQL Best Practices](https://strawberry.rocks/docs/guides/best-practices)
- [SQLAlchemy 2.0 Async](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html)
- [Alembic Tutorial](https://alembic.sqlalchemy.org/en/latest/tutorial.html)
- [E.164 Phone Number Format](https://en.wikipedia.org/wiki/E.164)
@@ -0,0 +1,208 @@
# Proposal: Add GraphQL Member API
**Change ID:** `add-graphql-member-api`
**Status:** Draft
**Created:** 2025-11-20
**Author:** System
## Overview
This proposal implements the foundational GraphQL API for member management in the Clubber application. It establishes the complete Python project infrastructure and delivers basic CRUD operations for member records through a GraphQL interface backed by SQLite.
## Motivation
**Why now?**
- The project currently has only documentation; we need working code to validate the architecture
- Member management is the core domain entity for a club/society management system
- A simple, working API provides a foundation for iterative development
**What problem does this solve?**
- Enables creating, reading, updating, and deleting member records
- Provides a type-safe GraphQL API for future client applications
- Establishes project structure, tooling, and development patterns
- Validates technology stack choices (FastAPI, Strawberry, SQLAlchemy)
**Why this approach?**
- GraphQL provides flexible queries and strong typing for client needs
- Strawberry integrates naturally with Python type hints and FastAPI
- SQLAlchemy offers database-agnostic code for future PostgreSQL migration
- Alembic ensures schema changes are versioned and reproducible
## Scope
### In Scope
**Project Infrastructure:**
- Python project setup with uv dependency management
- pyproject.toml with all dependencies (FastAPI, Strawberry, SQLAlchemy, Alembic)
- Source directory structure (models, schemas, resolvers, services)
- Development tooling (black, ruff, isort, pytest)
- Testing infrastructure with pytest-asyncio
**Database Layer:**
- SQLAlchemy async models for Member entity
- Alembic configuration and initial migration
- SQLite database for development
- Database connection and session management
**GraphQL API:**
- Strawberry GraphQL schema definition
- FastAPI application with GraphQL endpoint
- Member type with all fields (name, address, contact)
- Query resolvers (getMember, listMembers)
- Mutation resolvers (createMember, updateMember, deleteMember)
**Member Management:**
- Member model with:
- First name (required)
- Last name (optional)
- Address fields (street, apartment number, zip, city, country) - all optional
- Email address (optional, format validated when provided, non-unique)
- Phone number (optional, format validated when provided, non-unique)
- CRUD operations with conditional validation
- Only firstName required for member creation
- Database seed script with one sample member
### Out of Scope
- MCP server integration (future enhancement)
- Authentication and authorization (future security layer)
- Member status tracking (active/inactive/honorary)
- Membership and payment tracking (separate features)
- Email/phone uniqueness constraints (may add later if needed)
- PostgreSQL support (database-agnostic code only)
- API rate limiting or advanced security
- Frontend/client implementation
- Deployment configuration
### Dependencies
- No dependencies on other changes (this is foundational)
- Future changes will build on this API structure
## Changes
### New Capabilities
Four new capabilities with detailed specifications:
1. **project-setup** - Python project foundation
- Location: `openspec/changes/add-graphql-member-api/specs/project-setup/spec.md`
- Establishes pyproject.toml, directory structure, tooling
2. **database-layer** - SQLAlchemy models and migrations
- Location: `openspec/changes/add-graphql-member-api/specs/database-layer/spec.md`
- Defines Member model, Alembic setup, database connections
3. **graphql-api** - GraphQL schema and FastAPI integration
- Location: `openspec/changes/add-graphql-member-api/specs/graphql-api/spec.md`
- Strawberry schema, types, resolvers, FastAPI endpoint
4. **member-crud** - Member business operations
- Location: `openspec/changes/add-graphql-member-api/specs/member-crud/spec.md`
- Create, read, update, delete operations with validation
### Modified Capabilities
None (no existing code to modify)
### Removed Capabilities
None
## Impact Analysis
### Breaking Changes
None (this is the first implementation)
### Migration Path
Not applicable (greenfield development)
### Risks and Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| Technology stack mismatch | High | Align with project.md specifications; validate with simple implementation first |
| Database schema changes | Medium | Use Alembic from the start; follow migration best practices |
| Validation complexity | Low | Start with simple format validation; enhance later if needed |
| Over-engineering | Medium | Keep implementation minimal; no premature abstractions |
### Performance Considerations
- SQLite is single-writer; acceptable for <1000 members initially
- No pagination in v1 (listMembers returns all); add when needed
- Database connection pooling deferred until PostgreSQL migration
### Security Considerations
- No authentication in v1 (explicitly out of scope)
- Basic input validation (email/phone format)
- SQL injection protected by SQLAlchemy ORM
- GDPR compliance deferred to future auth/privacy features
## Success Criteria
### Definition of Done
- [ ] All code passes `ruff` and `black` checks
- [ ] All tests pass with `pytest`
- [ ] Database migrations apply successfully with `alembic upgrade head`
- [ ] GraphQL schema is introspectable via GraphiQL
- [ ] Sample member exists in database after seed script
- [ ] All CRUD operations work via GraphQL playground
- [ ] Documentation includes setup instructions
### Validation Steps
1. Clone repository and navigate to project root
2. Run `uv sync` to install dependencies
3. Run `alembic upgrade head` to create database
4. Run `python scripts/seed.py` to create sample member
5. Start server with `uv run uvicorn src.main:app --reload`
6. Open GraphiQL at http://localhost:8000/graphql
7. Execute queries and mutations to verify CRUD operations
8. Run `pytest` to verify all tests pass
### Metrics
- Code coverage: Minimum 80% for new code
- GraphQL schema: 100% of spec requirements implemented
- Database migrations: Zero manual SQL required
- Setup time: <5 minutes from clone to running server
## Timeline
This proposal includes tasks.md with ~15-20 discrete work items, estimated to be completable in sequence. No explicit timeline commitment per OpenSpec conventions.
## Alternatives Considered
### REST API instead of GraphQL
**Rejected because:**
- GraphQL provides better flexibility for future client needs
- Type safety and introspection are valuable for development
- Project.md explicitly specifies Strawberry GraphQL
### Skip Alembic, use create_all()
**Rejected because:**
- Migrations are essential for production schema evolution
- Small cost now, significant benefit later
- Aligns with project.md best practices
### PostgreSQL from the start
**Rejected because:**
- Adds complexity without immediate value
- SQLite sufficient for development and testing
- Database-agnostic code enables future migration
## References
- [project.md](/openspec/project.md) - Technology stack and conventions
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
- [Strawberry GraphQL](https://strawberry.rocks/)
- [SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/)
- [Alembic](https://alembic.sqlalchemy.org/)
@@ -0,0 +1,274 @@
# Spec: Database Layer
**Capability:** database-layer
**Status:** Draft
**Last Updated:** 2025-11-20
## Overview
This capability defines the data persistence layer using SQLAlchemy 2.0 ORM with async support, Alembic for schema migrations, and SQLite for development storage.
## ADDED Requirements
### Requirement: SQLAlchemy Member model with async support
The system MUST define a Member SQLAlchemy model with async session support for persisting member data with name, address, and contact information.
**Model fields:**
- `id`: Integer primary key (auto-increment)
- `first_name`: String (max 100 chars, required)
- `last_name`: String (max 100 chars, optional)
- `street`: String (max 200 chars, optional)
- `apartment_number`: String (max 20 chars, optional)
- `zip`: String (max 20 chars, optional)
- `city`: String (max 100 chars, optional)
- `country`: String (max 100 chars, optional)
- `email`: String (max 255 chars, optional)
- `phone`: String (max 50 chars, optional)
- `created_at`: DateTime (auto-set on creation)
- `updated_at`: DateTime (auto-update on modification)
#### Scenario: Member model is defined with proper constraints
**Given** src/models/member.py exists
**When** the Member class is inspected
**Then** it inherits from SQLAlchemy Base
**And** tablename is "members"
**And** first_name has nullable=False
**And** all other data fields (last_name, email, phone, address fields) have nullable=True
**And** string fields have length constraints via String(N)
#### Scenario: Timestamps are automatically managed
**Given** a new Member instance is created
**When** the instance is added to session and committed
**Then** created_at is set to current UTC timestamp
**And** updated_at is set to current UTC timestamp
**When** the instance is later modified and committed
**Then** updated_at is updated to new UTC timestamp
**And** created_at remains unchanged
#### Scenario: Model supports async operations
**Given** Member model is defined
**When** async database session is used
**Then** CRUD operations execute without blocking event loop
**And** SQLAlchemy async patterns are followed (select, add, commit)
### Requirement: Database connection and session management
The system MUST provide async database connection factory and session management with proper lifecycle handling.
**Connection configuration:**
- Async engine using aiosqlite for SQLite
- Connection pooling disabled for SQLite (single-writer)
- Echo mode configurable via DEBUG setting
#### Scenario: Async engine is created on application startup
**Given** src/database.py defines engine initialization
**When** FastAPI app starts
**Then** async engine is created with database_url from config
**And** engine is configured for SQLite with aiosqlite driver
**And** SQL echo is enabled if DEBUG=True
#### Scenario: Session factory provides isolated sessions
**Given** async_session_maker is defined
**When** resolver requests database session
**Then** new AsyncSession is created from factory
**And** session is isolated from other concurrent requests
**And** session is properly closed after request completes
#### Scenario: Session lifecycle is managed via dependency injection
**Given** get_db_session() dependency is defined
**When** FastAPI resolver depends on db_session
**Then** session is yielded for resolver use
**And** session is committed if no exceptions occur
**And** session is rolled back if exceptions occur
**And** session is closed in finally block
### Requirement: Alembic database migration setup
The system MUST use Alembic for version-controlled schema migrations with async support for SQLite database.
**Alembic configuration:**
- Migrations stored in `migrations/versions/`
- Environment configured for async operations
- Migration template includes docstring and revision metadata
#### Scenario: Alembic is initialized with project structure
**Given** alembic init migrations was run
**When** migrations/ directory is inspected
**Then** migrations/env.py exists with async configuration
**And** migrations/versions/ directory exists for migration files
**And** alembic.ini contains database connection template
#### Scenario: Initial migration creates members table
**Given** Alembic is configured
**When** developer runs `alembic revision --autogenerate -m "create members table"`
**Then** new migration file is generated in migrations/versions/
**And** upgrade() function contains CREATE TABLE for members
**And** downgrade() function contains DROP TABLE for members
**And** all Member model columns are included
#### Scenario: Migrations are applied to database
**Given** migration files exist in migrations/versions/
**When** developer runs `alembic upgrade head`
**Then** all pending migrations are executed in order
**And** members table is created in database
**And** alembic_version table tracks current revision
**And** command exits with success code
#### Scenario: Migrations are reversible
**Given** database is at current migration head
**When** developer runs `alembic downgrade -1`
**Then** most recent migration is reversed
**And** members table is dropped (for initial migration)
**And** alembic_version is updated to previous revision
### Requirement: Database initialization on application startup
The system MUST verify database connectivity and schema readiness when FastAPI application starts.
#### Scenario: Application startup checks database connection
**Given** FastAPI app has startup event handler
**When** application starts
**Then** database engine connection is tested
**And** exception is raised if database is unreachable
**And** startup log message confirms database ready
#### Scenario: Database file is created if missing
**Given** SQLite database file does not exist
**When** application starts
**Then** database file is created automatically
**And** schema tables exist after migrations run
**And** application continues startup normally
### Requirement: Test database fixtures with isolation
The system MUST provide pytest fixtures for database testing with transaction rollback to ensure test isolation.
#### Scenario: Test database session fixture is available
**Given** tests/conftest.py defines db_session fixture
**When** test function requests db_session parameter
**Then** isolated AsyncSession is provided
**And** session uses in-memory SQLite database (`:memory:`)
**And** schema is created before test runs
**And** all changes are rolled back after test completes
#### Scenario: Test database is independent from development database
**Given** tests use db_session fixture
**When** tests create or modify data
**Then** changes are not visible in development database
**And** test data does not persist between test runs
**And** tests can run in parallel without interference
### Requirement: Database query helpers for common operations
The system MUST provide reusable query patterns for fetching, creating, updating, and deleting members.
#### Scenario: Get member by ID query
**Given** member exists in database with id=1
**When** query executes `select(Member).where(Member.id == 1)`
**Then** Member instance is returned
**When** query executes with non-existent id
**Then** None is returned (not exception)
#### Scenario: List all members query
**Given** multiple members exist in database
**When** query executes `select(Member).order_by(Member.last_name.nulls_last(), Member.first_name)`
**Then** all members are returned as list
**And** members are sorted by last name (nulls last), then first name
**And** empty list is returned if no members exist
#### Scenario: Update member fields
**Given** member exists with id=1
**When** member attributes are modified and session commits
**Then** database record is updated
**And** updated_at timestamp is refreshed
**And** created_at timestamp is unchanged
#### Scenario: Delete member from database
**Given** member exists with id=1
**When** session executes delete(member) and commits
**Then** member is removed from database
**And** subsequent queries for that id return None
## MODIFIED Requirements
None (new capability)
## REMOVED Requirements
None (new capability)
## Cross-References
### Depends On
- **project-setup** - Requires SQLAlchemy, Alembic, and project structure
### Enables
- **graphql-api** - Provides data models for GraphQL schema
- **member-crud** - Provides persistence layer for operations
### Related To
None
## Implementation Notes
1. **Async patterns:** Use `await session.execute(select(...))` not `session.query(...)`
2. **SQLAlchemy 2.0:** Follow modern declarative mapping with Mapped[T] type hints
3. **Migration safety:** Always review autogenerated migrations before applying
4. **Connection pooling:** Disable for SQLite (use NullPool), enable for PostgreSQL
5. **Timestamp handling:** Use `onupdate=func.now()` for updated_at field
6. **Test isolation:** Use `@pytest.fixture(scope="function")` for db_session
## Database Schema (SQL Reference)
```sql
CREATE TABLE members (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100),
street VARCHAR(200),
apartment_number VARCHAR(20),
zip VARCHAR(20),
city VARCHAR(100),
country VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE alembic_version (
version_num VARCHAR(32) NOT NULL PRIMARY KEY
);
```
## Validation Checklist
- [ ] Member model defined in src/models/member.py
- [ ] Database connection setup in src/database.py
- [ ] Alembic initialized with migrations/ directory
- [ ] Initial migration creates members table
- [ ] `alembic upgrade head` executes successfully
- [ ] SQLite database file created with correct schema
- [ ] Async session dependency injection works in FastAPI
- [ ] Test fixtures provide isolated database sessions
- [ ] All timestamp fields auto-populate correctly
- [ ] CRUD operations execute without blocking
@@ -0,0 +1,408 @@
# Spec: GraphQL API
**Capability:** graphql-api
**Status:** Draft
**Last Updated:** 2025-11-20
## Overview
This capability defines the GraphQL API layer using Strawberry GraphQL integrated with FastAPI, providing type-safe schema, queries, and mutations for member management.
## ADDED Requirements
### Requirement: Strawberry GraphQL schema with Member type
The system MUST define a GraphQL schema using Strawberry with Member object type mapping to database model fields.
**Member GraphQL type fields:**
- `id`: ID! (non-null unique identifier)
- `firstName`: String! (non-null)
- `lastName`: String (nullable)
- `street`: String (nullable)
- `apartmentNumber`: String (nullable)
- `zip`: String (nullable)
- `city`: String (nullable)
- `country`: String (nullable)
- `email`: String (nullable)
- `phone`: String (nullable)
- `createdAt`: DateTime! (non-null timestamp)
- `updatedAt`: DateTime! (non-null timestamp)
#### Scenario: Member type is defined with Strawberry decorator
**Given** src/schemas/member.py exists
**When** Member class is decorated with @strawberry.type
**Then** GraphQL type "Member" is registered in schema
**And** all fields use camelCase naming (GraphQL convention)
**And** field types match SQLAlchemy model types
**And** snake_case database fields map to camelCase GraphQL fields
#### Scenario: GraphQL schema is introspectable
**Given** FastAPI app with GraphQL endpoint is running
**When** client accesses /graphql endpoint
**Then** GraphiQL playground is displayed
**And** schema introspection reveals Member type
**And** all fields and their types are documented
### Requirement: Input types for mutations
The system MUST define separate input types for create and update operations with appropriate field requirements.
**CreateMemberInput fields (only firstName required):**
- `firstName`: String!
- `lastName`: String
- `street`: String
- `apartmentNumber`: String
- `zip`: String
- `city`: String
- `country`: String
- `email`: String
- `phone`: String
**UpdateMemberInput fields (id required, all others optional):**
- `id`: ID!
- `firstName`: String
- `lastName`: String
- `street`: String
- `apartmentNumber`: String
- `zip`: String
- `city`: String
- `country`: String
- `email`: String
- `phone`: String
#### Scenario: CreateMemberInput enforces required fields
**Given** CreateMemberInput type is defined
**When** client sends mutation without firstName
**Then** GraphQL validation error is returned
**And** error message indicates firstName is required
**When** client sends mutation with only firstName
**Then** input validation passes
**And** member is created with only firstName populated
#### Scenario: UpdateMemberInput allows partial updates
**Given** UpdateMemberInput type is defined
**When** client sends mutation with only id and email
**Then** only email field is updated in database
**And** all other fields remain unchanged
**When** client sends mutation without id
**Then** GraphQL validation error is returned
### Requirement: Query resolvers for reading members
The system MUST provide GraphQL query resolvers for fetching individual members and listing all members.
**Query operations:**
- `member(id: ID!): Member` - Get single member by ID
- `members: [Member!]!` - List all members
#### Scenario: Query single member by ID
**Given** member exists with id=1
**When** client executes query:
```graphql
query {
member(id: 1) {
id
firstName
lastName
email
}
}
```
**Then** response contains member data
**And** response matches GraphQL Member type structure
#### Scenario: Query member with non-existent ID
**Given** no member exists with id=999
**When** client executes query `member(id: 999)`
**Then** response returns null for member field
**And** no error is raised (null is valid for nullable return)
#### Scenario: List all members
**Given** multiple members exist in database
**When** client executes query:
```graphql
query {
members {
id
firstName
lastName
}
}
```
**Then** response contains array of all members
**And** members are sorted by last name, first name
**When** no members exist
**Then** response contains empty array
### Requirement: Mutation resolvers for modifying members
The system MUST provide GraphQL mutation resolvers for creating, updating, and deleting members.
**Mutation operations:**
- `createMember(input: CreateMemberInput!): Member!` - Create new member
- `updateMember(input: UpdateMemberInput!): Member!` - Update existing member
- `deleteMember(id: ID!): Boolean!` - Delete member
#### Scenario: Create new member mutation
**Given** valid CreateMemberInput is provided
**When** client executes mutation:
```graphql
mutation {
createMember(input: {
firstName: "Jane"
lastName: "Smith"
street: "123 Main St"
zip: "12345"
city: "Springfield"
country: "USA"
email: "jane@example.com"
phone: "+15551234567"
}) {
id
firstName
email
}
}
```
**Then** new member is persisted to database
**And** response contains newly created member with generated id
**And** createdAt and updatedAt are populated
#### Scenario: Create member with minimal data (firstName only)
**Given** CreateMemberInput with only firstName is provided
**When** client executes mutation:
```graphql
mutation {
createMember(input: {
firstName: "John"
}) {
id
firstName
lastName
email
}
}
```
**Then** new member is created in database
**And** firstName is "John"
**And** lastName, email, phone, and address fields are null
**And** response contains member with null optional fields
#### Scenario: Create member with invalid input
**Given** CreateMemberInput has invalid email format
**When** client executes createMember mutation
**Then** GraphQL error is returned
**And** error message indicates validation failure
**And** no database record is created
#### Scenario: Update existing member mutation
**Given** member exists with id=1
**When** client executes mutation:
```graphql
mutation {
updateMember(input: {
id: 1
email: "newemail@example.com"
phone: "+15559876543"
}) {
id
email
phone
updatedAt
}
}
```
**Then** member email and phone are updated in database
**And** updatedAt timestamp is refreshed
**And** all other fields remain unchanged
#### Scenario: Update non-existent member
**Given** no member exists with id=999
**When** client executes updateMember with id=999
**Then** GraphQL error is returned
**And** error message indicates "Member not found"
#### Scenario: Delete member mutation
**Given** member exists with id=1
**When** client executes mutation:
```graphql
mutation {
deleteMember(id: 1)
}
```
**Then** member is removed from database
**And** response returns true
**When** query attempts to fetch deleted member
**Then** response returns null
#### Scenario: Delete non-existent member
**Given** no member exists with id=999
**When** client executes deleteMember(id: 999)
**Then** GraphQL error is returned
**And** error message indicates "Member not found"
### Requirement: FastAPI integration with GraphQL endpoint
The system MUST integrate Strawberry GraphQL schema with FastAPI application at /graphql path with GraphiQL playground enabled.
#### Scenario: GraphQL endpoint is mounted on FastAPI app
**Given** src/main.py creates FastAPI app
**When** Strawberry schema is created from Query and Mutation classes
**Then** GraphQLRouter is created with schema
**And** router is mounted at /graphql path
**And** GraphiQL is enabled for development
#### Scenario: GraphQL playground is accessible
**Given** FastAPI app is running
**When** browser navigates to http://localhost:8000/graphql
**Then** GraphiQL interface is displayed
**And** schema documentation is available
**And** queries can be executed interactively
#### Scenario: GraphQL endpoint accepts POST requests
**Given** GraphQL endpoint is configured
**When** client sends POST to /graphql with query in body
**Then** query is executed against schema
**And** JSON response is returned with data or errors
### Requirement: Error handling with meaningful messages
The system MUST provide clear, actionable error messages for validation failures, not found errors, and server errors.
#### Scenario: Input validation error provides field-level details
**Given** createMember mutation receives invalid email
**When** mutation executes
**Then** GraphQL error includes message "Invalid email format"
**And** error path indicates which input field failed
**And** HTTP status code is 400 (Bad Request)
#### Scenario: Not found error provides resource context
**Given** member query requests non-existent id=999
**When** query executes
**Then** error message is "Member with ID 999 not found"
**And** error type indicates resource not found
#### Scenario: Database error is handled gracefully
**Given** database connection fails during query
**When** query executes
**Then** GraphQL error indicates server error
**And** internal error details are logged
**And** client receives generic "Internal server error" message
**And** HTTP status code is 500
## MODIFIED Requirements
None (new capability)
## REMOVED Requirements
None (new capability)
## Cross-References
### Depends On
- **project-setup** - Requires FastAPI and Strawberry dependencies
- **database-layer** - Requires Member model and database sessions
### Enables
- **member-crud** - Provides API interface for business operations
### Related To
None
## Implementation Notes
1. **Naming convention:** Use camelCase for GraphQL fields (firstName), snake_case for Python (first_name)
2. **Type conversion:** Strawberry automatically converts between Python and GraphQL types
3. **Async resolvers:** All resolvers must be async functions for database operations
4. **Dependency injection:** Use FastAPI's Depends() for database session in resolvers
5. **Error handling:** Raise Python exceptions, Strawberry converts to GraphQL errors
6. **GraphiQL:** Disable in production by setting `graphiql=False`
## GraphQL Schema (SDL Reference)
```graphql
type Member {
id: ID!
firstName: String!
lastName: String
street: String
apartmentNumber: String
zip: String
city: String
country: String
email: String
phone: String
createdAt: DateTime!
updatedAt: DateTime!
}
input CreateMemberInput {
firstName: String!
lastName: String
street: String
apartmentNumber: String
zip: String
city: String
country: String
email: String
phone: String
}
input UpdateMemberInput {
id: ID!
firstName: String
lastName: String
street: String
apartmentNumber: String
zip: String
city: String
country: String
email: String
phone: String
}
type Query {
member(id: ID!): Member
members: [Member!]!
}
type Mutation {
createMember(input: CreateMemberInput!): Member!
updateMember(input: UpdateMemberInput!): Member!
deleteMember(id: ID!): Boolean!
}
```
## Validation Checklist
- [ ] Member Strawberry type defined in src/schemas/member.py
- [ ] CreateMemberInput and UpdateMemberInput defined
- [ ] Query class with member and members resolvers
- [ ] Mutation class with create, update, delete resolvers
- [ ] GraphQL schema created in src/main.py
- [ ] /graphql endpoint mounted on FastAPI app
- [ ] GraphiQL accessible at http://localhost:8000/graphql
- [ ] All queries and mutations execute successfully
- [ ] Error messages are clear and actionable
- [ ] Schema introspection shows all types correctly
@@ -0,0 +1,363 @@
# Spec: Member CRUD Operations
**Capability:** member-crud
**Status:** Draft
**Last Updated:** 2025-11-20
## Overview
This capability defines the business logic for creating, reading, updating, and deleting member records with validation, error handling, and sample data seeding.
## ADDED Requirements
### Requirement: Create member with validation
The system MUST allow creating new members with only firstName required, optionally validating email and phone format when provided.
**Validation rules:**
- firstName: Required field, must be non-empty string
- Email (when provided): Must match regex pattern `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
- Phone (when provided): Must match E.164 format `^\+?[1-9]\d{1,14}$`
- All other fields (lastName, address fields, email, phone) are optional
- Email and phone do NOT require uniqueness checking
- Format validation only applies when field is provided (not null/empty)
#### Scenario: Create member with valid data
**Given** client provides valid member data:
```python
{
"first_name": "John",
"last_name": "Doe",
"street": "123 Main Street",
"apartment_number": "Apt 4B",
"zip": "12345",
"city": "Springfield",
"country": "USA",
"email": "john.doe@example.com",
"phone": "+15551234567"
}
```
**When** createMember mutation executes
**Then** new Member record is created in database
**And** member.id is auto-generated
**And** member.created_at is set to current timestamp
**And** member.updated_at is set to current timestamp
**And** member object is returned to client
#### Scenario: Create member with invalid email format
**Given** client provides email "invalid-email"
**When** createMember mutation executes
**Then** validation error is raised
**And** error message is "Invalid email format: invalid-email"
**And** no database record is created
#### Scenario: Create member with invalid phone format
**Given** client provides phone "123-456"
**When** createMember mutation executes
**Then** validation error is raised
**And** error message is "Invalid phone format: 123-456"
**And** no database record is created
#### Scenario: Create member with duplicate email (allowed)
**Given** member exists with email "john@example.com"
**When** client creates another member with email "john@example.com"
**Then** new member is created successfully
**And** both members coexist in database
**And** no uniqueness constraint error occurs
#### Scenario: Create member without apartment number
**Given** client omits apartment_number field
**When** createMember mutation executes
**Then** member is created with apartment_number=None
**And** all other fields are populated correctly
#### Scenario: Create member with only firstName (minimal data)
**Given** client provides only first_name="Jane"
**When** createMember mutation executes
**Then** new Member record is created in database
**And** member.first_name is "Jane"
**And** member.last_name is None
**And** member.email is None
**And** member.phone is None
**And** all address fields (street, zip, city, country, apartment_number) are None
**And** created_at and updated_at are populated
#### Scenario: Create member with optional email (valid format)
**Given** client provides first_name="John" and email="john@example.com"
**When** createMember mutation executes
**Then** email format is validated
**And** member is created with validated email
**And** other optional fields are None
#### Scenario: Create member with optional email (invalid format)
**Given** client provides first_name="John" and email="invalid"
**When** createMember mutation executes
**Then** validation error is raised
**And** error message is "Invalid email format: invalid"
**And** no database record is created
### Requirement: Read member by ID
The system MUST allow fetching a single member by unique identifier with all fields populated.
#### Scenario: Get existing member by ID
**Given** member exists with id=1
**When** member(id=1) query executes
**Then** Member object is returned
**And** all fields match database record
**And** timestamps are in ISO 8601 format
#### Scenario: Get member with non-existent ID
**Given** no member exists with id=999
**When** member(id=999) query executes
**Then** None is returned
**And** no exception is raised
### Requirement: List all members with sorting
The system MUST allow fetching all members sorted alphabetically by last name (with nulls last), then first name.
#### Scenario: List members in sorted order
**Given** members exist:
- id=1: firstName="Alice", lastName="Smith"
- id=2: firstName="Bob", lastName="Jones"
- id=3: firstName="Charlie", lastName="Smith"
- id=4: firstName="Diana", lastName=None
**When** members query executes
**Then** members are returned in order: Jones, Smith (Alice), Smith (Charlie), Diana (null lastName)
**And** sorting is case-insensitive
**And** members with null lastName appear at the end
#### Scenario: List members when database is empty
**Given** no members exist in database
**When** members query executes
**Then** empty list is returned
**And** no error is raised
#### Scenario: List members returns all fields
**Given** members exist in database
**When** members query requests all fields
**Then** each member includes id, name, address, contact, timestamps
**And** no fields are null except apartmentNumber (if not provided)
### Requirement: Update member with partial field changes
The system MUST allow updating specific member fields while preserving unchanged fields and refreshing the updated_at timestamp.
#### Scenario: Update member email
**Given** member exists with id=1, email="old@example.com"
**When** updateMember mutation executes with:
```python
{"id": 1, "email": "new@example.com"}
```
**Then** member.email is updated to "new@example.com"
**And** member.updated_at is refreshed to current timestamp
**And** member.created_at remains unchanged
**And** all other fields remain unchanged
#### Scenario: Update multiple fields simultaneously
**Given** member exists with id=1
**When** updateMember mutation provides phone, street, and city
**Then** all three fields are updated
**And** other fields remain unchanged
**And** updated_at is refreshed
#### Scenario: Update member with invalid email
**Given** member exists with id=1
**When** updateMember mutation provides email="invalid"
**Then** validation error is raised
**And** database record is not modified
**And** error message indicates invalid email format
#### Scenario: Update non-existent member
**Given** no member exists with id=999
**When** updateMember(id=999) mutation executes
**Then** error is raised
**And** error message is "Member with ID 999 not found"
#### Scenario: Update with no field changes
**Given** member exists with id=1
**When** updateMember mutation provides only id (no other fields)
**Then** no fields are modified
**And** updated_at is NOT refreshed (no actual changes)
### Requirement: Delete member from system
The system MUST allow permanent deletion of member records by ID.
#### Scenario: Delete existing member
**Given** member exists with id=1
**When** deleteMember(id=1) mutation executes
**Then** member is removed from database
**And** mutation returns true
**When** subsequent query for id=1 executes
**Then** None is returned
#### Scenario: Delete non-existent member
**Given** no member exists with id=999
**When** deleteMember(id=999) mutation executes
**Then** error is raised
**And** error message is "Member with ID 999 not found"
**And** mutation returns false or raises exception
### Requirement: Seed database with sample member
The system MUST provide an idempotent script to populate database with one sample member for development and testing.
**Sample member data:**
- first_name: "Jane"
- last_name: "Doe"
- street: None (demonstrates optional address fields)
- apartment_number: None
- zip: None
- city: None
- country: None
- email: "jane.doe@example.com"
- phone: None (demonstrates optional phone)
#### Scenario: Seed script creates sample member
**Given** database schema exists (migrations applied)
**And** no member exists with email "jane.doe@example.com"
**When** seed script executes
**Then** sample member is created in database
**And** script prints "Sample member created successfully"
**And** script exits with code 0
#### Scenario: Seed script is idempotent
**Given** sample member already exists with email "jane.doe@example.com"
**When** seed script executes again
**Then** no new member is created
**And** existing member is not modified
**And** script prints "Sample member already exists"
**And** script exits with code 0
#### Scenario: Seed script verifies sample member
**Given** seed script has completed
**When** query executes for email "jane.doe@example.com"
**Then** member exists with all sample data fields populated
**And** created_at and updated_at timestamps are valid
### Requirement: Input validation with clear error messages
The system MUST validate all inputs and provide actionable error messages identifying the validation failure.
#### Scenario: Empty required field error
**Given** createMember mutation has firstName=""
**When** mutation executes
**Then** validation error is raised
**And** error message is "firstName cannot be empty"
#### Scenario: Field length exceeds maximum
**Given** createMember mutation has firstName with 150 characters
**When** mutation executes
**Then** validation error is raised
**And** error message indicates maximum length of 100 characters
#### Scenario: Multiple validation errors
**Given** createMember mutation has invalid email AND invalid phone
**When** mutation executes
**Then** validation errors for both fields are returned
**And** error messages clearly identify each invalid field
## MODIFIED Requirements
None (new capability)
## REMOVED Requirements
None (new capability)
## Cross-References
### Depends On
- **project-setup** - Requires Python environment and testing infrastructure
- **database-layer** - Requires Member model and database sessions
- **graphql-api** - Requires GraphQL resolvers for API interface
### Enables
None (this is a terminal capability - enables end-user features)
### Related To
None
## Implementation Notes
1. **Validation location:** Implement in resolvers before database operations
2. **Regex compilation:** Compile email/phone patterns once at module level
3. **Error types:** Use custom exception classes (MemberNotFoundError, ValidationError)
4. **Transaction handling:** Database session auto-commits on success, rolls back on exception
5. **Testing:** Use pytest fixtures for database setup, test each scenario independently
6. **Seed script:** Use asyncio.run() to execute async database operations
## Validation Patterns (Python)
```python
import re
from typing import Optional
EMAIL_PATTERN = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
PHONE_PATTERN = re.compile(r'^\+?[1-9]\d{1,14}$')
def validate_email(email: Optional[str]) -> None:
"""Validate email format only if email is provided (not None or empty)."""
if email is not None and email != "":
if not EMAIL_PATTERN.match(email):
raise ValidationError(f"Invalid email format: {email}")
def validate_phone(phone: Optional[str]) -> None:
"""Validate phone format only if phone is provided (not None or empty)."""
if phone is not None and phone != "":
if not PHONE_PATTERN.match(phone):
raise ValidationError(f"Invalid phone format: {phone}")
def validate_first_name(first_name: str) -> None:
"""Ensure firstName is provided and non-empty."""
if not first_name or first_name.strip() == "":
raise ValidationError("firstName cannot be empty")
```
## Validation Checklist
- [ ] Create member with valid data succeeds
- [ ] Create member with only firstName succeeds (all other fields null)
- [ ] Invalid email format raises ValidationError (when provided)
- [ ] Invalid phone format raises ValidationError (when provided)
- [ ] Email/phone validation skipped when fields are null/empty
- [ ] Duplicate emails are allowed (no uniqueness constraint)
- [ ] Get member by ID returns correct data
- [ ] Get non-existent member returns None
- [ ] List members returns sorted results (nulls last for lastName)
- [ ] Update member modifies only specified fields
- [ ] Update refreshes updated_at timestamp
- [ ] Update non-existent member raises error
- [ ] Delete member removes record from database
- [ ] Delete non-existent member raises error
- [ ] Seed script creates sample member with some null fields
- [ ] Seed script is idempotent (safe to run multiple times)
- [ ] All error messages are clear and actionable
@@ -0,0 +1,235 @@
# Spec: Project Setup
**Capability:** project-setup
**Status:** Draft
**Last Updated:** 2025-11-20
## Overview
This capability establishes the foundational Python project structure, dependency management, tooling configuration, and development environment for the Clubber application.
## ADDED Requirements
### Requirement: Python project structure with uv dependency management
The project MUST use Python 3.11+ with uv for fast, reliable dependency management and provide a standard src/ layout for code organization.
#### Scenario: Developer initializes new development environment
**Given** a developer has cloned the repository
**When** they run `uv sync`
**Then** all dependencies are installed in a virtual environment
**And** the environment is ready for development within 30 seconds
#### Scenario: Developer runs the application
**Given** dependencies are installed
**When** developer runs `uv run uvicorn src.main:app --reload`
**Then** the FastAPI server starts on http://localhost:8000
**And** GraphQL playground is available at http://localhost:8000/graphql
### Requirement: Project configuration in pyproject.toml
The project MUST define all metadata, dependencies, and tool configurations in pyproject.toml following modern Python packaging standards.
**Dependencies required:**
- fastapi >= 0.104.0 (Web framework)
- strawberry-graphql[fastapi] >= 0.215.0 (GraphQL integration)
- sqlalchemy[asyncio] >= 2.0.0 (ORM with async support)
- aiosqlite >= 0.19.0 (Async SQLite driver)
- alembic >= 1.12.0 (Database migrations)
- pydantic-settings >= 2.0.0 (Configuration management)
- uvicorn[standard] >= 0.24.0 (ASGI server)
**Development dependencies required:**
- pytest >= 7.4.0 (Test framework)
- pytest-asyncio >= 0.21.0 (Async test support)
- black >= 23.0.0 (Code formatting)
- ruff >= 0.1.0 (Linting)
- isort >= 5.12.0 (Import sorting)
#### Scenario: Dependencies are declared with version constraints
**Given** pyproject.toml exists
**When** developer inspects [project.dependencies]
**Then** all required packages are listed with minimum versions
**And** version constraints allow patch/minor updates
#### Scenario: Development tools are configured
**Given** pyproject.toml contains tool configurations
**When** developer runs `black .`
**Then** code is formatted with line length 88
**When** developer runs `ruff check .`
**Then** code is linted against configured rules
### Requirement: Source directory structure following layered architecture
The project MUST organize code into src/ directory with clear separation of concerns across API, business logic, and data layers.
**Required directory structure:**
```
src/
├── __init__.py
├── main.py # FastAPI application, startup/shutdown
├── config.py # Settings and configuration
├── database.py # Database connection, session factory
├── models/ # SQLAlchemy ORM models
│ ├── __init__.py
│ └── member.py
├── schemas/ # Strawberry GraphQL types
│ ├── __init__.py
│ └── member.py
└── resolvers/ # GraphQL query/mutation resolvers
├── __init__.py
└── member.py
```
#### Scenario: Code is organized by architectural layer
**Given** the src/ directory exists
**When** a developer navigates the codebase
**Then** models/ contains only SQLAlchemy ORM definitions
**And** schemas/ contains only Strawberry GraphQL type definitions
**And** resolvers/ contains only GraphQL resolver functions
**And** each module has clear, single responsibility
#### Scenario: Main application entry point is defined
**Given** src/main.py exists
**When** the file is imported
**Then** it exports a FastAPI `app` instance
**And** app includes GraphQL route at /graphql
**And** app includes startup event to verify database connection
### Requirement: Testing infrastructure with pytest
The project MUST provide pytest configuration for running async tests with database fixtures and code coverage reporting.
#### Scenario: Async tests can be executed
**Given** pytest and pytest-asyncio are installed
**When** developer runs `pytest`
**Then** all tests in tests/ directory are discovered
**And** async test functions execute correctly
**And** test results are displayed with pass/fail status
#### Scenario: Database fixtures are available for tests
**Given** tests/conftest.py defines database fixtures
**When** a test function requests `db_session` fixture
**Then** an isolated test database session is provided
**And** session is rolled back after test completion
**And** no test data persists between test runs
### Requirement: Code quality tooling configuration
The project MUST configure black, ruff, and isort for consistent code formatting and linting with settings in pyproject.toml.
**Black configuration:**
- Line length: 88 characters
- Target version: Python 3.11
- Skip string normalization: false
**Ruff configuration:**
- Line length: 88 characters
- Select: E, F, W, I (pycodestyle, pyflakes, warnings, isort)
- Ignore: E501 (line too long, handled by black)
**Isort configuration:**
- Profile: black (compatible settings)
- Multi-line output: 3 (vertical hanging indent)
#### Scenario: Code formatting is enforced
**Given** black is configured in pyproject.toml
**When** developer runs `black --check .`
**Then** all Python files are checked for formatting
**And** exit code is 0 if all files are formatted correctly
**And** exit code is 1 if any files need formatting
#### Scenario: Code quality checks pass
**Given** ruff is configured in pyproject.toml
**When** developer runs `ruff check .`
**Then** all Python files are linted
**And** no errors are reported for compliant code
**And** clear error messages are shown for violations
### Requirement: Environment configuration with .env support
The project MUST support environment-based configuration using .env files with pydantic-settings for type-safe config values.
#### Scenario: Default configuration works for development
**Given** no .env file exists
**When** application starts
**Then** it uses default SQLite database path
**And** it runs in debug mode
**And** application starts successfully
#### Scenario: Environment variables override defaults
**Given** .env file contains `DATABASE_URL=sqlite+aiosqlite:///./test.db`
**When** application loads configuration
**Then** settings.database_url equals "sqlite+aiosqlite:///./test.db"
**And** custom database path is used
#### Scenario: Configuration is type-safe
**Given** src/config.py defines Settings class
**When** invalid configuration value is provided
**Then** pydantic validation raises clear error
**And** application fails fast on startup
### Requirement: Development scripts for common tasks
The project MUST provide executable scripts for database seeding and common development tasks.
**Required scripts:**
- scripts/seed.py - Populate database with sample member
#### Scenario: Seed script creates sample data
**Given** database schema exists from migrations
**When** developer runs `uv run python scripts/seed.py`
**Then** sample member is created in database
**And** script is idempotent (safe to run multiple times)
**And** success message is displayed
## MODIFIED Requirements
None (new capability)
## REMOVED Requirements
None (new capability)
## Cross-References
### Depends On
None (foundational capability)
### Enables
- **database-layer** - Requires project structure and dependencies
- **graphql-api** - Requires FastAPI app and Strawberry installation
- **member-crud** - Requires complete project infrastructure
## Implementation Notes
1. **Dependency resolution:** Use `uv add <package>` to ensure lock file is updated
2. **Python version:** Minimum 3.11 for modern async features and performance
3. **Import ordering:** Isort configured to work with black (no conflicts)
4. **Testing:** Pytest configuration in pyproject.toml, not separate pytest.ini
5. **Scripts:** Use `uv run` prefix to execute in correct virtual environment
## Validation Checklist
- [ ] `uv sync` completes without errors
- [ ] `uv run pytest` discovers and runs tests (even if no tests exist yet)
- [ ] `uv run black --check .` passes
- [ ] `uv run ruff check .` passes
- [ ] `uv run uvicorn src.main:app --reload` starts server
- [ ] pyproject.toml includes all required dependencies
- [ ] src/ directory structure matches specification
- [ ] .env.example file documents available settings
@@ -0,0 +1,461 @@
# Implementation Tasks: Add GraphQL Member API
**Change ID:** `add-graphql-member-api`
**Status:** Draft
**Created:** 2025-11-20
## Task Breakdown
This change is implemented through ~18 discrete, verifiable tasks. Each task delivers user-visible progress and includes validation steps.
---
## Phase 1: Project Foundation
### Task 1: Initialize Python project with uv
**Objective:** Create pyproject.toml and configure uv for dependency management
**Steps:**
1. Run `uv init` to create basic project structure
2. Edit pyproject.toml to set name="clubber", version="0.1.0"
3. Set Python requirement to ">=3.11"
4. Add project metadata (description, authors, license)
**Validation:**
- [ ] `uv sync` completes successfully
- [ ] pyproject.toml contains project metadata
**Dependencies:** None
---
### Task 2: Add core dependencies
**Objective:** Install FastAPI, Strawberry, SQLAlchemy, and Alembic
**Steps:**
1. Run `uv add fastapi uvicorn[standard]`
2. Run `uv add strawberry-graphql[fastapi]`
3. Run `uv add sqlalchemy[asyncio] aiosqlite`
4. Run `uv add alembic pydantic-settings`
**Validation:**
- [ ] All packages appear in pyproject.toml dependencies
- [ ] `uv sync` resolves dependencies without conflicts
- [ ] uv.lock file is generated
**Dependencies:** Task 1
---
### Task 3: Add development dependencies
**Objective:** Install testing and code quality tools
**Steps:**
1. Run `uv add --dev pytest pytest-asyncio`
2. Run `uv add --dev black ruff isort`
**Validation:**
- [ ] Development packages in [tool.uv.dev-dependencies] or similar
- [ ] `uv run pytest --version` works
- [ ] `uv run black --version` works
**Dependencies:** Task 1
---
### Task 4: Configure development tools in pyproject.toml
**Objective:** Set up black, ruff, and isort configurations
**Steps:**
1. Add [tool.black] section with line-length=88, target-version=["py311"]
2. Add [tool.ruff] section with line-length=88, select=["E", "F", "W", "I"]
3. Add [tool.isort] section with profile="black"
4. Add [tool.pytest.ini_options] with asyncio_mode="auto"
**Validation:**
- [ ] `uv run black --check .` runs (passes even if no code yet)
- [ ] `uv run ruff check .` runs
- [ ] Tool configurations are in pyproject.toml
**Dependencies:** Task 3
---
### Task 5: Create src directory structure
**Objective:** Set up layered architecture directories
**Steps:**
1. Create directories: `src/models/`, `src/schemas/`, `src/resolvers/`
2. Create empty `__init__.py` in each directory
3. Create `src/main.py`, `src/config.py`, `src/database.py` as placeholders
**Validation:**
- [ ] Directory structure matches design.md
- [ ] All directories have __init__.py files
- [ ] `ls -R src/` shows complete structure
**Dependencies:** Task 1
**Can run in parallel with:** Task 2, Task 3
---
## Phase 2: Database Layer
### Task 6: Create configuration management
**Objective:** Implement Settings class with environment variable support
**Steps:**
1. In `src/config.py`, create Settings class using pydantic-settings
2. Add fields: database_url (default: "sqlite+aiosqlite:///./clubber.db"), debug (default: False)
3. Configure .env file loading
4. Create `.env.example` with documented settings
**Validation:**
- [ ] Settings() instantiates with defaults
- [ ] DATABASE_URL environment variable overrides default
- [ ] .env.example exists and documents all settings
**Dependencies:** Task 2, Task 5
---
### Task 7: Set up database connection and session factory
**Objective:** Create async SQLAlchemy engine and session management
**Steps:**
1. In `src/database.py`, import SQLAlchemy async components
2. Create async_engine using settings.database_url
3. Create async_session_maker with AsyncSession
4. Implement get_db_session() dependency for FastAPI
5. Create declarative Base for models
**Validation:**
- [ ] async_engine is created without errors
- [ ] async_session_maker is callable
- [ ] get_db_session yields AsyncSession
**Dependencies:** Task 6
---
### Task 8: Define Member SQLAlchemy model
**Objective:** Create database model for members table with minimal required fields
**Steps:**
1. In `src/models/member.py`, import SQLAlchemy components
2. Define Member class inheriting from Base
3. Add id field (primary key, auto-increment)
4. Add first_name field (nullable=False, required)
5. Add optional fields with nullable=True: last_name, street, apartment_number, zip, city, country, email, phone
6. Add timestamps: created_at, updated_at with defaults
7. Set __tablename__ = "members"
**Validation:**
- [ ] Member class has all fields defined
- [ ] Only first_name has nullable=False
- [ ] All other data fields have nullable=True
- [ ] Field types and length constraints match spec
- [ ] `from src.models.member import Member` works
**Dependencies:** Task 7
---
### Task 9: Initialize Alembic for migrations
**Objective:** Set up Alembic configuration for database versioning
**Steps:**
1. Run `uv run alembic init migrations`
2. Edit `migrations/env.py` to import Base and use async operations
3. Edit `alembic.ini` to use config.py for database URL
4. Update env.py to reference all models (import src.models.member)
**Validation:**
- [ ] `migrations/` directory exists
- [ ] `alembic.ini` is configured
- [ ] `uv run alembic current` executes without errors
**Dependencies:** Task 8
---
### Task 10: Create initial database migration
**Objective:** Generate migration to create members table
**Steps:**
1. Run `uv run alembic revision --autogenerate -m "create members table"`
2. Review generated migration in `migrations/versions/`
3. Verify upgrade() creates members table with all columns
4. Verify downgrade() drops members table
**Validation:**
- [ ] Migration file exists in migrations/versions/
- [ ] Migration includes all Member model fields
- [ ] `uv run alembic upgrade head` creates database
- [ ] clubber.db file exists with members table
**Dependencies:** Task 9
---
## Phase 3: GraphQL API Layer
### Task 11: Define Strawberry GraphQL types
**Objective:** Create GraphQL schema types with minimal required fields
**Steps:**
1. In `src/schemas/member.py`, import strawberry
2. Define @strawberry.type Member with camelCase fields (only firstName, createdAt, updatedAt as non-null)
3. Define @strawberry.input CreateMemberInput (only firstName required)
4. Define @strawberry.input UpdateMemberInput (all fields optional except id)
5. Map snake_case Python to camelCase GraphQL with field aliases
**Validation:**
- [ ] Member type has all fields from spec with correct nullability
- [ ] CreateMemberInput requires only firstName
- [ ] All other fields are optional (nullable GraphQL types)
- [ ] `from src.schemas.member import Member` works
**Dependencies:** Task 2
**Can run in parallel with:** Database layer tasks (Task 6-10)
---
### Task 12: Implement validation helpers
**Objective:** Create conditional validation functions for optional fields
**Steps:**
1. Create `src/validation.py` (or add to schemas)
2. Define EMAIL_PATTERN and PHONE_PATTERN regex
3. Implement validate_email(email: Optional[str]) with conditional logic (only validate if provided)
4. Implement validate_phone(phone: Optional[str]) with conditional logic (only validate if provided)
5. Implement validate_first_name(first_name: str) to ensure non-empty
6. Create ValidationError custom exception
**Validation:**
- [ ] validate_email("test@example.com") passes
- [ ] validate_email(None) passes (no validation)
- [ ] validate_email("invalid") raises ValidationError
- [ ] validate_phone("+15551234567") passes
- [ ] validate_phone(None) passes (no validation)
- [ ] validate_phone("123") raises ValidationError
- [ ] validate_first_name("") raises ValidationError
**Dependencies:** Task 11
---
### Task 13: Implement Query resolvers
**Objective:** Create GraphQL query resolvers for reading members
**Steps:**
1. In `src/resolvers/member.py`, import necessary components
2. Define Query class with @strawberry.type
3. Implement member(id: ID) -> Optional[Member] resolver
4. Implement members() -> List[Member] resolver with sorting
5. Use get_db_session dependency for database access
**Validation:**
- [ ] Resolvers are async functions
- [ ] Database queries use SQLAlchemy 2.0 select() syntax
- [ ] Resolvers convert DB models to GraphQL types
**Dependencies:** Task 11, Task 12
---
### Task 14: Implement Mutation resolvers
**Objective:** Create GraphQL mutation resolvers for modifying members
**Steps:**
1. In `src/resolvers/member.py`, define Mutation class
2. Implement createMember(input: CreateMemberInput) -> Member
3. Implement updateMember(input: UpdateMemberInput) -> Member
4. Implement deleteMember(id: ID) -> bool
5. Add validation calls in createMember and updateMember
6. Implement error handling for not found cases
**Validation:**
- [ ] createMember validates email and phone
- [ ] updateMember allows partial updates
- [ ] deleteMember raises error for non-existent ID
- [ ] All mutations use database session correctly
**Dependencies:** Task 13
---
### Task 15: Integrate GraphQL with FastAPI
**Objective:** Mount Strawberry schema on FastAPI app
**Steps:**
1. In `src/main.py`, import FastAPI and Strawberry components
2. Create FastAPI app instance
3. Create Strawberry schema from Query and Mutation
4. Create GraphQLRouter with schema and graphiql=True
5. Mount router at app.add_route("/graphql", ...)
6. Add startup event to test database connection
**Validation:**
- [ ] `uv run uvicorn src.main:app --reload` starts server
- [ ] http://localhost:8000/graphql shows GraphiQL
- [ ] Schema introspection shows Member type and operations
**Dependencies:** Task 14
---
## Phase 4: Testing and Sample Data
### Task 16: Create test infrastructure
**Objective:** Set up pytest fixtures for database testing
**Steps:**
1. Create `tests/` directory with `__init__.py`
2. Create `tests/conftest.py` with fixtures
3. Implement async_db_session fixture using in-memory SQLite
4. Implement test database setup/teardown
5. Configure pytest-asyncio in pyproject.toml
**Validation:**
- [ ] `uv run pytest --collect-only` finds tests directory
- [ ] Fixtures can be imported by test files
- [ ] Test database is isolated from development database
**Dependencies:** Task 3, Task 10
---
### Task 17: Write tests for CRUD operations
**Objective:** Verify all member operations work correctly including minimal creation
**Steps:**
1. Create `tests/test_member_crud.py`
2. Write test_create_member_success (with all fields)
3. Write test_create_member_minimal (only firstName)
4. Write test_create_member_invalid_email (conditional validation)
5. Write test_create_member_no_validation_when_null (email=None doesn't validate)
6. Write test_get_member_by_id
7. Write test_list_members_sorted (including members with null lastName)
8. Write test_update_member_partial
9. Write test_delete_member
10. Write test_validation_errors
**Validation:**
- [ ] `uv run pytest` runs all tests
- [ ] All tests pass including minimal member creation
- [ ] Conditional validation tests pass (null fields skip validation)
- [ ] Coverage for resolvers is >80%
**Dependencies:** Task 16, Task 15
---
### Task 18: Create database seed script
**Objective:** Implement idempotent script to create sample member with partial data
**Steps:**
1. Create `scripts/` directory
2. Create `scripts/seed.py` with async main function
3. Check if member with email "jane.doe@example.com" exists
4. If not, create sample member with firstName, lastName, email (address and phone fields null)
5. Demonstrates that only firstName is required, other fields optional
6. Add asyncio.run() to execute main()
7. Make script executable and add shebang
**Validation:**
- [ ] `uv run python scripts/seed.py` creates sample member
- [ ] Sample member has firstName, lastName, email populated
- [ ] Sample member has null values for address and phone fields
- [ ] Running script twice doesn't create duplicate
- [ ] Sample member is queryable via GraphQL
- [ ] Script prints confirmation message
**Dependencies:** Task 10, Task 15
---
## Phase 5: Validation and Documentation
### Task 19: Run code quality checks
**Objective:** Ensure all code meets formatting and linting standards
**Steps:**
1. Run `uv run black .` to format all code
2. Run `uv run isort .` to sort imports
3. Run `uv run ruff check .` to verify linting
4. Fix any reported issues
**Validation:**
- [ ] `uv run black --check .` exits with code 0
- [ ] `uv run ruff check .` reports no errors
- [ ] All Python files are consistently formatted
**Dependencies:** Task 4, Task 18
---
### Task 20: Verify end-to-end functionality
**Objective:** Test complete user workflow via GraphQL playground
**Steps:**
1. Start server with `uv run uvicorn src.main:app --reload`
2. Open http://localhost:8000/graphql
3. Execute createMember mutation with valid data
4. Execute members query to list all members
5. Execute updateMember mutation to change email
6. Execute deleteMember mutation to remove member
7. Verify error handling with invalid inputs
**Validation:**
- [ ] All GraphQL operations work via playground
- [ ] Sample member exists after seed script
- [ ] Error messages are clear and helpful
- [ ] Database persists data between server restarts
**Dependencies:** Task 18, Task 19
---
## Summary
**Total tasks:** 20
**Parallel opportunities:**
- Tasks 2, 3, 5 can start together after Task 1
- Task 11 can run in parallel with Tasks 6-10
- Tasks 16-18 can run in parallel
**Critical path:** Task 1 → Task 2 → Task 6 → Task 7 → Task 8 → Task 9 → Task 10 → Task 15 → Task 20
**Estimated completion:** Tasks build incrementally; each phase validates previous work.
**Verification:** After Task 20, run:
```bash
uv run pytest # All tests pass
uv run alembic upgrade head # Database schema current
uv run python scripts/seed.py # Sample data loaded
uv run uvicorn src.main:app --reload # Server starts
# Open http://localhost:8000/graphql # GraphiQL works
```