feat: Meeting Assistant MVP - Complete implementation
Enterprise Meeting Knowledge Management System with: Backend (FastAPI): - Authentication proxy with JWT (pj-auth-api integration) - MySQL database with 4 tables (users, meetings, conclusions, actions) - Meeting CRUD with system code generation (C-YYYYMMDD-XX, A-YYYYMMDD-XX) - Dify LLM integration for AI summarization - Excel export with openpyxl - 20 unit tests (all passing) Client (Electron): - Login page with company auth - Meeting list with create/delete - Meeting detail with real-time transcription - Editable transcript textarea (single block, easy editing) - AI summarization with conclusions/action items - 5-second segment recording (efficient for long meetings) Sidecar (Python): - faster-whisper medium model with int8 quantization - ONNX Runtime VAD (lightweight, ~20MB vs PyTorch ~2GB) - Chinese punctuation processing - OpenCC for Traditional Chinese conversion - Anti-hallucination parameters - Auto-cleanup of temp audio files OpenSpec: - add-meeting-assistant-mvp (47 tasks, archived) - add-realtime-transcription (29 tasks, archived) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Meeting Assistant Backend
|
||||
24
backend/app/config.py
Normal file
24
backend/app/config.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Settings:
|
||||
DB_HOST: str = os.getenv("DB_HOST", "mysql.theaken.com")
|
||||
DB_PORT: int = int(os.getenv("DB_PORT", "33306"))
|
||||
DB_USER: str = os.getenv("DB_USER", "A060")
|
||||
DB_PASS: str = os.getenv("DB_PASS", "")
|
||||
DB_NAME: str = os.getenv("DB_NAME", "db_A060")
|
||||
|
||||
AUTH_API_URL: str = os.getenv(
|
||||
"AUTH_API_URL", "https://pj-auth-api.vercel.app/api/auth/login"
|
||||
)
|
||||
DIFY_API_URL: str = os.getenv("DIFY_API_URL", "https://dify.theaken.com/v1")
|
||||
DIFY_API_KEY: str = os.getenv("DIFY_API_KEY", "")
|
||||
|
||||
ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL", "ymirliu@panjit.com.tw")
|
||||
JWT_SECRET: str = os.getenv("JWT_SECRET", "meeting-assistant-secret")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
96
backend/app/database.py
Normal file
96
backend/app/database.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import mysql.connector
|
||||
from mysql.connector import pooling
|
||||
from contextlib import contextmanager
|
||||
from .config import settings
|
||||
|
||||
connection_pool = None
|
||||
|
||||
|
||||
def init_db_pool():
|
||||
global connection_pool
|
||||
connection_pool = pooling.MySQLConnectionPool(
|
||||
pool_name="meeting_pool",
|
||||
pool_size=5,
|
||||
host=settings.DB_HOST,
|
||||
port=settings.DB_PORT,
|
||||
user=settings.DB_USER,
|
||||
password=settings.DB_PASS,
|
||||
database=settings.DB_NAME,
|
||||
)
|
||||
return connection_pool
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection():
|
||||
conn = connection_pool.get_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_cursor(commit=False):
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
try:
|
||||
yield cursor
|
||||
if commit:
|
||||
conn.commit()
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def init_tables():
|
||||
"""Create all required tables if they don't exist."""
|
||||
create_statements = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_users (
|
||||
user_id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
email VARCHAR(100) UNIQUE NOT NULL,
|
||||
display_name VARCHAR(50),
|
||||
role ENUM('admin', 'user') DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_records (
|
||||
meeting_id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
uuid VARCHAR(64) UNIQUE,
|
||||
subject VARCHAR(200) NOT NULL,
|
||||
meeting_time DATETIME NOT NULL,
|
||||
location VARCHAR(100),
|
||||
chairperson VARCHAR(50),
|
||||
recorder VARCHAR(50),
|
||||
attendees TEXT,
|
||||
transcript_blob LONGTEXT,
|
||||
created_by VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_conclusions (
|
||||
conclusion_id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
meeting_id INT,
|
||||
content TEXT,
|
||||
system_code VARCHAR(20),
|
||||
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_action_items (
|
||||
action_id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
meeting_id INT,
|
||||
content TEXT,
|
||||
owner VARCHAR(50),
|
||||
due_date DATE,
|
||||
status ENUM('Open', 'In Progress', 'Done', 'Delayed') DEFAULT 'Open',
|
||||
system_code VARCHAR(20),
|
||||
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
with get_db_cursor(commit=True) as cursor:
|
||||
for statement in create_statements:
|
||||
cursor.execute(statement)
|
||||
44
backend/app/main.py
Normal file
44
backend/app/main.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from .database import init_db_pool, init_tables
|
||||
from .routers import auth, meetings, ai, export
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
init_db_pool()
|
||||
init_tables()
|
||||
yield
|
||||
# Shutdown (cleanup if needed)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Meeting Assistant API",
|
||||
description="Enterprise meeting knowledge management API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS configuration for Electron client
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api", tags=["Authentication"])
|
||||
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
|
||||
app.include_router(ai.router, prefix="/api", tags=["AI"])
|
||||
app.include_router(export.router, prefix="/api", tags=["Export"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy", "service": "meeting-assistant"}
|
||||
37
backend/app/models/__init__.py
Normal file
37
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from .schemas import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
TokenPayload,
|
||||
MeetingCreate,
|
||||
MeetingUpdate,
|
||||
MeetingResponse,
|
||||
MeetingListResponse,
|
||||
ConclusionCreate,
|
||||
ConclusionResponse,
|
||||
ActionItemCreate,
|
||||
ActionItemUpdate,
|
||||
ActionItemResponse,
|
||||
SummarizeRequest,
|
||||
SummarizeResponse,
|
||||
ActionItemStatus,
|
||||
UserRole,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LoginRequest",
|
||||
"LoginResponse",
|
||||
"TokenPayload",
|
||||
"MeetingCreate",
|
||||
"MeetingUpdate",
|
||||
"MeetingResponse",
|
||||
"MeetingListResponse",
|
||||
"ConclusionCreate",
|
||||
"ConclusionResponse",
|
||||
"ActionItemCreate",
|
||||
"ActionItemUpdate",
|
||||
"ActionItemResponse",
|
||||
"SummarizeRequest",
|
||||
"SummarizeResponse",
|
||||
"ActionItemStatus",
|
||||
"UserRole",
|
||||
]
|
||||
128
backend/app/models/schemas.py
Normal file
128
backend/app/models/schemas.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ActionItemStatus(str, Enum):
|
||||
OPEN = "Open"
|
||||
IN_PROGRESS = "In Progress"
|
||||
DONE = "Done"
|
||||
DELAYED = "Delayed"
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
|
||||
|
||||
# Auth schemas
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
email: str
|
||||
role: str
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
email: str
|
||||
role: str
|
||||
exp: Optional[int] = None
|
||||
|
||||
|
||||
# Meeting schemas
|
||||
class ConclusionCreate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class ConclusionResponse(BaseModel):
|
||||
conclusion_id: int
|
||||
meeting_id: int
|
||||
content: str
|
||||
system_code: Optional[str] = None
|
||||
|
||||
|
||||
class ActionItemCreate(BaseModel):
|
||||
content: str
|
||||
owner: Optional[str] = ""
|
||||
due_date: Optional[date] = None
|
||||
|
||||
|
||||
class ActionItemUpdate(BaseModel):
|
||||
content: Optional[str] = None
|
||||
owner: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
status: Optional[ActionItemStatus] = None
|
||||
|
||||
|
||||
class ActionItemResponse(BaseModel):
|
||||
action_id: int
|
||||
meeting_id: int
|
||||
content: str
|
||||
owner: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
status: ActionItemStatus
|
||||
system_code: Optional[str] = None
|
||||
|
||||
|
||||
class MeetingCreate(BaseModel):
|
||||
subject: str
|
||||
meeting_time: datetime
|
||||
location: Optional[str] = ""
|
||||
chairperson: Optional[str] = ""
|
||||
recorder: Optional[str] = ""
|
||||
attendees: Optional[str] = ""
|
||||
transcript_blob: Optional[str] = ""
|
||||
conclusions: Optional[List[ConclusionCreate]] = []
|
||||
actions: Optional[List[ActionItemCreate]] = []
|
||||
|
||||
|
||||
class MeetingUpdate(BaseModel):
|
||||
subject: Optional[str] = None
|
||||
meeting_time: Optional[datetime] = None
|
||||
location: Optional[str] = None
|
||||
chairperson: Optional[str] = None
|
||||
recorder: Optional[str] = None
|
||||
attendees: Optional[str] = None
|
||||
transcript_blob: Optional[str] = None
|
||||
conclusions: Optional[List[ConclusionCreate]] = None
|
||||
actions: Optional[List[ActionItemCreate]] = None
|
||||
|
||||
|
||||
class MeetingResponse(BaseModel):
|
||||
meeting_id: int
|
||||
uuid: str
|
||||
subject: str
|
||||
meeting_time: datetime
|
||||
location: Optional[str] = None
|
||||
chairperson: Optional[str] = None
|
||||
recorder: Optional[str] = None
|
||||
attendees: Optional[str] = None
|
||||
transcript_blob: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
created_at: datetime
|
||||
conclusions: List[ConclusionResponse] = []
|
||||
actions: List[ActionItemResponse] = []
|
||||
|
||||
|
||||
class MeetingListResponse(BaseModel):
|
||||
meeting_id: int
|
||||
uuid: str
|
||||
subject: str
|
||||
meeting_time: datetime
|
||||
chairperson: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# AI schemas
|
||||
class SummarizeRequest(BaseModel):
|
||||
transcript: str
|
||||
|
||||
|
||||
class SummarizeResponse(BaseModel):
|
||||
conclusions: List[str]
|
||||
action_items: List[ActionItemCreate]
|
||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Router modules
|
||||
102
backend/app/routers/ai.py
Normal file
102
backend/app/routers/ai.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import httpx
|
||||
import json
|
||||
|
||||
from ..config import settings
|
||||
from ..models import SummarizeRequest, SummarizeResponse, ActionItemCreate, TokenPayload
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/ai/summarize", response_model=SummarizeResponse)
|
||||
async def summarize_transcript(
|
||||
request: SummarizeRequest, current_user: TokenPayload = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Send transcript to Dify for AI summarization.
|
||||
Returns structured conclusions and action items.
|
||||
"""
|
||||
if not settings.DIFY_API_KEY:
|
||||
raise HTTPException(status_code=503, detail="Dify API not configured")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{settings.DIFY_API_URL}/chat-messages",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.DIFY_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"inputs": {},
|
||||
"query": request.transcript,
|
||||
"response_mode": "blocking",
|
||||
"user": current_user.email,
|
||||
},
|
||||
timeout=120.0, # Long timeout for LLM processing
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Dify API error: {response.text}",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
answer = data.get("answer", "")
|
||||
|
||||
# Try to parse structured JSON from Dify response
|
||||
parsed = parse_dify_response(answer)
|
||||
|
||||
return SummarizeResponse(
|
||||
conclusions=parsed["conclusions"],
|
||||
action_items=[
|
||||
ActionItemCreate(
|
||||
content=item.get("content", ""),
|
||||
owner=item.get("owner", ""),
|
||||
due_date=item.get("due_date"),
|
||||
)
|
||||
for item in parsed["action_items"]
|
||||
],
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=504, detail="Dify API timeout - transcript may be too long"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=503, detail=f"Dify API unavailable: {str(e)}")
|
||||
|
||||
|
||||
def parse_dify_response(answer: str) -> dict:
|
||||
"""
|
||||
Parse Dify response to extract conclusions and action items.
|
||||
Attempts JSON parsing first, then falls back to text parsing.
|
||||
"""
|
||||
# Try to find JSON in the response
|
||||
try:
|
||||
# Look for JSON block
|
||||
if "```json" in answer:
|
||||
json_start = answer.index("```json") + 7
|
||||
json_end = answer.index("```", json_start)
|
||||
json_str = answer[json_start:json_end].strip()
|
||||
elif "{" in answer and "}" in answer:
|
||||
# Try to find JSON object
|
||||
json_start = answer.index("{")
|
||||
json_end = answer.rindex("}") + 1
|
||||
json_str = answer[json_start:json_end]
|
||||
else:
|
||||
raise ValueError("No JSON found")
|
||||
|
||||
data = json.loads(json_str)
|
||||
return {
|
||||
"conclusions": data.get("conclusions", []),
|
||||
"action_items": data.get("action_items", []),
|
||||
}
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
# Fallback: return raw answer as single conclusion
|
||||
return {
|
||||
"conclusions": [answer] if answer else [],
|
||||
"action_items": [],
|
||||
}
|
||||
109
backend/app/routers/auth.py
Normal file
109
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import httpx
|
||||
from jose import jwt, JWTError
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from ..config import settings
|
||||
from ..models import LoginRequest, LoginResponse, TokenPayload
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def create_token(email: str, role: str) -> str:
|
||||
"""Create a JWT token with email and role."""
|
||||
payload = {
|
||||
"email": email,
|
||||
"role": role,
|
||||
"exp": datetime.utcnow() + timedelta(hours=24),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> TokenPayload:
|
||||
"""Decode and validate a JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||
return TokenPayload(**payload)
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> TokenPayload:
|
||||
"""Dependency to get current authenticated user."""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||
return TokenPayload(**payload)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": "token_expired", "message": "Token has expired"},
|
||||
)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": "invalid_token", "message": "Invalid token"},
|
||||
)
|
||||
|
||||
|
||||
def is_admin(user: TokenPayload) -> bool:
|
||||
"""Check if user has admin role."""
|
||||
return user.role == "admin"
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(request: LoginRequest):
|
||||
"""
|
||||
Proxy login to company Auth API.
|
||||
Adds admin role for ymirliu@panjit.com.tw.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
settings.AUTH_API_URL,
|
||||
json={"username": request.email, "password": request.password},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail="Authentication service error",
|
||||
)
|
||||
|
||||
# Parse response from external Auth API
|
||||
auth_data = response.json()
|
||||
|
||||
# Check if authentication was successful
|
||||
if not auth_data.get("success"):
|
||||
error_msg = auth_data.get("error", "Authentication failed")
|
||||
raise HTTPException(status_code=401, detail=error_msg)
|
||||
|
||||
# Determine role
|
||||
role = "admin" if request.email == settings.ADMIN_EMAIL else "user"
|
||||
|
||||
# Create our own token with role info
|
||||
token = create_token(request.email, role)
|
||||
|
||||
return LoginResponse(token=token, email=request.email, role=role)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(status_code=504, detail="Authentication service timeout")
|
||||
except httpx.RequestError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Authentication service unavailable"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: TokenPayload = Depends(get_current_user)):
|
||||
"""Get current user information."""
|
||||
return {"email": current_user.email, "role": current_user.role}
|
||||
177
backend/app/routers/export.py
Normal file
177
backend/app/routers/export.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Font, Alignment, Border, Side
|
||||
import io
|
||||
import os
|
||||
|
||||
from ..database import get_db_cursor
|
||||
from ..models import TokenPayload
|
||||
from .auth import get_current_user, is_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "..", "templates")
|
||||
|
||||
|
||||
def create_default_workbook(meeting: dict, conclusions: list, actions: list) -> Workbook:
|
||||
"""Create Excel workbook with meeting data."""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Meeting Record"
|
||||
|
||||
# Styles
|
||||
header_font = Font(bold=True, size=14)
|
||||
label_font = Font(bold=True)
|
||||
thin_border = Border(
|
||||
left=Side(style="thin"),
|
||||
right=Side(style="thin"),
|
||||
top=Side(style="thin"),
|
||||
bottom=Side(style="thin"),
|
||||
)
|
||||
|
||||
# Title
|
||||
ws.merge_cells("A1:F1")
|
||||
ws["A1"] = "Meeting Record"
|
||||
ws["A1"].font = Font(bold=True, size=16)
|
||||
ws["A1"].alignment = Alignment(horizontal="center")
|
||||
|
||||
# Metadata section
|
||||
row = 3
|
||||
metadata = [
|
||||
("Subject", meeting.get("subject", "")),
|
||||
("Date/Time", str(meeting.get("meeting_time", ""))),
|
||||
("Location", meeting.get("location", "")),
|
||||
("Chairperson", meeting.get("chairperson", "")),
|
||||
("Recorder", meeting.get("recorder", "")),
|
||||
("Attendees", meeting.get("attendees", "")),
|
||||
]
|
||||
|
||||
for label, value in metadata:
|
||||
ws[f"A{row}"] = label
|
||||
ws[f"A{row}"].font = label_font
|
||||
ws.merge_cells(f"B{row}:F{row}")
|
||||
ws[f"B{row}"] = value
|
||||
row += 1
|
||||
|
||||
# Conclusions section
|
||||
row += 1
|
||||
ws.merge_cells(f"A{row}:F{row}")
|
||||
ws[f"A{row}"] = "Conclusions"
|
||||
ws[f"A{row}"].font = header_font
|
||||
row += 1
|
||||
|
||||
ws[f"A{row}"] = "Code"
|
||||
ws[f"B{row}"] = "Content"
|
||||
ws[f"A{row}"].font = label_font
|
||||
ws[f"B{row}"].font = label_font
|
||||
row += 1
|
||||
|
||||
for c in conclusions:
|
||||
ws[f"A{row}"] = c.get("system_code", "")
|
||||
ws.merge_cells(f"B{row}:F{row}")
|
||||
ws[f"B{row}"] = c.get("content", "")
|
||||
row += 1
|
||||
|
||||
# Action Items section
|
||||
row += 1
|
||||
ws.merge_cells(f"A{row}:F{row}")
|
||||
ws[f"A{row}"] = "Action Items"
|
||||
ws[f"A{row}"].font = header_font
|
||||
row += 1
|
||||
|
||||
headers = ["Code", "Content", "Owner", "Due Date", "Status"]
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=row, column=col, value=header)
|
||||
cell.font = label_font
|
||||
cell.border = thin_border
|
||||
row += 1
|
||||
|
||||
for a in actions:
|
||||
ws.cell(row=row, column=1, value=a.get("system_code", "")).border = thin_border
|
||||
ws.cell(row=row, column=2, value=a.get("content", "")).border = thin_border
|
||||
ws.cell(row=row, column=3, value=a.get("owner", "")).border = thin_border
|
||||
ws.cell(row=row, column=4, value=str(a.get("due_date", "") or "")).border = thin_border
|
||||
ws.cell(row=row, column=5, value=a.get("status", "")).border = thin_border
|
||||
row += 1
|
||||
|
||||
# Adjust column widths
|
||||
ws.column_dimensions["A"].width = 18
|
||||
ws.column_dimensions["B"].width = 40
|
||||
ws.column_dimensions["C"].width = 15
|
||||
ws.column_dimensions["D"].width = 12
|
||||
ws.column_dimensions["E"].width = 12
|
||||
ws.column_dimensions["F"].width = 12
|
||||
|
||||
return wb
|
||||
|
||||
|
||||
@router.get("/meetings/{meeting_id}/export")
|
||||
async def export_meeting(
|
||||
meeting_id: int, current_user: TokenPayload = Depends(get_current_user)
|
||||
):
|
||||
"""Export meeting to Excel file."""
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
|
||||
)
|
||||
meeting = cursor.fetchone()
|
||||
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||
|
||||
# Check access
|
||||
if not is_admin(current_user):
|
||||
if (
|
||||
meeting["created_by"] != current_user.email
|
||||
and meeting["recorder"] != current_user.email
|
||||
and current_user.email not in (meeting["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 = cursor.fetchall()
|
||||
|
||||
# Get action items
|
||||
cursor.execute(
|
||||
"SELECT * FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,)
|
||||
)
|
||||
actions = cursor.fetchall()
|
||||
|
||||
# Check for custom template
|
||||
template_path = os.path.join(TEMPLATE_DIR, "template.xlsx")
|
||||
if os.path.exists(template_path):
|
||||
wb = load_workbook(template_path)
|
||||
ws = wb.active
|
||||
|
||||
# Replace placeholders
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.value and isinstance(cell.value, str):
|
||||
cell.value = (
|
||||
cell.value.replace("{{subject}}", meeting.get("subject", ""))
|
||||
.replace("{{time}}", str(meeting.get("meeting_time", "")))
|
||||
.replace("{{location}}", meeting.get("location", ""))
|
||||
.replace("{{chair}}", meeting.get("chairperson", ""))
|
||||
.replace("{{recorder}}", meeting.get("recorder", ""))
|
||||
.replace("{{attendees}}", meeting.get("attendees", ""))
|
||||
)
|
||||
else:
|
||||
# Use default template
|
||||
wb = create_default_workbook(meeting, conclusions, actions)
|
||||
|
||||
# Save to bytes buffer
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
buffer.seek(0)
|
||||
|
||||
filename = f"meeting_{meeting.get('uuid', meeting_id)}.xlsx"
|
||||
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
372
backend/app/routers/meetings.py
Normal file
372
backend/app/routers/meetings.py
Normal file
@@ -0,0 +1,372 @@
|
||||
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}-%"
|
||||
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:
|
||||
# Insert meeting record
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO meeting_records
|
||||
(uuid, subject, meeting_time, location, chairperson, recorder, attendees, transcript_blob, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
meeting_uuid,
|
||||
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"],
|
||||
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"],
|
||||
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)
|
||||
Reference in New Issue
Block a user