diff --git a/SECURITY_CONSIDERATIONS.md b/SECURITY_CONSIDERATIONS.md new file mode 100644 index 0000000..8ce2078 --- /dev/null +++ b/SECURITY_CONSIDERATIONS.md @@ -0,0 +1,605 @@ +# Security Considerations for Public/Larger Deployment + +This document outlines security considerations if the Booth Tracker application is expanded beyond its current internal-only, VPN-protected deployment. + +## Current Security Posture + +**Current Deployment Context:** +- Internal use only +- Protected by Tailscale VPN +- Trusted employee access only +- Small team of users +- No public internet exposure + +**Risk Mitigation:** +Many security concerns are currently mitigated by the VPN boundary and trusted user base. However, expanding to a larger group or public deployment would require addressing the following issues. + +--- + +## Critical Issues for Public Deployment + +### 1. Audit Trail and Data Integrity + +**Current State:** +- No audit logging for data modifications +- Users can edit interaction data retroactively without tracking +- No record of who changed what and when + +**Risks for Larger Deployment:** +- Data manipulation without accountability +- Inability to investigate discrepancies +- Potential for revenue data tampering +- No compliance trail for financial auditing + +**Recommended Solutions:** +```sql +-- Create audit log table +CREATE TABLE interaction_audit ( + id SERIAL PRIMARY KEY, + interaction_id UUID REFERENCES interactions(id), + changed_by VARCHAR(100), + changed_at TIMESTAMPTZ DEFAULT NOW(), + field_name VARCHAR(50), + old_value TEXT, + new_value TEXT, + ip_address INET +); + +-- Create trigger function +CREATE OR REPLACE FUNCTION log_interaction_changes() +RETURNS TRIGGER AS $$ +BEGIN + -- Log changes to relevant fields + IF OLD.timestamp != NEW.timestamp THEN + INSERT INTO interaction_audit (interaction_id, field_name, old_value, new_value) + VALUES (NEW.id, 'timestamp', OLD.timestamp::text, NEW.timestamp::text); + END IF; + -- Add similar checks for other sensitive fields + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Attach trigger +CREATE TRIGGER audit_interaction_updates +AFTER UPDATE ON interactions +FOR EACH ROW EXECUTE FUNCTION log_interaction_changes(); +``` + +**Implementation Priority:** HIGH + +--- + +### 2. Timestamp Manipulation Controls + +**Current State:** +- Users can set any timestamp (past or future) +- No age limits on backdating +- No warnings for unusual timestamp patterns + +**Risks for Larger Deployment:** +- Gaming of metrics and analytics +- False reporting of historical sales +- Manipulation of performance metrics +- Skewed trend analysis + +**Recommended Solutions:** + +```python +# In api/main.py - add to update_interaction and create_interaction + +MAX_BACKDATE_DAYS = 30 # Maximum days to backdate +MAX_FUTURE_MINUTES = 5 # Allow small clock skew + +def validate_timestamp(timestamp: datetime) -> None: + """Validate timestamp is within acceptable bounds.""" + now = datetime.now(timezone.utc) + + # Prevent future timestamps (with small tolerance for clock skew) + if timestamp > now + timedelta(minutes=MAX_FUTURE_MINUTES): + raise HTTPException( + status_code=400, + detail=f"Timestamp cannot be more than {MAX_FUTURE_MINUTES} minutes in the future" + ) + + # Prevent excessive backdating + oldest_allowed = now - timedelta(days=MAX_BACKDATE_DAYS) + if timestamp < oldest_allowed: + raise HTTPException( + status_code=400, + detail=f"Timestamp cannot be older than {MAX_BACKDATE_DAYS} days" + ) +``` + +**Frontend Warning:** +```javascript +// Add visual warning when logging past events +if (timestampAge > 24 hours) { + showWarning("⚠️ You're logging an event from more than 24 hours ago. Please confirm this is accurate."); +} +``` + +**Implementation Priority:** MEDIUM-HIGH + +--- + +### 3. Authentication and Authorization + +**Current State:** +- Authentication handled by Tailscale VPN +- All authenticated users have full access +- No role-based access control (RBAC) +- No differentiation between staff and administrators + +**Risks for Larger Deployment:** +- All users can edit/delete all data +- No separation of duties +- Staff could modify their own performance metrics +- No administrative controls + +**Recommended Solutions:** + +```python +# Add user roles +class UserRole(str, Enum): + ADMIN = "admin" + MANAGER = "manager" + STAFF = "staff" + READONLY = "readonly" + +# Add to staff table +ALTER TABLE staff ADD COLUMN role VARCHAR(20) DEFAULT 'staff'; + +# Implement permission checks +def require_permission(required_role: UserRole): + def decorator(func): + async def wrapper(*args, request: Request, **kwargs): + user = await get_current_user(request) + if user.role not in get_allowed_roles(required_role): + raise HTTPException(status_code=403, detail="Insufficient permissions") + return await func(*args, request=request, **kwargs) + return wrapper + return decorator + +@app.patch("/api/interactions/{interaction_id}") +@require_permission(UserRole.MANAGER) # Only managers+ can edit interactions +async def update_interaction(...): + ... +``` + +**Permissions Matrix:** +| Action | Staff | Manager | Admin | +|--------|-------|---------|-------| +| Log interaction | ✓ | ✓ | ✓ | +| Edit own interaction (< 1 hour) | ✓ | ✓ | ✓ | +| Edit any interaction | ✗ | ✓ | ✓ | +| Delete interaction | ✗ | ✗ | ✓ | +| View analytics | ✓ | ✓ | ✓ | +| Export data | ✗ | ✓ | ✓ | + +**Implementation Priority:** HIGH + +--- + +### 4. Rate Limiting and DoS Protection + +**Current State:** +- No rate limiting on API endpoints +- No protection against rapid-fire requests +- No request throttling + +**Risks for Larger Deployment:** +- API abuse +- Database overload +- Denial of service attacks +- Resource exhaustion + +**Recommended Solutions:** + +```python +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + +limiter = Limiter(key_func=get_remote_address) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +@app.post("/api/interactions") +@limiter.limit("60/minute") # Max 60 interactions per minute +async def create_interaction(...): + ... + +@app.patch("/api/interactions/{interaction_id}") +@limiter.limit("30/minute") # Max 30 edits per minute +async def update_interaction(...): + ... +``` + +**Implementation Priority:** MEDIUM + +--- + +### 5. Input Validation and Injection Prevention + +**Current State:** +- ✅ Now implemented: Input validation for interaction fields +- ✅ Parameterized queries prevent SQL injection +- ⚠️ No input sanitization for notes/free-text fields +- ⚠️ No XSS protection for user-generated content + +**Risks for Larger Deployment:** +- Cross-site scripting (XSS) attacks via notes field +- Injection of malicious scripts +- Data corruption via invalid input + +**Recommended Solutions:** + +```python +import html +import bleach + +ALLOWED_HTML_TAGS = [] # No HTML allowed in notes +ALLOWED_ATTRIBUTES = {} + +def sanitize_text(text: str) -> str: + """Sanitize user input to prevent XSS.""" + if not text: + return text + # Strip all HTML tags + return bleach.clean(text, tags=ALLOWED_HTML_TAGS, attributes=ALLOWED_ATTRIBUTES, strip=True) + +# Apply to notes field +if update.notes is not None: + updates.append(f"notes = ${param_idx}") + params.append(sanitize_text(update.notes)) + param_idx += 1 +``` + +**Frontend:** +```javascript +// Escape HTML when displaying notes +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} +``` + +**Implementation Priority:** HIGH (for public deployment) + +--- + +### 6. Data Privacy and GDPR Compliance + +**Current State:** +- No data retention policies +- No data export capability for users +- No data deletion guarantees +- Soft-delete allows data recovery indefinitely + +**Risks for Larger Deployment:** +- GDPR/privacy law violations +- Inability to comply with "right to be forgotten" +- Regulatory fines +- Loss of customer trust + +**Recommended Solutions:** + +1. **Data Retention Policy:** +```python +# Schedule hard-delete of soft-deleted items after 90 days +async def cleanup_old_deleted_records(): + """Permanently delete soft-deleted records older than 90 days.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=90) + async with db_pool.acquire() as conn: + result = await conn.execute(""" + DELETE FROM interactions + WHERE deleted_at IS NOT NULL + AND deleted_at < $1 + """, cutoff) + return result +``` + +2. **Data Export Endpoint:** +```python +@app.get("/api/data-export") +async def export_user_data(request: Request): + """Export all data for current user (GDPR compliance).""" + # Return JSON with all user's data + pass +``` + +3. **Hard Delete Endpoint:** +```python +@app.delete("/api/interactions/{id}/permanent") +@require_permission(UserRole.ADMIN) +async def permanent_delete(interaction_id: str): + """Permanently delete interaction (GDPR right to deletion).""" + pass +``` + +**Implementation Priority:** CRITICAL (for public deployment with EU users) + +--- + +### 7. Secure Session Management + +**Current State:** +- Sessions managed by Tailscale +- No session timeout +- No session invalidation mechanism + +**Risks for Larger Deployment:** +- Session hijacking +- Unlimited session lifetime +- No forced logout capability + +**Recommended Solutions:** + +```python +# Add session management +from datetime import timedelta +import secrets + +SESSION_TIMEOUT = timedelta(hours=8) + +sessions = {} # In production, use Redis + +def create_session(user_id: str) -> str: + session_token = secrets.token_urlsafe(32) + sessions[session_token] = { + 'user_id': user_id, + 'created_at': datetime.now(timezone.utc), + 'last_activity': datetime.now(timezone.utc) + } + return session_token + +def validate_session(session_token: str) -> bool: + if session_token not in sessions: + return False + + session = sessions[session_token] + now = datetime.now(timezone.utc) + + # Check timeout + if now - session['last_activity'] > SESSION_TIMEOUT: + del sessions[session_token] + return False + + # Update last activity + session['last_activity'] = now + return True +``` + +**Implementation Priority:** HIGH + +--- + +### 8. HTTPS and Transport Security + +**Current State:** +- Running over Tailscale encrypted tunnel +- No HTTPS enforcement at application level + +**Risks for Larger Deployment:** +- Man-in-the-middle attacks (if outside VPN) +- Data interception +- Session hijacking + +**Recommended Solutions:** + +1. **Enforce HTTPS:** +```python +from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware + +# Add to production config only +if os.environ.get("ENVIRONMENT") == "production": + app.add_middleware(HTTPSRedirectMiddleware) +``` + +2. **Security Headers:** +```python +from fastapi.middleware.trustedhost import TrustedHostMiddleware + +app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=["yourdomain.com", "*.yourdomain.com"] +) + +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response +``` + +**Implementation Priority:** CRITICAL + +--- + +### 9. Database Security + +**Current State:** +- Database credentials in plaintext in code +- Connection string exposed +- No connection encryption +- Direct database access + +**Risks for Larger Deployment:** +- Credential theft +- Unauthorized database access +- Data breaches + +**Recommended Solutions:** + +1. **Use Environment Variables:** +```python +# Never commit credentials +DATABASE_URL = os.environ.get("DATABASE_URL") +if not DATABASE_URL: + raise RuntimeError("DATABASE_URL environment variable not set") +``` + +2. **Use Secrets Management:** +```python +# Use AWS Secrets Manager, HashiCorp Vault, etc. +import boto3 + +def get_database_credentials(): + client = boto3.client('secretsmanager') + secret = client.get_secret_value(SecretId='booth-tracker/db') + return json.loads(secret['SecretString']) +``` + +3. **Enable SSL/TLS for Database Connections:** +```python +db_pool = await asyncpg.create_pool( + DATABASE_URL, + ssl='require', # Require SSL connection + min_size=2, + max_size=10 +) +``` + +**Implementation Priority:** HIGH + +--- + +### 10. Error Handling and Information Disclosure + +**Current State:** +- Detailed error messages returned to client +- Stack traces potentially exposed +- Database errors visible to users + +**Risks for Larger Deployment:** +- Information leakage +- System architecture disclosure +- Aid to attackers + +**Recommended Solutions:** + +```python +@app.exception_handler(Exception) +async def generic_exception_handler(request: Request, exc: Exception): + # Log detailed error server-side + logger.error(f"Unhandled exception: {exc}", exc_info=True) + + # Return generic error to client + return JSONResponse( + status_code=500, + content={ + "detail": "An internal error occurred. Please try again later.", + "error_id": generate_error_id() # For support reference + } + ) +``` + +**Implementation Priority:** MEDIUM + +--- + +## Testing and Monitoring + +### Security Testing Checklist + +Before public deployment, perform: + +- [ ] Penetration testing +- [ ] SQL injection testing +- [ ] XSS vulnerability scanning +- [ ] Authentication bypass testing +- [ ] Authorization testing (privilege escalation) +- [ ] Rate limiting verification +- [ ] Session management testing +- [ ] Input validation testing +- [ ] CSRF protection verification +- [ ] API abuse testing + +### Monitoring and Alerting + +Implement monitoring for: + +- **Failed login attempts** (threshold: 5 per user per hour) +- **Mass data modifications** (threshold: 50 edits per user per hour) +- **Unusual API patterns** (spikes in requests) +- **Database errors** (connection failures, query timeouts) +- **Timestamp anomalies** (backdating beyond normal range) +- **Rate limit hits** (users hitting rate limits) + +--- + +## Compliance Considerations + +### For Public Deployment + +- **GDPR** (EU users): Data export, right to deletion, consent management +- **CCPA** (California users): Data disclosure, opt-out mechanisms +- **SOC 2** (Enterprise customers): Audit trails, access controls, encryption +- **PCI DSS** (If handling payments in future): Payment data isolation, encryption + +--- + +## Implementation Roadmap + +### Phase 1: Critical Security (Before ANY public access) +1. Implement HTTPS and transport security +2. Add authentication/authorization beyond VPN +3. Implement audit logging +4. Add rate limiting +5. Secure database credentials +6. Add input sanitization + +### Phase 2: Enhanced Security (Before larger deployment) +1. Timestamp manipulation controls +2. Session management +3. Error handling improvements +4. Security headers and CSP +5. Monitoring and alerting + +### Phase 3: Compliance (Before public/enterprise deployment) +1. GDPR compliance features +2. Data retention policies +3. Privacy controls +4. Compliance documentation +5. Third-party security audit + +--- + +## Cost-Benefit Analysis + +For **internal-only** deployment: +- Current security posture is ACCEPTABLE +- VPN provides strong boundary protection +- Trusted users mitigate many risks +- Audit trail would be NICE TO HAVE but not critical + +For **larger internal** deployment (50+ users): +- Audit trail becomes RECOMMENDED +- Timestamp controls become IMPORTANT +- RBAC becomes RECOMMENDED + +For **public** deployment: +- ALL items become CRITICAL +- Estimated implementation: 4-6 weeks development +- Ongoing security maintenance required +- Third-party security audit recommended ($10k-$50k) + +--- + +## Conclusion + +The current implementation is appropriate for internal, VPN-protected use with a small team of trusted employees. However, expansion beyond this context would require significant security enhancements as outlined in this document. + +**Key Takeaway:** Do not expose this application to the public internet or untrusted users without implementing AT MINIMUM the Phase 1 critical security measures. + +--- + +## Contact + +For security concerns or questions about this document, contact your security team or the application maintainer. + +**Last Updated:** 2025-12-22 diff --git a/api/main.py b/api/main.py index cb2781f..7fba6e7 100644 --- a/api/main.py +++ b/api/main.py @@ -4,7 +4,7 @@ import os import re from contextlib import asynccontextmanager -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from typing import Optional, List, Set from uuid import UUID @@ -21,6 +21,26 @@ ) TAILSCALE_SOCKET = "/var/run/tailscale/tailscaled.sock" +# Validation constants +VALID_INTERACTION_TYPES = {"walk_by", "conversation"} +VALID_PERSONAS = {"parent", "gift_buyer", "expat", "future_parent"} +VALID_HOOKS = {"physical_kits", "big_garden", "signage"} +VALID_SALE_TYPES = {"none", "single", "bundle_3", "full_year"} +VALID_LEAD_TYPES = {"line", "email"} +VALID_OBJECTIONS = { + "too_expensive", "not_interested", "no_time", "already_have", + "need_to_think", "language_barrier", "other" +} + +# Pricing constants +PRICE_990 = 990 +PRICE_1290 = 1290 +BUNDLE_3_PRICE = 2690 +FULL_YEAR_PRICE = 4990 + +# Timestamp validation constants +MAX_BACKDATE_DAYS = 30 # Maximum days in the past for custom timestamps + # Database connection pool db_pool: Optional[asyncpg.Pool] = None @@ -112,6 +132,7 @@ class InteractionCreate(BaseModel): total_amount: Optional[int] = None lead_type: Optional[str] = None objection: Optional[str] = None + timestamp: Optional[datetime] = None # Custom timestamp for logging past events class StaffCreate(BaseModel): @@ -123,6 +144,16 @@ class StaffCreate(BaseModel): class InteractionUpdate(BaseModel): notes: Optional[str] = None deleted: Optional[bool] = None # True = soft delete, False = restore + interaction_type: Optional[str] = None + persona: Optional[str] = None + hook: Optional[str] = None + sale_type: Optional[str] = None + quantity: Optional[int] = None + unit_price: Optional[int] = None + total_amount: Optional[int] = None + lead_type: Optional[str] = None + objection: Optional[str] = None + timestamp: Optional[datetime] = None class SellerCreate(BaseModel): @@ -198,7 +229,7 @@ def get_client_ip(request: Request) -> str: @app.get("/api/health") async def health(): """Health check endpoint.""" - return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()} + return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()} @app.get("/api/whoami") @@ -239,14 +270,14 @@ async def whoami(request: Request): @app.get("/api/stats") async def get_stats(period: str = "today"): """Get aggregated stats for dashboard.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc) if period == "today": start_date = now.replace(hour=0, minute=0, second=0, microsecond=0) elif period == "week": start_date = now - timedelta(days=7) else: # all - start_date = datetime(2020, 1, 1) + start_date = datetime(2020, 1, 1, tzinfo=timezone.utc) async with db_pool.acquire() as conn: # Total visitors (walk_by + conversation) - exclude deleted @@ -405,20 +436,37 @@ async def create_interaction(interaction: InteractionCreate, request: Request): if interaction.sale_type == "single" and interaction.unit_price: total_amount = interaction.quantity * interaction.unit_price elif interaction.sale_type == "bundle_3": - total_amount = 2690 + total_amount = BUNDLE_3_PRICE elif interaction.sale_type == "full_year": - total_amount = 4990 + total_amount = FULL_YEAR_PRICE # Determine engaged status based on interaction type engaged = interaction.interaction_type == "conversation" - # Insert interaction with engaged and seller_id + # Use provided timestamp or current time (timezone-aware) + timestamp = interaction.timestamp or datetime.now(timezone.utc) + + # Validate timestamp if provided + if interaction.timestamp: + now = datetime.now(timezone.utc) + # Prevent future timestamps + if timestamp > now: + raise HTTPException(status_code=400, detail="Timestamp cannot be in the future") + # Prevent backdating beyond limit + min_allowed = now - timedelta(days=MAX_BACKDATE_DAYS) + if timestamp < min_allowed: + raise HTTPException( + status_code=400, + detail=f"Timestamp cannot be more than {MAX_BACKDATE_DAYS} days in the past" + ) + + # Insert interaction with engaged, seller_id, and custom timestamp row = await conn.fetchrow(""" INSERT INTO interactions ( staff_device, interaction_type, engaged, persona, hook, sale_type, quantity, unit_price, total_amount, - lead_type, objection, seller_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + lead_type, objection, seller_id, timestamp + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, timestamp """, hostname, @@ -432,7 +480,8 @@ async def create_interaction(interaction: InteractionCreate, request: Request): total_amount, interaction.lead_type, interaction.objection, - seller_id + seller_id, + timestamp ) return { @@ -695,46 +744,157 @@ async def get_interaction(interaction_id: str): @app.patch("/api/interactions/{interaction_id}") async def update_interaction(interaction_id: str, update: InteractionUpdate): - """Update interaction (notes, soft delete/restore).""" - async with db_pool.acquire() as conn: - # Check if exists - exists = await conn.fetchval( - "SELECT 1 FROM interactions WHERE id = $1", - UUID(interaction_id) - ) - if not exists: - raise HTTPException(status_code=404, detail="Interaction not found") - - # Build update query - updates = [] - params = [] - param_idx = 1 - - if update.notes is not None: - updates.append(f"notes = ${param_idx}") - params.append(update.notes) - param_idx += 1 - - if update.deleted is not None: - if update.deleted: - updates.append(f"deleted_at = ${param_idx}") - params.append(datetime.utcnow()) - else: - updates.append("deleted_at = NULL") - param_idx += 1 - - if not updates: - raise HTTPException(status_code=400, detail="No updates provided") + """Update interaction (notes, soft delete/restore, and all interaction fields).""" + # Input validation + if update.interaction_type is not None and update.interaction_type not in VALID_INTERACTION_TYPES: + raise HTTPException(status_code=400, detail=f"Invalid interaction_type. Must be one of: {', '.join(VALID_INTERACTION_TYPES)}") + + if update.persona is not None and update.persona and update.persona not in VALID_PERSONAS: + raise HTTPException(status_code=400, detail=f"Invalid persona. Must be one of: {', '.join(VALID_PERSONAS)}") + + if update.hook is not None and update.hook and update.hook not in VALID_HOOKS: + raise HTTPException(status_code=400, detail=f"Invalid hook. Must be one of: {', '.join(VALID_HOOKS)}") + + if update.sale_type is not None and update.sale_type and update.sale_type not in VALID_SALE_TYPES: + raise HTTPException(status_code=400, detail=f"Invalid sale_type. Must be one of: {', '.join(VALID_SALE_TYPES)}") + + if update.lead_type is not None and update.lead_type and update.lead_type not in VALID_LEAD_TYPES: + raise HTTPException(status_code=400, detail=f"Invalid lead_type. Must be one of: {', '.join(VALID_LEAD_TYPES)}") + + if update.objection is not None and update.objection and update.objection not in VALID_OBJECTIONS: + raise HTTPException(status_code=400, detail=f"Invalid objection. Must be one of: {', '.join(VALID_OBJECTIONS)}") + + if update.quantity is not None and update.quantity < 1: + raise HTTPException(status_code=400, detail="Quantity must be at least 1") + + if update.unit_price is not None and update.unit_price not in [PRICE_990, PRICE_1290, None]: + raise HTTPException(status_code=400, detail=f"Invalid unit_price. Must be {PRICE_990} or {PRICE_1290}") + + if update.total_amount is not None and update.total_amount < 0: + raise HTTPException(status_code=400, detail="Total amount cannot be negative") + + if update.timestamp is not None: + now = datetime.now(timezone.utc) + # Prevent future timestamps + if update.timestamp > now: + raise HTTPException(status_code=400, detail="Timestamp cannot be in the future") + # Prevent backdating beyond limit + min_allowed = now - timedelta(days=MAX_BACKDATE_DAYS) + if update.timestamp < min_allowed: + raise HTTPException( + status_code=400, + detail=f"Timestamp cannot be more than {MAX_BACKDATE_DAYS} days in the past" + ) - params.append(UUID(interaction_id)) - query = f""" - UPDATE interactions - SET {", ".join(updates)} - WHERE id = ${param_idx} - RETURNING * - """ + async with db_pool.acquire() as conn: + # Use transaction for atomicity + async with conn.transaction(): + # Check if exists and get current state + current = await conn.fetchrow( + "SELECT interaction_type FROM interactions WHERE id = $1", + UUID(interaction_id) + ) + if not current: + raise HTTPException(status_code=404, detail="Interaction not found") + + # Build update query + updates = [] + params = [] + param_idx = 1 + + if update.notes is not None: + updates.append(f"notes = ${param_idx}") + params.append(update.notes) + param_idx += 1 + + if update.deleted is not None: + if update.deleted: + updates.append(f"deleted_at = ${param_idx}") + params.append(datetime.now(timezone.utc)) + else: + updates.append("deleted_at = NULL") + param_idx += 1 + + if update.interaction_type is not None: + updates.append(f"interaction_type = ${param_idx}") + params.append(update.interaction_type) + param_idx += 1 + # Update engaged based on interaction type + engaged = update.interaction_type == "conversation" + updates.append(f"engaged = ${param_idx}") + params.append(engaged) + param_idx += 1 + + # Clear conversation-specific fields when changing to walk_by + if update.interaction_type == "walk_by" and current["interaction_type"] == "conversation": + updates.extend([ + "persona = NULL", + "hook = NULL", + "sale_type = NULL", + "quantity = NULL", + "unit_price = NULL", + "total_amount = NULL", + "lead_type = NULL", + "objection = NULL" + ]) + + if update.persona is not None: + updates.append(f"persona = ${param_idx}") + params.append(update.persona if update.persona else None) + param_idx += 1 + + if update.hook is not None: + updates.append(f"hook = ${param_idx}") + params.append(update.hook if update.hook else None) + param_idx += 1 + + if update.sale_type is not None: + updates.append(f"sale_type = ${param_idx}") + params.append(update.sale_type if update.sale_type else None) + param_idx += 1 + + if update.quantity is not None: + updates.append(f"quantity = ${param_idx}") + params.append(update.quantity) + param_idx += 1 + + if update.unit_price is not None: + updates.append(f"unit_price = ${param_idx}") + params.append(update.unit_price) + param_idx += 1 + + if update.total_amount is not None: + updates.append(f"total_amount = ${param_idx}") + params.append(update.total_amount) + param_idx += 1 + + if update.lead_type is not None: + updates.append(f"lead_type = ${param_idx}") + params.append(update.lead_type if update.lead_type else None) + param_idx += 1 + + if update.objection is not None: + updates.append(f"objection = ${param_idx}") + params.append(update.objection if update.objection else None) + param_idx += 1 + + if update.timestamp is not None: + updates.append(f"timestamp = ${param_idx}") + params.append(update.timestamp) + param_idx += 1 + + if not updates: + raise HTTPException(status_code=400, detail="No updates provided") + + params.append(UUID(interaction_id)) + query = f""" + UPDATE interactions + SET {", ".join(updates)} + WHERE id = ${param_idx} + RETURNING * + """ - row = await conn.fetchrow(query, *params) + row = await conn.fetchrow(query, *params) record = dict(row) record["id"] = str(record["id"]) @@ -791,7 +951,7 @@ async def get_sankey_data( period: Optional[str] = None # today, week, all - alternative to dates ): """Aggregated data for Sankey diagram visualization.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc) # Determine date range if period == "today": @@ -804,7 +964,7 @@ async def get_sankey_data( start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) if end_date else now else: - start_dt = datetime(2020, 1, 1) + start_dt = datetime(2020, 1, 1, tzinfo=timezone.utc) end_dt = now async with db_pool.acquire() as conn: @@ -1104,7 +1264,7 @@ async def get_seller_analytics( period: Optional[str] = None ): """Performance metrics grouped by seller.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc) if period == "today": start_dt = now.replace(hour=0, minute=0, second=0, microsecond=0) @@ -1116,7 +1276,7 @@ async def get_seller_analytics( start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) if end_date else now else: - start_dt = datetime(2020, 1, 1) + start_dt = datetime(2020, 1, 1, tzinfo=timezone.utc) end_dt = now async with db_pool.acquire() as conn: @@ -1406,7 +1566,7 @@ async def event_generator(): queue = await broadcaster.subscribe() try: # Send initial connection confirmation - yield f"data: {json.dumps({'type': 'connected', 'timestamp': datetime.utcnow().isoformat()})}\n\n" + yield f"data: {json.dumps({'type': 'connected', 'timestamp': datetime.now(timezone.utc).isoformat()})}\n\n" # Send heartbeat every 30 seconds to keep connection alive heartbeat_interval = 30 @@ -1424,7 +1584,7 @@ async def event_generator(): yield f"data: {json.dumps({'type': 'data_change', 'raw': message})}\n\n" except asyncio.TimeoutError: # Send heartbeat to keep connection alive - yield f"data: {json.dumps({'type': 'heartbeat', 'timestamp': datetime.utcnow().isoformat()})}\n\n" + yield f"data: {json.dumps({'type': 'heartbeat', 'timestamp': datetime.now(timezone.utc).isoformat()})}\n\n" except asyncio.CancelledError: pass finally: diff --git a/web/src/App.jsx b/web/src/App.jsx index 6bd98c1..0a6fe8a 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -139,6 +139,8 @@ function App() { const [notesModal, setNotesModal] = useState(null) const [deleteModal, setDeleteModal] = useState(null) const [eventModal, setEventModal] = useState(false) + const [editModal, setEditModal] = useState(null) + const [customTimestampModal, setCustomTimestampModal] = useState(null) // Initialize app - go straight to home, seller assigned by device config useEffect(() => { @@ -358,6 +360,11 @@ function App() { objection: data.objection } + // Add custom timestamp if provided + if (data.customTimestamp) { + payload.timestamp = data.customTimestamp + } + await api('/interactions', { method: 'POST', body: JSON.stringify(payload) @@ -365,6 +372,7 @@ function App() { showConfirmation(data) loadStats() + setFlowData({}) // Clear flow data including custom timestamp } catch (err) { alert('Failed to save: ' + err.message) setScreen('home') @@ -446,6 +454,7 @@ function App() { setScreen('stats') }} onLogEvent={() => setEventModal(true)} + onPastLog={() => setCustomTimestampModal({ type: 'choose' })} /> )} @@ -505,6 +514,7 @@ function App() { }} onAddNote={() => setNotesModal(selectedInteraction)} onDelete={() => setDeleteModal(selectedInteraction)} + onEdit={() => setEditModal(selectedInteraction)} /> )} @@ -584,6 +594,36 @@ function App() { /> )} + {screen === 'past-log-type' && ( + { + setScreen('flow-persona') + }} + onWalkBy={async () => { + try { + await api('/interactions', { + method: 'POST', + body: JSON.stringify({ + interaction_type: 'walk_by', + timestamp: flowData.customTimestamp + }) + }) + setConfirmation({ type: 'walk_by' }) + loadStats() + setTimeout(() => { + setConfirmation(null) + setScreen('home') + setFlowData({}) + }, 1200) + } catch (err) { + alert('Failed to save: ' + err.message) + setScreen('home') + } + }} + onBack={() => setScreen('home')} + /> + )} + {confirmation && } {notesModal && ( @@ -619,6 +659,40 @@ function App() { onClose={() => setEventModal(false)} /> )} + + {editModal && ( + { + try { + await updateInteraction(editModal.id, updates) + setEditModal(null) + // Refresh selected interaction + const updated = await api(`/interactions/${editModal.id}`) + setSelectedInteraction(updated) + } catch (err) { + alert('Failed to update interaction: ' + (err.message || 'Unknown error')) + console.error('Update error:', err) + } + }} + onClose={() => setEditModal(null)} + /> + )} + + {customTimestampModal && ( + { + setFlowData({ customTimestamp: timestamp }) + setCustomTimestampModal(null) + if (customTimestampModal.type === 'choose') { + // Show options: conversation or walk-by + setScreen('past-log-type') + } + }} + onClose={() => setCustomTimestampModal(null)} + /> + )} ) } @@ -702,7 +776,7 @@ function SellerSelectScreen({ session, onSelect }) { } // Home Screen -function HomeScreen({ session, staff, stats, statsPeriod, onCyclePeriod, onConversation, onWalkBy, onViewStats, onLogEvent }) { +function HomeScreen({ session, staff, stats, statsPeriod, onCyclePeriod, onConversation, onWalkBy, onViewStats, onLogEvent, onPastLog }) { const displayName = session?.active_seller?.display_name || staff?.name || 'Staff' const periodLabels = { today: 'Today', week: 'Week', all: 'All Time' } const currentStats = stats?.[statsPeriod] @@ -788,6 +862,13 @@ function HomeScreen({ session, staff, stats, statsPeriod, onCyclePeriod, onConve
{displayName.charAt(0)}
{displayName} +
K Village
@@ -1106,7 +1187,7 @@ function InteractionCard({ item, onClick }) { } // Detail Screen (Phase 2) -function DetailScreen({ interaction, onBack, onAddNote, onDelete }) { +function DetailScreen({ interaction, onBack, onAddNote, onDelete, onEdit }) { const i = interaction return ( @@ -1114,7 +1195,10 @@ function DetailScreen({ interaction, onBack, onAddNote, onDelete }) {
Details - +
+ + +
@@ -2005,4 +2089,282 @@ function ConfirmationOverlay({ data, flowData }) { ) } +// Past Log Type Selection Screen +function PastLogTypeScreen({ onConversation, onWalkBy, onBack }) { + return ( +
+
+ + Log Past Event +
+
+

