# Phase 2.1: Notes Management - Quick Reference

## Overview

Quick reference guide for implementing Phase 2.1: Notes Management (CRUD operations) in StarPunk.

**File**: `starpunk/notes.py`
**Estimated Time**: 6-8 hours
**Dependencies**: `utils.py`, `models.py`, `database.py`

---

## Function Checklist

### Required Functions

- [ ] **create_note(content, published=False, created_at=None) -> Note**
  - Generate unique slug
  - Write file atomically
  - Insert database record
  - Return Note object

- [ ] **get_note(slug=None, id=None, load_content=True) -> Optional[Note]**
  - Query database by slug or id
  - Load content from file if requested
  - Return Note or None

- [ ] **list_notes(published_only=False, limit=50, offset=0, order_by='created_at', order_dir='DESC') -> list[Note]**
  - Query database with filters
  - Support pagination
  - No file I/O (metadata only)
  - Return list of Notes

- [ ] **update_note(slug=None, id=None, content=None, published=None) -> Note**
  - Update file if content changed
  - Update database record
  - Return updated Note

- [ ] **delete_note(slug=None, id=None, soft=True) -> None**
  - Soft delete: mark deleted_at in database
  - Hard delete: remove file and database record
  - Return None

### Custom Exceptions

- [ ] **NoteNotFoundError(Exception)**
  - Raised when note doesn't exist

- [ ] **InvalidNoteDataError(Exception)**
  - Raised for invalid content/parameters

- [ ] **NoteSyncError(Exception)**
  - Raised when file/database sync fails

---

## Implementation Order

### Step 1: Module Setup (15 minutes)

```python
# starpunk/notes.py
"""Notes management for StarPunk"""

# Imports
from datetime import datetime
from pathlib import Path
from typing import Optional
from flask import current_app
from starpunk.database import get_db
from starpunk.models import Note
from starpunk.utils import (
    generate_slug, make_slug_unique, generate_note_path,
    ensure_note_directory, write_note_file, read_note_file,
    delete_note_file, calculate_content_hash,
    validate_note_path, validate_slug
)

# Exception classes (define all 3)
```

**Time**: 15 minutes

### Step 2: create_note() (90 minutes)

**Algorithm**:
1. Validate content not empty
2. Set created_at to now if not provided
3. Query existing slugs from database
4. Generate unique slug
5. Generate file path
6. Validate path (security)
7. Calculate content hash
8. Write file (ensure_note_directory + write_note_file)
9. Insert database record
10. If DB fails: delete file, raise NoteSyncError
11. If success: commit, return Note object

**Testing**:
- Create basic note
- Create with empty content (should fail)
- Create with duplicate slug (should add suffix)
- Create with specific timestamp
- File write fails (should not create DB record)

**Time**: 90 minutes (45 min implementation + 45 min testing)

### Step 3: get_note() (45 minutes)

**Algorithm**:
1. Validate parameters (exactly one of slug or id)
2. Query database
3. Return None if not found
4. Create Note.from_row()
5. Optionally verify integrity (log warning if mismatch)
6. Return Note

**Testing**:
- Get by slug
- Get by id
- Get nonexistent (returns None)
- Get with both parameters (should fail)
- Get without loading content

**Time**: 45 minutes (25 min implementation + 20 min testing)

### Step 4: list_notes() (60 minutes)

