"""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, 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) # Action items (tasks linked to this meeting) 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] # Notes linked to this meeting result = await db.execute(text(""" SELECT * FROM notes WHERE meeting_id = :mid AND is_deleted = false ORDER BY created_at """), {"mid": meeting_id}) meeting_notes = [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] # Attendees result = await db.execute(text(""" SELECT c.*, cm.role FROM contact_meetings cm JOIN contacts c ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false ORDER BY c.first_name """), {"mid": meeting_id}) attendees = [dict(r._mapping) for r in result] # Domains for action item creation domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() return templates.TemplateResponse("meeting_detail.html", { "request": request, "sidebar": sidebar, "item": item, "action_items": action_items, "meeting_notes": meeting_notes, "decisions": decisions, "attendees": attendees, "domains": domains, "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)