add journaling feature: categories, tags, attachments, calendar, and multi-select emotions

Adds an 8th development stage that lets users keep a free-form journal alongside
habit tracking, reusing the existing Google OAuth/DB/PWA infrastructure instead
of a separate project. Users organize entries into custom categories, filter by
a month calendar with day-detail drill-down, attach photos/videos (served via an
authenticated route, never /static), tag entries, and pick multiple emotions per
entry from a curated 9-option set. Includes a global journal prompt bank for
lightweight guided journaling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:40:07 +09:00
co-authored by Claude Sonnet 5
parent ef2a3ea082
commit 54c971875c
24 changed files with 2485 additions and 7 deletions
+18
View File
@@ -1,5 +1,15 @@
from app.models.habit import Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.models.journal import (
JournalAttachment,
JournalAttachmentType,
JournalCategory,
JournalEntry,
JournalEntryMood,
JournalMood,
JournalPrompt,
JournalTag,
)
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
from app.models.push_subscription import PushSubscription
from app.models.user import User
@@ -9,6 +19,14 @@ __all__ = [
"HabitStatus",
"HabitType",
"HabitLog",
"JournalAttachment",
"JournalAttachmentType",
"JournalCategory",
"JournalEntry",
"JournalEntryMood",
"JournalMood",
"JournalPrompt",
"JournalTag",
"HabitNotificationLog",
"SummaryNotificationLog",
"PushSubscription",
+131
View File
@@ -0,0 +1,131 @@
import enum
from datetime import date, datetime
from sqlalchemy import Column, Date, Enum, ForeignKey, Integer, String, Table, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
from app.models.habit import _by_value
class JournalMood(str, enum.Enum):
PAIN = "pain" # 아픔
ACHIEVEMENT = "achievement" # 성취
ANGER = "anger" # 분노
EXCITED = "excited" # 신남
CALM = "calm" # 평온
HAPPY = "happy" # 행복
WORRY = "worry" # 걱정
TIRED = "tired" # 피곤
SAD = "sad" # 슬픔
class JournalAttachmentType(str, enum.Enum):
IMAGE = "image"
VIDEO = "video"
journal_entry_tag = Table(
"journal_entry_tag",
Base.metadata,
Column("entry_id", ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", ForeignKey("journal_tag.id", ondelete="CASCADE"), primary_key=True),
)
class JournalCategory(Base):
__tablename__ = "journal_category"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_category_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
color: Mapped[str | None] = mapped_column(String(20), nullable=True)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
back_populates="category", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntry(Base):
__tablename__ = "journal_entry"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int] = mapped_column(
ForeignKey("journal_category.id", ondelete="CASCADE"), nullable=False
)
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
title: Mapped[str | None] = mapped_column(String(200), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)
category: Mapped["JournalCategory"] = relationship(back_populates="entries")
tags: Mapped[list["JournalTag"]] = relationship(
secondary=journal_entry_tag, back_populates="entries"
)
moods: Mapped[list["JournalEntryMood"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
attachments: Mapped[list["JournalAttachment"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntryMood(Base):
"""엔트리 하나에 여러 감정을 태그처럼 붙일 수 있게 하는 연결 테이블. mood 자체가 고정 enum이라
JournalTag처럼 별도 엔티티(이름 등)를 둘 필요가 없어 journal_entry_tag와 달리 값 자체를 PK로 쓴다."""
__tablename__ = "journal_entry_mood"
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True)
mood: Mapped[JournalMood] = mapped_column(
Enum(JournalMood, native_enum=False, length=20, values_callable=_by_value), primary_key=True
)
entry: Mapped["JournalEntry"] = relationship(back_populates="moods")
class JournalTag(Base):
__tablename__ = "journal_tag"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_tag_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
secondary=journal_entry_tag, back_populates="tags"
)
class JournalAttachment(Base):
__tablename__ = "journal_attachment"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), nullable=False)
media_type: Mapped[JournalAttachmentType] = mapped_column(
Enum(JournalAttachmentType, native_enum=False, length=10, values_callable=_by_value), nullable=False
)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
thumbnail_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entry: Mapped["JournalEntry"] = relationship(back_populates="attachments")
class JournalPrompt(Base):
"""카테고리 무관 전역 질문 뱅크. 특정 카테고리 전용 프롬프트는 v1 범위 밖(향후 확장 여지)."""
__tablename__ = "journal_prompt"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
question_text: Mapped[str] = mapped_column(String(300), nullable=False)