Skip to content
63 changes: 63 additions & 0 deletions alembic/versions/7e51810ce786_fix_date_columns_timezone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""fix date columns timezone

Revision ID: 7e51810ce786
Revises: d8b2114a1c79
Create Date: 2026-09-03 18:02:46.590486

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlalchemy_utc
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = '7e51810ce786'
down_revision: Union[str, Sequence[str], None] = 'd8b2114a1c79'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('projects', 'start_date',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False)
op.alter_column('projects', 'end_date',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False)
op.alter_column('works', 'start_date',
existing_type=postgresql.TIMESTAMP(),
type_=sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True),
existing_nullable=False)
op.alter_column('works', 'end_date',
existing_type=postgresql.TIMESTAMP(),
type_=sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True),
existing_nullable=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('works', 'end_date',
existing_type=sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False)
op.alter_column('works', 'start_date',
existing_type=sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False)
op.alter_column('projects', 'end_date',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False)
op.alter_column('projects', 'start_date',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False)
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions alembic/versions/d8b2114a1c79_add_chat_feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,5 @@ def downgrade() -> None:
op.drop_table('chat_room_members')
op.drop_table('chat_messages')
op.drop_table('chat_rooms')
sa.Enum(name='chatroomtype').drop(op.get_bind(), checkfirst=True)
# ### end Alembic commands ###
3 changes: 2 additions & 1 deletion app/modules/chat/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from app.core.database import get_session
from app.modules.chat.repository import ChatRepository
from app.modules.chat.service import ChatService
from app.modules.project.repository import ProjectRepository

async def get_chat_repository(session: AsyncSession = Depends(get_session)) -> ChatRepository:
return ChatRepository(session)
Expand All @@ -11,4 +12,4 @@ async def get_chat_service(
session: AsyncSession = Depends(get_session),
repository: ChatRepository = Depends(get_chat_repository)
) -> ChatService:
return ChatService(session, repository)
return ChatService(session, repository, ProjectRepository(session))
1 change: 0 additions & 1 deletion app/modules/chat/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ class ChatRoomCreate(ChatRoomBase):
pass

class DirectChatRoomCreate(BaseModel):
project_id: UUID
target_member_id: UUID

class ChatRoomRead(ChatRoomBase):
Expand Down
27 changes: 19 additions & 8 deletions app/modules/chat/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from app.modules.chat.repository import ChatRepository
from app.modules.chat.models import ChatRoom, ChatMessage
from app.modules.chat.schemas import ChatMessageRead
from app.modules.project.repository import ProjectRepository
from app.shared.enums import ChatRoomType
from app.core.exceptions import AppError
from app.core.redis import redis_client
Expand Down Expand Up @@ -74,9 +75,10 @@ async def publish(self, room_id: UUID, message: dict):
manager = ConnectionManager()

class ChatService:
def __init__(self, session: AsyncSession, repository: ChatRepository):
def __init__(self, session: AsyncSession, repository: ChatRepository, project_repository: ProjectRepository):
self.session = session
self.repository = repository
self.project_repository = project_repository

