"""Meetings: CRUD with agenda, transcript, notes, and action item -> task conversion.""" from fastapi import APIRouter, Request, Form, Depends from fastapi.templating import Jinja2Templates from fastapi.responses import RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from typing import Optional from datetime import datetime, timezone from core.database import get_db from core.base_repository import BaseRepository from core.sidebar import get_sidebar_data router = APIRouter(prefix="/meetings", tags=["meetings"]) templates = Jinja2Templates(directory="templates") @router.get("/") async def list_meetings( request: Request, status: Optional[str] = None, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) where_clauses = ["m.is_deleted = false"] params = {} if status: where_clauses.append("m.status = :status") params["status"] = status where_sql = " AND ".join(where_clauses) result = await db.execute(text(f""" SELECT m.*, (SELECT count(*) FROM meeting_tasks mt JOIN tasks t ON mt.task_id = t.id WHERE mt.meeting_id = m.id AND t.is_deleted = false) as action_count FROM meetings m WHERE {where_sql} ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST """), params) items = [dict(r._mapping) for r in result] return templates.TemplateResponse("meetings.html", { "request": request, "sidebar": sidebar, "items": items, "current_status": status or "", "page_title": "Meetings", "active_nav": "meetings", }) @router.get("/create") async def create_form(request: Request, db: AsyncSession = Depends(get_db)): sidebar = await get_sidebar_data(db) # Get contacts for attendee selection contacts_repo = BaseRepository("contacts", db) contacts = await contacts_repo.list() # Parent meetings for series result = await db.execute(text(""" SELECT id, title, meeting_date FROM meetings WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50 """)) parent_meetings = [dict(r._mapping) for r in result] return templates.TemplateResponse("meeting_form.html", { "request": request, "sidebar": sidebar, "contacts": contacts, "parent_meetings": parent_meetings, "page_title": "New Meeting", "active_nav": "meetings", "item": None, }) @router.post("/create") async def create_meeting( request: Request, title: str = Form(...), meeting_date: str = Form(...), start_at: Optional[str] = Form(None), end_at: Optional[str] = Form(None), location: Optional[str] = Form(None), status: str = Form("scheduled"), priority: Optional[str] = Form(None), parent_id: Optional[str] = Form(None), agenda: Optional[str] = Form(None), tags: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("meetings", db) data = { "title": title, "meeting_date": meeting_date, "status": status, "location": location, "agenda": agenda, } if start_at and start_at.strip(): data["start_at"] = start_at if end_at and end_at.strip(): data["end_at"] = end_at if priority and priority.strip(): data["priority"] = int(priority) if parent_id and parent_id.strip(): data["parent_id"] = parent_id if tags and tags.strip(): data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] meeting = await repo.create(data) return RedirectResponse(url=f"/meetings/{meeting['id']}", status_code=303) @router.get("/{meeting_id}") async def meeting_detail( meeting_id: str, request: Request, tab: str = "overview", db: AsyncSession = Depends(get_db), ): repo = BaseRepository("meetings", db) sidebar = await get_sidebar_data(db) item = await repo.get(meeting_id) if not item: return RedirectResponse(url="/meetings", status_code=303) # Linked projects (always shown in header) result = await db.execute(text(""" SELECT p.id, p.name, d.color as domain_color FROM projects p JOIN project_meetings pm ON pm.project_id = p.id LEFT JOIN domains d ON p.domain_id = d.id WHERE pm.meeting_id = :mid AND p.is_deleted = false ORDER BY p.name """), {"mid": meeting_id}) projects = [dict(r._mapping) for r in result] # Overview data (always needed for overview tab) action_items = [] decisions = [] domains = [] tab_data = [] all_contacts = [] all_decisions = [] if tab == "overview": # Action items result = await db.execute(text(""" SELECT t.*, mt.source, d.name as domain_name, d.color as domain_color, p.name as project_name FROM meeting_tasks mt JOIN tasks t ON mt.task_id = t.id LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id WHERE mt.meeting_id = :mid AND t.is_deleted = false ORDER BY t.sort_order, t.created_at """), {"mid": meeting_id}) action_items = [dict(r._mapping) for r in result] # Decisions from this meeting result = await db.execute(text(""" SELECT * FROM decisions WHERE meeting_id = :mid AND is_deleted = false ORDER BY created_at """), {"mid": meeting_id}) decisions = [dict(r._mapping) for r in result] # Domains for action item creation domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() elif tab == "notes": result = await db.execute(text(""" SELECT * FROM notes WHERE meeting_id = :mid AND is_deleted = false ORDER BY updated_at DESC """), {"mid": meeting_id}) tab_data = [dict(r._mapping) for r in result] elif tab == "links": result = await db.execute(text(""" SELECT * FROM links WHERE meeting_id = :mid AND is_deleted = false ORDER BY sort_order, label """), {"mid": meeting_id}) tab_data = [dict(r._mapping) for r in result] elif tab == "files": result = await db.execute(text(""" SELECT f.* FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false ORDER BY f.created_at DESC """), {"mid": meeting_id}) tab_data = [dict(r._mapping) for r in result] elif tab == "lists": result = await db.execute(text(""" SELECT l.*, (SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count FROM lists l WHERE l.meeting_id = :mid AND l.is_deleted = false ORDER BY l.sort_order, l.created_at DESC """), {"mid": meeting_id}) tab_data = [dict(r._mapping) for r in result] elif tab == "decisions": result = await db.execute(text(""" SELECT * FROM decisions WHERE meeting_id = :mid AND is_deleted = false ORDER BY created_at DESC """), {"mid": meeting_id}) tab_data = [dict(r._mapping) for r in result] result = await db.execute(text(""" SELECT id, title FROM decisions WHERE (meeting_id IS NULL) AND is_deleted = false ORDER BY created_at DESC LIMIT 50 """)) all_decisions = [dict(r._mapping) for r in result] elif tab == "contacts": result = await db.execute(text(""" SELECT c.*, cm.role, cm.created_at as linked_at FROM contacts c JOIN contact_meetings cm ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false ORDER BY c.first_name """), {"mid": meeting_id}) tab_data = [dict(r._mapping) for r in result] result = await db.execute(text(""" SELECT id, first_name, last_name FROM contacts WHERE is_deleted = false ORDER BY first_name """)) all_contacts = [dict(r._mapping) for r in result] # Tab counts counts = {} for count_tab, count_sql in [ ("notes", "SELECT count(*) FROM notes WHERE meeting_id = :mid AND is_deleted = false"), ("links", "SELECT count(*) FROM links WHERE meeting_id = :mid AND is_deleted = false"), ("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false"), ("lists", "SELECT count(*) FROM lists WHERE meeting_id = :mid AND is_deleted = false"), ("decisions", "SELECT count(*) FROM decisions WHERE meeting_id = :mid AND is_deleted = false"), ("contacts", "SELECT count(*) FROM contacts c JOIN contact_meetings cm ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false"), ]: result = await db.execute(text(count_sql), {"mid": meeting_id}) counts[count_tab] = result.scalar() or 0 return templates.TemplateResponse("meeting_detail.html", { "request": request, "sidebar": sidebar, "item": item, "action_items": action_items, "decisions": decisions, "domains": domains, "projects": projects, "tab": tab, "tab_data": tab_data, "all_contacts": all_contacts, "all_decisions": all_decisions, "counts": counts, "page_title": item["title"], "active_nav": "meetings", }) @router.get("/{meeting_id}/edit") async def edit_form(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("meetings", db) sidebar = await get_sidebar_data(db) item = await repo.get(meeting_id) if not item: return RedirectResponse(url="/meetings", status_code=303) contacts_repo = BaseRepository("contacts", db) contacts = await contacts_repo.list() result = await db.execute(text(""" SELECT id, title, meeting_date FROM meetings WHERE is_deleted = false AND id != :mid ORDER BY meeting_date DESC LIMIT 50 """), {"mid": meeting_id}) parent_meetings = [dict(r._mapping) for r in result] return templates.TemplateResponse("meeting_form.html", { "request": request, "sidebar": sidebar, "contacts": contacts, "parent_meetings": parent_meetings, "page_title": "Edit Meeting", "active_nav": "meetings", "item": item, }) @router.post("/{meeting_id}/edit") async def update_meeting( meeting_id: str, title: str = Form(...), meeting_date: str = Form(...), start_at: Optional[str] = Form(None), end_at: Optional[str] = Form(None), location: Optional[str] = Form(None), status: str = Form("scheduled"), priority: Optional[str] = Form(None), parent_id: Optional[str] = Form(None), agenda: Optional[str] = Form(None), transcript: Optional[str] = Form(None), notes_body: Optional[str] = Form(None), tags: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("meetings", db) data = { "title": title, "meeting_date": meeting_date, "status": status, "location": location if location and location.strip() else None, "agenda": agenda if agenda and agenda.strip() else None, "transcript": transcript if transcript and transcript.strip() else None, "notes_body": notes_body if notes_body and notes_body.strip() else None, "start_at": start_at if start_at and start_at.strip() else None, "end_at": end_at if end_at and end_at.strip() else None, "parent_id": parent_id if parent_id and parent_id.strip() else None, } if priority and priority.strip(): data["priority"] = int(priority) else: data["priority"] = None if tags and tags.strip(): data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] else: data["tags"] = None await repo.update(meeting_id, data) return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303) @router.post("/{meeting_id}/delete") async def delete_meeting(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("meetings", db) await repo.soft_delete(meeting_id) return RedirectResponse(url="/meetings", status_code=303) # ---- Action Items ---- @router.post("/{meeting_id}/action-item") async def create_action_item( meeting_id: str, request: Request, title: str = Form(...), domain_id: str = Form(...), db: AsyncSession = Depends(get_db), ): """Create a task and link it to this meeting as an action item.""" task_repo = BaseRepository("tasks", db) task = await task_repo.create({ "title": title, "domain_id": domain_id, "status": "open", "priority": 2, }) # Link via meeting_tasks junction await db.execute(text(""" INSERT INTO meeting_tasks (meeting_id, task_id, source) VALUES (:mid, :tid, 'action_item') ON CONFLICT DO NOTHING """), {"mid": meeting_id, "tid": task["id"]}) return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303) # ---- Decision linking ---- @router.post("/{meeting_id}/decisions/add") async def add_decision( meeting_id: str, decision_id: str = Form(...), db: AsyncSession = Depends(get_db), ): await db.execute(text(""" UPDATE decisions SET meeting_id = :mid WHERE id = :did """), {"mid": meeting_id, "did": decision_id}) return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303) @router.post("/{meeting_id}/decisions/{decision_id}/remove") async def remove_decision( meeting_id: str, decision_id: str, db: AsyncSession = Depends(get_db), ): await db.execute(text(""" UPDATE decisions SET meeting_id = NULL WHERE id = :did AND meeting_id = :mid """), {"did": decision_id, "mid": meeting_id}) return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303) # ---- Contact linking ---- @router.post("/{meeting_id}/contacts/add") async def add_contact( meeting_id: str, contact_id: str = Form(...), role: str = Form("attendee"), db: AsyncSession = Depends(get_db), ): await db.execute(text(""" INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:cid, :mid, :role) ON CONFLICT DO NOTHING """), {"cid": contact_id, "mid": meeting_id, "role": role if role and role.strip() else "attendee"}) return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303) @router.post("/{meeting_id}/contacts/{contact_id}/remove") async def remove_contact( meeting_id: str, contact_id: str, db: AsyncSession = Depends(get_db), ): await db.execute(text( "DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid" ), {"cid": contact_id, "mid": meeting_id}) return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303) @router.post("/reorder") async def reorder_meeting( request: Request, item_id: str = Form(...), direction: str = Form(...), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("meetings", db) await repo.move_in_order(item_id, direction) return RedirectResponse(url=request.headers.get("referer", "/meetings"), status_code=303)