What type of interaction?

+
+ + +
+
+
+ ) +} + +// Custom Timestamp Modal +function CustomTimestampModal({ data, onContinue, onClose }) { + const [date, setDate] = useState('') + const [time, setTime] = useState('') + const [error, setError] = useState('') + + useEffect(() => { + // Set default to current date/time + const now = new Date() + const dateStr = now.toISOString().split('T')[0] + const timeStr = now.toTimeString().slice(0, 5) + setDate(dateStr) + setTime(timeStr) + }, []) + + const handleContinue = () => { + if (!date || !time) { + setError('Please select both date and time') + return + } + + const timestamp = new Date(`${date}T${time}:00`) + const now = new Date() + + // Validate not in the future + if (timestamp > now) { + setError('Cannot select a future date/time') + return + } + + // Validate not more than 30 days in the past + const maxBackdate = new Date() + maxBackdate.setDate(maxBackdate.getDate() - 30) + if (timestamp < maxBackdate) { + setError('Cannot backdate more than 30 days') + return + } + + setError('') + onContinue(timestamp.toISOString()) + } + + return ( +
+
e.stopPropagation()}> +

⏰ When did this happen?

+
+
+ + setDate(e.target.value)} + max={new Date().toISOString().split('T')[0]} + /> +
+
+ + setTime(e.target.value)} + /> +
+
+