async def get_or_create_group_room(self, project_id: UUID) -> ChatRoom:
"""프로젝트의 단체 채팅방을 조회하거나 없으면 생성합니다."""
Expand All @@ -98,6 +100,9 @@ async def get_or_create_direct_room(self, project_id: UUID, member_a: UUID, memb
if member_a == member_b:
raise AppError.bad_request("자기 자신과는 대화할 수 없습니다.")

if not await self.project_repository.is_member(project_id, member_b):
raise AppError.bad_request("상대방이 해당 프로젝트의 멤버가 아닙니다.")

room = await self.repository.get_direct_room(project_id, member_a, member_b)
if not room:
room = ChatRoom(
Expand All @@ -116,13 +121,14 @@ async def get_or_create_direct_room(self, project_id: UUID, member_a: UUID, memb

async def send_message(self, room_id: UUID, sender_id: UUID, content: str) -> ChatMessage:
"""메시지를 DB에 저장하고 반환합니다."""
# 삭제된(또는 존재하지 않는) 방인지 먼저 확인 — 멤버십 행이 남아있어도 삭제된 방엔 메시지를 못 쓰게 막는다.
room = await self.repository.get_room_by_id(room_id)
if not room:
raise AppError.not_found("채팅방")

# 멤버십 확인
if not await self.repository.is_room_member(room_id, sender_id):
# 단체 채팅방인 경우 자동 참여 처리 고려 가능하나, 여기서는 에러 처리
room = await self.repository.get_room_by_id(room_id)
if not room:
raise AppError.not_found("채팅방을 찾을 수 없습니다.")

if room.type == ChatRoomType.GROUP:
# 단체방은 프로젝트 멤버면 자동 참여
await self.repository.add_member_to_room(room_id, sender_id)
Expand All @@ -141,9 +147,14 @@ async def send_message(self, room_id: UUID, sender_id: UUID, content: str) -> Ch

async def get_history(self, room_id: UUID, member_id: UUID, limit: int = 50, offset: int = 0):
"""채팅 이력을 조회합니다."""
# 삭제된(또는 존재하지 않는) 방이면, 멤버십 행이 남아있어도 이력을 볼 수 없게 막는다.
room = await self.repository.get_room_by_id(room_id)
if not room:
raise AppError.not_found("채팅방")

if not await self.repository.is_room_member(room_id, member_id):
raise AppError.forbidden("채팅방 멤버가 아닙니다.")

return await self.repository.get_messages(room_id, limit, offset)

async def list_rooms(self, project_id: UUID, member_id: UUID):
Expand All @@ -163,8 +174,8 @@ async def delete_room(self, room_id: UUID, member_id: UUID):
"""채팅방을 삭제합니다."""
room = await self.repository.get_room_by_id(room_id)
if not room:
raise AppError.not_found("채팅방을 찾을 수 없습니다.")
raise AppError.not_found("채팅방")

if room.type == ChatRoomType.GROUP:
raise AppError.bad_request("단체 채팅방은 삭제할 수 없습니다.")

Expand Down
2 changes: 1 addition & 1 deletion app/modules/favorite/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ async def create(self, project_id: UUID, member_id: UUID) -> Favorite:
async def delete(self, project_id: UUID, member_id: UUID) -> None:
favorite = await self.repository.get_by_project_member(project_id, member_id)
if not favorite:
raise AppError.not_found("즐겨찾기하지 않은 프로젝트입니다.")
raise AppError.not_found("해당 프로젝트를 즐겨찾기 한 이력")

try:
await self.repository.delete_favorite(favorite)
Expand Down
8 changes: 4 additions & 4 deletions app/modules/member/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async def get(self, member_id: UUID, *, include_deleted: bool = False) -> Member
async def get_by_email(self, email: str, *, include_deleted: bool = False) -> Member:
member = await self.repository.get_by_email(email, include_deleted=include_deleted)
if not member:
raise AppError.not_found(f"해당 이메일[{email}]을 가진 회원을 찾을 수 없습니다.")
raise AppError.not_found(f"해당 이메일[{email}]을 가진 회원")
return member

#list, count 서비스 부분
Expand Down Expand Up @@ -191,7 +191,7 @@ async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdate

member = await self.repository.get_by_id(target_member_id, include_deleted=False)
if not member:
raise AppError.not_found(f"Member[{target_member_id}")
raise AppError.not_found(f"Member[{target_member_id}]")

#exclude_unset: 코드 작성자가 해당 객체의 값을 지정할 때, 넣지 않은 값은 빼버리는 옵션
patch = data.model_dump( #patch: 수정할 데이터
Expand Down Expand Up @@ -256,7 +256,7 @@ async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdate
except Exception:
logger.error(f"업로드 실패 후 이미지 롤백 삭제 실패: {profile_object_name}",exc_info=True)
await self.session.rollback() #아까 했던 DB 작업 전부 취소
raise AppError.bad_request(f"[{data.email}]은(는) 이미 존재하는 회원 이메일입니다.")
raise AppError.bad_request("요청하신 정보로 회원 정보를 수정할 수 없습니다.")

async def update_role(self, member_id: UUID, role: MemberRole) -> Member:
"""
Expand Down Expand Up @@ -407,7 +407,7 @@ async def confirm_password_reset(self, email: str, code: str, new_password: str)
# 2. 사용자 확인
member = await self.repository.get_by_email(email)
if not member:
raise AppError.not_found("사용자를 찾을 수 없습니다.")
raise AppError.not_found("사용자")

# 3. 비밀번호 업데이트 (해싱 후 저장)
member.hashed_password = password_hash(new_password)
Expand Down
8 changes: 4 additions & 4 deletions app/modules/notice/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async def get_project_notice(
# 해당 공지가 해당 프로젝트의 것인지 확인
if notice.project_id != project.id:
from app.core.exceptions import AppError
raise AppError.not_found("해당 프로젝트에서 해당 공지를 찾을 수 없습니다.")
raise AppError.not_found("해당 프로젝트에서 해당 공지")

return ApiResponse.success(
code="NOTICE_FETCHED",
Expand All @@ -109,7 +109,7 @@ async def update_project_notice(
notice = await service.get(notice_id)
if notice.project_id != project.id:
from app.core.exceptions import AppError
raise AppError.not_found("해당 프로젝트에서 해당 공지를 찾을 수 없습니다.")
raise AppError.not_found("해당 프로젝트에서 해당 공지")

updated = await service.update(
target_notice_id=notice_id,
Expand Down Expand Up @@ -137,7 +137,7 @@ async def delete_project_notice(
notice = await service.get(notice_id, include_deleted=True)
if notice.project_id != project.id:
from app.core.exceptions import AppError
raise AppError.not_found("해당 프로젝트에서 해당 공지를 찾을 수 없습니다.")
raise AppError.not_found("해당 프로젝트에서 해당 공지")

await service.delete(
target_notice_id=notice_id,
Expand All @@ -164,7 +164,7 @@ async def restore_project_notice(
notice = await service.get(notice_id, include_deleted=True)
if notice.project_id != project.id:
from app.core.exceptions import AppError
raise AppError.not_found("해당 프로젝트에서 해당 공지를 찾을 수 없습니다.")
raise AppError.not_found("해당 프로젝트에서 해당 공지")

restored = await service.restore(notice_id)
return ApiResponse.success(
Expand Down
4 changes: 3 additions & 1 deletion app/modules/notice/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def __init__(self, session: AsyncSession, repository: NoticeRepository):
async def get(self, notice_id: UUID, *, include_deleted: bool = False) -> Notice:
notice = await self.repository.get_by_id(notice_id, include_deleted=include_deleted)
if not notice:
raise AppError.not_found("해당 공지를 찾을 수 없습니다.")
raise AppError.not_found("해당 공지")
return notice

async def list(
Expand Down Expand Up @@ -86,6 +86,8 @@ async def delete(self, target_notice_id: UUID, *, hard: bool = False) -> None:

try:
if hard:
if not notice.is_deleted:
raise AppError.bad_request("삭제되지 않은 공지입니다.")
await self.repository.hard_delete(notice)
else:
if notice.is_deleted:
Expand Down
4 changes: 2 additions & 2 deletions app/modules/project/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@ class Project(BaseModel, table=True):
)

start_date: datetime = Field(
nullable=False,
sa_column=Column(DateTime(timezone=True), nullable=False),
description="프로젝트 시작 일자"
)

end_date: datetime = Field(
nullable=False,
sa_column=Column(DateTime(timezone=True), nullable=False),
description="프로젝트 종료 일자"
)

Expand Down
8 changes: 4 additions & 4 deletions app/modules/project/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ async def invite_member(self, project_id: UUID, member_id: UUID) -> ProjectInvit
project = await self.get(project_id)
invitee = await self.member_repository.get_by_id(member_id)
if not invitee:
raise AppError.not_found(f"회원[{member_id}]을 찾을 수 없습니다.")
raise AppError.not_found(f"회원[{member_id}]")

# 이미 멤버인지 확인
if await self.repository.is_member(project_id, invitee.id):
Expand Down Expand Up @@ -229,7 +229,7 @@ async def accept_invitation(self, token: str, current_member_id: UUID) -> Projec
"""
invitation = await self.repository.get_invitation_by_token(token)
if not invitation or invitation.status != InvitationStatus.PENDING:
raise AppError.not_found("유효하지 않은 초대입니다.")
raise AppError.not_found("유효한 초대")

if invitation.expires_at < datetime.now(timezone.utc):
invitation.status = InvitationStatus.EXPIRED
Expand Down Expand Up @@ -258,7 +258,7 @@ async def decline_invitation(self, token: str, current_member_id: UUID) -> Proje
"""
invitation = await self.repository.get_invitation_by_token(token)
if not invitation or invitation.status != InvitationStatus.PENDING:
raise AppError.not_found("유효하지 않은 초대입니다.")
raise AppError.not_found("유효한 초대")

if invitation.member_id != current_member_id:
raise AppError.forbidden("본인에게 발송된 초대가 아닙니다.")
Expand Down Expand Up @@ -311,7 +311,7 @@ async def transfer_leader(

new_leader = await self.member_repository.get_by_id(new_leader_member_id)
if not new_leader:
raise AppError.not_found(f"회원[{new_leader_member_id}]을 찾을 수 없습니다.")
raise AppError.not_found(f"회원[{new_leader_member_id}]")

is_member = await self.repository.is_member(project_id, new_leader_member_id)
if not is_member:
Expand Down
3 changes: 3 additions & 0 deletions app/modules/work/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from uuid import UUID, uuid4

from sqlalchemy import Enum, Column
from sqlalchemy_utc import UtcDateTime
from sqlmodel import Field, Relationship

from app.shared.models.base import BaseModel
Expand Down Expand Up @@ -47,11 +48,13 @@ class Work(BaseModel, table=True):

start_date: datetime = Field(
nullable=False,
sa_type=UtcDateTime,
description="작업 시작 일자"
)

end_date: datetime = Field(
nullable=False,
sa_type=UtcDateTime,
description="작업 종료 일자"
)

Expand Down
2 changes: 1 addition & 1 deletion app/modules/work/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ async def get_project_work(
# 해당 작업이 해당 프로젝트의 것인지 확인
if work.project_id != project.id:
from app.core.exceptions import AppError
raise AppError.not_found("해당 프로젝트에서 해당 작업을 찾을 수 없습니다.")
raise AppError.not_found("해당 프로젝트에서 해당 작업")

return ApiResponse.success(
code="WORK_FETCHED",
Expand Down
Loading
Loading