diff --git a/alembic/versions/7e51810ce786_fix_date_columns_timezone.py b/alembic/versions/7e51810ce786_fix_date_columns_timezone.py new file mode 100644 index 0000000..f865ff6 --- /dev/null +++ b/alembic/versions/7e51810ce786_fix_date_columns_timezone.py @@ -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 ### diff --git a/alembic/versions/d8b2114a1c79_add_chat_feature.py b/alembic/versions/d8b2114a1c79_add_chat_feature.py index 2ce6970..1c54d5d 100644 --- a/alembic/versions/d8b2114a1c79_add_chat_feature.py +++ b/alembic/versions/d8b2114a1c79_add_chat_feature.py @@ -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 ### diff --git a/app/modules/chat/dependencies.py b/app/modules/chat/dependencies.py index 7644d8f..58057d7 100644 --- a/app/modules/chat/dependencies.py +++ b/app/modules/chat/dependencies.py @@ -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) @@ -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)) diff --git a/app/modules/chat/schemas.py b/app/modules/chat/schemas.py index 5d48f9d..a462644 100644 --- a/app/modules/chat/schemas.py +++ b/app/modules/chat/schemas.py @@ -29,7 +29,6 @@ class ChatRoomCreate(ChatRoomBase): pass class DirectChatRoomCreate(BaseModel): - project_id: UUID target_member_id: UUID class ChatRoomRead(ChatRoomBase): diff --git a/app/modules/chat/service.py b/app/modules/chat/service.py index 9778d7c..07fbc13 100644 --- a/app/modules/chat/service.py +++ b/app/modules/chat/service.py @@ -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 @@ -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: """프로젝트의 단체 채팅방을 조회하거나 없으면 생성합니다.""" @@ -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( @@ -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) @@ -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): @@ -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("단체 채팅방은 삭제할 수 없습니다.") diff --git a/app/modules/favorite/service.py b/app/modules/favorite/service.py index fbeec4f..987c470 100644 --- a/app/modules/favorite/service.py +++ b/app/modules/favorite/service.py @@ -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) diff --git a/app/modules/member/service.py b/app/modules/member/service.py index 621ef77..db37670 100644 --- a/app/modules/member/service.py +++ b/app/modules/member/service.py @@ -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 서비스 부분 @@ -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: 수정할 데이터 @@ -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: """ @@ -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) diff --git a/app/modules/notice/router.py b/app/modules/notice/router.py index 98cd4f9..e863627 100644 --- a/app/modules/notice/router.py +++ b/app/modules/notice/router.py @@ -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", @@ -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, @@ -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, @@ -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( diff --git a/app/modules/notice/service.py b/app/modules/notice/service.py index cf88394..87c6770 100644 --- a/app/modules/notice/service.py +++ b/app/modules/notice/service.py @@ -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( @@ -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: diff --git a/app/modules/project/models.py b/app/modules/project/models.py index 0cee422..e37a3ea 100644 --- a/app/modules/project/models.py +++ b/app/modules/project/models.py @@ -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="프로젝트 종료 일자" ) diff --git a/app/modules/project/service.py b/app/modules/project/service.py index 89166ea..4c7acd3 100644 --- a/app/modules/project/service.py +++ b/app/modules/project/service.py @@ -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): @@ -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 @@ -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("본인에게 발송된 초대가 아닙니다.") @@ -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: diff --git a/app/modules/work/models.py b/app/modules/work/models.py index e3295d2..67c39c0 100644 --- a/app/modules/work/models.py +++ b/app/modules/work/models.py @@ -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 @@ -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="작업 종료 일자" ) diff --git a/app/modules/work/router.py b/app/modules/work/router.py index 93d54a0..45a0d7f 100644 --- a/app/modules/work/router.py +++ b/app/modules/work/router.py @@ -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", diff --git a/app/static/member-detail.html b/app/static/member-detail.html index 1cca35d..1310bbf 100644 --- a/app/static/member-detail.html +++ b/app/static/member-detail.html @@ -44,61 +44,6 @@ font-size: 1.1rem; margin-bottom: 2rem; } - .dm-section { - background: #f8f9fa; - border-radius: 12px; - padding: 1.5rem; - text-align: left; - border: 1px solid #e9ecef; - } - .dm-section label { - display: block; - font-weight: 700; - color: #495057; - margin-bottom: 0.8rem; - font-size: 0.9rem; - } - /* 커스텀 드롭다운 스타일 */ - .custom-select { - width: 100%; - padding: 0.8rem 1rem; - border: 2px solid #dee2e6; - border-radius: 8px; - font-size: 1rem; - background-color: #fff; - cursor: pointer; - transition: all 0.2s; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%236c757d' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 1rem center; - } - .custom-select:focus { - border-color: #6f42c1; - outline: none; - box-shadow: 0 0 0 4px rgba(111, 66, 193, 0.1); - } - .btn-dm { - width: 100%; - padding: 1rem; - background: #6f42c1; - color: #fff; - border: none; - border-radius: 8px; - font-size: 1.1rem; - font-weight: 700; - cursor: pointer; - margin-top: 1.5rem; - transition: transform 0.2s, background 0.2s; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - } - .btn-dm:hover { - background: #59359a; - transform: translateY(-2px); - } .btn-back { background: none; border: none; @@ -124,20 +69,10 @@
소속 정보 없음
-- 💬 선택한 프로젝트 내에서 1:1 채팅방이 생성되거나 기존 대화로 이동합니다. -
-+ 💬 DM은 함께 참여 중인 프로젝트 페이지의 멤버 목록에서 시작할 수 있습니다. +
- - @@ -155,7 +90,6 @@