Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
1377 lines
50 KiB
Python
1377 lines
50 KiB
Python
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'<p class="error">{error}</p>' if error else ""
|
|
return f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>OrgMyLife - Login</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<style>
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
background: #1a1a2e; color: #e0e0e0; display: flex;
|
|
justify-content: center; align-items: center; min-height: 100vh; margin: 0; }}
|
|
.login-box {{ background: #16213e; padding: 2rem; border-radius: 12px;
|
|
width: 100%; max-width: 360px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); }}
|
|
h1 {{ color: #00d4aa; margin-bottom: 0.5rem; }}
|
|
p.subtitle {{ color: #888; margin-top: 0; }}
|
|
input {{ width: 100%; padding: 12px; margin: 8px 0; border: 1px solid #333;
|
|
border-radius: 6px; background: #0f3460; color: #e0e0e0;
|
|
font-size: 1rem; box-sizing: border-box; }}
|
|
input:focus {{ outline: none; border-color: #00d4aa; }}
|
|
button {{ width: 100%; padding: 12px; margin-top: 16px; border: none;
|
|
border-radius: 6px; background: #00d4aa; color: #1a1a2e;
|
|
font-size: 1rem; font-weight: bold; cursor: pointer; }}
|
|
button:hover {{ background: #00b894; }}
|
|
.error {{ color: #ff6b6b; font-size: 0.9rem; margin-top: 8px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-box">
|
|
<h1>OrgMyLife</h1>
|
|
<p class="subtitle">Your coordination system</p>
|
|
{error_html}
|
|
<form method="POST" action="/login">
|
|
<input type="text" name="username" placeholder="Username" required autofocus>
|
|
<input type="password" name="password" placeholder="Password" required>
|
|
<button type="submit">Log in</button>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</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)}"}
|