Files
FabledScribe/src/fabledassistant/routes/projects.py
T
bvandeusen c7ef709633 Multi-user sharing, groups, and in-app notifications
Backend:
- Alembic migration 0025: groups, group_memberships, project_shares,
  note_shares, notifications tables
- models: Group, GroupMembership, ProjectShare, NoteShare, Notification
- services/access.py: permission resolution (viewer/editor/admin/owner)
- services/groups.py + routes/groups.py: full group CRUD + membership
- services/sharing.py + routes/shares.py: project/note sharing API
- services/notifications.py: in-app notification create/list/mark-read
- routes/in_app_notifications.py: GET/POST notification endpoints
- routes/users.py: user search endpoint
- services/projects.py + services/notes.py: *_for_user variants
- routes updated to use *_for_user on get/list; adds permission field
- app.py: register all new blueprints

Frontend:
- api/client.ts: ShareEntry, GroupEntry, NotificationEntry types + helpers
- stores/notifications.ts: Pinia store with polling
- NotificationBell.vue: bell icon + badge + dropdown toggle + 60s poll
- NotificationsPanel.vue: unread notification list with mark-all-read
- ShareDialog.vue: teleport modal for sharing projects/notes with users/groups
- SharedWithMeView.vue: /shared route listing shared projects and notes
- AppHeader: NotificationBell, Shared nav link
- ProjectView, NoteViewerView, TaskViewerView: Share button + ShareDialog
- SettingsView: Groups admin tab with create/delete groups + member mgmt
- router: /shared route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:37:00 -04:00

127 lines
3.8 KiB
Python

"""Project management routes."""
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.services.milestones import list_milestones
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import (
create_project,
delete_project,
get_project,
get_project_for_user,
get_project_summary,
list_projects_for_user,
update_project,
)
logger = logging.getLogger(__name__)
projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
@projects_bp.route("", methods=["GET"])
@login_required
async def list_projects_route():
uid = get_current_user_id()
status = request.args.get("status")
projects = await list_projects_for_user(uid, status=status)
return jsonify({"projects": projects})
@projects_bp.route("", methods=["POST"])
@login_required
async def create_project_route():
uid = get_current_user_id()
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
project = await create_project(
uid,
title=data["title"],
description=data.get("description", ""),
goal=data.get("goal", ""),
color=data.get("color"),
)
return jsonify(project.to_dict()), 201
@projects_bp.route("/<int:project_id>", methods=["GET"])
@login_required
async def get_project_route(project_id: int):
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, permission = result
# Summary uses the project owner's uid for stats when viewer is not the owner
owner_uid = project.user_id or uid
summary = await get_project_summary(owner_uid, project_id)
data = project.to_dict()
data["summary"] = summary
data["permission"] = permission
return jsonify(data)
@projects_bp.route("/<int:project_id>", methods=["PATCH"])
@login_required
async def update_project_route(project_id: int):
uid = get_current_user_id()
data = await request.get_json()
allowed = {"title", "description", "goal", "status", "color"}
fields = {k: v for k, v in data.items() if k in allowed}
project = await update_project(uid, project_id, **fields)
if project is None:
return not_found("Project")
return jsonify(project.to_dict())
@projects_bp.route("/<int:project_id>", methods=["DELETE"])
@login_required
async def delete_project_route(project_id: int):
uid = get_current_user_id()
deleted = await delete_project(uid, project_id)
if not deleted:
return not_found("Project")
return "", 204
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required
async def get_project_notes_route(project_id: int):
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, _ = result
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
is_task: bool | None = None
if type_filter == "task":
is_task = True
elif type_filter == "note":
is_task = False
ms_list = await list_milestones(uid, project_id)
milestone_ids = [m.id for m in ms_list]
notes, total = await list_notes(
uid,
is_task=is_task,
status=status_filter,
project_id=project_id,
milestone_ids=milestone_ids,
limit=limit,
offset=offset,
sort="updated_at",
order="desc",
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})