Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@
coverage.xml
.coverage
htmlcov/

# feedback
**/feedback/
feedback/
2 changes: 2 additions & 0 deletions backend/api/inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ class CreateTaskInput:
description: str | None = None
due_date: datetime | None = None
assignee_id: strawberry.ID | None = None
assignee_team_id: strawberry.ID | None = None
previous_task_ids: list[strawberry.ID] | None = None
properties: list[PropertyValueInput] | None = None
priority: TaskPriority | None = None
Expand All @@ -123,6 +124,7 @@ class UpdateTaskInput:
done: bool | None = None
due_date: datetime | None = strawberry.UNSET
assignee_id: strawberry.ID | None = None
assignee_team_id: strawberry.ID | None = strawberry.UNSET
previous_task_ids: list[strawberry.ID] | None = None
properties: list[PropertyValueInput] | None = None
checksum: str | None = None
Expand Down
22 changes: 17 additions & 5 deletions backend/api/resolvers/patient.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ async def patient(
result = await info.context.db.execute(
select(models.Patient)
.where(models.Patient.id == id)
.where(models.Patient.deleted.is_(False))
.options(
selectinload(models.Patient.assigned_locations),
selectinload(models.Patient.tasks),
Expand Down Expand Up @@ -55,7 +56,7 @@ async def patients(
selectinload(models.Patient.assigned_locations),
selectinload(models.Patient.tasks),
selectinload(models.Patient.teams),
)
).where(models.Patient.deleted.is_(False))

if states:
state_values = [s.value for s in states]
Expand Down Expand Up @@ -151,6 +152,7 @@ async def recent_patients(
selectinload(models.Patient.tasks),
selectinload(models.Patient.teams),
)
.where(models.Patient.deleted.is_(False))
.limit(limit)
)
auth_service = AuthorizationService(info.context.db)
Expand Down Expand Up @@ -395,19 +397,29 @@ async def update_patient(
@strawberry.mutation
@audit_log("delete_patient")
async def delete_patient(self, info: Info, id: strawberry.ID) -> bool:
repo = BaseMutationResolver.get_repository(info.context.db, models.Patient)
patient = await repo.get_by_id(id)
db = info.context.db
result = await db.execute(
select(models.Patient)
.where(models.Patient.id == id)
.where(models.Patient.deleted.is_(False))
.options(
selectinload(models.Patient.assigned_locations),
selectinload(models.Patient.teams),
),
)
patient = result.scalars().first()
if not patient:
return False

auth_service = AuthorizationService(info.context.db)
auth_service = AuthorizationService(db)
if not await auth_service.can_access_patient(info.context.user, patient, info.context):
raise GraphQLError(
"Forbidden: You do not have access to this patient",
extensions={"code": "FORBIDDEN"},
)

await BaseMutationResolver.delete_entity(
patient.deleted = True
await BaseMutationResolver.update_and_notify(
info, patient, models.Patient, "patient"
)
return True
Expand Down
64 changes: 62 additions & 2 deletions backend/api/resolvers/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ async def tasks(
info: Info,
patient_id: strawberry.ID | None = None,
assignee_id: strawberry.ID | None = None,
assignee_team_id: strawberry.ID | None = None,
root_location_ids: list[strawberry.ID] | None = None,
) -> list[TaskType]:
auth_service = AuthorizationService(info.context.db)
Expand All @@ -58,6 +59,8 @@ async def tasks(

if assignee_id:
query = query.where(models.Task.assignee_id == assignee_id)
if assignee_team_id:
query = query.where(models.Task.assignee_team_id == assignee_team_id)

result = await info.context.db.execute(query)
return result.scalars().all()
Expand Down Expand Up @@ -134,6 +137,8 @@ async def tasks(

if assignee_id:
query = query.where(models.Task.assignee_id == assignee_id)
if assignee_team_id:
query = query.where(models.Task.assignee_team_id == assignee_team_id)

result = await info.context.db.execute(query)
return result.scalars().all()
Expand Down Expand Up @@ -218,11 +223,18 @@ async def create_task(self, info: Info, data: CreateTaskInput) -> TaskType:
extensions={"code": "FORBIDDEN"},
)

if data.assignee_id and data.assignee_team_id:
raise GraphQLError(
"Cannot assign both a user and a team. Please assign either a user or a team.",
extensions={"code": "BAD_REQUEST"},
)

new_task = models.Task(
title=data.title,
description=data.description,
patient_id=data.patient_id,
assignee_id=data.assignee_id,
assignee_team_id=data.assignee_team_id if not data.assignee_id else None,
due_date=normalize_datetime_to_utc(data.due_date),
priority=data.priority.value if data.priority else None,
estimated_time=data.estimated_time,
Expand Down Expand Up @@ -292,6 +304,19 @@ async def update_task(
if data.estimated_time is not strawberry.UNSET:
task.estimated_time = data.estimated_time

if data.assignee_id is not None and data.assignee_team_id is not strawberry.UNSET and data.assignee_team_id is not None:
raise GraphQLError(
"Cannot assign both a user and a team. Please assign either a user or a team.",
extensions={"code": "BAD_REQUEST"},
)

if data.assignee_id is not None:
task.assignee_id = data.assignee_id
task.assignee_team_id = None
elif data.assignee_team_id is not strawberry.UNSET:
task.assignee_team_id = data.assignee_team_id
task.assignee_id = None

if data.properties is not None:
property_service = TaskMutation._get_property_service(db)
await property_service.process_properties(
Expand Down Expand Up @@ -348,7 +373,10 @@ async def assign_task(
return await TaskMutation._update_task_field(
info,
id,
lambda task: setattr(task, "assignee_id", user_id),
lambda task: (
setattr(task, "assignee_id", user_id),
setattr(task, "assignee_team_id", None)
),
)

@strawberry.mutation
Expand All @@ -357,7 +385,39 @@ async def unassign_task(self, info: Info, id: strawberry.ID) -> TaskType:
return await TaskMutation._update_task_field(
info,
id,
lambda task: setattr(task, "assignee_id", None),
lambda task: (
setattr(task, "assignee_id", None),
setattr(task, "assignee_team_id", None)
),
)

@strawberry.mutation
@audit_log("assign_task_to_team")
async def assign_task_to_team(
self,
info: Info,
id: strawberry.ID,
team_id: strawberry.ID,
) -> TaskType:
return await TaskMutation._update_task_field(
info,
id,
lambda task: (
setattr(task, "assignee_id", None),
setattr(task, "assignee_team_id", team_id)
),
)

@strawberry.mutation
@audit_log("unassign_task_from_team")
async def unassign_task_from_team(self, info: Info, id: strawberry.ID) -> TaskType:
return await TaskMutation._update_task_field(
info,
id,
lambda task: (
setattr(task, "assignee_id", None),
setattr(task, "assignee_team_id", None)
),
)

@strawberry.mutation
Expand Down
14 changes: 14 additions & 0 deletions backend/api/types/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sqlalchemy.orm import selectinload

if TYPE_CHECKING:
from api.types.location import LocationNodeType
from api.types.patient import PatientType
from api.types.user import UserType

Expand All @@ -24,6 +25,7 @@ class TaskType:
creation_date: datetime
update_date: datetime | None
assignee_id: strawberry.ID | None
assignee_team_id: strawberry.ID | None
patient_id: strawberry.ID
priority: str | None
estimated_time: int | None
Expand All @@ -41,6 +43,18 @@ async def assignee(
)
return result.scalars().first()

@strawberry.field
async def assignee_team(
self,
info: Info,
) -> Annotated["LocationNodeType", strawberry.lazy("api.types.location")] | None:
if not self.assignee_team_id:
return None
result = await info.context.db.execute(
select(models.LocationNode).where(models.LocationNode.id == self.assignee_team_id),
)
return result.scalars().first()

@strawberry.field
async def patient(
self,
Expand Down
29 changes: 29 additions & 0 deletions backend/database/migrations/versions/add_patient_deleted_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Add deleted field to patients for soft delete

Revision ID: add_patient_deleted
Revises: add_task_priority_time
Create Date: 2025-12-28 00:00:00.000000
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "add_patient_deleted"
down_revision: Union[str, Sequence[str], None] = "add_task_priority_time"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"patients",
sa.Column("deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
)


def downgrade() -> None:
op.drop_column("patients", "deleted")

37 changes: 37 additions & 0 deletions backend/database/migrations/versions/add_task_assignee_team.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Add assignee_team_id to tasks for team assignment

Revision ID: add_task_assignee_team
Revises: add_patient_deleted
Create Date: 2025-12-28 00:00:00.000000
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "add_task_assignee_team"
down_revision: Union[str, Sequence[str], None] = "add_patient_deleted"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"tasks",
sa.Column("assignee_team_id", sa.String(), nullable=True),
)
op.create_foreign_key(
"fk_tasks_assignee_team_id",
"tasks",
"location_nodes",
["assignee_team_id"],
["id"],
)


def downgrade() -> None:
op.drop_constraint("fk_tasks_assignee_team_id", "tasks", type_="foreignkey")
op.drop_column("tasks", "assignee_team_id")

3 changes: 2 additions & 1 deletion backend/database/models/patient.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import TYPE_CHECKING

from database.models.base import Base
from sqlalchemy import Column, ForeignKey, String, Table
from sqlalchemy import Boolean, Column, ForeignKey, String, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship

if TYPE_CHECKING:
Expand Down Expand Up @@ -41,6 +41,7 @@ class Patient(Base):
birthdate: Mapped[date] = mapped_column()
sex: Mapped[str] = mapped_column(String)
state: Mapped[str] = mapped_column(String, default="WAIT")
deleted: Mapped[bool] = mapped_column(Boolean, default=False)
assigned_location_id: Mapped[str | None] = mapped_column(
ForeignKey("location_nodes.id"),
nullable=True,
Expand Down
9 changes: 9 additions & 0 deletions backend/database/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship

if TYPE_CHECKING:
from .location import LocationNode
from .patient import Patient
from .property import PropertyValue
from .user import User
Expand Down Expand Up @@ -43,6 +44,10 @@ class Task(Base):
ForeignKey("users.id"),
nullable=True,
)
assignee_team_id: Mapped[str | None] = mapped_column(
ForeignKey("location_nodes.id"),
nullable=True,
)
patient_id: Mapped[str] = mapped_column(ForeignKey("patients.id"))
priority: Mapped[str | None] = mapped_column(String, nullable=True)
estimated_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
Expand All @@ -51,6 +56,10 @@ class Task(Base):
"User",
back_populates="tasks",
)
assignee_team: Mapped["LocationNode | None"] = relationship(
"LocationNode",
foreign_keys=[assignee_team_id],
)
patient: Mapped[Patient] = relationship("Patient", back_populates="tasks")
properties: Mapped[list[PropertyValue]] = relationship(
"PropertyValue",
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ services:
environment:
RUNTIME_ISSUER_URI: "http://localhost:8080/realms/tasks"
RUNTIME_CLIENT_ID: "tasks-web"
volumes:
- "./feedback:/feedback"
depends_on:
- backend

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ services:
RUNTIME_REDIRECT_URI: "http://localhost/auth/callback"
RUNTIME_POST_LOGOUT_REDIRECT_URI: "http://localhost/"
RUNTIME_CLIENT_ID: "tasks-web"
volumes:
- "./feedback:/feedback"
depends_on:
- backend

Expand Down
14 changes: 9 additions & 5 deletions web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ ENV HOSTNAME="0.0.0.0"
RUN apk add --no-cache libcap=2.77-r0 && \
setcap 'cap_net_bind_service=+ep' /usr/local/bin/node && \
addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
adduser --system --uid 1001 tasks && \
mkdir -p /feedback && \
chown tasks:nodejs /feedback

COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/build/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/build/static ./build/static
ENV FEEDBACK_DIR=/feedback

COPY --from=builder --chown=tasks:nodejs /app/public ./public
COPY --from=builder --chown=tasks:nodejs /app/build/standalone ./
COPY --from=builder --chown=tasks:nodejs /app/build/static ./build/static

RUN printf "#!/bin/sh\n\
echo \"window.__ENV = {\" > /app/public/env-config.js\n\
Expand All @@ -37,7 +41,7 @@ done\n\
echo \"}\" >> /app/public/env-config.js\n\
exec \"\$@\"\n" > /app/entrypoint.sh && chmod +x /app/entrypoint.sh

USER nextjs
USER tasks
EXPOSE 80
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["node", "server.js"]
Loading
Loading