**Algorithm**:
1. Validate order_by (whitelist check)
2. Validate order_dir (ASC/DESC)
3. Validate limit (max 1000)
4. Build SQL query with filters
5. Add ORDER BY and LIMIT/OFFSET
6. Execute query
7. Create Note objects (don't load content)
8. Return list

**Testing**:
- List all notes
- List published only
- List with pagination
- List with different ordering
- Invalid order_by (should fail - SQL injection test)

**Time**: 60 minutes (35 min implementation + 25 min testing)

### Step 5: update_note() (90 minutes)

**Algorithm**:
1. Validate parameters
2. Get existing note (raises NoteNotFoundError if missing)
3. Validate content if provided
4. Setup paths and timestamps
5. If content changed: write new file, calculate new hash
6. Build UPDATE query for changed fields
7. Execute database update
8. If DB fails: log error, raise NoteSyncError
9. If success: commit, return updated Note

**Testing**:
- Update content only
- Update published only
- Update both
- Update nonexistent (should fail)
- Update with empty content (should fail)
- Update with no changes (should fail)

**Time**: 90 minutes (50 min implementation + 40 min testing)

### Step 6: delete_note() (60 minutes)

**Algorithm**:
1. Validate parameters
2. Get existing note (return if None - idempotent)
3. Validate path
4. If soft delete:
   - UPDATE notes SET deleted_at = now WHERE id = ?
   - Optionally move file to trash (best effort)
5. If hard delete:
   - DELETE FROM notes WHERE id = ?
   - Delete file (best effort)
6. Return None

**Testing**:
- Soft delete
- Hard delete
- Delete nonexistent (should succeed)
- Delete already deleted (should succeed)

**Time**: 60 minutes (35 min implementation + 25 min testing)

### Step 7: Integration Tests (60 minutes)

**Full CRUD cycle test**:
1. Create note
2. Retrieve note
3. Update content
4. Update published status
5. List notes (verify appears)
6. Delete note
7. Verify gone

**Sync tests**:
- Verify file exists after create
- Verify DB record exists after create
- Verify file updated after update
- Verify file deleted after hard delete
- Verify DB record deleted after hard delete

**Time**: 60 minutes

### Step 8: Documentation and Cleanup (30 minutes)

- Review all docstrings
- Format with Black
- Run flake8
- Check type hints
- Review error messages

**Time**: 30 minutes

---

## Common Pitfalls

### 1. Forgetting to Commit Transactions

```python
# BAD - no commit
db.execute("INSERT INTO notes ...")

# GOOD - explicit commit
db.execute("INSERT INTO notes ...")
db.commit()
```

### 2. Not Cleaning Up on Failure

```python
# BAD - orphaned file if DB fails
write_note_file(path, content)
db.execute("INSERT ...")  # What if this fails?

# GOOD - cleanup on failure
write_note_file(path, content)
try:
    db.execute("INSERT ...")
    db.commit()
except Exception as e:
    path.unlink()  # Delete file we created
    raise NoteSyncError(...)
```

### 3. SQL Injection in ORDER BY

```python
# BAD - SQL injection risk
order_by = request.args.get('order')
query = f"SELECT * FROM notes ORDER BY {order_by}"

# GOOD - whitelist validation
ALLOWED = ['id', 'slug', 'created_at', 'updated_at']
if order_by not in ALLOWED:
    raise ValueError(f"Invalid order_by: {order_by}")
query = f"SELECT * FROM notes ORDER BY {order_by}"
```

### 4. Not Using Parameterized Queries

```python
# BAD - SQL injection risk
slug = request.args.get('slug')
query = f"SELECT * FROM notes WHERE slug = '{slug}'"

# GOOD - parameterized query
query = "SELECT * FROM notes WHERE slug = ?"
db.execute(query, (slug,))
```

### 5. Forgetting Path Validation

```python
# BAD - directory traversal risk
note_path = data_dir / file_path
write_note_file(note_path, content)

# GOOD - validate path
note_path = data_dir / file_path
if not validate_note_path(note_path, data_dir):
    raise NoteSyncError(...)
write_note_file(note_path, content)
```

### 6. Not Handling None in Optional Parameters

```python
# BAD - will crash on None
if slug and id:
    raise ValueError(...)

# GOOD - explicit None checks
if slug is None and id is None:
    raise ValueError("Must provide slug or id")
if slug is not None and id is not None:
    raise ValueError("Cannot provide both")
```

---

## Testing Checklist

### Unit Tests

- [ ] create_note with valid content
- [ ] create_note with empty content (fail)
- [ ] create_note with duplicate slug (unique suffix)
- [ ] create_note with specific timestamp
- [ ] create_note with unicode content
- [ ] create_note file write failure (no DB record)
- [ ] get_note by slug
- [ ] get_note by id
- [ ] get_note nonexistent (returns None)
- [ ] get_note with invalid parameters
- [ ] get_note without loading content
- [ ] list_notes all
- [ ] list_notes published only
- [ ] list_notes with pagination
- [ ] list_notes with ordering
- [ ] list_notes with invalid order_by (fail)
- [ ] update_note content
- [ ] update_note published
- [ ] update_note both
- [ ] update_note nonexistent (fail)
- [ ] update_note empty content (fail)
- [ ] delete_note soft
- [ ] delete_note hard
- [ ] delete_note nonexistent (succeed)

### Integration Tests

- [ ] Full CRUD cycle (create → read → update → delete)
- [ ] File-database sync maintained throughout lifecycle
- [ ] Orphaned files cleaned up on DB failure
- [ ] Soft-deleted notes excluded from queries
- [ ] Hard-deleted notes removed from DB and filesystem

### Performance Tests

- [ ] list_notes with 1000 notes (< 10ms)
- [ ] get_note (< 10ms)
- [ ] create_note (< 20ms)

---

## Time Estimates

| Task | Time |
|------|------|
| Module setup | 15 min |
| create_note() | 90 min |
| get_note() | 45 min |
| list_notes() | 60 min |
| update_note() | 90 min |
| delete_note() | 60 min |
| Integration tests | 60 min |
| Documentation/cleanup | 30 min |
| **Total** | **7.5 hours** |

Add 30-60 minutes for unexpected issues and debugging.

---

## Key Design Decisions

### 1. File Operations Before Database

**Rationale**: Fail fast on disk issues before database changes.

**Pattern**:
```python
# Write file first
write_note_file(path, content)

# Then update database
db.execute("INSERT ...")
db.commit()

# If DB fails, cleanup file
```

### 2. Best-Effort File Cleanup

**Rationale**: Database is source of truth. Missing files can be recreated or cleaned up later.

**Pattern**:
```python
try:
    path.unlink()
except OSError:
    logger.warning("Cleanup failed")
    # Don't fail - log and continue
```

### 3. Idempotent Delete

**Rationale**: DELETE operations should succeed even if already deleted.

**Pattern**:
```python
note = get_note(slug=slug)
if note is None:
    return  # Already deleted, that's fine
# ... proceed with delete
```

### 4. Lazy Content Loading

**Rationale**: list_notes() should not trigger file I/O for every note.

**Pattern**:
```python
# list_notes creates Notes without loading content
notes = [Note.from_row(row, data_dir) for row in rows]

# Content loaded on access
for note in notes:
    print(note.slug)      # Fast (metadata)
    print(note.content)   # Triggers file I/O
```

### 5. Parameterized Queries Only

**Rationale**: Prevent SQL injection.

**Pattern**:
```python
# Always use parameter binding
db.execute("SELECT * FROM notes WHERE slug = ?", (slug,))

# Never use string interpolation
db.execute(f"SELECT * FROM notes WHERE slug = '{slug}'")  # NO!
```

---

## Dependencies Reference

### From utils.py

```python
generate_slug(content, created_at) -> str
make_slug_unique(base_slug, existing_slugs) -> str
validate_slug(slug) -> bool
generate_note_path(slug, created_at, data_dir) -> Path
ensure_note_directory(note_path) -> Path
write_note_file(file_path, content) -> None
read_note_file(file_path) -> str
delete_note_file(file_path, soft=False, data_dir=None) -> None
calculate_content_hash(content) -> str
validate_note_path(file_path, data_dir) -> bool
```

### From models.py

```python
Note.from_row(row, data_dir) -> Note
Note.content -> str (property, lazy-loaded)
Note.to_dict(include_content=False) -> dict
Note.verify_integrity() -> bool
```

### From database.py

```python
get_db() -> sqlite3.Connection
db.execute(query, params) -> Cursor
db.commit() -> None
db.rollback() -> None
```

---

## Error Handling Quick Reference

### When to Raise vs Return None

| Scenario | Action |
|----------|--------|
| Note not found in get_note() | Return None |
| Note not found in update_note() | Raise NoteNotFoundError |
| Note not found in delete_note() | Return None (idempotent) |
| Empty content | Raise InvalidNoteDataError |
| File write fails | Raise NoteSyncError |
| Database fails | Raise NoteSyncError |
| Invalid parameters | Raise ValueError |

### Error Message Examples

```python
# NoteNotFoundError
raise NoteNotFoundError(
    slug,
    f"Note '{slug}' does not exist or has been deleted"
)

# InvalidNoteDataError
raise InvalidNoteDataError(
    'content',
    content,
    "Note content cannot be empty. Please provide markdown content."
)

# NoteSyncError
raise NoteSyncError(
    'create',
    f"Database insert failed: {str(e)}",
    f"Failed to create note. File written but database update failed."
)
```

---

## Security Checklist

- [ ] All SQL queries use parameterized binding (no string interpolation)
- [ ] order_by field validated against whitelist
- [ ] All file paths validated with validate_note_path()
- [ ] No symlinks followed in path operations
- [ ] Content validated (not empty)
- [ ] Slug validated before use in file paths
- [ ] No code execution from user content

---

## Final Checks

Before submitting Phase 2.1 as complete:

- [ ] All 5 functions implemented
- [ ] All 3 exceptions implemented
- [ ] Full type hints on all functions
- [ ] Comprehensive docstrings with examples
- [ ] Test coverage > 90%
- [ ] All tests passing
- [ ] Black formatting applied
- [ ] flake8 linting passes (no errors)
- [ ] Integration test passes (full CRUD cycle)
- [ ] No orphaned files in test runs
- [ ] No orphaned database records in test runs
- [ ] Error messages are clear and actionable
- [ ] Performance targets met:
  - [ ] create_note < 20ms
  - [ ] get_note < 10ms
  - [ ] list_notes < 10ms
  - [ ] update_note < 20ms
  - [ ] delete_note < 10ms

---

## Quick Command Reference

```bash
# Run tests
pytest tests/test_notes.py -v

# Check coverage
pytest tests/test_notes.py --cov=starpunk.notes --cov-report=term-missing

# Format code
black starpunk/notes.py tests/test_notes.py

# Lint code
flake8 starpunk/notes.py --max-line-length=100

# Type check (optional)
mypy starpunk/notes.py
```

---

## What's Next?

After completing Phase 2.1:

**Phase 3: Authentication**
- IndieLogin OAuth flow
- Session management
- Admin access control

**Phase 4: Web Routes**
- Admin interface (create/edit/delete notes)
- Public note views
- Template rendering

**Phase 5: Micropub**
- Micropub endpoint
- Token validation
- IndieWeb API compliance

**Phase 6: RSS Feed**
- Feed generation
- RFC-822 date formatting
- Published notes only

---

## Help & Resources

- **Full Design Doc**: [phase-2.1-notes-management.md](/home/phil/Projects/starpunk/docs/design/phase-2.1-notes-management.md)
- **Utilities Design**: [phase-1.1-core-utilities.md](/home/phil/Projects/starpunk/docs/design/phase-1.1-core-utilities.md)
- **Models Design**: [phase-1.2-data-models.md](/home/phil/Projects/starpunk/docs/design/phase-1.2-data-models.md)
- **Coding Standards**: [python-coding-standards.md](/home/phil/Projects/starpunk/docs/standards/python-coding-standards.md)
- **Implementation Plan**: [implementation-plan.md](/home/phil/Projects/starpunk/docs/projectplan/v1/implementation-plan.md)

Remember: "Every line of code must justify its existence. When in doubt, leave it out."