Select the date and time of the past interaction (max 30 days ago)

+ {error &&

{error}

} +
+ + +
+
+
+ ) +} + +// Edit Interaction Modal +function EditInteractionModal({ interaction, onSave, onClose }) { + const [formData, setFormData] = useState({ + timestamp: '', + persona: interaction.persona || '', + hook: interaction.hook || '', + sale_type: interaction.sale_type || '', + quantity: interaction.quantity || 1, + unit_price: interaction.unit_price || null, + total_amount: interaction.total_amount || null, + lead_type: interaction.lead_type || '', + objection: interaction.objection || '' + }) + const [error, setError] = useState('') + + useEffect(() => { + if (interaction.timestamp) { + const dt = new Date(interaction.timestamp) + const dateStr = dt.toISOString().split('T')[0] + const timeStr = dt.toTimeString().slice(0, 5) + setFormData(prev => ({ ...prev, timestamp: `${dateStr}T${timeStr}` })) + } + }, [interaction.timestamp]) + + const handleSave = () => { + const updates = {} + + // Only include changed fields + if (formData.timestamp && formData.timestamp !== interaction.timestamp) { + const [dateStr, timeStr] = formData.timestamp.split('T') + const newTimestamp = new Date(`${dateStr}T${timeStr}:00`) + const now = new Date() + + // Validate not in the future + if (newTimestamp > now) { + setError('Cannot select a future date/time') + return + } + + // Validate not more than 30 days in the past + const maxBackdate = new Date() + maxBackdate.setDate(maxBackdate.getDate() - 30) + if (newTimestamp < maxBackdate) { + setError('Cannot backdate more than 30 days') + return + } + + updates.timestamp = newTimestamp.toISOString() + } + if (formData.persona !== interaction.persona) updates.persona = formData.persona || null + if (formData.hook !== interaction.hook) updates.hook = formData.hook || null + if (formData.sale_type !== interaction.sale_type) updates.sale_type = formData.sale_type || null + if (formData.quantity !== interaction.quantity) updates.quantity = formData.quantity + if (formData.unit_price !== interaction.unit_price) updates.unit_price = formData.unit_price + if (formData.total_amount !== interaction.total_amount) updates.total_amount = formData.total_amount + if (formData.lead_type !== interaction.lead_type) updates.lead_type = formData.lead_type || null + if (formData.objection !== interaction.objection) updates.objection = formData.objection || null + + if (Object.keys(updates).length === 0) { + onClose() + return + } + + setError('') + onSave(updates) + } + + return ( +
+
e.stopPropagation()}> +

✏️ Edit Interaction

+ +
+
+ + setFormData({ ...formData, timestamp: e.target.value })} + /> +
+ + {interaction.engaged && ( + <> +
+ + +
+ +
+ + +
+ +
+ + +
+ + {formData.sale_type === 'single' && ( + <> +
+ + setFormData({ ...formData, quantity: parseInt(e.target.value) })} + /> +
+
+ + +
+ + )} + +
+ + +
+ + {formData.sale_type === 'none' && ( +
+ + +
+ )} + + )} +
+ + {error &&

{error}

} +
+ + +
+
+
+ ) +} + export default App