from fastapi import FastAPI, Depends, HTTPException, Request, Form, Response from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from pydantic import BaseModel from typing import Optional, List from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session as DBSession from sqlalchemy import or_ from app.db.session import get_db, SessionLocal, engine, Base from app.models import Event, Task, UserSession, ProjectTask, CallItem from app.services.nextcloud_sync import sync_calendar_events, sync_todo_tasks, update_remote_todo from app.services.backlog_sync import sync_backlog from app.services.email_sync import sync_emails, mark_email_done_and_archive, delete_email_from_server from app.services.gmail_sync import sync_gmail, gmail_archive_email, gmail_delete_email, gmail_cleanup_dismissed from app.services.prioritization import generate_suggestions from app.services.digest import generate_daily_digest, generate_weekly_retro from app.services.phone_extractor import extract_phone_number from datetime import datetime, time, timedelta from contextlib import asynccontextmanager from zoneinfo import ZoneInfo import asyncio import os import secrets BERLIN_TZ = ZoneInfo("Europe/Berlin") def _extract_sender(description: str | None, origin: str | None) -> str: """Extract sender from email task description (first line: 'From: ...').""" if not description or not origin: return "" if origin in ("dhive_email", "gmail") and description.startswith("From: "): first_line = description.split("\n")[0] return first_line.replace("From: ", "").strip() return "" # --- Auto-sync background task --- async def auto_sync_loop(): """Background task that syncs Nextcloud and email every 5 minutes.""" while True: await asyncio.sleep(300) # 5 minutes try: sync_calendar_events() except Exception as e: print(f"Auto-sync (calendar) error: {e}") try: sync_todo_tasks() except Exception as e: print(f"Auto-sync (todo) error: {e}") try: sync_emails() except Exception as e: print(f"Auto-sync (email) error: {e}") try: sync_gmail() except Exception as e: print(f"Auto-sync (gmail) error: {e}") try: gmail_cleanup_dismissed() except Exception as e: print(f"Auto-sync (gmail cleanup) error: {e}") @asynccontextmanager async def lifespan(app: FastAPI): """App lifespan: start background sync, create tables.""" # Create tables and add missing columns Base.metadata.create_all(bind=engine) # Ensure source_url column exists (for upgrades from older schema) from sqlalchemy import inspect, text with engine.connect() as conn: inspector = inspect(engine) task_columns = [c['name'] for c in inspector.get_columns('tasks')] if 'source_url' not in task_columns: conn.execute(text("ALTER TABLE tasks ADD COLUMN source_url VARCHAR")) conn.commit() if 'agent_ready' not in task_columns: conn.execute(text("ALTER TABLE tasks ADD COLUMN agent_ready BOOLEAN DEFAULT FALSE")) conn.commit() if 'hidden_until' not in task_columns: conn.execute(text("ALTER TABLE tasks ADD COLUMN hidden_until TIMESTAMP")) conn.commit() if 'labels' not in task_columns: conn.execute(text("ALTER TABLE tasks ADD COLUMN labels VARCHAR")) conn.commit() if 'last_synced_at' not in task_columns: conn.execute(text("ALTER TABLE tasks ADD COLUMN last_synced_at TIMESTAMP")) conn.commit() if 'has_conflict' not in task_columns: conn.execute(text("ALTER TABLE tasks ADD COLUMN has_conflict BOOLEAN DEFAULT FALSE")) conn.commit() if 'conflict_data' not in task_columns: conn.execute(text("ALTER TABLE tasks ADD COLUMN conflict_data TEXT")) conn.commit() # Ensure project_tasks table exists if not inspector.has_table('project_tasks'): Base.metadata.tables['project_tasks'].create(bind=engine) # Ensure call_items table exists if not inspector.has_table('call_items'): Base.metadata.tables['call_items'].create(bind=engine) # Restore dismissed tasks from backup file (survives VPS resets) _restore_dismissed_from_backup() task = asyncio.create_task(auto_sync_loop()) yield task.cancel() def _restore_dismissed_from_backup(): """Load dismissed_tasks.json and mark those source_ids as dismissed in DB.""" import json backup_path = os.path.join(os.path.dirname(__file__), '..', 'dismissed_tasks.json') if not os.path.exists(backup_path): return try: with open(backup_path, 'r') as f: data = json.load(f) dismissed_list = data.get("dismissed", []) if not dismissed_list: return db = SessionLocal() restored = 0 for item in dismissed_list: source_id = item.get("source_id") if not source_id: continue existing = db.query(Task).filter(Task.source_id == source_id).first() if existing and existing.status == "open": existing.status = "dismissed" restored += 1 if restored: db.commit() print(f"Restored {restored} dismissed tasks from backup") db.close() except Exception as e: print(f"Error restoring dismissed backup: {e}") app = FastAPI(title="OrgMyLife API", description="Personal coordination system", lifespan=lifespan) # Authentication config API_SECRET = os.getenv("API_SECRET", "") APP_USERNAME = os.getenv("APP_USERNAME", "admin") APP_PASSWORD = os.getenv("APP_PASSWORD", "changeme") SESSION_COOKIE = "orgmylife_session" SESSION_MAX_AGE = 7 * 24 * 3600 # 7 days in seconds # --- Session helpers (DB-persistent) --- def verify_session(request: Request): """Check if user has a valid session cookie (DB-backed).""" session_id = request.cookies.get(SESSION_COOKIE) if session_id: db = SessionLocal() try: sess = db.query(UserSession).filter( UserSession.session_id == session_id, UserSession.expires_at > datetime.utcnow() ).first() if sess: return sess.username finally: db.close() # Also allow API_SECRET bearer token (for orchestrator) auth_header = request.headers.get("Authorization", "") if API_SECRET and auth_header == f"Bearer {API_SECRET}": return "api" return None def verify_api_key(request: Request): """Verify API key for orchestrator endpoints.""" # Check X-API-Key header (avoids conflict with Caddy basic auth) api_key_header = request.headers.get("X-API-Key", "") if API_SECRET and api_key_header == API_SECRET: return # Also check Authorization Bearer (backward compat) auth_header = request.headers.get("Authorization", "") if API_SECRET and auth_header == f"Bearer {API_SECRET}": return if not API_SECRET: return # No secret configured = allow all (dev mode only) raise HTTPException(status_code=401, detail="Invalid API key") # --- Login/Logout --- @app.get("/login", response_class=HTMLResponse) def login_page(request: Request): """Serve the login page.""" # If already logged in, redirect to home if verify_session(request): return RedirectResponse(url="/", status_code=303) error = request.query_params.get("error", "") error_html = f'

