- Add meeting_number field (M-YYYYMMDD-XX format) with auto-generation - Refactor Excel export to use cell coordinates instead of placeholders - Export files saved to backend/record/ directory with meeting number filename - Add database migration for meeting_number column - Add start.sh script for managing frontend/backend/sidecar services - Update OpenSpec documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
398 lines
14 KiB
Python
398 lines
14 KiB
Python
from fastapi import APIRouter, HTTPException, Depends
|
|
from typing import List
|
|
import uuid
|
|
from datetime import date
|
|
|
|
from ..database import get_db_cursor
|
|
from ..models import (
|
|
MeetingCreate,
|
|
MeetingUpdate,
|
|
MeetingResponse,
|
|
MeetingListResponse,
|
|
ConclusionResponse,
|
|
ActionItemResponse,
|
|
ActionItemUpdate,
|
|
TokenPayload,
|
|
)
|
|
from .auth import get_current_user, is_admin
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def generate_system_code(prefix: str, meeting_date: date, sequence: int) -> str:
|
|
"""Generate system code like C-20251210-01 or A-20251210-01."""
|
|
date_str = meeting_date.strftime("%Y%m%d")
|
|
return f"{prefix}-{date_str}-{sequence:02d}"
|
|
|
|
|
|
def get_next_sequence(cursor, prefix: str, date_str: str) -> int:
|
|
"""Get next sequence number for a given prefix and date."""
|
|
pattern = f"{prefix}-{date_str}-%"
|
|
|
|
if prefix == "M":
|
|
# For meeting numbers, search in meeting_records table
|
|
cursor.execute(
|
|
"""
|
|
SELECT meeting_number FROM meeting_records WHERE meeting_number LIKE %s
|
|
ORDER BY meeting_number DESC LIMIT 1
|
|
""",
|
|
(pattern,),
|
|
)
|
|
result = cursor.fetchone()
|
|
if result and result.get("meeting_number"):
|
|
last_code = result["meeting_number"]
|
|
last_seq = int(last_code.split("-")[-1])
|
|
return last_seq + 1
|
|
return 1
|
|
else:
|
|
# For conclusions (C) and action items (A)
|
|
cursor.execute(
|
|
"""
|
|
SELECT system_code FROM meeting_conclusions WHERE system_code LIKE %s
|
|
UNION
|
|
SELECT system_code FROM meeting_action_items WHERE system_code LIKE %s
|
|
ORDER BY system_code DESC LIMIT 1
|
|
""",
|
|
(pattern, pattern),
|
|
)
|
|
result = cursor.fetchone()
|
|
if result:
|
|
last_code = result["system_code"]
|
|
last_seq = int(last_code.split("-")[-1])
|
|
return last_seq + 1
|
|
return 1
|
|
|
|
|
|
@router.post("/meetings", response_model=MeetingResponse)
|
|
async def create_meeting(
|
|
meeting: MeetingCreate, current_user: TokenPayload = Depends(get_current_user)
|
|
):
|
|
"""Create a new meeting with optional conclusions and action items."""
|
|
meeting_uuid = str(uuid.uuid4())
|
|
recorder = meeting.recorder or current_user.email
|
|
meeting_date = meeting.meeting_time.date()
|
|
date_str = meeting_date.strftime("%Y%m%d")
|
|
|
|
with get_db_cursor(commit=True) as cursor:
|
|
# Generate meeting number
|
|
meeting_seq = get_next_sequence(cursor, "M", date_str)
|
|
meeting_number = generate_system_code("M", meeting_date, meeting_seq)
|
|
|
|
# Insert meeting record
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO meeting_records
|
|
(uuid, meeting_number, subject, meeting_time, location, chairperson, recorder, attendees, transcript_blob, created_by)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
meeting_uuid,
|
|
meeting_number,
|
|
meeting.subject,
|
|
meeting.meeting_time,
|
|
meeting.location,
|
|
meeting.chairperson,
|
|
recorder,
|
|
meeting.attendees,
|
|
meeting.transcript_blob,
|
|
current_user.email,
|
|
),
|
|
)
|
|
meeting_id = cursor.lastrowid
|
|
|
|
# Insert conclusions
|
|
conclusions = []
|
|
seq = get_next_sequence(cursor, "C", date_str)
|
|
for conclusion in meeting.conclusions or []:
|
|
system_code = generate_system_code("C", meeting_date, seq)
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO meeting_conclusions (meeting_id, content, system_code)
|
|
VALUES (%s, %s, %s)
|
|
""",
|
|
(meeting_id, conclusion.content, system_code),
|
|
)
|
|
conclusions.append(
|
|
ConclusionResponse(
|
|
conclusion_id=cursor.lastrowid,
|
|
meeting_id=meeting_id,
|
|
content=conclusion.content,
|
|
system_code=system_code,
|
|
)
|
|
)
|
|
seq += 1
|
|
|
|
# Insert action items
|
|
actions = []
|
|
seq = get_next_sequence(cursor, "A", date_str)
|
|
for action in meeting.actions or []:
|
|
system_code = generate_system_code("A", meeting_date, seq)
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO meeting_action_items (meeting_id, content, owner, due_date, system_code)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""",
|
|
(meeting_id, action.content, action.owner, action.due_date, system_code),
|
|
)
|
|
actions.append(
|
|
ActionItemResponse(
|
|
action_id=cursor.lastrowid,
|
|
meeting_id=meeting_id,
|
|
content=action.content,
|
|
owner=action.owner,
|
|
due_date=action.due_date,
|
|
status="Open",
|
|
system_code=system_code,
|
|
)
|
|
)
|
|
seq += 1
|
|
|
|
# Fetch created meeting
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
record = cursor.fetchone()
|
|
|
|
return MeetingResponse(
|
|
meeting_id=record["meeting_id"],
|
|
uuid=record["uuid"],
|
|
meeting_number=record.get("meeting_number"),
|
|
subject=record["subject"],
|
|
meeting_time=record["meeting_time"],
|
|
location=record["location"],
|
|
chairperson=record["chairperson"],
|
|
recorder=record["recorder"],
|
|
attendees=record["attendees"],
|
|
transcript_blob=record["transcript_blob"],
|
|
created_by=record["created_by"],
|
|
created_at=record["created_at"],
|
|
conclusions=conclusions,
|
|
actions=actions,
|
|
)
|
|
|
|
|
|
@router.get("/meetings", response_model=List[MeetingListResponse])
|
|
async def list_meetings(current_user: TokenPayload = Depends(get_current_user)):
|
|
"""List meetings. Admin sees all, users see only their own."""
|
|
with get_db_cursor() as cursor:
|
|
if is_admin(current_user):
|
|
cursor.execute(
|
|
"""
|
|
SELECT meeting_id, uuid, subject, meeting_time, chairperson, created_at
|
|
FROM meeting_records ORDER BY meeting_time DESC
|
|
"""
|
|
)
|
|
else:
|
|
cursor.execute(
|
|
"""
|
|
SELECT meeting_id, uuid, subject, meeting_time, chairperson, created_at
|
|
FROM meeting_records
|
|
WHERE created_by = %s OR recorder = %s OR attendees LIKE %s
|
|
ORDER BY meeting_time DESC
|
|
""",
|
|
(
|
|
current_user.email,
|
|
current_user.email,
|
|
f"%{current_user.email}%",
|
|
),
|
|
)
|
|
records = cursor.fetchall()
|
|
|
|
return [MeetingListResponse(**record) for record in records]
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}", response_model=MeetingResponse)
|
|
async def get_meeting(
|
|
meeting_id: int, current_user: TokenPayload = Depends(get_current_user)
|
|
):
|
|
"""Get meeting details with conclusions and action items."""
|
|
with get_db_cursor() as cursor:
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
record = cursor.fetchone()
|
|
|
|
if not record:
|
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
|
|
|
# Check access
|
|
if not is_admin(current_user):
|
|
if (
|
|
record["created_by"] != current_user.email
|
|
and record["recorder"] != current_user.email
|
|
and current_user.email not in (record["attendees"] or "")
|
|
):
|
|
raise HTTPException(status_code=403, detail="Access denied")
|
|
|
|
# Get conclusions
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
conclusions = [ConclusionResponse(**c) for c in cursor.fetchall()]
|
|
|
|
# Get action items
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
actions = [ActionItemResponse(**a) for a in cursor.fetchall()]
|
|
|
|
return MeetingResponse(
|
|
meeting_id=record["meeting_id"],
|
|
uuid=record["uuid"],
|
|
meeting_number=record.get("meeting_number"),
|
|
subject=record["subject"],
|
|
meeting_time=record["meeting_time"],
|
|
location=record["location"],
|
|
chairperson=record["chairperson"],
|
|
recorder=record["recorder"],
|
|
attendees=record["attendees"],
|
|
transcript_blob=record["transcript_blob"],
|
|
created_by=record["created_by"],
|
|
created_at=record["created_at"],
|
|
conclusions=conclusions,
|
|
actions=actions,
|
|
)
|
|
|
|
|
|
@router.put("/meetings/{meeting_id}", response_model=MeetingResponse)
|
|
async def update_meeting(
|
|
meeting_id: int,
|
|
meeting: MeetingUpdate,
|
|
current_user: TokenPayload = Depends(get_current_user),
|
|
):
|
|
"""Update meeting details."""
|
|
with get_db_cursor(commit=True) as cursor:
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
record = cursor.fetchone()
|
|
|
|
if not record:
|
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
|
|
|
# Check access
|
|
if not is_admin(current_user) and record["created_by"] != current_user.email:
|
|
raise HTTPException(status_code=403, detail="Access denied")
|
|
|
|
# Build update query dynamically
|
|
updates = []
|
|
values = []
|
|
for field in ["subject", "meeting_time", "location", "chairperson", "recorder", "attendees", "transcript_blob"]:
|
|
value = getattr(meeting, field)
|
|
if value is not None:
|
|
updates.append(f"{field} = %s")
|
|
values.append(value)
|
|
|
|
if updates:
|
|
values.append(meeting_id)
|
|
cursor.execute(
|
|
f"UPDATE meeting_records SET {', '.join(updates)} WHERE meeting_id = %s",
|
|
values,
|
|
)
|
|
|
|
# Update conclusions if provided
|
|
if meeting.conclusions is not None:
|
|
cursor.execute(
|
|
"DELETE FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
meeting_date = (meeting.meeting_time or record["meeting_time"]).date() if hasattr(meeting.meeting_time or record["meeting_time"], 'date') else date.today()
|
|
date_str = meeting_date.strftime("%Y%m%d")
|
|
seq = get_next_sequence(cursor, "C", date_str)
|
|
for conclusion in meeting.conclusions:
|
|
system_code = generate_system_code("C", meeting_date, seq)
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO meeting_conclusions (meeting_id, content, system_code)
|
|
VALUES (%s, %s, %s)
|
|
""",
|
|
(meeting_id, conclusion.content, system_code),
|
|
)
|
|
seq += 1
|
|
|
|
# Update action items if provided
|
|
if meeting.actions is not None:
|
|
cursor.execute(
|
|
"DELETE FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
meeting_date = (meeting.meeting_time or record["meeting_time"]).date() if hasattr(meeting.meeting_time or record["meeting_time"], 'date') else date.today()
|
|
date_str = meeting_date.strftime("%Y%m%d")
|
|
seq = get_next_sequence(cursor, "A", date_str)
|
|
for action in meeting.actions:
|
|
system_code = generate_system_code("A", meeting_date, seq)
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO meeting_action_items (meeting_id, content, owner, due_date, system_code)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""",
|
|
(meeting_id, action.content, action.owner, action.due_date, system_code),
|
|
)
|
|
seq += 1
|
|
|
|
# Return updated meeting
|
|
return await get_meeting(meeting_id, current_user)
|
|
|
|
|
|
@router.delete("/meetings/{meeting_id}")
|
|
async def delete_meeting(
|
|
meeting_id: int, current_user: TokenPayload = Depends(get_current_user)
|
|
):
|
|
"""Delete meeting and all related data (cascade)."""
|
|
with get_db_cursor(commit=True) as cursor:
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
record = cursor.fetchone()
|
|
|
|
if not record:
|
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
|
|
|
# Check access - admin or creator can delete
|
|
if not is_admin(current_user) and record["created_by"] != current_user.email:
|
|
raise HTTPException(status_code=403, detail="Access denied")
|
|
|
|
# Delete (cascade will handle conclusions and action items)
|
|
cursor.execute("DELETE FROM meeting_records WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
return {"message": "Meeting deleted successfully"}
|
|
|
|
|
|
@router.put("/meetings/{meeting_id}/actions/{action_id}")
|
|
async def update_action_item(
|
|
meeting_id: int,
|
|
action_id: int,
|
|
action: ActionItemUpdate,
|
|
current_user: TokenPayload = Depends(get_current_user),
|
|
):
|
|
"""Update a specific action item's status, owner, or due date."""
|
|
with get_db_cursor(commit=True) as cursor:
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_action_items WHERE action_id = %s AND meeting_id = %s",
|
|
(action_id, meeting_id),
|
|
)
|
|
record = cursor.fetchone()
|
|
|
|
if not record:
|
|
raise HTTPException(status_code=404, detail="Action item not found")
|
|
|
|
updates = []
|
|
values = []
|
|
for field in ["content", "owner", "due_date", "status"]:
|
|
value = getattr(action, field)
|
|
if value is not None:
|
|
updates.append(f"{field} = %s")
|
|
values.append(value.value if hasattr(value, "value") else value)
|
|
|
|
if updates:
|
|
values.append(action_id)
|
|
cursor.execute(
|
|
f"UPDATE meeting_action_items SET {', '.join(updates)} WHERE action_id = %s",
|
|
values,
|
|
)
|
|
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_action_items WHERE action_id = %s", (action_id,)
|
|
)
|
|
updated = cursor.fetchone()
|
|
|
|
return ActionItemResponse(**updated)
|