diff --git a/app/main.py b/app/main.py index 9a9a88a..2dae198 100644 --- a/app/main.py +++ b/app/main.py @@ -268,6 +268,7 @@ def startup(): from app.models import apikey from app.models import activity from app.models import milestone + from app.models import notification Base.metadata.create_all(bind=engine) @@ -870,6 +871,7 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): # ============ Export API ============ import csv +import json import io from fastapi.responses import StreamingResponse @@ -900,3 +902,115 @@ def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)): headers={"Content-Disposition": "attachment; filename=issues.csv"} ) + + +# ============ Notifications API ============ + +from app.models.notification import Notification as NotificationModel + + +class NotificationResponse(PydanticBaseModel): + id: int + user_id: int + type: str + title: str + message: str | None = None + entity_type: str | None = None + entity_id: int | None = None + is_read: bool + created_at: datetime + class Config: + from_attributes = True + + +def notify_user(db: Session, user_id: int, ntype: str, title: str, message: str = None, entity_type: str = None, entity_id: int = None): + """Helper to create a notification.""" + n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message, entity_type=entity_type, entity_id=entity_id) + db.add(n) + db.commit() + return n + + +@app.get("/notifications", response_model=List[NotificationResponse]) +def list_notifications(user_id: int, unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db)): + """List notifications for a user.""" + query = db.query(NotificationModel).filter(NotificationModel.user_id == user_id) + if unread_only: + query = query.filter(NotificationModel.is_read == False) + return query.order_by(NotificationModel.created_at.desc()).limit(limit).all() + + +@app.get("/notifications/count") +def notification_count(user_id: int, db: Session = Depends(get_db)): + """Get unread notification count.""" + count = db.query(NotificationModel).filter( + NotificationModel.user_id == user_id, + NotificationModel.is_read == False + ).count() + return {"user_id": user_id, "unread": count} + + +@app.post("/notifications/{notification_id}/read") +def mark_read(notification_id: int, db: Session = Depends(get_db)): + n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first() + if not n: + raise HTTPException(status_code=404, detail="Notification not found") + n.is_read = True + db.commit() + return {"status": "read"} + + +@app.post("/notifications/read-all") +def mark_all_read(user_id: int, db: Session = Depends(get_db)): + """Mark all notifications as read for a user.""" + db.query(NotificationModel).filter( + NotificationModel.user_id == user_id, + NotificationModel.is_read == False + ).update({"is_read": True}) + db.commit() + return {"status": "all_read"} + + +# ============ Webhook Retry ============ + +@app.post("/webhooks/{webhook_id}/retry/{log_id}") +def retry_webhook(webhook_id: int, log_id: int, bg: BackgroundTasks, db: Session = Depends(get_db)): + """Retry a failed webhook delivery.""" + log_entry = db.query(WebhookLog).filter( + WebhookLog.id == log_id, + WebhookLog.webhook_id == webhook_id + ).first() + if not log_entry: + raise HTTPException(status_code=404, detail="Webhook log not found") + wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not wh: + raise HTTPException(status_code=404, detail="Webhook not found") + + bg.add_task(fire_webhooks_sync, log_entry.event, json.loads(log_entry.payload), wh.project_id, db) + return {"status": "retry_queued", "log_id": log_id} + + +# ============ Issue Assignment with Notification ============ + +@app.post("/issues/{issue_id}/assign") +def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db)): + """Assign issue to user and send notification.""" + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + user = db.query(models.User).filter(models.User.id == assignee_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + old_assignee = issue.assignee_id + issue.assignee_id = assignee_id + db.commit() + db.refresh(issue) + + # Notify assignee + notify_user(db, assignee_id, "issue.assigned", + f"Issue #{issue.id} assigned to you", + f"'{issue.title}' has been assigned to you.", + "issue", issue.id) + + return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title} diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..e33ecad --- /dev/null +++ b/app/models/notification.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.core.config import Base + + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + type = Column(String(50), nullable=False) # issue.assigned, issue.mentioned, comment.added, milestone.due + title = Column(String(255), nullable=False) + message = Column(Text, nullable=True) + entity_type = Column(String(50), nullable=True) # issue, comment, milestone + entity_id = Column(Integer, nullable=True) + is_read = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + user = relationship("User") diff --git a/cli.py b/cli.py index b226160..91dcd79 100755 --- a/cli.py +++ b/cli.py @@ -145,6 +145,54 @@ def cmd_stats(args): print(f" {t}: {c}") + + +def cmd_milestones(args): + params = f"?project_id={args.project}" if args.project else "" + milestones = _request("GET", f"/milestones{params}") + if not milestones: + print(" No milestones found.") + return + for m in milestones: + status_icon = "🟢" if m["status"] == "open" else "⚫" + due = f" (due: {m['due_date'][:10]})" if m.get("due_date") else "" + print(f" {status_icon} #{m['id']} {m['title']}{due}") + + +def cmd_milestone_progress(args): + result = _request("GET", f"/milestones/{args.milestone_id}/progress") + bar_len = 20 + filled = int(bar_len * result["progress_pct"] / 100) + bar = "█" * filled + "░" * (bar_len - filled) + print(f" {result['title']}") + print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_issues']})") + + +def cmd_notifications(args): + params = [f"user_id={args.user}"] + if args.unread: + params.append("unread_only=true") + qs = "&".join(params) + notifs = _request("GET", f"/notifications?{qs}") + if not notifs: + print(" No notifications.") + return + for n in notifs: + icon = "🔴" if not n["is_read"] else "⚪" + print(f" {icon} [{n['type']}] {n['title']}") + + +def cmd_overdue(args): + params = f"?project_id={args.project}" if args.project else "" + issues = _request("GET", f"/issues/overdue{params}") + if not issues: + print(" No overdue issues! 🎉") + return + for i in issues: + due = i.get("due_date", "?")[:10] if i.get("due_date") else "?" + print(f" ⏰ #{i['id']} [{i['priority']}] {i['title']} (due: {due})") + + def main(): parser = argparse.ArgumentParser(description="HarborForge CLI") sub = parser.add_subparsers(dest="command") @@ -201,6 +249,24 @@ def main(): p_stats = sub.add_parser("stats", help="Dashboard stats") p_stats.add_argument("--project", "-p", type=int) + + # milestones + p_ms = sub.add_parser("milestones", help="List milestones") + p_ms.add_argument("--project", "-p", type=int) + + # milestone progress + p_msp = sub.add_parser("milestone-progress", help="Show milestone progress") + p_msp.add_argument("milestone_id", type=int) + + # notifications + p_notif = sub.add_parser("notifications", help="List notifications") + p_notif.add_argument("--user", "-u", type=int, required=True) + p_notif.add_argument("--unread", action="store_true") + + # overdue + p_overdue = sub.add_parser("overdue", help="List overdue issues") + p_overdue.add_argument("--project", "-p", type=int) + args = parser.parse_args() if not args.command: parser.print_help() @@ -217,6 +283,10 @@ def main(): "search": cmd_search, "transition": cmd_transition, "stats": cmd_stats, + "milestones": cmd_milestones, + "milestone-progress": cmd_milestone_progress, + "notifications": cmd_notifications, + "overdue": cmd_overdue, } cmds[args.command](args)