{error}

' if error else "" return f""" OrgMyLife - Login

OrgMyLife

Your coordination system

{error_html}
""" @app.post("/login") def login_submit(username: str = Form(...), password: str = Form(...)): """Handle login form submission.""" if username == APP_USERNAME and password == APP_PASSWORD: session_id = secrets.token_hex(32) expires_at = datetime.utcnow() + timedelta(seconds=SESSION_MAX_AGE) # Store session in DB db = SessionLocal() try: db.add(UserSession(session_id=session_id, username=username, expires_at=expires_at)) db.commit() finally: db.close() response = RedirectResponse(url="/", status_code=303) response.set_cookie( SESSION_COOKIE, session_id, httponly=True, secure=True, samesite="lax", max_age=SESSION_MAX_AGE ) return response return RedirectResponse(url="/login?error=Invalid+credentials", status_code=303) @app.get("/logout") def logout(request: Request): """Log out and clear session.""" session_id = request.cookies.get(SESSION_COOKIE) if session_id: db = SessionLocal() try: db.query(UserSession).filter(UserSession.session_id == session_id).delete() db.commit() finally: db.close() response = RedirectResponse(url="/login", status_code=303) response.delete_cookie(SESSION_COOKIE) return response # --- Pydantic models --- class TaskCreate(BaseModel): title: str description: Optional[str] = "" priority: Optional[int] = 4 agent_ready: Optional[bool] = False due_date: Optional[str] = None labels: Optional[str] = None def model_post_init(self, __context): if self.title: self.title = self.title[:200] if self.description: self.description = self.description[:5000] if self.priority is not None: self.priority = max(1, min(4, self.priority)) if self.labels: self.labels = self.labels[:200] class TaskUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None priority: Optional[int] = None status: Optional[str] = None agent_ready: Optional[bool] = None due_date: Optional[str] = None labels: Optional[str] = None def model_post_init(self, __context): if self.title: self.title = self.title[:200] if self.description: self.description = self.description[:5000] if self.priority is not None: self.priority = max(1, min(4, self.priority)) if self.status and self.status not in ("open", "in_progress", "review", "completed", "dismissed"): self.status = None if self.labels is not None: self.labels = self.labels[:200] class ConflictResolution(BaseModel): resolution: str # "local" or "remote" class ReorderItem(BaseModel): id: int position: int class ReorderRequest(BaseModel): order: List[ReorderItem] # --- Call List Pydantic models --- class CallItemCreate(BaseModel): task_id: Optional[int] = None person: Optional[str] = None reason: Optional[str] = None phone_number: Optional[str] = None priority: int = 3 def model_post_init(self, __context): if self.person: self.person = self.person[:200] if self.reason: self.reason = self.reason[:500] if self.phone_number: self.phone_number = self.phone_number[:30] if self.priority is not None: self.priority = max(1, min(4, self.priority)) class CallItemUpdate(BaseModel): person: Optional[str] = None reason: Optional[str] = None phone_number: Optional[str] = None priority: Optional[int] = None status: Optional[str] = None resolve_task: bool = False def model_post_init(self, __context): if self.person: self.person = self.person.strip()[:200] if self.reason: self.reason = self.reason.strip()[:500] if self.phone_number: self.phone_number = self.phone_number[:30] if self.priority is not None: self.priority = max(1, min(4, self.priority)) class CallItemResponse(BaseModel): id: int person: str reason: str phone_number: Optional[str] = None original_task_id: Optional[int] = None priority: int status: str created_at: str # --- Call List API --- @app.post("/api/calls") def create_call_item(call: CallItemCreate, request: Request, db: DBSession = Depends(get_db)): """Create a new call item, either from an existing task or manually.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") if call.task_id: # Create from existing task task = db.query(Task).filter(Task.id == call.task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") # Extract sender name from description for email-origin tasks person = "Unknown" if task.origin in ("gmail", "dhive_email") and task.description: sender = _extract_sender(task.description, task.origin) if sender: person = sender # Extract phone number from task description phone = None if task.description: phone = extract_phone_number(task.description) new_call = CallItem( person=person[:200], reason=(task.title or "")[:500], phone_number=phone[:30] if phone else None, original_task_id=task.id, priority=call.priority, status="pending", ) else: # Manual creation — require person and reason if not call.person or not call.reason: raise HTTPException(status_code=422, detail="person and reason are required when no task_id is provided") new_call = CallItem( person=call.person[:200], reason=call.reason[:500], phone_number=call.phone_number[:30] if call.phone_number else None, original_task_id=None, priority=call.priority, status="pending", ) db.add(new_call) db.commit() db.refresh(new_call) return CallItemResponse( id=new_call.id, person=new_call.person, reason=new_call.reason, phone_number=new_call.phone_number, original_task_id=new_call.original_task_id, priority=new_call.priority, status=new_call.status, created_at=new_call.created_at.strftime("%Y-%m-%d %H:%M") if new_call.created_at else "", ) @app.get("/api/calls") def get_call_items(request: Request, db: DBSession = Depends(get_db)): """Return all pending call items, ordered by priority ASC then created_at ASC.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") calls = db.query(CallItem).filter( CallItem.status == "pending" ).order_by(CallItem.priority.asc(), CallItem.created_at.asc()).all() return { "calls": [ CallItemResponse( id=c.id, person=c.person, reason=c.reason, phone_number=c.phone_number, original_task_id=c.original_task_id, priority=c.priority, status=c.status, created_at=c.created_at.strftime("%Y-%m-%d %H:%M") if c.created_at else "", ) for c in calls ] } @app.put("/api/calls/{id}") def update_call_item(id: int, update: CallItemUpdate, request: Request, db: DBSession = Depends(get_db)): """Update a call item's fields. Optionally resolve the original task on completion.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") call = db.query(CallItem).filter(CallItem.id == id).first() if not call: raise HTTPException(status_code=404, detail="Call item not found") # Validate status if provided if update.status is not None: if update.status not in ("pending", "done"): raise HTTPException(status_code=400, detail="Status must be 'pending' or 'done'") # Update only fields that are not None if update.person is not None: call.person = update.person if update.reason is not None: call.reason = update.reason if update.phone_number is not None: call.phone_number = update.phone_number if update.priority is not None: call.priority = update.priority if update.status is not None: call.status = update.status db.commit() # If marking as done and resolve_task=true, resolve the original task if update.status == "done" and update.resolve_task and call.original_task_id: original_task = db.query(Task).filter(Task.id == call.original_task_id).first() if original_task: original_task.status = "completed" db.commit() # Trigger existing side effects (email archiving, Nextcloud sync) if original_task.origin == "dhive_email" and original_task.source_id: try: mark_email_done_and_archive(original_task.source_id) except Exception as e: print(f"Error archiving email for task {original_task.id}: {e}") if original_task.origin == "gmail" and original_task.source_id: try: gmail_archive_email(original_task.source_id) except Exception as e: print(f"Error archiving gmail for task {original_task.id}: {e}") if original_task.origin and original_task.origin.startswith("nextcloud_todo") and original_task.source_id: try: update_remote_todo(original_task.source_id, {"status": "completed"}) except Exception as e: print(f"Error syncing nextcloud todo for task {original_task.id}: {e}") else: import logging logging.warning(f"Original task {call.original_task_id} not found when resolving call item {call.id}") db.refresh(call) return CallItemResponse( id=call.id, person=call.person, reason=call.reason, phone_number=call.phone_number, original_task_id=call.original_task_id, priority=call.priority, status=call.status, created_at=call.created_at.strftime("%Y-%m-%d %H:%M") if call.created_at else "", ) @app.delete("/api/calls/{id}") def delete_call_item(id: int, request: Request, db: DBSession = Depends(get_db)): """Delete a call item from the database.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") call = db.query(CallItem).filter(CallItem.id == id).first() if not call: raise HTTPException(status_code=404, detail="Call item not found") db.delete(call) db.commit() return {"detail": "Call item deleted"} # Mount frontend static files app.mount("/static", StaticFiles(directory="frontend"), name="static") # --- Page routes --- @app.get("/") def read_root(request: Request): user = verify_session(request) if not user: return RedirectResponse(url="/login", status_code=303) return FileResponse("frontend/index.html") @app.get("/api/health") def health_check(): return {"status": "ok"} @app.get("/api/debug/env") def debug_env(request: Request): """Show which env vars are set (no values, just presence).""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") return { "IMAP_SERVER": os.getenv("IMAP_SERVER", "NOT SET"), "IMAP_PORT": os.getenv("IMAP_PORT", "NOT SET"), "EMAIL_USERNAME": os.getenv("EMAIL_USERNAME", "NOT SET"), "EMAIL_PASSWORD_SET": bool(os.getenv("EMAIL_PASSWORD")), "GMAIL_USERNAME": os.getenv("GMAIL_USERNAME", "NOT SET"), "GMAIL_PASSWORD_SET": bool(os.getenv("GMAIL_PASSWORD")), "NEXTCLOUD_URL": os.getenv("NEXTCLOUD_URL", "NOT SET"), } @app.get("/api/debug/imap-folders") def debug_imap_folders(request: Request): """List available IMAP folders.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") try: from app.services.email_sync import _get_imap_connection conn = _get_imap_connection() status, folders = conn.list() conn.logout() if status == "OK": return {"folders": [f.decode() for f in folders]} return {"error": "Could not list folders"} except Exception as e: return {"error": str(e)} @app.get("/api/tasks/labels") def get_task_labels(request: Request, db: DBSession = Depends(get_db)): """Return all unique labels used across open tasks.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") tasks = db.query(Task.labels).filter(Task.status == "open", Task.labels != None, Task.labels != "").all() all_labels = set() for (labels_str,) in tasks: for label in labels_str.split(","): label = label.strip() if label: all_labels.add(label) return {"labels": sorted(all_labels)} @app.get("/api/tasks/conflicts") def get_conflict_tasks(request: Request, db: DBSession = Depends(get_db)): """Return all tasks with sync conflicts.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") import json as json_mod tasks = db.query(Task).filter(Task.has_conflict == True).all() priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"} return { "conflicts": [ { "id": t.id, "title": t.title, "description": t.description or "", "status": t.status, "priority": priority_labels.get(t.priority, "Low"), "conflict_data": json_mod.loads(t.conflict_data) if t.conflict_data else {}, } for t in tasks ], "total": len(tasks), } @app.post("/api/tasks/{task_id}/resolve-conflict") def resolve_conflict(task_id: int, body: ConflictResolution, request: Request, db: DBSession = Depends(get_db)): """Resolve a sync conflict by choosing local or remote version.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") import json as json_mod task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") if not task.has_conflict: raise HTTPException(status_code=400, detail="Task has no conflict") now = datetime.now(tz=ZoneInfo("UTC")) if body.resolution == "local": # Keep local version, push to remote if task.source_id and task.origin and task.origin.startswith("nextcloud_todo"): push_updates = { "title": task.title, "description": task.description or "", "status": task.status, } update_remote_todo(task.source_id, push_updates) task.has_conflict = False task.conflict_data = None task.last_synced_at = now elif body.resolution == "remote": # Apply remote version from conflict_data if task.conflict_data: remote = json_mod.loads(task.conflict_data) if "title" in remote: task.title = remote["title"] if "description" in remote: task.description = remote["description"] if "status" in remote: task.status = remote["status"] task.has_conflict = False task.conflict_data = None task.last_synced_at = now else: raise HTTPException(status_code=400, detail="resolution must be 'local' or 'remote'") db.commit() return {"status": "success", "task_id": task.id} @app.get("/api/tasks/dismissed") def get_dismissed_tasks(request: Request, db: DBSession = Depends(get_db)): """Export all dismissed/completed task source_ids for backup.""" verify_api_key(request) tasks = db.query(Task).filter( Task.status.in_(["dismissed", "completed"]), Task.source_id != None ).all() return { "dismissed": [ {"source_id": t.source_id, "title": t.title, "origin": t.origin} for t in tasks ] } # --- Sync endpoints --- @app.post("/api/sync/nextcloud") def trigger_nextcloud_sync(request: Request): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") success = sync_calendar_events() if success: return {"status": "success", "message": "Nextcloud synced successfully"} return {"status": "error", "message": "Sync failed. Check server logs."} @app.post("/api/sync/backlog") def trigger_backlog_sync(request: Request): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") success = sync_backlog() if success: return {"status": "success", "message": "Backlog synced successfully"} return {"status": "error", "message": "Backlog sync failed. Check server logs."} @app.post("/api/sync/nextcloud-todo") def trigger_nextcloud_todo_sync(request: Request): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") success = sync_todo_tasks() if success: return {"status": "success", "message": "Nextcloud ToDo synced successfully"} return {"status": "error", "message": "Nextcloud ToDo sync failed. Check server logs."} @app.post("/api/sync/email") def trigger_email_sync(request: Request): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") try: # Test connection first and return specific error from app.services.email_sync import _get_imap_connection try: conn = _get_imap_connection() conn.logout() except ValueError as e: return {"status": "error", "message": f"IMAP not configured: {e}"} except Exception as e: return {"status": "error", "message": f"IMAP connection failed: {e}"} success = sync_emails() if success: return {"status": "success", "message": "Email sync completed successfully"} return {"status": "error", "message": "Email sync failed after connecting. Check server logs."} except Exception as e: import traceback return {"status": "error", "message": f"Email sync exception: {str(e)}", "trace": traceback.format_exc()} @app.post("/api/sync/gmail") def trigger_gmail_sync(request: Request): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") success = sync_gmail() if success: return {"status": "success", "message": "Gmail sync completed successfully"} return {"status": "error", "message": "Gmail sync failed. Check server logs."} # --- Task endpoints --- @app.get("/api/tasks") def get_all_tasks( request: Request, sort: Optional[str] = None, filter_origin: Optional[str] = None, filter_agent: Optional[str] = None, filter_priority: Optional[str] = None, filter_label: Optional[str] = None, db: DBSession = Depends(get_db), ): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") query = db.query(Task).filter(Task.status == "open") # Hide snoozed tasks (hidden_until in the future) now = datetime.now(tz=BERLIN_TZ) query = query.filter(or_(Task.hidden_until == None, Task.hidden_until <= now)) # Filters if filter_origin: if filter_origin == "nextcloud_todo": query = query.filter(Task.origin.like("nextcloud_todo%")) else: query = query.filter(Task.origin == filter_origin) if filter_agent == "true": query = query.filter(Task.agent_ready == True) elif filter_agent == "false": query = query.filter((Task.agent_ready == False) | (Task.agent_ready == None)) if filter_priority: try: query = query.filter(Task.priority == int(filter_priority)) except ValueError: pass if filter_label: query = query.filter(Task.labels.like(f"%{filter_label}%")) # Sorting if sort == "priority": query = query.order_by(Task.priority, Task.position) elif sort == "due_date": query = query.order_by(Task.due_date.asc().nullslast(), Task.priority) elif sort == "created": query = query.order_by(Task.created_at.desc()) else: query = query.order_by(Task.position) real_tasks = query.all() priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"} tasks_payload = [ { "id": t.id, "title": t.title, "description": t.description, "sender": _extract_sender(t.description, t.origin), "priority": priority_labels.get(t.priority, "Low"), "priority_value": t.priority, "position": t.position, "status": t.status, "origin": t.origin or "", "source_url": t.source_url or "", "agent_ready": t.agent_ready or False, "labels": t.labels or "", "has_conflict": t.has_conflict or False, "due_date": t.due_date.strftime("%Y-%m-%d") if t.due_date else None, "created_at": t.created_at.strftime("%Y-%m-%d %H:%M") if t.created_at else None, } for t in real_tasks ] return {"tasks": tasks_payload, "total": len(tasks_payload)} @app.post("/api/tasks") def create_task(task: TaskCreate, request: Request, db: DBSession = Depends(get_db)): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") max_pos = db.query(Task).filter(Task.status == "open").count() due = None if task.due_date: try: due = datetime.strptime(task.due_date, "%Y-%m-%d") except ValueError: pass new_task = Task( title=task.title, description=task.description, priority=task.priority, position=max_pos, origin="manual", status="open", agent_ready=task.agent_ready, due_date=due, labels=task.labels, ) db.add(new_task) db.commit() db.refresh(new_task) return {"status": "success", "task_id": new_task.id} @app.put("/api/tasks/reorder") def reorder_tasks(req: ReorderRequest, request: Request, db: DBSession = Depends(get_db)): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") for item in req.order: task = db.query(Task).filter(Task.id == item.id).first() if task: task.position = item.position db.commit() return {"status": "success"} @app.put("/api/tasks/{task_id}/move") def move_task(task_id: int, request: Request, db: DBSession = Depends(get_db)): """Move a task to top or bottom. Query param: direction=top|bottom""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") direction = request.query_params.get("direction", "top") task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") if direction == "top": # Set position to -1, then reindex all min_pos = db.query(Task).filter(Task.status == "open").count() task.position = -1 db.flush() # Reindex: task at -1 becomes 0, others shift all_tasks = db.query(Task).filter(Task.status == "open").order_by(Task.position).all() for i, t in enumerate(all_tasks): t.position = i else: # Move to bottom max_pos = db.query(Task).filter(Task.status == "open").count() task.position = max_pos + 1 db.flush() all_tasks = db.query(Task).filter(Task.status == "open").order_by(Task.position).all() for i, t in enumerate(all_tasks): t.position = i db.commit() return {"status": "success"} class SnoozeRequest(BaseModel): until: str # ISO date string, e.g. "2026-05-13" @app.post("/api/tasks/{task_id}/snooze") def snooze_task(task_id: int, snooze: SnoozeRequest, request: Request, db: DBSession = Depends(get_db)): """Snooze a task until a given date (hide it until then).""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") try: hidden_date = datetime.strptime(snooze.until, "%Y-%m-%d").replace(tzinfo=BERLIN_TZ) except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.") task.hidden_until = hidden_date db.commit() return {"status": "success", "hidden_until": snooze.until} @app.put("/api/tasks/{task_id}") def update_task(task_id: int, task_update: TaskUpdate, request: Request, db: DBSession = Depends(get_db)): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") if task_update.title is not None: task.title = task_update.title if task_update.description is not None: task.description = task_update.description if task_update.priority is not None: task.priority = task_update.priority if task_update.status is not None: task.status = task_update.status if task_update.agent_ready is not None: task.agent_ready = task_update.agent_ready if task_update.labels is not None: task.labels = task_update.labels if task_update.labels else None if task_update.due_date is not None: if task_update.due_date == "": task.due_date = None else: try: task.due_date = datetime.strptime(task_update.due_date, "%Y-%m-%d") except ValueError: pass db.commit() # Two-way sync: push changes back to external source if task.origin == "nextcloud_todo" and task.source_id: push_updates = {} if task_update.title is not None: push_updates["title"] = task.title if task_update.description is not None: push_updates["description"] = task.description if task_update.status is not None: push_updates["status"] = task.status if push_updates: update_remote_todo(task.source_id, push_updates) if task.origin == "dhive_email" and task.source_id and task_update.status == "completed": mark_email_done_and_archive(task.source_id) if task.origin == "gmail" and task.source_id and task_update.status == "completed": gmail_archive_email(task.source_id) return {"status": "success"} @app.delete("/api/tasks/{task_id}") def delete_task(task_id: int, request: Request, db: DBSession = Depends(get_db)): """Won't do: archive email, dismiss from board. Does NOT delete from server.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") # Archive email on dismiss if task.origin == "dhive_email" and task.source_id: mark_email_done_and_archive(task.source_id) if task.origin == "gmail" and task.source_id: gmail_archive_email(task.source_id) # Mark as dismissed locally (won't show up again) task.status = "dismissed" db.commit() return {"status": "success"} @app.post("/api/tasks/{task_id}/purge") def purge_task(task_id: int, request: Request, db: DBSession = Depends(get_db)): """Delete permanently: delete email from server, mark nextcloud task done, remove from board.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") # Delete email from server (move to Trash) if task.origin == "dhive_email" and task.source_id: delete_email_from_server(task.source_id) if task.origin == "gmail" and task.source_id: gmail_delete_email(task.source_id) # Mark nextcloud task as completed on server if task.origin and task.origin.startswith("nextcloud_todo") and task.source_id: update_remote_todo(task.source_id, {"status": "completed"}) # Remove from local DB entirely db.delete(task) db.commit() return {"status": "success"} # --- Dashboard --- @app.get("/api/dashboard") def get_dashboard_data(request: Request, db: DBSession = Depends(get_db)): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") now = datetime.now(BERLIN_TZ) today_start = datetime.combine(now.date(), time.min).replace(tzinfo=BERLIN_TZ) today_end = datetime.combine(now.date(), time.max).replace(tzinfo=BERLIN_TZ) # Today's events todays_events = db.query(Event).filter( Event.start_time >= today_start, Event.start_time <= today_end ).order_by(Event.start_time).all() # Upcoming events week_end = today_start + timedelta(days=7) upcoming_events = db.query(Event).filter( Event.start_time > today_end, Event.start_time <= week_end ).order_by(Event.start_time).limit(5).all() events_payload = [ {"id": e.id, "title": e.title, "time": f"{e.start_time.astimezone(BERLIN_TZ).strftime('%H:%M')} - {e.end_time.astimezone(BERLIN_TZ).strftime('%H:%M')}"} for e in todays_events ] if not events_payload: events_payload = [{"id": 0, "title": "No events today", "time": ""}] # Top priorities real_tasks = db.query(Task).filter(Task.status == "open").order_by(Task.priority, Task.position).limit(5).all() priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"} tasks_payload = [ {"id": t.id, "title": t.title, "priority": priority_labels.get(t.priority, "Low")} for t in real_tasks ] if not tasks_payload: tasks_payload = [{"id": 0, "title": "No open tasks", "priority": ""}] # Overdue tasks overdue = db.query(Task).filter( Task.status == "open", Task.due_date != None, Task.due_date < today_start ).count() # Tasks due this week due_this_week = db.query(Task).filter( Task.status == "open", Task.due_date != None, Task.due_date >= today_start, Task.due_date <= week_end ).count() total_open = db.query(Task).filter(Task.status == "open").count() # Build digest parts = [f"You have {total_open} open tasks."] if overdue: parts.append(f"⚠️ {overdue} overdue!") if due_this_week: parts.append(f"📅 {due_this_week} due this week.") digest = " ".join(parts) # Suggestions from prioritization engine suggestions = generate_suggestions(db) return { "digest": digest, "priorities": tasks_payload, "events": events_payload, "upcoming": [ {"id": e.id, "title": e.title, "time": f"{e.start_time.astimezone(BERLIN_TZ).strftime('%a %b %d, %H:%M')}"} for e in upcoming_events ], "suggestions": suggestions, } # --- Suggestions endpoint --- @app.get("/api/suggestions") def get_suggestions(request: Request, db: DBSession = Depends(get_db)): """Get prioritization suggestions based on current task state.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") return {"suggestions": generate_suggestions(db)} # --- Project Tasks (Kanban Board) --- class ProjectTaskSync(BaseModel): repo: str tasks: List[dict] # Each: {"title": str, "status": str (optional), "description": str (optional)} @app.post("/api/projects/sync") def sync_project_tasks(payload: ProjectTaskSync, request: Request, db: DBSession = Depends(get_db)): """Sync tasks from a repo's PLAN.md into the Projects board. Creates new tasks, skips existing ones (by title+repo). Does not delete.""" verify_api_key(request) repo = payload.repo[:100] synced = 0 for item in payload.tasks: title = item.get("title", "")[:200] if not title: continue # Check if already exists existing = db.query(ProjectTask).filter( ProjectTask.repo == repo, ProjectTask.title == title ).first() if existing: continue status = item.get("status", "backlog") if status not in ("backlog", "ready", "in_progress", "blocked", "review", "done"): status = "backlog" db.add(ProjectTask( title=title, description=item.get("description", "")[:2000], repo=repo, status=status, )) synced += 1 db.commit() return {"status": "success", "synced": synced, "repo": repo} @app.get("/api/projects") def get_project_tasks(request: Request, db: DBSession = Depends(get_db)): """Get all project tasks grouped by status.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") tasks = db.query(ProjectTask).order_by(ProjectTask.priority, ProjectTask.created_at).all() return { "tasks": [ { "id": t.id, "title": t.title, "description": t.description, "repo": t.repo, "status": t.status, "priority": t.priority, "created_at": t.created_at.strftime("%Y-%m-%d") if t.created_at else None, } for t in tasks ] } @app.post("/api/projects") def create_project_task(request: Request, db: DBSession = Depends(get_db)): """Create a new project task.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") import json body = json.loads(request._receive.__self__._body.decode() if hasattr(request, '_receive') else '{}') # Use a simpler approach return {"status": "error", "message": "Use the JSON body endpoint"} class ProjectTaskCreate(BaseModel): title: str repo: Optional[str] = "" description: Optional[str] = "" class ProjectTaskUpdate(BaseModel): status: Optional[str] = None title: Optional[str] = None description: Optional[str] = None repo: Optional[str] = None @app.post("/api/projects/create") def create_project_task_json(task: ProjectTaskCreate, request: Request, db: DBSession = Depends(get_db)): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") new_task = ProjectTask( title=task.title[:200], repo=task.repo[:100] if task.repo else "", description=task.description[:2000] if task.description else "", status="backlog", ) db.add(new_task) db.commit() db.refresh(new_task) return {"status": "success", "id": new_task.id} @app.put("/api/projects/{task_id}") def update_project_task(task_id: int, update: ProjectTaskUpdate, request: Request, db: DBSession = Depends(get_db)): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") task = db.query(ProjectTask).filter(ProjectTask.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") valid_statuses = ["backlog", "ready", "in_progress", "blocked", "review", "done"] if update.status and update.status in valid_statuses: task.status = update.status if update.title: task.title = update.title[:200] if update.description is not None: task.description = update.description[:2000] if update.repo is not None: task.repo = update.repo[:100] db.commit() return {"status": "success"} @app.delete("/api/projects/{task_id}") def delete_project_task(task_id: int, request: Request, db: DBSession = Depends(get_db)): if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") task = db.query(ProjectTask).filter(ProjectTask.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") db.delete(task) db.commit() return {"status": "success"} # --- Daily Digest & Weekly Retro --- @app.get("/api/digest") def get_daily_digest(request: Request, db: DBSession = Depends(get_db)): """Get structured daily digest.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") return generate_daily_digest(db) @app.get("/api/retro") def get_weekly_retro(request: Request, db: DBSession = Depends(get_db)): """Get weekly retrospective summary.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") return generate_weekly_retro(db) # --------------------------------------------------------------------------- # Orchestrator API — used by AI-Orchestrator to poll and update tasks # --------------------------------------------------------------------------- @app.get("/api/orchestrator/tasks") def get_orchestrator_tasks(request: Request, db: DBSession = Depends(get_db)): """Fetch tasks eligible for agent dispatch.""" verify_api_key(request) active_statuses = ["open", "in_progress"] tasks = ( db.query(Task) .filter(Task.agent_ready == True, Task.status.in_(active_statuses)) .order_by(Task.priority, Task.created_at) .all() ) return { "tasks": [ { "id": str(t.id), "identifier": f"OML-{t.id}", "title": t.title, "description": t.description, "priority": t.priority, "state": t.status, "labels": [l.strip() for l in (t.labels or "").split(",") if l.strip()], "created_at": t.created_at.isoformat() if t.created_at else None, "due_date": t.due_date.isoformat() if t.due_date else None, } for t in tasks ] } @app.get("/api/orchestrator/tasks/by-states") def get_orchestrator_tasks_by_states(states: str, request: Request, db: DBSession = Depends(get_db)): """Fetch tasks in specific states.""" verify_api_key(request) state_list = [s.strip() for s in states.split(",") if s.strip()] tasks = ( db.query(Task) .filter(Task.agent_ready == True, Task.status.in_(state_list)) .all() ) return { "tasks": [ {"id": str(t.id), "identifier": f"OML-{t.id}", "title": t.title, "state": t.status} for t in tasks ] } @app.post("/api/orchestrator/tasks/{task_id}/state") def update_orchestrator_task_state(task_id: int, state: dict, request: Request, db: DBSession = Depends(get_db)): """Update task state from the orchestrator.""" verify_api_key(request) task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") new_status = state.get("status") if new_status and new_status in ("open", "in_progress", "review", "completed"): task.status = new_status db.commit() return {"status": "success", "task_id": task.id, "new_state": new_status} raise HTTPException(status_code=400, detail="Invalid status") # --- Send to NoteGraph --- NOTEGRAPH_TOKEN = os.getenv("NOTEGRAPH_TOKEN", "") NOTEGRAPH_API_URL = "https://notes.andreknie.de/browse/api/notes" @app.post("/api/tasks/{task_id}/to-note") def send_task_to_note(task_id: int, request: Request, db: DBSession = Depends(get_db)): """Send a task's content to NoteGraph as a new note.""" if not verify_session(request): raise HTTPException(status_code=401, detail="Not authenticated") if not NOTEGRAPH_TOKEN: raise HTTPException(status_code=500, detail="NOTEGRAPH_TOKEN not configured") task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") import httpx note_payload = { "title": task.title or "Untitled Task", "content": task.description or "", "tags": ["from-task"], } try: resp = httpx.post( f"{NOTEGRAPH_API_URL}?token={NOTEGRAPH_TOKEN}", json=note_payload, timeout=10.0, ) if resp.status_code in (200, 201): return {"status": "success", "message": "Note created in NoteGraph"} else: return {"status": "error", "message": f"NoteGraph returned {resp.status_code}: {resp.text[:200]}"} except httpx.RequestError as e: return {"status": "error", "message": f"Failed to reach NoteGraph: {str(e)}"}