Compare commits
16 Commits
7903e454dc
...
688ce1c132
| Author | SHA1 | Date | |
|---|---|---|---|
| 688ce1c132 | |||
| 0a449f166c | |||
| dd637bdc84 | |||
| e0a5f4855f | |||
| 7eac213c20 | |||
| 957939a165 | |||
| fc2068be70 | |||
| d6e4938aa4 | |||
| 990c660fbf | |||
| f42175b3fe | |||
| 61e48c3f14 | |||
| 05f5b49e26 | |||
| f0850ad3bf | |||
| a7e93aa2a3 | |||
| dad5c0e606 | |||
| bef856fd15 |
49
backend/alembic/versions/057_project_collab_prep.py
Normal file
49
backend/alembic/versions/057_project_collab_prep.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""project collab prep: indexes, task version, comment user_id
|
||||
|
||||
Revision ID: 057
|
||||
Revises: 056
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "057"
|
||||
down_revision = "056"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1a. Performance indexes for project_tasks
|
||||
# Use IF NOT EXISTS to handle indexes that may already exist on the DB
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_id ON project_tasks (project_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_parent_task_id ON project_tasks (parent_task_id) WHERE parent_task_id IS NOT NULL")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_updated ON project_tasks (project_id, updated_at DESC)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_updated ON projects (user_id, updated_at DESC)")
|
||||
|
||||
# 1b. Add user_id to task_comments for multi-user attribution
|
||||
op.add_column(
|
||||
"task_comments",
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
|
||||
# 1c. Add version column to project_tasks for optimistic locking
|
||||
op.add_column(
|
||||
"project_tasks",
|
||||
sa.Column("version", sa.Integer(), server_default="1", nullable=False),
|
||||
)
|
||||
|
||||
# Calendar delta polling index (Phase 4 prep)
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_events_calendar_updated ON calendar_events (calendar_id, updated_at DESC)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_events_calendar_updated", table_name="calendar_events")
|
||||
op.drop_column("project_tasks", "version")
|
||||
op.drop_column("task_comments", "user_id")
|
||||
op.drop_index("ix_projects_user_updated", table_name="projects")
|
||||
op.drop_index("ix_project_tasks_project_updated", table_name="project_tasks")
|
||||
op.drop_index("ix_project_tasks_parent_task_id", table_name="project_tasks")
|
||||
op.drop_index("ix_project_tasks_project_id", table_name="project_tasks")
|
||||
45
backend/alembic/versions/058_project_members.py
Normal file
45
backend/alembic/versions/058_project_members.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""add project_members table
|
||||
|
||||
Revision ID: 058
|
||||
Revises: 057
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "058"
|
||||
down_revision = "057"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_members",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("permission", sa.String(20), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("source", sa.String(20), nullable=False, server_default="invited"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"),
|
||||
sa.CheckConstraint("permission IN ('read_only', 'create_modify')", name="ck_project_members_permission"),
|
||||
sa.CheckConstraint("status IN ('pending', 'accepted', 'rejected')", name="ck_project_members_status"),
|
||||
sa.CheckConstraint("source IN ('invited', 'auto_assigned')", name="ck_project_members_source"),
|
||||
)
|
||||
op.create_index("ix_project_members_user_id", "project_members", ["user_id"])
|
||||
op.create_index("ix_project_members_project_id", "project_members", ["project_id"])
|
||||
op.create_index("ix_project_members_status", "project_members", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_project_members_status", table_name="project_members")
|
||||
op.drop_index("ix_project_members_project_id", table_name="project_members")
|
||||
op.drop_index("ix_project_members_user_id", table_name="project_members")
|
||||
op.drop_table("project_members")
|
||||
35
backend/alembic/versions/059_project_task_assignments.py
Normal file
35
backend/alembic/versions/059_project_task_assignments.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""add project_task_assignments table
|
||||
|
||||
Revision ID: 059
|
||||
Revises: 058
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "059"
|
||||
down_revision = "058"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_task_assignments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("task_id", sa.Integer(), sa.ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("assigned_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"),
|
||||
)
|
||||
op.create_index("ix_task_assignments_task_id", "project_task_assignments", ["task_id"])
|
||||
op.create_index("ix_task_assignments_user_id", "project_task_assignments", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_task_assignments_user_id", table_name="project_task_assignments")
|
||||
op.drop_index("ix_task_assignments_task_id", table_name="project_task_assignments")
|
||||
op.drop_table("project_task_assignments")
|
||||
@ -0,0 +1,36 @@
|
||||
"""Expand notification type CHECK for project invite types
|
||||
|
||||
Revision ID: 060
|
||||
Revises: 059
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "060"
|
||||
down_revision = "059"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_OLD_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
"event_invite", "event_invite_response",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
_NEW_TYPES = _OLD_TYPES + (
|
||||
"project_invite", "project_invite_accepted", "project_invite_rejected",
|
||||
"task_assigned",
|
||||
)
|
||||
|
||||
|
||||
def _check_sql(types: tuple) -> str:
|
||||
return f"type IN ({', '.join(repr(t) for t in types)})"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES))
|
||||
@ -21,6 +21,8 @@ from app.models.user_connection import UserConnection
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
@ -47,4 +49,6 @@ __all__ = [
|
||||
"EventLock",
|
||||
"EventInvitation",
|
||||
"EventInvitationOverride",
|
||||
"ProjectMember",
|
||||
"ProjectTaskAssignment",
|
||||
]
|
||||
|
||||
@ -9,6 +9,8 @@ _NOTIFICATION_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
"event_invite", "event_invite_response",
|
||||
"project_invite", "project_invite_accepted", "project_invite_rejected",
|
||||
"task_assigned",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ class Project(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
||||
todos: Mapped[List["Todo"]] = relationship(back_populates="project")
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
|
||||
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan", lazy="raise")
|
||||
todos: Mapped[List["Todo"]] = relationship(back_populates="project", lazy="raise")
|
||||
members: Mapped[List["ProjectMember"]] = relationship(back_populates="project", cascade="all, delete-orphan", lazy="raise")
|
||||
|
||||
58
backend/app/models/project_member.py
Normal file
58
backend/app/models/project_member.py
Normal file
@ -0,0 +1,58 @@
|
||||
from sqlalchemy import (
|
||||
CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||
String, UniqueConstraint, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"),
|
||||
CheckConstraint(
|
||||
"permission IN ('read_only', 'create_modify')",
|
||||
name="ck_project_members_permission",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_project_members_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"source IN ('invited', 'auto_assigned')",
|
||||
name="ck_project_members_source",
|
||||
),
|
||||
Index("ix_project_members_user_id", "user_id"),
|
||||
Index("ix_project_members_project_id", "project_id"),
|
||||
Index("ix_project_members_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
invited_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
permission: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
source: Mapped[str] = mapped_column(String(20), nullable=False, default="invited")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember)
|
||||
project: Mapped["Project"] = relationship(back_populates="members", lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
inviter: Mapped[Optional["User"]] = relationship(
|
||||
foreign_keys=[invited_by], lazy="raise"
|
||||
)
|
||||
@ -1,3 +1,4 @@
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import String, Text, Integer, Date, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||
from datetime import datetime, date
|
||||
@ -20,21 +21,30 @@ class ProjectTask(Base):
|
||||
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||
person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, server_default=sa.text("1"))
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = sa_relationship(back_populates="tasks")
|
||||
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks")
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
|
||||
project: Mapped["Project"] = sa_relationship(back_populates="tasks", lazy="raise")
|
||||
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks", lazy="raise")
|
||||
parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship(
|
||||
back_populates="subtasks",
|
||||
remote_side=[id],
|
||||
lazy="raise",
|
||||
)
|
||||
subtasks: Mapped[List["ProjectTask"]] = sa_relationship(
|
||||
back_populates="parent_task",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="raise",
|
||||
)
|
||||
comments: Mapped[List["TaskComment"]] = sa_relationship(
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="raise",
|
||||
)
|
||||
assignments: Mapped[List["ProjectTaskAssignment"]] = sa_relationship(
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="raise",
|
||||
)
|
||||
|
||||
30
backend/app/models/project_task_assignment.py
Normal file
30
backend/app/models/project_task_assignment.py
Normal file
@ -0,0 +1,30 @@
|
||||
from sqlalchemy import DateTime, Integer, ForeignKey, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProjectTaskAssignment(Base):
|
||||
__tablename__ = "project_task_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
assigned_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1
|
||||
task: Mapped["ProjectTask"] = relationship(back_populates="assignments", lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
assigner: Mapped["User"] = relationship(foreign_keys=[assigned_by], lazy="raise")
|
||||
@ -1,6 +1,7 @@
|
||||
from sqlalchemy import Text, Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@ -11,8 +12,12 @@ class TaskComment(Base):
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments")
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
|
||||
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments", lazy="raise")
|
||||
user: Mapped[Optional["User"]] = sa_relationship(lazy="raise")
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import func, select, update
|
||||
from typing import List
|
||||
@ -8,6 +11,7 @@ from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse
|
||||
from app.services.calendar_sharing import require_permission
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
@ -136,3 +140,62 @@ async def delete_calendar(
|
||||
await db.delete(calendar)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# DELTA POLLING
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class CalendarPollResponse(BaseModel):
|
||||
has_changes: bool
|
||||
calendar_updated_at: str | None = None
|
||||
changed_event_ids: list[int] = []
|
||||
|
||||
|
||||
@router.get("/{calendar_id}/poll", response_model=CalendarPollResponse)
|
||||
async def poll_calendar(
|
||||
calendar_id: int = Path(ge=1, le=2147483647),
|
||||
since: str = Query(..., description="ISO timestamp to check for changes since"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Lightweight poll endpoint — returns changed event IDs since timestamp."""
|
||||
await require_permission(db, calendar_id, current_user.id, "read_only")
|
||||
|
||||
try:
|
||||
since_dt = datetime.fromisoformat(since)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
|
||||
|
||||
# Clamp to max 24h in the past to prevent expensive full-table scans
|
||||
from datetime import timedelta
|
||||
min_since = datetime.now() - timedelta(hours=24)
|
||||
if since_dt < min_since:
|
||||
since_dt = min_since
|
||||
|
||||
# Check calendar-level update
|
||||
cal_result = await db.execute(
|
||||
select(Calendar.updated_at).where(Calendar.id == calendar_id)
|
||||
)
|
||||
calendar_updated = cal_result.scalar_one_or_none()
|
||||
if not calendar_updated:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
calendar_changed = calendar_updated > since_dt
|
||||
|
||||
# Check event-level changes using the ix_events_calendar_updated index
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent.id).where(
|
||||
CalendarEvent.calendar_id == calendar_id,
|
||||
CalendarEvent.updated_at > since_dt,
|
||||
)
|
||||
)
|
||||
changed_event_ids = [r[0] for r in event_result.all()]
|
||||
|
||||
has_changes = calendar_changed or len(changed_event_ids) > 0
|
||||
|
||||
return CalendarPollResponse(
|
||||
has_changes=has_changes,
|
||||
calendar_updated_at=calendar_updated.isoformat() if calendar_updated else None,
|
||||
changed_event_ids=changed_event_ids,
|
||||
)
|
||||
|
||||
@ -51,6 +51,7 @@ from app.services.connection import (
|
||||
)
|
||||
from app.services.calendar_sharing import cascade_on_disconnect
|
||||
from app.services.notification import create_notification
|
||||
from app.services.project_sharing import cascade_projects_on_disconnect
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -827,6 +828,9 @@ async def remove_connection(
|
||||
# Cascade: remove calendar memberships and event locks between these users
|
||||
await cascade_on_disconnect(db, current_user.id, counterpart_id)
|
||||
|
||||
# Cascade: remove project memberships and task assignments between these users
|
||||
await cascade_projects_on_disconnect(db, current_user.id, counterpart_id)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="connection.removed",
|
||||
|
||||
@ -1,18 +1,31 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete as sa_delete, select, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
from app.models.project_task import ProjectTask
|
||||
from app.models.task_comment import TaskComment
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||
from app.models.settings import Settings
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
|
||||
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
|
||||
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
|
||||
from app.schemas.project_member import (
|
||||
ProjectMemberInvite, ProjectMemberUpdate, ProjectMemberRespond, ProjectMemberResponse,
|
||||
)
|
||||
from app.schemas.project_task_assignment import TaskAssignmentCreate, TaskAssignmentResponse
|
||||
from app.services.project_sharing import (
|
||||
get_project_permission, require_project_permission, get_accessible_project_ids,
|
||||
validate_project_connections, get_effective_task_permission, ensure_auto_membership,
|
||||
cleanup_auto_membership, ASSIGNEE_ALLOWED_FIELDS,
|
||||
)
|
||||
from app.services.notification import create_notification
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
@ -26,34 +39,61 @@ class ReorderItem(BaseModel):
|
||||
|
||||
|
||||
def _project_load_options():
|
||||
"""All load options needed for project responses (tasks + subtasks + comments at each level)."""
|
||||
"""All load options needed for project responses (tasks + subtasks + comments + assignments)."""
|
||||
return [
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.comments),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
selectinload(Project.members),
|
||||
]
|
||||
|
||||
|
||||
def _task_load_options():
|
||||
"""All load options needed for task responses."""
|
||||
return [
|
||||
selectinload(ProjectTask.comments),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
|
||||
selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
|
||||
selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
]
|
||||
|
||||
|
||||
async def _get_user_name(db: AsyncSession, user_id: int) -> str | None:
|
||||
"""Get display name for a user from settings.preferred_name or user.username."""
|
||||
result = await db.execute(
|
||||
select(Settings.preferred_name, User.username)
|
||||
.outerjoin(Settings, Settings.user_id == User.id)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
preferred, username = row.tuple()
|
||||
return preferred or username
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# PROJECT CRUD
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=List[ProjectResponse])
|
||||
async def get_projects(
|
||||
tracked: Optional[bool] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all projects with their tasks. Optionally filter by tracked status."""
|
||||
"""Get all projects the user owns or has accepted membership in."""
|
||||
accessible_ids = await get_accessible_project_ids(db, current_user.id)
|
||||
if not accessible_ids:
|
||||
return []
|
||||
|
||||
query = (
|
||||
select(Project)
|
||||
.options(*_project_load_options())
|
||||
.where(Project.user_id == current_user.id)
|
||||
.where(Project.id.in_(accessible_ids))
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
if tracked is not None:
|
||||
@ -72,6 +112,10 @@ async def get_tracked_tasks(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get tasks and subtasks from tracked projects with due dates within the next N days."""
|
||||
accessible_ids = await get_accessible_project_ids(db, current_user.id)
|
||||
if not accessible_ids:
|
||||
return []
|
||||
|
||||
today = date.today()
|
||||
cutoff = today + timedelta(days=days)
|
||||
|
||||
@ -83,7 +127,7 @@ async def get_tracked_tasks(
|
||||
selectinload(ProjectTask.parent_task),
|
||||
)
|
||||
.where(
|
||||
Project.user_id == current_user.id,
|
||||
Project.id.in_(accessible_ids),
|
||||
Project.is_tracked == True,
|
||||
ProjectTask.due_date.isnot(None),
|
||||
ProjectTask.due_date >= today,
|
||||
@ -110,6 +154,31 @@ async def get_tracked_tasks(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/shared", response_model=List[ProjectResponse])
|
||||
async def get_shared_projects(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List projects where user is an accepted member (not owner)."""
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember.project_id).where(
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
project_ids = [r[0] for r in member_result.all()]
|
||||
if not project_ids:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(Project)
|
||||
.options(*_project_load_options())
|
||||
.where(Project.id.in_(project_ids))
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=ProjectResponse, status_code=201)
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
@ -121,7 +190,6 @@ async def create_project(
|
||||
db.add(new_project)
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with eagerly loaded tasks for response serialization
|
||||
query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one()
|
||||
@ -134,10 +202,12 @@ async def get_project(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific project by ID with its tasks."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
query = (
|
||||
select(Project)
|
||||
.options(*_project_load_options())
|
||||
.where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
.where(Project.id == project_id)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
project = result.scalar_one_or_none()
|
||||
@ -155,10 +225,10 @@ async def update_project(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a project."""
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
"""Update a project. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
@ -171,7 +241,6 @@ async def update_project(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with eagerly loaded tasks for response serialization
|
||||
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one()
|
||||
@ -183,10 +252,10 @@ async def delete_project(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a project and all its tasks."""
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
"""Delete a project and all its tasks. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
@ -198,6 +267,10 @@ async def delete_project(
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# TASK CRUD (permission-aware)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
|
||||
async def get_project_tasks(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
@ -205,14 +278,7 @@ async def get_project_tasks(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get top-level tasks for a specific project (subtasks are nested)."""
|
||||
# Verify project ownership first
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
query = (
|
||||
select(ProjectTask)
|
||||
@ -236,15 +302,8 @@ async def create_project_task(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new task or subtask for a project."""
|
||||
# Verify project ownership first
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
"""Create a new task or subtask for a project. Requires create_modify permission."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
|
||||
# Validate parent_task_id if creating a subtask
|
||||
if task.parent_task_id is not None:
|
||||
@ -268,7 +327,6 @@ async def create_project_task(
|
||||
db.add(new_task)
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with subtasks loaded
|
||||
query = (
|
||||
select(ProjectTask)
|
||||
.options(*_task_load_options())
|
||||
@ -285,15 +343,8 @@ async def reorder_tasks(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Bulk update sort_order for tasks."""
|
||||
# Verify project ownership first
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
"""Bulk update sort_order for tasks. Requires create_modify permission."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
|
||||
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries
|
||||
task_ids = [item.id for item in items]
|
||||
@ -323,13 +374,12 @@ async def update_project_task(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a project task."""
|
||||
# Verify project ownership first, then fetch task scoped to that project
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
"""Update a project task. Permission checked at project and task level."""
|
||||
perm, project_perm = await get_effective_task_permission(db, current_user.id, task_id, project_id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if perm == "read_only":
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
@ -344,12 +394,28 @@ async def update_project_task(
|
||||
|
||||
update_data = task_update.model_dump(exclude_unset=True)
|
||||
|
||||
# SEC-P02: Assignees (non-owner, non-project-member with create_modify) restricted to content fields
|
||||
if project_perm not in ("owner", "create_modify"):
|
||||
# This user's create_modify comes from task assignment — enforce allowlist
|
||||
disallowed = set(update_data.keys()) - ASSIGNEE_ALLOWED_FIELDS
|
||||
if disallowed:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Task assignees cannot modify: {', '.join(sorted(disallowed))}",
|
||||
)
|
||||
|
||||
# Optimistic locking: if version provided, check it matches
|
||||
client_version = update_data.pop("version", None)
|
||||
if client_version is not None and task.version != client_version:
|
||||
raise HTTPException(status_code=409, detail="Task was modified by another user")
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(task, key, value)
|
||||
|
||||
task.version += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with subtasks loaded
|
||||
query = (
|
||||
select(ProjectTask)
|
||||
.options(*_task_load_options())
|
||||
@ -366,13 +432,8 @@ async def delete_project_task(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a project task (cascades to subtasks)."""
|
||||
# Verify project ownership first, then fetch task scoped to that project
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
"""Delete a project task (cascades to subtasks). Requires create_modify permission."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
@ -391,6 +452,10 @@ async def delete_project_task(
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# COMMENTS (permission-aware)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
|
||||
async def create_task_comment(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
@ -399,13 +464,8 @@ async def create_task_comment(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Add a comment to a task."""
|
||||
# Verify project ownership first, then fetch task scoped to that project
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
"""Add a comment to a task. All members can comment (read_only minimum)."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
@ -418,12 +478,23 @@ async def create_task_comment(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
new_comment = TaskComment(task_id=task_id, content=comment.content)
|
||||
new_comment = TaskComment(task_id=task_id, user_id=current_user.id, content=comment.content)
|
||||
db.add(new_comment)
|
||||
|
||||
# Get author name before commit
|
||||
author_name = await _get_user_name(db, current_user.id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_comment)
|
||||
|
||||
return new_comment
|
||||
return TaskCommentResponse(
|
||||
id=new_comment.id,
|
||||
task_id=new_comment.task_id,
|
||||
user_id=new_comment.user_id,
|
||||
author_name=author_name,
|
||||
content=new_comment.content,
|
||||
created_at=new_comment.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
|
||||
@ -434,12 +505,9 @@ async def delete_task_comment(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a task comment."""
|
||||
# Verify project ownership first, then fetch comment scoped through task
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
"""Delete a task comment. Comment author or project owner only."""
|
||||
perm = await get_project_permission(db, project_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
@ -453,7 +521,484 @@ async def delete_task_comment(
|
||||
if not comment:
|
||||
raise HTTPException(status_code=404, detail="Comment not found")
|
||||
|
||||
# Only comment author or project owner can delete
|
||||
if comment.user_id != current_user.id and perm != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the comment author or project owner can delete this comment")
|
||||
|
||||
await db.delete(comment)
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# MEMBERSHIP ROUTES
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/members", response_model=List[ProjectMemberResponse], status_code=201)
|
||||
async def invite_members(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
invite: ProjectMemberInvite = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Invite connection(s) to a project. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
# Validate connections
|
||||
await validate_project_connections(db, current_user.id, invite.user_ids)
|
||||
|
||||
# Check pending invite cap (max 10 pending per project)
|
||||
pending_count_result = await db.execute(
|
||||
select(ProjectMember.id).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.status == "pending",
|
||||
)
|
||||
)
|
||||
pending_count = len(pending_count_result.all())
|
||||
if pending_count + len(invite.user_ids) > 10:
|
||||
raise HTTPException(status_code=400, detail="Maximum 10 pending invites per project")
|
||||
|
||||
# Filter out self and existing members
|
||||
existing_result = await db.execute(
|
||||
select(ProjectMember.user_id).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id.in_(invite.user_ids),
|
||||
)
|
||||
)
|
||||
existing_user_ids = {r[0] for r in existing_result.all()}
|
||||
|
||||
# Get project for notifications
|
||||
project_result = await db.execute(select(Project.name).where(Project.id == project_id))
|
||||
project_name = project_result.scalar_one()
|
||||
|
||||
inviter_name = await _get_user_name(db, current_user.id)
|
||||
created_members = []
|
||||
|
||||
for uid in invite.user_ids:
|
||||
if uid == current_user.id or uid in existing_user_ids:
|
||||
continue
|
||||
|
||||
member = ProjectMember(
|
||||
project_id=project_id,
|
||||
user_id=uid,
|
||||
invited_by=current_user.id,
|
||||
permission=invite.permission,
|
||||
status="pending",
|
||||
source="invited",
|
||||
)
|
||||
db.add(member)
|
||||
created_members.append(member)
|
||||
|
||||
# In-app notification
|
||||
await create_notification(
|
||||
db, uid, "project_invite",
|
||||
f"Project invitation from {inviter_name}",
|
||||
f"You've been invited to collaborate on \"{project_name}\"",
|
||||
data={"project_id": project_id},
|
||||
source_type="project_member",
|
||||
)
|
||||
|
||||
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
|
||||
member_ids = [m.id for m in created_members]
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with relationships
|
||||
if not created_members:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(
|
||||
selectinload(ProjectMember.user),
|
||||
selectinload(ProjectMember.inviter),
|
||||
)
|
||||
.where(ProjectMember.id.in_(member_ids))
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
# Build response with names
|
||||
responses = []
|
||||
for m in members:
|
||||
resp = ProjectMemberResponse.model_validate(m)
|
||||
resp.user_name = m.user.username
|
||||
resp.inviter_name = m.inviter.username if m.inviter else None
|
||||
responses.append(resp)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.get("/{project_id}/members", response_model=List[ProjectMemberResponse])
|
||||
async def get_members(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List members + statuses. Any member can view."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(
|
||||
selectinload(ProjectMember.user),
|
||||
selectinload(ProjectMember.inviter),
|
||||
)
|
||||
.where(ProjectMember.project_id == project_id)
|
||||
.order_by(ProjectMember.created_at.asc())
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
# Batch-fetch settings for preferred_name
|
||||
user_ids = [m.user_id for m in members] + [m.invited_by for m in members]
|
||||
settings_result = await db.execute(
|
||||
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
|
||||
)
|
||||
name_map = {r[0]: r[1] for r in settings_result.all()}
|
||||
|
||||
responses = []
|
||||
for m in members:
|
||||
resp = ProjectMemberResponse.model_validate(m)
|
||||
resp.user_name = name_map.get(m.user_id) or m.user.username
|
||||
resp.inviter_name = name_map.get(m.invited_by) or (m.inviter.username if m.inviter else None)
|
||||
responses.append(resp)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.patch("/{project_id}/members/{user_id}", response_model=ProjectMemberResponse)
|
||||
async def update_member_permission(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
update: ProjectMemberUpdate = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a member's permission level. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
|
||||
.where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
member.permission = update.permission
|
||||
|
||||
# Extract response data BEFORE commit (ORM objects expire after commit)
|
||||
resp = ProjectMemberResponse.model_validate(member)
|
||||
resp.user_name = member.user.username
|
||||
resp.inviter_name = member.inviter.username if member.inviter else None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@router.delete("/{project_id}/members/{user_id}", status_code=204)
|
||||
async def remove_member(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a member. Owner or self (leave project)."""
|
||||
perm = await get_project_permission(db, project_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Only owner can remove others; anyone can remove themselves
|
||||
if user_id != current_user.id and perm != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the project owner can remove members")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Remove task assignments for this user in this project
|
||||
await db.execute(
|
||||
sa_delete(ProjectTaskAssignment).where(
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
ProjectTaskAssignment.task_id.in_(
|
||||
select(ProjectTask.id).where(ProjectTask.project_id == project_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(member)
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/memberships/{project_id}/respond", response_model=ProjectMemberResponse)
|
||||
async def respond_to_invite(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
respond: ProjectMemberRespond = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Accept or reject a project invite."""
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
|
||||
.where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.status == "pending",
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="No pending invitation found")
|
||||
|
||||
# Extract response data before any mutations (ORM objects expire after commit)
|
||||
resp = ProjectMemberResponse.model_validate(member)
|
||||
resp.user_name = member.user.username
|
||||
resp.inviter_name = member.inviter.username if member.inviter else None
|
||||
|
||||
if respond.response == "accepted":
|
||||
member.status = "accepted"
|
||||
member.accepted_at = datetime.now()
|
||||
|
||||
# Get project owner for notification
|
||||
project_result = await db.execute(
|
||||
select(Project.user_id, Project.name).where(Project.id == project_id)
|
||||
)
|
||||
project_row = project_result.one()
|
||||
owner_id, project_name = project_row.tuple()
|
||||
|
||||
responder_name = await _get_user_name(db, current_user.id)
|
||||
|
||||
await create_notification(
|
||||
db, owner_id, "project_invite_accepted",
|
||||
f"{responder_name} joined your project",
|
||||
f"{responder_name} accepted the invitation to \"{project_name}\"",
|
||||
data={"project_id": project_id},
|
||||
source_type="project_member",
|
||||
)
|
||||
|
||||
resp.status = "accepted"
|
||||
else:
|
||||
# Rejected — delete the row to prevent accumulation (W-06)
|
||||
await db.delete(member)
|
||||
resp.status = "rejected"
|
||||
|
||||
await db.commit()
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# TASK ASSIGNMENT ROUTES
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/tasks/{task_id}/assignments", response_model=List[TaskAssignmentResponse], status_code=201)
|
||||
async def assign_users_to_task(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
task_id: int = Path(ge=1, le=2147483647),
|
||||
assignment: TaskAssignmentCreate = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Assign user(s) to a task. Requires create_modify on project or be owner."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
|
||||
# Verify task exists in project
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
ProjectTask.id == task_id,
|
||||
ProjectTask.project_id == project_id,
|
||||
)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Get project owner for connection validation
|
||||
project_result = await db.execute(
|
||||
select(Project.user_id, Project.name).where(Project.id == project_id)
|
||||
)
|
||||
project_row = project_result.one()
|
||||
owner_id, project_name = project_row.tuple()
|
||||
|
||||
# Validate connections (all assignees must be connections of the project owner)
|
||||
non_owner_ids = [uid for uid in assignment.user_ids if uid != owner_id]
|
||||
if non_owner_ids:
|
||||
await validate_project_connections(db, owner_id, non_owner_ids)
|
||||
|
||||
# Filter out existing assignments
|
||||
existing_result = await db.execute(
|
||||
select(ProjectTaskAssignment.user_id).where(
|
||||
ProjectTaskAssignment.task_id == task_id,
|
||||
ProjectTaskAssignment.user_id.in_(assignment.user_ids),
|
||||
)
|
||||
)
|
||||
existing_user_ids = {r[0] for r in existing_result.all()}
|
||||
|
||||
assigner_name = await _get_user_name(db, current_user.id)
|
||||
created = []
|
||||
|
||||
for uid in assignment.user_ids:
|
||||
if uid in existing_user_ids:
|
||||
continue
|
||||
|
||||
# Auto-membership: ensure user has ProjectMember row
|
||||
if uid != owner_id:
|
||||
await ensure_auto_membership(db, project_id, uid, current_user.id)
|
||||
|
||||
new_assignment = ProjectTaskAssignment(
|
||||
task_id=task_id,
|
||||
user_id=uid,
|
||||
assigned_by=current_user.id,
|
||||
)
|
||||
db.add(new_assignment)
|
||||
created.append(new_assignment)
|
||||
|
||||
# Notify assignee (don't notify self)
|
||||
if uid != current_user.id:
|
||||
await create_notification(
|
||||
db, uid, "task_assigned",
|
||||
f"Task assigned by {assigner_name}",
|
||||
f"You've been assigned to \"{task.title}\" in \"{project_name}\"",
|
||||
data={"project_id": project_id, "task_id": task_id},
|
||||
source_type="task_assignment",
|
||||
)
|
||||
|
||||
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
|
||||
assignment_ids = [a.id for a in created]
|
||||
await db.commit()
|
||||
|
||||
if not created:
|
||||
return []
|
||||
|
||||
# Re-fetch with user info
|
||||
result = await db.execute(
|
||||
select(ProjectTaskAssignment)
|
||||
.options(selectinload(ProjectTaskAssignment.user))
|
||||
.where(ProjectTaskAssignment.id.in_(assignment_ids))
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
# Get names
|
||||
user_ids = [a.user_id for a in assignments]
|
||||
settings_result = await db.execute(
|
||||
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
|
||||
)
|
||||
name_map = {r[0]: r[1] for r in settings_result.all()}
|
||||
|
||||
return [
|
||||
TaskAssignmentResponse(
|
||||
id=a.id,
|
||||
task_id=a.task_id,
|
||||
user_id=a.user_id,
|
||||
assigned_by=a.assigned_by,
|
||||
user_name=name_map.get(a.user_id) or a.user.username,
|
||||
created_at=a.created_at,
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{project_id}/tasks/{task_id}/assignments/{user_id}", status_code=204)
|
||||
async def remove_task_assignment(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
task_id: int = Path(ge=1, le=2147483647),
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a task assignment. Owner, create_modify member, or the assignee themselves."""
|
||||
perm = await get_project_permission(db, project_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Self-unassign is always allowed; otherwise need create_modify or owner
|
||||
if user_id != current_user.id and perm not in ("owner", "create_modify"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||
|
||||
result = await db.execute(
|
||||
sa_delete(ProjectTaskAssignment)
|
||||
.where(
|
||||
ProjectTaskAssignment.task_id == task_id,
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
)
|
||||
.returning(ProjectTaskAssignment.id)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Cleanup auto-membership if no more assignments
|
||||
await cleanup_auto_membership(db, project_id, user_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# DELTA POLLING
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class PollResponse(BaseModel):
|
||||
has_changes: bool
|
||||
project_updated_at: str | None = None
|
||||
changed_task_ids: list[int] = []
|
||||
|
||||
|
||||
@router.get("/{project_id}/poll", response_model=PollResponse)
|
||||
async def poll_project(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
since: str = Query(..., description="ISO timestamp to check for changes since"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Lightweight poll endpoint — returns changed task IDs since timestamp."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
try:
|
||||
since_dt = datetime.fromisoformat(since)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
|
||||
|
||||
# Clamp to max 24h in the past to prevent expensive full-table scans
|
||||
min_since = datetime.now() - timedelta(hours=24)
|
||||
if since_dt < min_since:
|
||||
since_dt = min_since
|
||||
|
||||
# Check project-level update
|
||||
proj_result = await db.execute(
|
||||
select(Project.updated_at).where(Project.id == project_id)
|
||||
)
|
||||
project_updated = proj_result.scalar_one_or_none()
|
||||
if not project_updated:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project_changed = project_updated > since_dt
|
||||
|
||||
# Check task-level changes using the index
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask.id).where(
|
||||
ProjectTask.project_id == project_id,
|
||||
ProjectTask.updated_at > since_dt,
|
||||
)
|
||||
)
|
||||
changed_task_ids = [r[0] for r in task_result.all()]
|
||||
|
||||
has_changes = project_changed or len(changed_task_ids) > 0
|
||||
|
||||
return PollResponse(
|
||||
has_changes=has_changes,
|
||||
project_updated_at=project_updated.isoformat() if project_updated else None,
|
||||
changed_task_ids=changed_task_ids,
|
||||
)
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
import logging
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Literal
|
||||
from app.schemas.project_task import ProjectTaskResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "review", "on_hold"]
|
||||
|
||||
|
||||
@ -30,18 +33,44 @@ class ProjectUpdate(BaseModel):
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int = 0
|
||||
name: str
|
||||
description: Optional[str]
|
||||
status: str
|
||||
color: Optional[str]
|
||||
due_date: Optional[date]
|
||||
is_tracked: bool
|
||||
member_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tasks: List[ProjectTaskResponse] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def compute_member_count(cls, data): # type: ignore[override]
|
||||
"""Compute member_count from eagerly loaded members relationship."""
|
||||
if hasattr(data, "members"):
|
||||
try:
|
||||
data = dict(
|
||||
id=data.id,
|
||||
user_id=data.user_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
status=data.status,
|
||||
color=data.color,
|
||||
due_date=data.due_date,
|
||||
is_tracked=data.is_tracked,
|
||||
member_count=len([m for m in data.members if m.status == "accepted"]),
|
||||
created_at=data.created_at,
|
||||
updated_at=data.updated_at,
|
||||
tasks=data.tasks,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("member_count compute skipped: %s", exc)
|
||||
return data
|
||||
|
||||
|
||||
class TrackedTaskResponse(BaseModel):
|
||||
id: int
|
||||
|
||||
43
backend/app/schemas/project_member.py
Normal file
43
backend/app/schemas/project_member.py
Normal file
@ -0,0 +1,43 @@
|
||||
from typing import Annotated, Optional, Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime
|
||||
|
||||
MemberPermission = Literal["read_only", "create_modify"]
|
||||
MemberStatus = Literal["pending", "accepted", "rejected"]
|
||||
InviteResponse = Literal["accepted", "rejected"]
|
||||
|
||||
|
||||
class ProjectMemberInvite(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=10)
|
||||
permission: MemberPermission = "create_modify"
|
||||
|
||||
|
||||
class ProjectMemberUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
permission: MemberPermission
|
||||
|
||||
|
||||
class ProjectMemberRespond(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
response: InviteResponse
|
||||
|
||||
|
||||
class ProjectMemberResponse(BaseModel):
|
||||
id: int
|
||||
project_id: int
|
||||
user_id: int
|
||||
invited_by: int
|
||||
permission: str
|
||||
status: str
|
||||
source: str
|
||||
user_name: str | None = None
|
||||
inviter_name: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
accepted_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@ -2,6 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Literal
|
||||
from app.schemas.task_comment import TaskCommentResponse
|
||||
from app.schemas.project_task_assignment import TaskAssignmentResponse
|
||||
|
||||
TaskStatus = Literal["pending", "in_progress", "completed", "blocked", "review", "on_hold"]
|
||||
TaskPriority = Literal["none", "low", "medium", "high"]
|
||||
@ -30,6 +31,7 @@ class ProjectTaskUpdate(BaseModel):
|
||||
due_date: Optional[date] = None
|
||||
person_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
version: Optional[int] = None # For optimistic locking
|
||||
|
||||
|
||||
class ProjectTaskResponse(BaseModel):
|
||||
@ -43,10 +45,12 @@ class ProjectTaskResponse(BaseModel):
|
||||
due_date: Optional[date]
|
||||
person_id: Optional[int]
|
||||
sort_order: int
|
||||
version: int = 1
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
subtasks: List["ProjectTaskResponse"] = []
|
||||
comments: List[TaskCommentResponse] = []
|
||||
assignments: List[TaskAssignmentResponse] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
31
backend/app/schemas/project_task_assignment.py
Normal file
31
backend/app/schemas/project_task_assignment.py
Normal file
@ -0,0 +1,31 @@
|
||||
from typing import Annotated
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskAssignmentCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TaskAssignmentResponse(BaseModel):
|
||||
id: int
|
||||
task_id: int
|
||||
user_id: int
|
||||
assigned_by: int
|
||||
user_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_user_name(cls, data): # type: ignore[override]
|
||||
"""Populate user_name from eagerly loaded user relationship."""
|
||||
if hasattr(data, "user") and data.user is not None and not getattr(data, "user_name", None):
|
||||
# Build dict from ORM columns so new fields are auto-included
|
||||
cols = {c.key: getattr(data, c.key) for c in data.__table__.columns}
|
||||
cols["user_name"] = data.user.username
|
||||
return cols
|
||||
return data
|
||||
@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@ -11,7 +11,19 @@ class TaskCommentCreate(BaseModel):
|
||||
class TaskCommentResponse(BaseModel):
|
||||
id: int
|
||||
task_id: int
|
||||
user_id: int | None = None
|
||||
author_name: str | None = None
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_author_name(cls, data): # type: ignore[override]
|
||||
"""Populate author_name from eagerly loaded user relationship."""
|
||||
if hasattr(data, "user") and data.user is not None and not getattr(data, "author_name", None):
|
||||
cols = {c.key: getattr(data, c.key) for c in data.__table__.columns}
|
||||
cols["author_name"] = data.user.username
|
||||
return cols
|
||||
return data
|
||||
|
||||
269
backend/app/services/project_sharing.py
Normal file
269
backend/app/services/project_sharing.py
Normal file
@ -0,0 +1,269 @@
|
||||
"""
|
||||
Project sharing service — permission checks, auto-membership, disconnect cascade.
|
||||
|
||||
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.project import Project
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_task import ProjectTask
|
||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||
from app.models.user_connection import UserConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERMISSION_RANK = {"read_only": 1, "create_modify": 2}
|
||||
|
||||
# Fields task assignees (from assignment, not project membership) may edit
|
||||
ASSIGNEE_ALLOWED_FIELDS = {"title", "description", "status", "priority", "due_date"}
|
||||
|
||||
|
||||
async def get_project_permission(
|
||||
db: AsyncSession, project_id: int, user_id: int
|
||||
) -> str | None:
|
||||
"""
|
||||
Returns 'owner', 'create_modify', 'read_only', or None.
|
||||
Single query with LEFT JOIN (mirrors calendar_sharing pattern).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(
|
||||
Project.user_id,
|
||||
ProjectMember.permission,
|
||||
)
|
||||
.outerjoin(
|
||||
ProjectMember,
|
||||
(ProjectMember.project_id == Project.id)
|
||||
& (ProjectMember.user_id == user_id)
|
||||
& (ProjectMember.status == "accepted"),
|
||||
)
|
||||
.where(Project.id == project_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
owner_id, member_permission = row.tuple()
|
||||
if owner_id == user_id:
|
||||
return "owner"
|
||||
return member_permission
|
||||
|
||||
|
||||
async def require_project_permission(
|
||||
db: AsyncSession, project_id: int, user_id: int, min_level: str
|
||||
) -> str:
|
||||
"""
|
||||
Raises 404 if project doesn't exist or user has no access.
|
||||
Raises 403 if user has insufficient permission.
|
||||
Returns the actual permission string (or 'owner').
|
||||
"""
|
||||
perm = await get_project_permission(db, project_id, user_id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if perm == "owner":
|
||||
return "owner"
|
||||
if min_level == "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the project owner can perform this action")
|
||||
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission on this project")
|
||||
return perm
|
||||
|
||||
|
||||
async def get_accessible_project_ids(db: AsyncSession, user_id: int) -> set[int]:
|
||||
"""Returns owned + accepted membership project IDs."""
|
||||
result = await db.execute(
|
||||
select(Project.id).where(Project.user_id == user_id)
|
||||
.union(
|
||||
select(ProjectMember.project_id).where(
|
||||
ProjectMember.user_id == user_id,
|
||||
ProjectMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
)
|
||||
return {r[0] for r in result.all()}
|
||||
|
||||
|
||||
async def validate_project_connections(
|
||||
db: AsyncSession, owner_id: int, user_ids: list[int]
|
||||
) -> None:
|
||||
"""Validates all target users are active connections of the owner. Raises 400 on failure."""
|
||||
if not user_ids:
|
||||
return
|
||||
result = await db.execute(
|
||||
select(UserConnection.connected_user_id).where(
|
||||
UserConnection.user_id == owner_id,
|
||||
UserConnection.connected_user_id.in_(user_ids),
|
||||
)
|
||||
)
|
||||
connected = {r[0] for r in result.all()}
|
||||
missing = set(user_ids) - connected
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Users {sorted(missing)} are not your connections",
|
||||
)
|
||||
|
||||
|
||||
async def get_effective_task_permission(
|
||||
db: AsyncSession, user_id: int, task_id: int, project_id: int
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Returns (effective_permission, project_level_permission) for a specific task.
|
||||
1. Get project-level permission (owner/create_modify/read_only)
|
||||
2. If user is assigned to THIS task → max(project_perm, create_modify)
|
||||
3. If task has parent and user assigned to PARENT → same as above
|
||||
4. Return (effective, project_level)
|
||||
"""
|
||||
project_perm = await get_project_permission(db, project_id, user_id)
|
||||
if project_perm is None:
|
||||
return None, None
|
||||
if project_perm == "owner":
|
||||
return "owner", "owner"
|
||||
|
||||
# Check direct assignment on this task
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask.parent_task_id).where(ProjectTask.id == task_id)
|
||||
)
|
||||
task_row = task_result.one_or_none()
|
||||
if not task_row:
|
||||
return project_perm, project_perm
|
||||
|
||||
parent_task_id = task_row[0]
|
||||
|
||||
# Check assignment on this task or its parent
|
||||
check_task_ids = [task_id]
|
||||
if parent_task_id is not None:
|
||||
check_task_ids.append(parent_task_id)
|
||||
|
||||
assignment_result = await db.execute(
|
||||
select(ProjectTaskAssignment.id).where(
|
||||
ProjectTaskAssignment.task_id.in_(check_task_ids),
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
).limit(1)
|
||||
)
|
||||
if assignment_result.scalar_one_or_none() is not None:
|
||||
# Assignment grants at least create_modify
|
||||
if PERMISSION_RANK.get(project_perm, 0) >= PERMISSION_RANK["create_modify"]:
|
||||
return project_perm, project_perm
|
||||
return "create_modify", project_perm
|
||||
|
||||
return project_perm, project_perm
|
||||
|
||||
|
||||
async def ensure_auto_membership(
|
||||
db: AsyncSession, project_id: int, user_id: int, invited_by: int
|
||||
) -> None:
|
||||
"""
|
||||
When assigning a user to a task, ensure they have a ProjectMember row.
|
||||
If none exists, create one with read_only + auto_assigned + accepted (no invite flow).
|
||||
"""
|
||||
existing = await db.execute(
|
||||
select(ProjectMember.id).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
return
|
||||
|
||||
member = ProjectMember(
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
invited_by=invited_by,
|
||||
permission="read_only",
|
||||
status="accepted",
|
||||
source="auto_assigned",
|
||||
accepted_at=datetime.now(),
|
||||
)
|
||||
db.add(member)
|
||||
|
||||
|
||||
async def cleanup_auto_membership(
|
||||
db: AsyncSession, project_id: int, user_id: int
|
||||
) -> None:
|
||||
"""
|
||||
After removing a task assignment, check if user has any remaining assignments
|
||||
in this project. If not and membership is auto_assigned, remove it.
|
||||
"""
|
||||
remaining = await db.execute(
|
||||
select(ProjectTaskAssignment.id)
|
||||
.join(ProjectTask, ProjectTaskAssignment.task_id == ProjectTask.id)
|
||||
.where(
|
||||
ProjectTask.project_id == project_id,
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if remaining.scalar_one_or_none() is not None:
|
||||
return # Still has assignments
|
||||
|
||||
# Remove auto_assigned membership only
|
||||
await db.execute(
|
||||
delete(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
ProjectMember.source == "auto_assigned",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def cascade_projects_on_disconnect(
|
||||
db: AsyncSession, user_a_id: int, user_b_id: int
|
||||
) -> None:
|
||||
"""
|
||||
When a connection is severed:
|
||||
1. Find all ProjectMember rows where one user is a member of the other's projects
|
||||
2. Find all ProjectTaskAssignment rows for those memberships
|
||||
3. Remove assignments, then remove memberships
|
||||
"""
|
||||
# Single query: find projects owned by each user
|
||||
result = await db.execute(
|
||||
select(Project.id, Project.user_id).where(
|
||||
Project.user_id.in_([user_a_id, user_b_id])
|
||||
)
|
||||
)
|
||||
a_proj_ids: list[int] = []
|
||||
b_proj_ids: list[int] = []
|
||||
for proj_id, owner_id in result.all():
|
||||
if owner_id == user_a_id:
|
||||
a_proj_ids.append(proj_id)
|
||||
else:
|
||||
b_proj_ids.append(proj_id)
|
||||
|
||||
# Remove user_b's assignments + memberships on user_a's projects
|
||||
if a_proj_ids:
|
||||
await db.execute(
|
||||
delete(ProjectTaskAssignment).where(
|
||||
ProjectTaskAssignment.user_id == user_b_id,
|
||||
ProjectTaskAssignment.task_id.in_(
|
||||
select(ProjectTask.id).where(ProjectTask.project_id.in_(a_proj_ids))
|
||||
),
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ProjectMember).where(
|
||||
ProjectMember.project_id.in_(a_proj_ids),
|
||||
ProjectMember.user_id == user_b_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Remove user_a's assignments + memberships on user_b's projects
|
||||
if b_proj_ids:
|
||||
await db.execute(
|
||||
delete(ProjectTaskAssignment).where(
|
||||
ProjectTaskAssignment.user_id == user_a_id,
|
||||
ProjectTaskAssignment.task_id.in_(
|
||||
select(ProjectTask.id).where(ProjectTask.project_id.in_(b_proj_ids))
|
||||
),
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ProjectMember).where(
|
||||
ProjectMember.project_id.in_(b_proj_ids),
|
||||
ProjectMember.user_id == user_a_id,
|
||||
)
|
||||
)
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react';
|
||||
import { Check, X, Bell, UserPlus, Calendar, Clock, FolderKanban, ClipboardList } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
@ -26,6 +27,9 @@ export default function NotificationToaster() {
|
||||
respondRef.current = respond;
|
||||
const markReadRef = useRef(markRead);
|
||||
markReadRef.current = markRead;
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = useRef(navigate);
|
||||
navigateRef.current = navigate;
|
||||
|
||||
const handleConnectionRespond = useCallback(
|
||||
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
|
||||
@ -123,6 +127,38 @@ export default function NotificationToaster() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleProjectInviteRespond = useCallback(
|
||||
async (projectId: number, action: 'accepted' | 'rejected', toastId: string | number, notificationId: number) => {
|
||||
const key = `proj-${projectId}`;
|
||||
if (respondingRef.current.has(key)) return;
|
||||
respondingRef.current.add(key);
|
||||
|
||||
toast.dismiss(toastId);
|
||||
const loadingId = toast.loading(
|
||||
action === 'accepted' ? 'Accepting project invite\u2026' : 'Declining invite\u2026',
|
||||
);
|
||||
|
||||
try {
|
||||
await api.post(`/projects/memberships/${projectId}/respond`, { response: action });
|
||||
toast.dismiss(loadingId);
|
||||
toast.success(action === 'accepted' ? 'Project invite accepted' : 'Project invite declined');
|
||||
markReadRef.current([notificationId]).catch(() => {});
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
} catch (err) {
|
||||
toast.dismiss(loadingId);
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
toast.success('Already responded');
|
||||
markReadRef.current([notificationId]).catch(() => {});
|
||||
} else {
|
||||
toast.error(getErrorMessage(err, 'Failed to respond to project invite'));
|
||||
}
|
||||
} finally {
|
||||
respondingRef.current.delete(key);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Track unread count changes to force-refetch the list
|
||||
useEffect(() => {
|
||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||
@ -141,7 +177,7 @@ export default function NotificationToaster() {
|
||||
initializedRef.current = true;
|
||||
|
||||
// Toast actionable unread notifications on login so the user can act immediately
|
||||
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']);
|
||||
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite', 'project_invite', 'task_assigned']);
|
||||
const actionable = notifications.filter(
|
||||
(n) => !n.is_read && actionableTypes.has(n.type),
|
||||
);
|
||||
@ -155,6 +191,10 @@ export default function NotificationToaster() {
|
||||
showCalendarInviteToast(notification);
|
||||
} else if (notification.type === 'event_invite' && notification.data) {
|
||||
showEventInviteToast(notification);
|
||||
} else if (notification.type === 'project_invite' && notification.data) {
|
||||
showProjectInviteToast(notification);
|
||||
} else if (notification.type === 'task_assigned' && notification.data) {
|
||||
showTaskAssignedToast(notification);
|
||||
}
|
||||
});
|
||||
return;
|
||||
@ -183,6 +223,9 @@ export default function NotificationToaster() {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||
}
|
||||
if (newNotifications.some((n) => n.type === 'project_invite' || n.type === 'project_invite_accepted' || n.type === 'task_assigned')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
}
|
||||
|
||||
// Show toasts
|
||||
newNotifications.forEach((notification) => {
|
||||
@ -192,6 +235,10 @@ export default function NotificationToaster() {
|
||||
showCalendarInviteToast(notification);
|
||||
} else if (notification.type === 'event_invite' && notification.data) {
|
||||
showEventInviteToast(notification);
|
||||
} else if (notification.type === 'project_invite' && notification.data) {
|
||||
showProjectInviteToast(notification);
|
||||
} else if (notification.type === 'task_assigned' && notification.data) {
|
||||
showTaskAssignedToast(notification);
|
||||
} else {
|
||||
toast(notification.title || 'New Notification', {
|
||||
description: notification.message || undefined,
|
||||
@ -200,7 +247,7 @@ export default function NotificationToaster() {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]);
|
||||
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond, handleProjectInviteRespond]);
|
||||
|
||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
||||
const requestId = notification.source_id!;
|
||||
@ -360,5 +407,96 @@ export default function NotificationToaster() {
|
||||
);
|
||||
};
|
||||
|
||||
const showTaskAssignedToast = (notification: AppNotification) => {
|
||||
const data = notification.data as Record<string, unknown> | undefined;
|
||||
const projectId = data?.project_id as number | undefined;
|
||||
const toastKey = `task-assigned-${notification.id}`;
|
||||
|
||||
toast.custom(
|
||||
(id) => (
|
||||
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-blue-500/15 flex items-center justify-center shrink-0">
|
||||
<ClipboardList className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Task Assigned</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{notification.message || 'You were assigned to a task'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{projectId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.dismiss(id);
|
||||
markReadRef.current([notification.id]).catch(() => {});
|
||||
navigateRef.current(`/projects/${projectId}`);
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
View Project
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.dismiss(id);
|
||||
markReadRef.current([notification.id]).catch(() => {});
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ id: toastKey, duration: 15000 },
|
||||
);
|
||||
};
|
||||
|
||||
const showProjectInviteToast = (notification: AppNotification) => {
|
||||
const data = notification.data as Record<string, unknown> | undefined;
|
||||
const projectId = data?.project_id as number | undefined;
|
||||
if (!projectId) return;
|
||||
|
||||
const toastKey = `project-invite-${notification.id}`;
|
||||
|
||||
toast.custom(
|
||||
(id) => (
|
||||
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
|
||||
<FolderKanban className="h-4 w-4 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Project Invitation</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{notification.message || 'You were invited to collaborate on a project'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleProjectInviteRespond(projectId, 'accepted', id, notification.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleProjectInviteRespond(projectId, 'rejected', id, notification.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ id: toastKey, duration: 30000 },
|
||||
);
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock } from 'lucide-react';
|
||||
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock, FolderKanban } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
@ -22,6 +22,9 @@ const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
|
||||
calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' },
|
||||
event_invite: { icon: Calendar, color: 'text-purple-400' },
|
||||
event_invite_response: { icon: Calendar, color: 'text-green-400' },
|
||||
project_invite: { icon: FolderKanban, color: 'text-purple-400' },
|
||||
project_invite_accepted: { icon: FolderKanban, color: 'text-green-400' },
|
||||
task_assigned: { icon: FolderKanban, color: 'text-blue-400' },
|
||||
info: { icon: Info, color: 'text-blue-400' },
|
||||
warning: { icon: AlertCircle, color: 'text-amber-400' },
|
||||
};
|
||||
@ -44,6 +47,7 @@ export default function NotificationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [respondingEventInvite, setRespondingEventInvite] = useState<number | null>(null);
|
||||
const [respondingProjectInvite, setRespondingProjectInvite] = useState<number | null>(null);
|
||||
|
||||
// Build a set of pending connection request IDs for quick lookup
|
||||
const pendingInviteIds = useMemo(
|
||||
@ -194,6 +198,36 @@ export default function NotificationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectInviteRespond = async (
|
||||
notification: AppNotification,
|
||||
action: 'accepted' | 'rejected',
|
||||
) => {
|
||||
const data = notification.data as Record<string, unknown> | undefined;
|
||||
const projectId = data?.project_id as number | undefined;
|
||||
if (!projectId) return;
|
||||
|
||||
setRespondingProjectInvite(notification.id);
|
||||
try {
|
||||
await api.post(`/projects/memberships/${projectId}/respond`, { response: action });
|
||||
toast.success(action === 'accepted' ? 'Project invite accepted' : 'Project invite declined');
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
if (!notification.is_read) {
|
||||
await markRead([notification.id]).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
toast.success('Already responded');
|
||||
if (!notification.is_read) {
|
||||
await markRead([notification.id]).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
toast.error(getErrorMessage(err, 'Failed to respond'));
|
||||
}
|
||||
} finally {
|
||||
setRespondingProjectInvite(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (notification: AppNotification) => {
|
||||
// Don't navigate for pending connection requests — let user act inline
|
||||
if (
|
||||
@ -207,6 +241,10 @@ export default function NotificationsPage() {
|
||||
if (notification.type === 'event_invite' && !notification.is_read) {
|
||||
return;
|
||||
}
|
||||
// Don't navigate for unread project invites — let user act inline
|
||||
if (notification.type === 'project_invite' && !notification.is_read) {
|
||||
return;
|
||||
}
|
||||
if (!notification.is_read) {
|
||||
await markRead([notification.id]).catch(() => {});
|
||||
}
|
||||
@ -218,6 +256,11 @@ export default function NotificationsPage() {
|
||||
if (notification.type === 'event_invite' || notification.type === 'event_invite_response') {
|
||||
navigate('/calendar');
|
||||
}
|
||||
// Navigate to project for project-related notifications
|
||||
if (['project_invite', 'project_invite_accepted', 'task_assigned'].includes(notification.type)) {
|
||||
const projectId = (notification.data as Record<string, unknown>)?.project_id;
|
||||
if (projectId) navigate(`/projects/${projectId}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -408,6 +451,31 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project invite actions (inline) */}
|
||||
{notification.type === 'project_invite' &&
|
||||
!notification.is_read && (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleProjectInviteRespond(notification, 'accepted'); }}
|
||||
disabled={respondingProjectInvite === notification.id}
|
||||
className="gap-1 h-7 text-xs"
|
||||
>
|
||||
{respondingProjectInvite === notification.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleProjectInviteRespond(notification, 'rejected'); }}
|
||||
disabled={respondingProjectInvite === notification.id}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp + actions */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
|
||||
286
frontend/src/components/projects/AssignmentPicker.tsx
Normal file
286
frontend/src/components/projects/AssignmentPicker.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, UserCircle, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskAssignment, ProjectMember } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initials(name: string | null | undefined): string {
|
||||
if (!name) return "?";
|
||||
return name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
interface AvatarCircleProps {
|
||||
name: string | null | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AvatarCircle({ name, className }: AvatarCircleProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-muted-foreground leading-none">
|
||||
{initials(name)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function roleBadge(
|
||||
userId: number,
|
||||
ownerId: number,
|
||||
permission: ProjectMember["permission"]
|
||||
): { label: string; className: string } {
|
||||
if (userId === ownerId)
|
||||
return { label: "Owner", className: "text-accent" };
|
||||
if (permission === "create_modify")
|
||||
return { label: "Editor", className: "text-muted-foreground" };
|
||||
return { label: "Viewer", className: "text-muted-foreground/60" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AssigneeAvatars — stacked display for TaskRow / KanbanBoard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AssigneeAvatars({
|
||||
assignments,
|
||||
max = 3,
|
||||
}: {
|
||||
assignments: TaskAssignment[];
|
||||
max?: number;
|
||||
}) {
|
||||
const visible = assignments.slice(0, max);
|
||||
const overflow = assignments.length - max;
|
||||
const allNames = assignments.map((a) => a.user_name ?? "Unknown").join(", ");
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex items-center"
|
||||
title={allNames}
|
||||
aria-label={`Assigned to: ${allNames}`}
|
||||
>
|
||||
{visible.map((a) => (
|
||||
<span
|
||||
key={a.user_id}
|
||||
className="w-5 h-5 -ml-1.5 first:ml-0 rounded-full bg-muted border border-background flex items-center justify-center shrink-0"
|
||||
>
|
||||
<span className="text-[9px] font-medium text-muted-foreground leading-none">
|
||||
{initials(a.user_name)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="w-5 h-5 -ml-1.5 rounded-full bg-muted border border-background flex items-center justify-center shrink-0">
|
||||
<span className="text-[9px] font-medium text-muted-foreground leading-none">
|
||||
+{overflow}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AssignmentPicker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AssignmentPickerProps {
|
||||
currentAssignments: TaskAssignment[];
|
||||
members: ProjectMember[];
|
||||
currentUserId: number;
|
||||
ownerId: number;
|
||||
onAssign: (userIds: number[]) => void;
|
||||
onUnassign: (userId: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AssignmentPicker({
|
||||
currentAssignments,
|
||||
members,
|
||||
currentUserId,
|
||||
ownerId,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
disabled = false,
|
||||
}: AssignmentPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handle(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handle);
|
||||
return () => document.removeEventListener("mousedown", handle);
|
||||
}, [open]);
|
||||
|
||||
const assignedIds = new Set(currentAssignments.map((a) => a.user_id));
|
||||
|
||||
// Build ordered member list: current user first, then rest alphabetically
|
||||
const sortedMembers = [...members].sort((a, b) => {
|
||||
if (a.user_id === currentUserId) return -1;
|
||||
if (b.user_id === currentUserId) return 1;
|
||||
return (a.user_name ?? "").localeCompare(b.user_name ?? "");
|
||||
});
|
||||
|
||||
const availableMembers = sortedMembers.filter(
|
||||
(m) => !assignedIds.has(m.user_id)
|
||||
);
|
||||
|
||||
function handleSelect(userId: number) {
|
||||
onAssign([userId]);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function handleRemove(e: React.MouseEvent, userId: number) {
|
||||
e.stopPropagation();
|
||||
onUnassign(userId);
|
||||
}
|
||||
|
||||
function handleTriggerClick() {
|
||||
if (disabled) return;
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
|
||||
function handleTriggerKeyDown(e: React.KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
|
||||
const hasAssignees = currentAssignments.length > 0;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Trigger area */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-disabled={disabled}
|
||||
onClick={handleTriggerClick}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1 min-h-[28px] px-1.5 py-1 rounded-md",
|
||||
"border border-transparent transition-colors",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer hover:border-border hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
)}
|
||||
>
|
||||
{hasAssignees ? (
|
||||
<>
|
||||
{currentAssignments.map((a) => (
|
||||
<span
|
||||
key={a.user_id}
|
||||
className="flex items-center gap-1 bg-secondary rounded-full pl-1 pr-1.5 py-0.5"
|
||||
>
|
||||
<AvatarCircle name={a.user_name} />
|
||||
<span className="text-xs text-secondary-foreground leading-none max-w-[80px] truncate">
|
||||
{a.user_id === currentUserId ? "Me" : (a.user_name ?? "Unknown")}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${a.user_name ?? "user"}`}
|
||||
onClick={(e) => handleRemove(e, a.user_id)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors ml-0.5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-full"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!disabled && availableMembers.length > 0 && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 text-muted-foreground/60 transition-transform ml-0.5 shrink-0",
|
||||
open && "rotate-180"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground/50 text-xs select-none">
|
||||
<UserCircle className="w-4 h-4 shrink-0" aria-hidden="true" />
|
||||
Assign...
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 transition-transform",
|
||||
open && "rotate-180"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && availableMembers.length > 0 && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label="Available members"
|
||||
className="border border-border bg-card shadow-lg rounded-lg absolute z-50 top-full left-0 mt-1 min-w-[180px] max-w-[240px] py-1 overflow-hidden"
|
||||
>
|
||||
{availableMembers.map((m) => {
|
||||
const badge = roleBadge(m.user_id, ownerId, m.permission);
|
||||
const displayName =
|
||||
m.user_id === currentUserId
|
||||
? "Me"
|
||||
: (m.user_name ?? "Unknown");
|
||||
|
||||
return (
|
||||
<li key={m.user_id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => handleSelect(m.user_id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50 transition-colors text-left focus-visible:outline-none focus-visible:bg-muted/50"
|
||||
>
|
||||
<AvatarCircle name={m.user_name} />
|
||||
<span className="flex-1 truncate text-foreground">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className={cn("text-[11px] shrink-0", badge.className)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Empty state when all members assigned */}
|
||||
{open && availableMembers.length === 0 && (
|
||||
<div className="border border-border bg-card shadow-lg rounded-lg absolute z-50 top-full left-0 mt-1 min-w-[180px] py-2 px-3">
|
||||
<p className="text-xs text-muted-foreground">All members assigned</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,219 +1,262 @@
|
||||
import {
|
||||
DndContext,
|
||||
closestCorners,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
} from '@dnd-kit/core';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { ProjectTask } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
const COLUMNS: { id: string; label: string; color: string }[] = [
|
||||
{ id: 'pending', label: 'Pending', color: 'text-gray-400' },
|
||||
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
|
||||
{ id: 'blocked', label: 'Blocked', color: 'text-red-400' },
|
||||
{ id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
|
||||
{ id: 'review', label: 'Review', color: 'text-yellow-400' },
|
||||
{ id: 'completed', label: 'Completed', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
none: 'bg-gray-500/20 text-gray-400',
|
||||
low: 'bg-green-500/20 text-green-400',
|
||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||
high: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
kanbanParentTask?: ProjectTask | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
onStatusChange: (taskId: number, status: string) => void;
|
||||
onBackToAllTasks?: () => void;
|
||||
}
|
||||
|
||||
function KanbanColumn({
|
||||
column,
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
}: {
|
||||
column: (typeof COLUMNS)[0];
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${
|
||||
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
|
||||
}`}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="px-3 py-2.5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-semibold font-heading ${column.color}`}>
|
||||
{column.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-2 space-y-2 min-h-[100px]">
|
||||
{tasks.map((task) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={() => onSelectTask(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCard({
|
||||
task,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
task: ProjectTask;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: task.id,
|
||||
data: { task },
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate(${transform.x}px, ${transform.y}px)`,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
|
||||
const totalSubtasks = task.subtasks?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onClick={onSelect}
|
||||
className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${
|
||||
isSelected
|
||||
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
|
||||
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{task.due_date && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{format(parseISO(task.due_date), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
{totalSubtasks > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{completedSubtasks}/{totalSubtasks}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KanbanBoard({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
kanbanParentTask,
|
||||
onSelectTask,
|
||||
onStatusChange,
|
||||
onBackToAllTasks,
|
||||
}: KanbanBoardProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
,
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||
);
|
||||
|
||||
// Subtask view is driven by kanbanParentTask (decoupled from selected task)
|
||||
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
|
||||
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as number;
|
||||
const newStatus = over.id as string;
|
||||
|
||||
const task = activeTasks.find((t) => t.id === taskId);
|
||||
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
|
||||
onStatusChange(taskId, newStatus);
|
||||
}
|
||||
};
|
||||
|
||||
const tasksByStatus = COLUMNS.map((col) => ({
|
||||
column: col,
|
||||
tasks: activeTasks.filter((t) => t.status === col.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Subtask view header */}
|
||||
{isSubtaskView && kanbanParentTask && (
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<button
|
||||
onClick={onBackToAllTasks}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||
>
|
||||
Back to all tasks
|
||||
</button>
|
||||
<span className="text-muted-foreground text-xs">/</span>
|
||||
<span className="text-xs text-foreground font-medium">
|
||||
Subtasks of: {kanbanParentTask.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{tasksByStatus.map(({ column, tasks: colTasks }) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
tasks={colTasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={onSelectTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { ProjectTask } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AssigneeAvatars } from './AssignmentPicker';
|
||||
|
||||
const COLUMNS: { id: string; label: string; color: string }[] = [
|
||||
{ id: 'pending', label: 'Pending', color: 'text-gray-400' },
|
||||
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
|
||||
{ id: 'blocked', label: 'Blocked', color: 'text-red-400' },
|
||||
{ id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
|
||||
{ id: 'review', label: 'Review', color: 'text-yellow-400' },
|
||||
{ id: 'completed', label: 'Completed', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
none: 'bg-gray-500/20 text-gray-400',
|
||||
low: 'bg-green-500/20 text-green-400',
|
||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||
high: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
kanbanParentTask?: ProjectTask | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
onStatusChange: (taskId: number, status: string) => void;
|
||||
onBackToAllTasks?: () => void;
|
||||
}
|
||||
|
||||
function KanbanColumn({
|
||||
column,
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
draggingId,
|
||||
onSelectTask,
|
||||
}: {
|
||||
column: (typeof COLUMNS)[0];
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
draggingId: number | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${
|
||||
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
|
||||
}`}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="px-3 py-2.5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-semibold font-heading ${column.color}`}>
|
||||
{column.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-2 space-y-2 min-h-[100px]">
|
||||
{tasks.map((task) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
isDragSource={draggingId === task.id}
|
||||
onSelect={() => onSelectTask(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card content — shared between in-place card and drag overlay
|
||||
function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelected: boolean; ghost?: boolean }) {
|
||||
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
|
||||
const totalSubtasks = task.subtasks?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border p-3 ${
|
||||
ghost
|
||||
? 'border-accent/20 bg-accent/5 opacity-40'
|
||||
: isSelected
|
||||
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
|
||||
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{task.due_date && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{format(parseISO(task.due_date), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
{totalSubtasks > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{completedSubtasks}/{totalSubtasks}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{task.assignments && task.assignments.length > 0 && (
|
||||
<div className="flex justify-end mt-2">
|
||||
<AssigneeAvatars assignments={task.assignments} max={2} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCard({
|
||||
task,
|
||||
isSelected,
|
||||
isDragSource,
|
||||
onSelect,
|
||||
}: {
|
||||
task: ProjectTask;
|
||||
isSelected: boolean;
|
||||
isDragSource: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: task.id,
|
||||
data: { task },
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onClick={onSelect}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<CardContent task={task} isSelected={isSelected} ghost={isDragSource} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KanbanBoard({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
kanbanParentTask,
|
||||
onSelectTask,
|
||||
onStatusChange,
|
||||
onBackToAllTasks,
|
||||
}: KanbanBoardProps) {
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||
);
|
||||
|
||||
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
|
||||
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
|
||||
|
||||
const draggingTask = draggingId ? activeTasks.find((t) => t.id === draggingId) ?? null : null;
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setDraggingId(event.active.id as number);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
setDraggingId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as number;
|
||||
const newStatus = over.id as string;
|
||||
|
||||
const task = activeTasks.find((t) => t.id === taskId);
|
||||
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
|
||||
onStatusChange(taskId, newStatus);
|
||||
}
|
||||
}, [activeTasks, onStatusChange]);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setDraggingId(null);
|
||||
}, []);
|
||||
|
||||
const tasksByStatus = COLUMNS.map((col) => ({
|
||||
column: col,
|
||||
tasks: activeTasks.filter((t) => t.status === col.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Subtask view header */}
|
||||
{isSubtaskView && kanbanParentTask && (
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<button
|
||||
onClick={onBackToAllTasks}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||
>
|
||||
Back to all tasks
|
||||
</button>
|
||||
<span className="text-muted-foreground text-xs">/</span>
|
||||
<span className="text-xs text-foreground font-medium">
|
||||
Subtasks of: {kanbanParentTask.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{tasksByStatus.map(({ column, tasks: colTasks }) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
tasks={colTasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
draggingId={draggingId}
|
||||
onSelectTask={onSelectTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Floating overlay — renders above everything, no layout impact */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggingTask ? (
|
||||
<div className="w-[200px] opacity-95 shadow-lg shadow-black/30 rotate-[2deg]">
|
||||
<CardContent task={draggingTask} isSelected={false} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,9 +2,10 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { format, isPast, parseISO } from 'date-fns';
|
||||
import { Calendar, Pin } from 'lucide-react';
|
||||
import { Calendar, Pin, Users } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import type { Project } from '@/types';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { statusColors, statusLabels } from './constants';
|
||||
@ -17,6 +18,8 @@ interface ProjectCardProps {
|
||||
export default function ProjectCard({ project }: ProjectCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { settings } = useSettings();
|
||||
const isShared = project.user_id !== (settings?.user_id ?? 0);
|
||||
|
||||
const toggleTrackMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@ -47,20 +50,22 @@ export default function ProjectCard({ project }: ProjectCardProps) {
|
||||
className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200 relative"
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTrackMutation.mutate();
|
||||
}}
|
||||
className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${
|
||||
project.is_tracked
|
||||
? 'text-accent hover:bg-accent/10'
|
||||
: 'text-muted-foreground/40 hover:text-muted-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
title={project.is_tracked ? 'Untrack project' : 'Track project'}
|
||||
>
|
||||
<Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
{!isShared && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTrackMutation.mutate();
|
||||
}}
|
||||
className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${
|
||||
project.is_tracked
|
||||
? 'text-accent hover:bg-accent/10'
|
||||
: 'text-muted-foreground/40 hover:text-muted-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
title={project.is_tracked ? 'Untrack project' : 'Track project'}
|
||||
>
|
||||
<Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2 pr-6">
|
||||
<CardTitle className="font-heading text-lg font-semibold">{project.name}</CardTitle>
|
||||
@ -95,6 +100,17 @@ export default function ProjectCard({ project }: ProjectCardProps) {
|
||||
Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
|
||||
</div>
|
||||
)}
|
||||
{/* Sharing indicator — shows for both owner and shared users */}
|
||||
{project.member_count > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-2">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{isShared
|
||||
? 'Shared with you'
|
||||
: `${project.member_count} member${project.member_count !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -23,21 +23,25 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
|
||||
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
|
||||
List, Columns3, ArrowUpDown,
|
||||
List, Columns3, ArrowUpDown, Users, Eye,
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import api from '@/lib/api';
|
||||
import type { Project, ProjectTask } from '@/types';
|
||||
import type { Project, ProjectTask, ProjectMember } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { useDeltaPoll } from '@/hooks/useDeltaPoll';
|
||||
import TaskRow from './TaskRow';
|
||||
import TaskDetailPanel from './TaskDetailPanel';
|
||||
import KanbanBoard from './KanbanBoard';
|
||||
import TaskForm from './TaskForm';
|
||||
import ProjectForm from './ProjectForm';
|
||||
import { ProjectShareSheet } from './ProjectShareSheet';
|
||||
import { statusColors, statusLabels } from './constants';
|
||||
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
||||
|
||||
@ -111,6 +115,9 @@ export default function ProjectDetail() {
|
||||
const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('manual');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [showShareSheet, setShowShareSheet] = useState(false);
|
||||
const { settings } = useSettings();
|
||||
const currentUserId = settings?.user_id ?? 0;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
@ -134,17 +141,54 @@ export default function ProjectDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// Permission derivation
|
||||
const isOwner = project ? project.user_id === currentUserId : true;
|
||||
const isShared = project ? project.user_id !== currentUserId : false;
|
||||
// For now, if they can see the project but don't own it, check if they can edit
|
||||
// The backend enforces actual permissions — this is just UI gating
|
||||
const canEdit = isOwner; // Members with create_modify can also edit tasks (handled per-task)
|
||||
const canManageProject = isOwner;
|
||||
|
||||
// Delta polling for real-time sync — only for shared projects (P-04)
|
||||
const pollKey = useMemo(() => ['projects', id], [id]);
|
||||
useDeltaPoll(
|
||||
id && project && project.member_count > 0 ? `/projects/${id}/poll` : null,
|
||||
pollKey,
|
||||
5000,
|
||||
);
|
||||
|
||||
// Fetch members for shared projects
|
||||
const { data: members = [] } = useQuery<ProjectMember[]>({
|
||||
queryKey: ['project-members', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/projects/${id}/members`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
const acceptedMembers = useMemo(() => members.filter((m) => m.status === 'accepted'), [members]);
|
||||
const myPermission = useMemo(
|
||||
() => acceptedMembers.find((m) => m.user_id === currentUserId)?.permission ?? null,
|
||||
[acceptedMembers, currentUserId]
|
||||
);
|
||||
const canEditTasks = isOwner || myPermission === 'create_modify';
|
||||
|
||||
const toggleTaskMutation = useMutation({
|
||||
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
||||
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
|
||||
const newStatus = status === 'completed' ? 'pending' : 'completed';
|
||||
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus });
|
||||
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus, version });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update task');
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
toast.error('Task was modified by another user — please refresh');
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||
} else {
|
||||
toast.error('Failed to update task');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -199,15 +243,20 @@ export default function ProjectDetail() {
|
||||
});
|
||||
|
||||
const updateTaskStatusMutation = useMutation({
|
||||
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
||||
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status });
|
||||
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
|
||||
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status, version });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update task status');
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
toast.error('Task was modified by another user — please refresh');
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||
} else {
|
||||
toast.error('Failed to update task status');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -389,31 +438,57 @@ export default function ProjectDetail() {
|
||||
<Badge className={`shrink-0 hidden sm:inline-flex ${statusColors[project.status]}`}>
|
||||
{statusLabels[project.status]}
|
||||
</Badge>
|
||||
{/* Permission badge for non-owners */}
|
||||
{isShared && (
|
||||
<Badge className="shrink-0 bg-blue-500/10 text-blue-400 border-0">
|
||||
{myPermission === 'create_modify' ? 'Editor' : 'Viewer'}
|
||||
</Badge>
|
||||
)}
|
||||
{canManageProject && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleTrackMutation.mutate()}
|
||||
disabled={toggleTrackMutation.isPending}
|
||||
className={`shrink-0 ${project.is_tracked ? 'text-accent' : 'text-muted-foreground'}`}
|
||||
title={project.is_tracked ? 'Untrack project' : 'Track project'}
|
||||
>
|
||||
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleTrackMutation.mutate()}
|
||||
disabled={toggleTrackMutation.isPending}
|
||||
className={`shrink-0 ${project.is_tracked ? 'text-accent' : 'text-muted-foreground'}`}
|
||||
title={project.is_tracked ? 'Untrack project' : 'Track project'}
|
||||
className="shrink-0 text-muted-foreground relative"
|
||||
onClick={() => setShowShareSheet(true)}
|
||||
title="Project members"
|
||||
>
|
||||
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setShowProjectForm(true)}>
|
||||
<Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (!window.confirm('Delete this project and all its tasks?')) return;
|
||||
deleteProjectMutation.mutate();
|
||||
}}
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
|
||||
<Users className="h-4 w-4" />
|
||||
{acceptedMembers.length > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-accent text-[9px] font-bold flex items-center justify-center text-background">
|
||||
{acceptedMembers.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{canManageProject && (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setShowProjectForm(true)}>
|
||||
<Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
|
||||
</Button>
|
||||
)}
|
||||
{canManageProject && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (!window.confirm('Delete this project and all its tasks?')) return;
|
||||
deleteProjectMutation.mutate();
|
||||
}}
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
@ -491,6 +566,14 @@ export default function ProjectDetail() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Read-only banner for viewers */}
|
||||
{isShared && !canEdit && (
|
||||
<div className="mx-4 md:mx-6 mb-3 px-3 py-2 rounded-md bg-secondary/50 border border-border flex items-center gap-2">
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground">You have view-only access to this project</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task list header + view controls */}
|
||||
<div className="px-4 md:px-6 pb-3 flex items-center justify-between flex-wrap gap-2 shrink-0">
|
||||
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
|
||||
@ -535,10 +618,12 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button size="sm" onClick={() => openTaskForm(null, null)}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add Task
|
||||
</Button>
|
||||
{canEditTasks && (
|
||||
<Button size="sm" onClick={() => openTaskForm(null, null)}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add Task
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -561,9 +646,10 @@ export default function ProjectDetail() {
|
||||
selectedTaskId={selectedTaskId}
|
||||
kanbanParentTask={kanbanParentTask}
|
||||
onSelectTask={handleKanbanSelectTask}
|
||||
onStatusChange={(taskId, status) =>
|
||||
updateTaskStatusMutation.mutate({ taskId, status })
|
||||
}
|
||||
onStatusChange={(taskId, status) => {
|
||||
const t = allTasks.find(tt => tt.id === taskId) ?? allTasks.flatMap(tt => tt.subtasks || []).find(st => st.id === taskId);
|
||||
updateTaskStatusMutation.mutate({ taskId, status, version: t?.version ?? 1 });
|
||||
}}
|
||||
onBackToAllTasks={handleBackToAllTasks}
|
||||
/>
|
||||
) : (
|
||||
@ -595,6 +681,7 @@ export default function ProjectDetail() {
|
||||
toggleTaskMutation.mutate({
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
version: task.version,
|
||||
})
|
||||
}
|
||||
togglePending={toggleTaskMutation.isPending}
|
||||
@ -641,6 +728,10 @@ export default function ProjectDetail() {
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
projectId={parseInt(id!)}
|
||||
members={acceptedMembers}
|
||||
currentUserId={currentUserId}
|
||||
ownerId={project?.user_id ?? 0}
|
||||
canAssign={canEditTasks}
|
||||
onDelete={handleDeleteTask}
|
||||
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
@ -698,6 +789,14 @@ export default function ProjectDetail() {
|
||||
onClose={() => setShowProjectForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProjectShareSheet
|
||||
open={showShareSheet}
|
||||
onOpenChange={setShowShareSheet}
|
||||
projectId={parseInt(id!)}
|
||||
isOwner={isOwner}
|
||||
ownerName={settings?.preferred_name || 'Owner'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
310
frontend/src/components/projects/ProjectShareSheet.tsx
Normal file
310
frontend/src/components/projects/ProjectShareSheet.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Users, UserPlus, Search, X, Crown, Eye, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import api from '@/lib/api';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import type { ProjectMember, ProjectPermission, Connection } from '@/types';
|
||||
|
||||
interface ProjectShareSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: number;
|
||||
isOwner: boolean;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
function AvatarCircle({ name }: { name: string }) {
|
||||
const letter = (name ?? '?').charAt(0).toUpperCase();
|
||||
return (
|
||||
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-medium text-muted-foreground">{letter}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionBadge({ permission }: { permission: ProjectPermission }) {
|
||||
if (permission === 'create_modify') {
|
||||
return (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-blue-500/10 text-blue-400 flex items-center gap-1">
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
Editor
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-500/10 text-gray-400 flex items-center gap-1">
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
Viewer
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectShareSheet({ open, onOpenChange, projectId, isOwner, ownerName }: ProjectShareSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { connections } = useConnections();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [invitePermission, setInvitePermission] = useState<ProjectPermission>('create_modify');
|
||||
|
||||
const membersQuery = useQuery<ProjectMember[]>({
|
||||
queryKey: ['project-members', projectId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/projects/${projectId}/members`);
|
||||
return data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const members = membersQuery.data ?? [];
|
||||
const acceptedMembers = members.filter((m) => m.status === 'accepted');
|
||||
const pendingMembers = members.filter((m) => m.status === 'pending');
|
||||
|
||||
const existingMemberIds = useMemo(
|
||||
() => new Set(members.map((m) => m.user_id)),
|
||||
[members]
|
||||
);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project-members', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project', projectId] });
|
||||
};
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (payload: { user_ids: number[]; permission: ProjectPermission }) =>
|
||||
api.post(`/projects/${projectId}/members`, payload),
|
||||
onSuccess: () => { toast.success('Invites sent'); setSelectedIds([]); invalidate(); },
|
||||
onError: () => toast.error('Failed to send invites'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ userId, permission }: { userId: number; permission: ProjectPermission }) =>
|
||||
api.patch(`/projects/${projectId}/members/${userId}`, { permission }),
|
||||
onSuccess: () => { toast.success('Permission updated'); invalidate(); },
|
||||
onError: () => toast.error('Failed to update permission'),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: number) => api.delete(`/projects/${projectId}/members/${userId}`),
|
||||
onSuccess: () => { toast.success('Member removed'); invalidate(); },
|
||||
onError: () => toast.error('Failed to remove member'),
|
||||
});
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!search.trim()) return [];
|
||||
const q = search.toLowerCase();
|
||||
return (connections as Connection[])
|
||||
.filter(
|
||||
(c) =>
|
||||
!existingMemberIds.has(c.connected_user_id) &&
|
||||
!selectedIds.includes(c.connected_user_id) &&
|
||||
((c.connected_preferred_name?.toLowerCase().includes(q)) ||
|
||||
c.connected_umbral_name.toLowerCase().includes(q))
|
||||
)
|
||||
.slice(0, 6);
|
||||
}, [search, connections, existingMemberIds, selectedIds]);
|
||||
|
||||
const selectedConnections = (connections as Connection[]).filter((c) =>
|
||||
selectedIds.includes(c.connected_user_id)
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
Project Members
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
|
||||
{/* Current Members */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
Members ({acceptedMembers.length + 1})
|
||||
</p>
|
||||
|
||||
{/* Owner row — always first */}
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<AvatarCircle name={ownerName} />
|
||||
<span className="text-sm flex-1 truncate">{ownerName}</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1"
|
||||
style={{ color: 'hsl(var(--accent-color))', backgroundColor: 'hsl(var(--accent-color) / 0.1)' }}
|
||||
>
|
||||
<Crown className="h-2.5 w-2.5" />
|
||||
Owner
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Accepted members */}
|
||||
{acceptedMembers.map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-2 py-1">
|
||||
<AvatarCircle name={member.user_name ?? 'Unknown'} />
|
||||
<span className="text-sm flex-1 truncate">{member.user_name ?? 'Unknown'}</span>
|
||||
|
||||
{isOwner ? (
|
||||
<select
|
||||
value={member.permission}
|
||||
onChange={(e) =>
|
||||
updateMutation.mutate({
|
||||
userId: member.user_id,
|
||||
permission: e.target.value as ProjectPermission,
|
||||
})
|
||||
}
|
||||
disabled={updateMutation.isPending}
|
||||
className="text-[11px] bg-transparent border border-border rounded px-1.5 py-0.5 text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="read_only">Viewer</option>
|
||||
<option value="create_modify">Editor</option>
|
||||
</select>
|
||||
) : (
|
||||
<PermissionBadge permission={member.permission} />
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(member.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
title="Remove member"
|
||||
className="p-1 rounded text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pending invites */}
|
||||
{pendingMembers.length > 0 && (
|
||||
<>
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-wider mt-3">
|
||||
Pending Invites ({pendingMembers.length})
|
||||
</p>
|
||||
{pendingMembers.map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-2 py-1 opacity-60">
|
||||
<AvatarCircle name={member.user_name ?? 'Unknown'} />
|
||||
<span className="text-sm flex-1 truncate">{member.user_name ?? 'Unknown'}</span>
|
||||
<span className="text-[10px] text-muted-foreground">Pending</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(member.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
title="Cancel invite"
|
||||
className="p-1 rounded text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite section (owner only) */}
|
||||
{isOwner && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<UserPlus className="h-3 w-3" />
|
||||
Invite Connections
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground shrink-0">Invite as</span>
|
||||
<Select
|
||||
value={invitePermission}
|
||||
onChange={(e) => setInvitePermission(e.target.value as ProjectPermission)}
|
||||
className="h-7 text-xs py-0"
|
||||
>
|
||||
<option value="create_modify">Editor</option>
|
||||
<option value="read_only">Viewer</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onBlur={() => setSearch('')}
|
||||
placeholder="Search connections..."
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
{search.trim() && searchResults.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
|
||||
{searchResults.map((conn) => (
|
||||
<button
|
||||
key={conn.connected_user_id}
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setSelectedIds(p => [...p, conn.connected_user_id]); setSearch(''); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate block">
|
||||
{conn.connected_preferred_name || conn.connected_umbral_name}
|
||||
</span>
|
||||
{conn.connected_preferred_name && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
@{conn.connected_umbral_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{search.trim() && searchResults.length === 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg p-3">
|
||||
<p className="text-xs text-muted-foreground text-center">No connections found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedConnections.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{selectedConnections.map((conn) => (
|
||||
<div key={conn.connected_user_id} className="flex items-center gap-2 py-1">
|
||||
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{conn.connected_preferred_name || conn.connected_umbral_name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIds(p => p.filter(id => id !== conn.connected_user_id))}
|
||||
className="p-0.5 rounded hover:bg-card-elevated text-muted-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => inviteMutation.mutate({ user_ids: selectedIds, permission: invitePermission })}
|
||||
disabled={inviteMutation.isPending}
|
||||
className="w-full mt-1"
|
||||
>
|
||||
{inviteMutation.isPending
|
||||
? 'Sending...'
|
||||
: `Invite ${selectedIds.length === 1 ? '1 Person' : `${selectedIds.length} People`}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import {
|
||||
Pencil, Trash2, Plus, MessageSquare, ClipboardList,
|
||||
Calendar, User, Flag, Activity, Send, X, Save,
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||
import type { ProjectTask, TaskComment, Person } from '@/types';
|
||||
import type { ProjectTask, TaskComment, ProjectMember } from '@/types';
|
||||
import { AssignmentPicker } from './AssignmentPicker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@ -45,6 +47,10 @@ const priorityColors: Record<string, string> = {
|
||||
interface TaskDetailPanelProps {
|
||||
task: ProjectTask | null;
|
||||
projectId: number;
|
||||
members?: ProjectMember[];
|
||||
currentUserId?: number;
|
||||
ownerId?: number;
|
||||
canAssign?: boolean;
|
||||
onDelete: (taskId: number) => void;
|
||||
onAddSubtask: (parentId: number) => void;
|
||||
onClose?: () => void;
|
||||
@ -81,6 +87,10 @@ function buildEditState(task: ProjectTask): EditState {
|
||||
export default function TaskDetailPanel({
|
||||
task,
|
||||
projectId,
|
||||
members = [],
|
||||
currentUserId = 0,
|
||||
ownerId = 0,
|
||||
canAssign = false,
|
||||
onDelete,
|
||||
onAddSubtask,
|
||||
onClose,
|
||||
@ -93,20 +103,24 @@ export default function TaskDetailPanel({
|
||||
task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' }
|
||||
);
|
||||
|
||||
const { data: people = [] } = useQuery({
|
||||
queryKey: ['people'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<Person[]>('/people');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
// Build a combined members list that includes the owner for the AssignmentPicker
|
||||
const allMembers: ProjectMember[] = [
|
||||
// Synthetic owner entry so they appear in the picker
|
||||
...(ownerId ? [{
|
||||
id: 0, project_id: projectId, user_id: ownerId, invited_by: ownerId,
|
||||
permission: 'create_modify' as const, status: 'accepted' as const,
|
||||
source: 'invited' as const, user_name: currentUserId === ownerId ? 'Me (Owner)' : 'Owner',
|
||||
inviter_name: null, created_at: '', updated_at: '', accepted_at: null,
|
||||
}] : []),
|
||||
...members.filter(m => m.status === 'accepted'),
|
||||
];
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
const toggleSubtaskMutation = useMutation({
|
||||
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
||||
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
|
||||
const newStatus = status === 'completed' ? 'pending' : 'completed';
|
||||
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus });
|
||||
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus, version });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -125,7 +139,12 @@ export default function TaskDetailPanel({
|
||||
toast.success('Task updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to update task'));
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
toast.error('Task was modified by another user — please refresh');
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||
} else {
|
||||
toast.error(getErrorMessage(error, 'Failed to update task'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -169,6 +188,30 @@ export default function TaskDetailPanel({
|
||||
},
|
||||
});
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: async (userIds: number[]) => {
|
||||
await api.post(`/projects/${projectId}/tasks/${task!.id}/assignments`, { user_ids: userIds });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to assign'));
|
||||
},
|
||||
});
|
||||
|
||||
const unassignMutation = useMutation({
|
||||
mutationFn: async (userId: number) => {
|
||||
await api.delete(`/projects/${projectId}/tasks/${task!.id}/assignments/${userId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to unassign'));
|
||||
},
|
||||
});
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleAddComment = () => {
|
||||
@ -197,6 +240,7 @@ export default function TaskDetailPanel({
|
||||
due_date: editState.due_date || null,
|
||||
person_id: editState.person_id ? Number(editState.person_id) : null,
|
||||
description: editState.description || null,
|
||||
version: task.version,
|
||||
};
|
||||
updateTaskMutation.mutate(payload);
|
||||
};
|
||||
@ -217,7 +261,6 @@ export default function TaskDetailPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const assignedPerson = task.person_id ? people.find((p) => p.id === task.person_id) : null;
|
||||
const comments = task.comments || [];
|
||||
|
||||
return (
|
||||
@ -376,21 +419,34 @@ export default function TaskDetailPanel({
|
||||
<User className="h-3 w-3" />
|
||||
Assigned
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={editState.person_id}
|
||||
onChange={(e) => setEditState((s) => ({ ...s, person_id: e.target.value }))}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{people.map((p) => (
|
||||
<option key={p.id} value={String(p.id)}>
|
||||
{p.name}
|
||||
</option>
|
||||
{canAssign ? (
|
||||
<AssignmentPicker
|
||||
currentAssignments={task.assignments ?? []}
|
||||
members={allMembers}
|
||||
currentUserId={currentUserId}
|
||||
ownerId={ownerId}
|
||||
onAssign={(userIds) => assignMutation.mutate(userIds)}
|
||||
onUnassign={(userId) => unassignMutation.mutate(userId)}
|
||||
disabled={assignMutation.isPending || unassignMutation.isPending}
|
||||
/>
|
||||
) : task.assignments && task.assignments.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{task.assignments.map((a) => (
|
||||
<span
|
||||
key={a.user_id}
|
||||
className="inline-flex items-center gap-1.5 bg-secondary rounded-full pl-1 pr-2.5 py-0.5"
|
||||
>
|
||||
<span className="w-5 h-5 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||||
<span className="text-[9px] font-medium text-muted-foreground">
|
||||
{(a.user_name ?? '?').charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs">{a.user_name ?? 'Unknown'}</span>
|
||||
</span>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm">{assignedPerson ? assignedPerson.name : '—'}</p>
|
||||
<p className="text-sm text-muted-foreground/50">Unassigned</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -458,6 +514,7 @@ export default function TaskDetailPanel({
|
||||
toggleSubtaskMutation.mutate({
|
||||
taskId: subtask.id,
|
||||
status: subtask.status,
|
||||
version: subtask.version,
|
||||
})
|
||||
}
|
||||
disabled={toggleSubtaskMutation.isPending}
|
||||
@ -522,6 +579,9 @@ export default function TaskDetailPanel({
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{comment.author_name && (
|
||||
<span className="font-medium text-foreground/70 mr-1">{comment.author_name}</span>
|
||||
)}
|
||||
{formatDistanceToNow(parseISO(comment.created_at), { addSuffix: true })}
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@ -3,6 +3,7 @@ import { ChevronRight, GripVertical } from 'lucide-react';
|
||||
import type { ProjectTask } from '@/types';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AssigneeAvatars } from './AssignmentPicker';
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
||||
@ -134,6 +135,22 @@ export default function TaskRow({
|
||||
{hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'}
|
||||
</span>
|
||||
|
||||
{/* Assigned column */}
|
||||
<span className="hidden sm:flex items-center gap-1.5 shrink-0 w-24 justify-end">
|
||||
{task.assignments && task.assignments.length > 0 ? (
|
||||
<>
|
||||
<AssigneeAvatars assignments={task.assignments} max={2} />
|
||||
<span className="text-[11px] text-muted-foreground truncate max-w-[60px]">
|
||||
{task.assignments.length === 1
|
||||
? (task.assignments[0].user_name ?? 'Unknown')
|
||||
: `${task.assignments.length} people`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted-foreground/30">unassigned</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Mobile-only: compact priority dot + overdue indicator */}
|
||||
<div className="flex items-center gap-1.5 sm:hidden shrink-0">
|
||||
<div className={`h-2 w-2 rounded-full ${
|
||||
|
||||
56
frontend/src/hooks/useDeltaPoll.ts
Normal file
56
frontend/src/hooks/useDeltaPoll.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../lib/api';
|
||||
import { toLocalDatetime } from '../lib/date-utils';
|
||||
|
||||
interface PollResponse {
|
||||
has_changes: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight delta poll hook. Polls a cheap endpoint every `interval` ms.
|
||||
* When changes are detected, invalidates the specified query key.
|
||||
*
|
||||
* HARD REQUIREMENT: refetchIntervalInBackground must be true so
|
||||
* background tabs still receive updates (mirrors useNotifications pattern).
|
||||
*/
|
||||
export function useDeltaPoll(
|
||||
endpoint: string | null,
|
||||
queryKeyToInvalidate: unknown[],
|
||||
interval: number = 5000,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const sinceRef = useRef<string>(toLocalDatetime());
|
||||
// Store key in ref to decouple from useEffect deps (prevents infinite loop
|
||||
// if caller passes an inline array literal instead of a memoized one)
|
||||
const keyRef = useRef(queryKeyToInvalidate);
|
||||
keyRef.current = queryKeyToInvalidate;
|
||||
|
||||
const { data } = useQuery<PollResponse>({
|
||||
queryKey: ['delta-poll', endpoint],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(endpoint!, {
|
||||
params: { since: sinceRef.current },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
enabled: !!endpoint,
|
||||
refetchInterval: interval,
|
||||
refetchIntervalInBackground: true, // HARD REQUIREMENT — do not remove
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.has_changes) {
|
||||
queryClient.invalidateQueries({ queryKey: keyRef.current });
|
||||
sinceRef.current = toLocalDatetime();
|
||||
}
|
||||
}, [data, queryClient]);
|
||||
|
||||
const updateSince = (timestamp: string) => {
|
||||
sinceRef.current = timestamp;
|
||||
};
|
||||
|
||||
return { updateSince };
|
||||
}
|
||||
@ -137,12 +137,14 @@ export interface Reminder {
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold';
|
||||
color?: string;
|
||||
due_date?: string;
|
||||
is_tracked: boolean;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tasks: ProjectTask[];
|
||||
@ -162,10 +164,21 @@ export interface TrackedTask {
|
||||
export interface TaskComment {
|
||||
id: number;
|
||||
task_id: number;
|
||||
user_id?: number | null;
|
||||
author_name?: string | null;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskAssignment {
|
||||
id: number;
|
||||
task_id: number;
|
||||
user_id: number;
|
||||
assigned_by: number;
|
||||
user_name?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectTask {
|
||||
id: number;
|
||||
project_id: number;
|
||||
@ -177,10 +190,12 @@ export interface ProjectTask {
|
||||
due_date?: string;
|
||||
person_id?: number;
|
||||
sort_order: number;
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
subtasks: ProjectTask[];
|
||||
comments: TaskComment[];
|
||||
assignments: TaskAssignment[];
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
@ -523,3 +538,22 @@ export interface EventLockInfo {
|
||||
expires_at: string | null;
|
||||
is_permanent: boolean;
|
||||
}
|
||||
|
||||
// ── Project Sharing ──────────────────────────────────────────────
|
||||
|
||||
export type ProjectPermission = 'read_only' | 'create_modify';
|
||||
|
||||
export interface ProjectMember {
|
||||
id: number;
|
||||
project_id: number;
|
||||
user_id: number;
|
||||
invited_by: number;
|
||||
permission: ProjectPermission;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
source: 'invited' | 'auto_assigned';
|
||||
user_name?: string | null;
|
||||
inviter_name?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
accepted_at?: string | null;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user