Shri Jayaram commited on
Commit
9c7f843
·
unverified ·
2 Parent(s): 853fb59fcdc4de

Merge pull request #38 from yuvabe-ai-labs/feat/journaling

Browse files
alembic/versions/217db60578fa_added_journal_table.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """added journal table
2
+
3
+ Revision ID: 217db60578fa
4
+ Revises: d9b4df655a55
5
+ Create Date: 2025-12-05 14:19:31.722971
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ import sqlmodel.sql.sqltypes
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = '217db60578fa'
17
+ down_revision: Union[str, Sequence[str], None] = 'd9b4df655a55'
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.create_table('journal_entries',
26
+ sa.Column('id', sa.Uuid(), nullable=False),
27
+ sa.Column('user_id', sa.UUID(), nullable=False),
28
+ sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
29
+ sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
30
+ sa.Column('journal_date', sa.Date(), nullable=False),
31
+ sa.Column('created_at', sa.DateTime(), nullable=False),
32
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
33
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
34
+ sa.PrimaryKeyConstraint('id'),
35
+ sa.UniqueConstraint('user_id', 'journal_date', name='unique_user_date_journal')
36
+ )
37
+ # ### end Alembic commands ###
38
+
39
+
40
+ def downgrade() -> None:
41
+ """Downgrade schema."""
42
+ # ### commands auto generated by Alembic - please adjust! ###
43
+ op.drop_table('journal_entries')
44
+ # ### end Alembic commands ###
alembic/versions/d9b4df655a55_updated_emotion_tag.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """updated emotion tag
2
+
3
+ Revision ID: d9b4df655a55
4
+ Revises: fec3872d7eba
5
+ Create Date: 2025-12-04 14:34:22.373838
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ import sqlmodel.sql.sqltypes
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = 'd9b4df655a55'
17
+ down_revision: Union[str, Sequence[str], None] = 'fec3872d7eba'
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ emotion_enum = sa.Enum(
25
+ 'JOYFUL', 'HAPPY', 'CALM', 'NEUTRAL', 'ANXIOUS', 'SAD', 'FRUSTRATED',
26
+ name='emotion_enum'
27
+ )
28
+ emotion_enum.create(op.get_bind(), checkfirst=True)
29
+ # ### commands auto generated by Alembic - please adjust! ###
30
+ op.add_column('emotion_logs', sa.Column('morning_emotion', sa.Enum('JOYFUL', 'HAPPY', 'CALM', 'NEUTRAL', 'ANXIOUS', 'SAD', 'FRUSTRATED', name='emotion_enum'), nullable=True))
31
+ op.add_column('emotion_logs', sa.Column('evening_emotion', sa.Enum('JOYFUL', 'HAPPY', 'CALM', 'NEUTRAL', 'ANXIOUS', 'SAD', 'FRUSTRATED', name='emotion_enum'), nullable=True))
32
+ # ### end Alembic commands ###
33
+
34
+
35
+ def downgrade() -> None:
36
+ """Downgrade schema."""
37
+ # ### commands auto generated by Alembic - please adjust! ###
38
+ op.drop_column('emotion_logs', 'evening_emotion')
39
+ op.drop_column('emotion_logs', 'morning_emotion')
40
+ emotion_enum = sa.Enum(
41
+ 'JOYFUL', 'HAPPY', 'CALM', 'NEUTRAL', 'ANXIOUS', 'SAD', 'FRUSTRATED',
42
+ name='emotion_enum'
43
+ )
44
+ emotion_enum.drop(op.get_bind(), checkfirst=True)
45
+ # ### end Alembic commands ###
alembic/versions/fec3872d7eba_add_payslip_table.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add:payslip table
2
+
3
+ Revision ID: fec3872d7eba
4
+ Revises: f6a1d6fc82d0
5
+ Create Date: 2025-12-04 10:58:25.795533
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ import sqlmodel.sql.sqltypes
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = 'fec3872d7eba'
17
+ down_revision: Union[str, Sequence[str], None] = 'f6a1d6fc82d0'
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.drop_constraint(op.f('comments_post_id_fkey'), 'comments', type_='foreignkey')
26
+ op.drop_constraint(op.f('comments_user_id_fkey'), 'comments', type_='foreignkey')
27
+ op.create_foreign_key(None, 'comments', 'posts', ['post_id'], ['id'], ondelete='CASCADE')
28
+ op.create_foreign_key(None, 'comments', 'users', ['user_id'], ['id'], ondelete='CASCADE')
29
+ op.add_column('users', sa.Column('join_date', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
30
+ # ### end Alembic commands ###
31
+
32
+
33
+ def downgrade() -> None:
34
+ """Downgrade schema."""
35
+ # ### commands auto generated by Alembic - please adjust! ###
36
+ op.drop_column('users', 'join_date')
37
+ op.drop_constraint(None, 'comments', type_='foreignkey')
38
+ op.drop_constraint(None, 'comments', type_='foreignkey')
39
+ op.create_foreign_key(op.f('comments_user_id_fkey'), 'comments', 'users', ['user_id'], ['id'])
40
+ op.create_foreign_key(op.f('comments_post_id_fkey'), 'comments', 'posts', ['post_id'], ['id'])
41
+ # ### end Alembic commands ###
src/auth/utils.py CHANGED
@@ -156,7 +156,7 @@ async def verify_verification_token(token: str) -> str:
156
  bearer_scheme = HTTPBearer()
157
 
158
 
159
- def get_current_user(
160
  credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
161
  ):
162
  """Decode JWT token and extract current user ID"""
@@ -169,7 +169,7 @@ def get_current_user(
169
  if user_id is None:
170
  raise HTTPException(
171
  status_code=status.HTTP_401_UNAUTHORIZED,
172
- detail="Invalid token: missing user id",
173
  )
174
  return user_id
175
 
 
156
  bearer_scheme = HTTPBearer()
157
 
158
 
159
+ def get_current_user(
160
  credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
161
  ):
162
  """Decode JWT token and extract current user ID"""
 
169
  if user_id is None:
170
  raise HTTPException(
171
  status_code=status.HTTP_401_UNAUTHORIZED,
172
+ detail="Invalid token: Missing user id",
173
  )
174
  return user_id
175
 
src/core/__init__.py CHANGED
@@ -4,4 +4,6 @@ from src.core import models as core_models
4
  from src.feed import models as feed_models
5
  from src.home import models as home_models
6
  from src.profile import models as profile_models
7
- from src.wellbeing import models as wellbeing_models
 
 
 
4
  from src.feed import models as feed_models
5
  from src.home import models as home_models
6
  from src.profile import models as profile_models
7
+ from src.wellbeing import models as wellbeing_models
8
+ from src.payslip import models as payslip_models
9
+ from src.journaling import models as journaling_models
src/core/models.py CHANGED
@@ -9,6 +9,7 @@ from typing import List, Optional
9
  from sqlalchemy.dialects.postgresql import UUID
10
  from sqlalchemy import CheckConstraint, UniqueConstraint, ForeignKey
11
  from sqlmodel import Field, Relationship, SQLModel
 
12
 
13
 
14
  class AssetStatus(str, Enum):
@@ -17,6 +18,14 @@ class AssetStatus(str, Enum):
17
  ON_REQUEST = "On Request"
18
  IN_SERVICE = "In Service"
19
 
 
 
 
 
 
 
 
 
20
 
21
  class Users(SQLModel, table=True):
22
  __tablename__ = "users"
@@ -30,9 +39,11 @@ class Users(SQLModel, table=True):
30
  dob: Optional[date] = None
31
  address: Optional[str] = None
32
  profile_picture: Optional[str] = None
 
33
  created_at: datetime = Field(default_factory=datetime.now)
34
  asset: List["Assets"] = Relationship(back_populates="user")
35
  water_logs: List["WaterLogs"] = Relationship(back_populates="user")
 
36
 
37
 
38
  class Teams(SQLModel, table=True):
@@ -51,31 +62,36 @@ class UserTeamsRole(SQLModel, table=True):
51
  __tablename__ = "user_teams_role"
52
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
53
  user_id: uuid.UUID = Field(
54
- sa_column=Column(UUID(as_uuid=True),
 
55
  ForeignKey("users.id", ondelete="CASCADE"),
56
- nullable=False
57
  )
58
  )
59
  team_id: uuid.UUID = Field(
60
- sa_column=Column(UUID(as_uuid=True),
 
61
  ForeignKey("teams.id", ondelete="CASCADE"),
62
- nullable=False
63
  )
64
  )
65
  role_id: uuid.UUID = Field(
66
- sa_column=Column(UUID(as_uuid=True),
 
67
  ForeignKey("roles.id", ondelete="CASCADE"),
68
- nullable=False
69
  )
70
  )
71
 
 
72
  class Assets(SQLModel, table=True):
73
  __tablename__ = "assets"
74
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
75
  user_id: uuid.UUID = Field(
76
- sa_column=Column(UUID(as_uuid=True),
 
77
  ForeignKey("users.id", ondelete="CASCADE"),
78
- nullable=False
79
  )
80
  )
81
  name: str = Field(nullable=False)
@@ -86,18 +102,24 @@ class Assets(SQLModel, table=True):
86
 
87
  class EmotionLogs(SQLModel, table=True):
88
  __tablename__ = "emotion_logs"
89
- __table_args__ = (
90
- UniqueConstraint("user_id", "log_date"),
91
- CheckConstraint("morning_emotion BETWEEN 1 AND 7 or morning_emotion IS NULL"),
92
- CheckConstraint("evening_emotion BETWEEN 1 AND 7 or evening_emotion IS NULL"),
93
- )
94
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
 
95
  user_id: uuid.UUID = Field(
96
- sa_column=Column(UUID(as_uuid=True),
 
97
  ForeignKey("users.id", ondelete="CASCADE"),
98
- nullable=False
99
  )
100
  )
101
- morning_emotion: Optional[int] = Field(default=None, ge=1, le=7)
102
- evening_emotion: Optional[int] = Field(default=None, ge=1, le=7)
 
 
 
 
 
 
103
  log_date: date = Field(default_factory=date.today)
 
 
9
  from sqlalchemy.dialects.postgresql import UUID
10
  from sqlalchemy import CheckConstraint, UniqueConstraint, ForeignKey
11
  from sqlmodel import Field, Relationship, SQLModel
12
+ from sqlalchemy import Enum as SQLEnum
13
 
14
 
15
  class AssetStatus(str, Enum):
 
18
  ON_REQUEST = "On Request"
19
  IN_SERVICE = "In Service"
20
 
21
+ class Emotion(str, Enum):
22
+ JOYFUL = "joyful"
23
+ HAPPY = "happy"
24
+ CALM = "calm"
25
+ NEUTRAL = "neutral"
26
+ ANXIOUS = "anxious"
27
+ SAD = "sad"
28
+ FRUSTRATED = "frustrated"
29
 
30
  class Users(SQLModel, table=True):
31
  __tablename__ = "users"
 
39
  dob: Optional[date] = None
40
  address: Optional[str] = None
41
  profile_picture: Optional[str] = None
42
+ join_date: Optional[str] = None
43
  created_at: datetime = Field(default_factory=datetime.now)
44
  asset: List["Assets"] = Relationship(back_populates="user")
45
  water_logs: List["WaterLogs"] = Relationship(back_populates="user")
46
+ journal_entries: List["JournalEntry"] = Relationship(back_populates="user")
47
 
48
 
49
  class Teams(SQLModel, table=True):
 
62
  __tablename__ = "user_teams_role"
63
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
64
  user_id: uuid.UUID = Field(
65
+ sa_column=Column(
66
+ UUID(as_uuid=True),
67
  ForeignKey("users.id", ondelete="CASCADE"),
68
+ nullable=False,
69
  )
70
  )
71
  team_id: uuid.UUID = Field(
72
+ sa_column=Column(
73
+ UUID(as_uuid=True),
74
  ForeignKey("teams.id", ondelete="CASCADE"),
75
+ nullable=False,
76
  )
77
  )
78
  role_id: uuid.UUID = Field(
79
+ sa_column=Column(
80
+ UUID(as_uuid=True),
81
  ForeignKey("roles.id", ondelete="CASCADE"),
82
+ nullable=False,
83
  )
84
  )
85
 
86
+
87
  class Assets(SQLModel, table=True):
88
  __tablename__ = "assets"
89
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
90
  user_id: uuid.UUID = Field(
91
+ sa_column=Column(
92
+ UUID(as_uuid=True),
93
  ForeignKey("users.id", ondelete="CASCADE"),
94
+ nullable=False,
95
  )
96
  )
97
  name: str = Field(nullable=False)
 
102
 
103
  class EmotionLogs(SQLModel, table=True):
104
  __tablename__ = "emotion_logs"
105
+ __table_args__ = (UniqueConstraint("user_id", "log_date"),)
106
+
 
 
 
107
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
108
+
109
  user_id: uuid.UUID = Field(
110
+ sa_column=Column(
111
+ UUID(as_uuid=True),
112
  ForeignKey("users.id", ondelete="CASCADE"),
113
+ nullable=False,
114
  )
115
  )
116
+
117
+ morning_emotion: Optional[Emotion] = Field(
118
+ sa_column=Column(SQLEnum(Emotion, name="emotion_enum", native_enum=True), nullable=True)
119
+ )
120
+ evening_emotion: Optional[Emotion] = Field(
121
+ sa_column=Column(SQLEnum(Emotion, name="emotion_enum", native_enum=True), nullable=True)
122
+ )
123
+
124
  log_date: date = Field(default_factory=date.today)
125
+
src/home/schemas.py CHANGED
@@ -1,22 +1,19 @@
1
- # pylint: disable=no-name-in-module
2
- # pylint: disable=no-self-argument
3
  from datetime import date
4
- from typing import List, Optional, Union
5
-
6
  from pydantic import BaseModel
7
-
8
 
9
  class EmotionLogCreate(BaseModel):
10
  user_id: str
11
- morning_emotion: Optional[int] = None
12
- evening_emotion: Optional[int] = None
13
  log_date: date
14
 
15
 
16
  class EmotionLogResponse(BaseModel):
17
  log_date: date
18
- morning_emotion: Optional[int]
19
- evening_emotion: Optional[int]
20
 
21
 
22
  class HomeResponseData(BaseModel):
@@ -25,6 +22,7 @@ class HomeResponseData(BaseModel):
25
  philosophy_text: str
26
  recent_emotions: List[EmotionLogResponse]
27
 
 
28
  class BroadcastNotificationRequest(BaseModel):
29
  title: str
30
  body: str
 
 
 
1
  from datetime import date
2
+ from typing import List, Optional
 
3
  from pydantic import BaseModel
4
+ from src.core.models import Emotion
5
 
6
  class EmotionLogCreate(BaseModel):
7
  user_id: str
8
+ morning_emotion: Optional[Emotion] = None
9
+ evening_emotion: Optional[Emotion] = None
10
  log_date: date
11
 
12
 
13
  class EmotionLogResponse(BaseModel):
14
  log_date: date
15
+ morning_emotion: Optional[Emotion]
16
+ evening_emotion: Optional[Emotion]
17
 
18
 
19
  class HomeResponseData(BaseModel):
 
22
  philosophy_text: str
23
  recent_emotions: List[EmotionLogResponse]
24
 
25
+
26
  class BroadcastNotificationRequest(BaseModel):
27
  title: str
28
  body: str
src/home/service.py CHANGED
@@ -46,6 +46,7 @@ async def get_home_data(user_id: str, session: AsyncSession) -> HomeResponseData
46
  async def add_or_update_emotion(
47
  data: EmotionLogCreate, session: AsyncSession
48
  ) -> EmotionLogResponse:
 
49
  user_exists = await session.exec(select(Users).where(Users.id == data.user_id))
50
  if not user_exists.first():
51
  raise ValueError("User not found. Cannot add emotion log.")
 
46
  async def add_or_update_emotion(
47
  data: EmotionLogCreate, session: AsyncSession
48
  ) -> EmotionLogResponse:
49
+
50
  user_exists = await session.exec(select(Users).where(Users.id == data.user_id))
51
  if not user_exists.first():
52
  raise ValueError("User not found. Cannot add emotion log.")
src/journaling/__init__.py ADDED
File without changes
src/journaling/models.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import date, datetime
3
+ from typing import Optional
4
+
5
+ from sqlalchemy.dialects.postgresql import UUID
6
+ from sqlalchemy import Column, ForeignKey, UniqueConstraint
7
+ from sqlmodel import SQLModel, Field, Relationship
8
+
9
+
10
+ class JournalEntry(SQLModel, table=True):
11
+ __tablename__ = "journal_entries"
12
+ __table_args__ = (
13
+ UniqueConstraint("user_id", "journal_date", name="unique_user_date_journal"),
14
+ )
15
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
16
+ user_id: uuid.UUID = Field(
17
+ sa_column=Column(
18
+ UUID(as_uuid=True),
19
+ ForeignKey("users.id", ondelete="CASCADE"),
20
+ nullable=False,
21
+ )
22
+ )
23
+ title: str = Field(nullable=False)
24
+ content: str = Field(nullable=False)
25
+ journal_date: date = Field(nullable=False)
26
+ created_at: datetime = Field(default_factory=datetime.now)
27
+ updated_at: datetime = Field(
28
+ default_factory=datetime.now,
29
+ sa_column_kwargs={"onupdate": datetime.now},
30
+ nullable=False,
31
+ )
32
+ user: Optional["Users"] = Relationship(back_populates="journal_entries")
src/journaling/router.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from sqlmodel.ext.asyncio.session import AsyncSession
4
+
5
+ from src.core.database import get_async_session
6
+ from src.core.schemas import BaseResponse
7
+ from src.auth.utils import get_current_user
8
+
9
+ from src.journaling.schemas import (
10
+ JournalCreate,
11
+ JournalUpdate,
12
+ JournalResponse,
13
+ )
14
+ from src.journaling.service import (
15
+ create_or_update_journal,
16
+ get_all_journals,
17
+ get_journal,
18
+ update_journal,
19
+ delete_journal,
20
+ )
21
+
22
+ router = APIRouter(prefix="/journal", tags=["Journal"])
23
+
24
+
25
+ @router.post("/", response_model=BaseResponse[JournalResponse], status_code=200)
26
+ async def create_or_update(
27
+ data: JournalCreate,
28
+ user_id: uuid.UUID = Depends(get_current_user),
29
+ session: AsyncSession = Depends(get_async_session),
30
+ ):
31
+ data.user_id = user_id
32
+
33
+ try:
34
+ record = await create_or_update_journal(data, session)
35
+ return BaseResponse(status_code=200, data=record)
36
+ except ValueError as e:
37
+ raise HTTPException(status_code=404, detail=str(e))
38
+
39
+
40
+ @router.get("/", response_model=BaseResponse[list[JournalResponse]], status_code=200)
41
+ async def list_user_journals(
42
+ user_id: uuid.UUID = Depends(get_current_user),
43
+ session: AsyncSession = Depends(get_async_session),
44
+ ):
45
+ records = await get_all_journals(user_id, session)
46
+ return BaseResponse(status_code=200, data=records)
47
+
48
+
49
+ @router.get("/entry/{journal_id}", response_model=BaseResponse[JournalResponse], status_code=200)
50
+ async def fetch_single(
51
+ journal_id: uuid.UUID,
52
+ user_id: uuid.UUID = Depends(get_current_user),
53
+ session: AsyncSession = Depends(get_async_session),
54
+ ):
55
+ try:
56
+ record = await get_journal(journal_id, user_id, session)
57
+ return BaseResponse(status_code=200, data=record)
58
+ except ValueError as e:
59
+ if str(e) == "Not authorized":
60
+ raise HTTPException(status_code=403, detail=str(e))
61
+ raise HTTPException(status_code=404, detail=str(e))
62
+
63
+
64
+ @router.put("/entry/{journal_id}", response_model=BaseResponse[JournalResponse], status_code=200)
65
+ async def update_entry(
66
+ journal_id: uuid.UUID,
67
+ data: JournalUpdate,
68
+ user_id: uuid.UUID = Depends(get_current_user),
69
+ session: AsyncSession = Depends(get_async_session),
70
+ ):
71
+ try:
72
+ record = await update_journal(journal_id, data, user_id, session)
73
+ return BaseResponse(status_code=200, data=record)
74
+ except ValueError as e:
75
+ if str(e) == "Not authorized":
76
+ raise HTTPException(status_code=403, detail=str(e))
77
+ raise HTTPException(status_code=404, detail=str(e))
78
+
79
+
80
+ @router.delete("/entry/{journal_id}", response_model=BaseResponse[str], status_code=200)
81
+ async def delete_entry(
82
+ journal_id: uuid.UUID,
83
+ user_id: uuid.UUID = Depends(get_current_user),
84
+ session: AsyncSession = Depends(get_async_session),
85
+ ):
86
+ try:
87
+ await delete_journal(journal_id, user_id, session)
88
+ return BaseResponse(status_code=200, data="Deleted")
89
+ except ValueError as e:
90
+ if str(e) == "Not authorized":
91
+ raise HTTPException(status_code=403, detail=str(e))
92
+ raise HTTPException(status_code=404, detail=str(e))
src/journaling/schemas.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import date, datetime
3
+ from typing import Optional
4
+
5
+ from sqlmodel import SQLModel
6
+
7
+
8
+ class JournalBase(SQLModel):
9
+ title: str
10
+ content: str
11
+ journal_date: date
12
+
13
+
14
+ class JournalCreate(JournalBase):
15
+ user_id: Optional[uuid.UUID] = None
16
+
17
+
18
+ class JournalUpdate(SQLModel):
19
+ title: Optional[str] = None
20
+ content: Optional[str] = None
21
+ journal_date: Optional[date] = None
22
+
23
+
24
+ class JournalResponse(SQLModel):
25
+ id: uuid.UUID
26
+ user_id: uuid.UUID
27
+ title: str
28
+ content: str
29
+ journal_date: date
30
+ created_at: datetime
31
+ updated_at: datetime
src/journaling/service.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ import uuid
3
+ from sqlmodel import select
4
+ from sqlmodel.ext.asyncio.session import AsyncSession
5
+
6
+ from src.core.models import Users
7
+ from src.journaling.models import JournalEntry
8
+ from src.journaling.schemas import JournalCreate, JournalUpdate
9
+
10
+
11
+ async def create_or_update_journal(data: JournalCreate, session: AsyncSession):
12
+ user = await session.exec(select(Users).where(Users.id == data.user_id))
13
+ if not user.first():
14
+ raise ValueError("User not found")
15
+
16
+ query = select(JournalEntry).where(
17
+ JournalEntry.user_id == data.user_id,
18
+ JournalEntry.journal_date == data.journal_date
19
+ )
20
+ existing = (await session.exec(query)).first()
21
+
22
+ if existing:
23
+ existing.title = data.title
24
+ existing.content = data.content
25
+ existing.updated_at = datetime.utcnow()
26
+ record = existing
27
+ else:
28
+ record = JournalEntry(
29
+ user_id=data.user_id,
30
+ title=data.title,
31
+ content=data.content,
32
+ journal_date=data.journal_date,
33
+ )
34
+ session.add(record)
35
+
36
+ await session.commit()
37
+ await session.refresh(record)
38
+ return record
39
+
40
+
41
+ async def get_all_journals(user_id: uuid.UUID, session: AsyncSession):
42
+ stmt = (
43
+ select(JournalEntry)
44
+ .where(JournalEntry.user_id == user_id)
45
+ .order_by(JournalEntry.journal_date.desc())
46
+ )
47
+ result = await session.exec(stmt)
48
+ return result.all()
49
+
50
+
51
+ async def get_journal(journal_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession):
52
+ stmt = select(JournalEntry).where(JournalEntry.id == journal_id)
53
+ result = await session.exec(stmt)
54
+ record = result.first()
55
+
56
+ if not record:
57
+ raise ValueError("Journal entry not found")
58
+ if str(record.user_id) != user_id:
59
+ raise ValueError("Not authorized")
60
+
61
+ return record
62
+
63
+
64
+ async def update_journal(
65
+ journal_id: uuid.UUID,
66
+ data: JournalUpdate,
67
+ user_id: uuid.UUID,
68
+ session: AsyncSession,
69
+ ):
70
+ record = await get_journal(journal_id, user_id, session)
71
+
72
+ if data.title is not None:
73
+ record.title = data.title
74
+
75
+ if data.content is not None:
76
+ record.content = data.content
77
+
78
+ if data.journal_date is not None:
79
+ record.journal_date = data.journal_date
80
+
81
+ record.updated_at = datetime.utcnow()
82
+
83
+ await session.commit()
84
+ await session.refresh(record)
85
+ return record
86
+
87
+
88
+ async def delete_journal(journal_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession):
89
+ record = await get_journal(journal_id, user_id, session)
90
+ await session.delete(record)
91
+ await session.commit()
src/main.py CHANGED
@@ -7,7 +7,9 @@ from src.chatbot.router import router as chatbot_router
7
  from src.core.database import init_db
8
  from src.home.router import router as home_router
9
  from src.notifications.router import router as notifications_router
 
10
  from src.profile.router import router as profile
 
11
  from fastapi.staticfiles import StaticFiles
12
 
13
 
@@ -33,6 +35,10 @@ app.include_router(wellbeing)
33
 
34
  app.include_router(notifications_router)
35
 
 
 
 
 
36
 
37
  @app.get("/")
38
  def root():
 
7
  from src.core.database import init_db
8
  from src.home.router import router as home_router
9
  from src.notifications.router import router as notifications_router
10
+ from src.payslip.router import router as payslip_router
11
  from src.profile.router import router as profile
12
+ from src.journaling.router import router as journal
13
  from fastapi.staticfiles import StaticFiles
14
 
15
 
 
35
 
36
  app.include_router(notifications_router)
37
 
38
+ app.include_router(payslip_router)
39
+
40
+ app.include_router(journal)
41
+
42
 
43
  @app.get("/")
44
  def root():
src/payslip/__init__.py ADDED
File without changes
src/payslip/googleservice.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import requests
4
+ from typing import Tuple
5
+ from fastapi import HTTPException
6
+
7
+ from src.core.config import settings
8
+
9
+
10
+ def exchange_code_for_tokens(code: str):
11
+ """
12
+ Exchange Google 'code' for access_token + refresh_token
13
+ """
14
+ data = {
15
+ "code": code,
16
+ "client_id": settings.GOOGLE_CLIENT_ID,
17
+ "client_secret": settings.GOOGLE_CLIENT_SECRET,
18
+ "redirect_uri": settings.GOOGLE_REDIRECT_URI,
19
+ "grant_type": "authorization_code",
20
+ }
21
+
22
+ res = requests.post(settings.TOKEN_URL, data=data)
23
+ if res.status_code != 200:
24
+ raise HTTPException(500, f"Google token exchange error: {res.text}")
25
+
26
+ return res.json()
27
+
28
+
29
+ def refresh_google_access_token(refresh_token: str) -> str:
30
+ """
31
+ Input → refresh_token
32
+ Output → new access_token
33
+ """
34
+ data = {
35
+ "refresh_token": refresh_token,
36
+ "client_id": settings.GOOGLE_CLIENT_ID,
37
+ "client_secret": settings.GOOGLE_CLIENT_SECRET,
38
+ "grant_type": "refresh_token",
39
+ }
40
+
41
+ res = requests.post(settings.TOKEN_URL, data=data)
42
+ if res.status_code != 200:
43
+ raise HTTPException(500, f"Failed to refresh access token: {res.text}")
44
+
45
+ return res.json()["access_token"]
46
+
47
+
48
+ def build_email(from_email: str, to_email: str, subject: str, body: str) -> str:
49
+ """
50
+ Gmail API expects Base64URL-encoded email.
51
+ """
52
+ message = (
53
+ f"From: {from_email}\r\n"
54
+ f"To: {to_email}\r\n"
55
+ f"Subject: {subject}\r\n"
56
+ "\r\n"
57
+ f"{body}"
58
+ )
59
+
60
+ message_bytes = message.encode("utf-8")
61
+ encoded = base64.urlsafe_b64encode(message_bytes).decode("utf-8")
62
+ return encoded
63
+
64
+
65
+ def send_gmail(access_token: str, raw_message: str) -> str:
66
+ url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
67
+
68
+ headers = {
69
+ "Authorization": f"Bearer {access_token}",
70
+ "Content-Type": "application/json",
71
+ }
72
+
73
+ payload = {"raw": raw_message}
74
+
75
+ res = requests.post(url, headers=headers, data=json.dumps(payload))
76
+
77
+ if res.status_code not in (200, 202):
78
+ raise HTTPException(500, f"Gmail API error: {res.text}")
79
+
80
+ return res.json().get("id") # message_id
src/payslip/grouter.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from fastapi.responses import HTMLResponse
3
+ from urllib.parse import urlencode
4
+ import uuid
5
+
6
+ from src.core.config import settings
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from src.core.database import get_async_session
9
+ from src.core.models import Users
10
+ from src.payslip.models import PayslipRequest
11
+ from src.payslip.googleservice import exchange_code_for_tokens
12
+
13
+
14
+ router = APIRouter(prefix="/google", tags=["Google OAuth"])
15
+
16
+
17
+ @router.get("/connect-url")
18
+ def get_connect_url(user_id: uuid.UUID):
19
+ """
20
+ App calls this → backend returns Google OAuth URL
21
+ """
22
+ params = {
23
+ "client_id": settings.GOOGLE_CLIENT_ID,
24
+ "redirect_uri": settings.GOOGLE_REDIRECT_URI,
25
+ "response_type": "code",
26
+ "scope": settings.GMAIL_SEND_SCOPE,
27
+ "access_type": "offline",
28
+ "prompt": "consent",
29
+ "state": str(user_id),
30
+ }
31
+
32
+ url = f"{settings.AUTH_BASE}?{urlencode(params)}"
33
+ return {"auth_url": url}
34
+
35
+
36
+ @router.get("/callback", response_class=HTMLResponse)
37
+ def google_callback(
38
+ code: str,
39
+ state: str,
40
+ session: AsyncSession = Depends(get_async_session),
41
+ ):
42
+ """
43
+ Google redirects here after user logs in.
44
+ We store the refresh token in payslip table.
45
+ """
46
+ user_id = uuid.UUID(state)
47
+ user = session.get(Users, user_id)
48
+
49
+ if not user:
50
+ raise HTTPException(404, "User not found")
51
+
52
+ token_data = exchange_code_for_tokens(code)
53
+
54
+ refresh_token = token_data.get("refresh_token")
55
+ if not refresh_token:
56
+ raise HTTPException(400, "No refresh token received. Try again.")
57
+
58
+ # Save refresh token using a dummy payslip entry
59
+ entry = PayslipRequest(
60
+ user_id=user_id,
61
+ refresh_token=refresh_token,
62
+ status="Pending",
63
+ )
64
+ session.add(entry)
65
+ session.commit()
66
+
67
+ return """
68
+ <h2>Gmail Connected Successfully!</h2>
69
+ <p>You can close this window.</p>
70
+ """
src/payslip/models.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+ from sqlalchemy.dialects.postgresql import UUID
7
+ from sqlalchemy import Column
8
+ from sqlmodel import SQLModel, Field, ForeignKey
9
+
10
+
11
+ class PayslipStatus(str, Enum):
12
+ PENDING = "Pending"
13
+ SENT = "Sent"
14
+ FAILED = "Failed"
15
+
16
+
17
+ class PayslipRequest(SQLModel, table=True):
18
+ __tablename__ = "payslip_requests"
19
+
20
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
21
+
22
+ user_id: uuid.UUID = Field(
23
+ sa_column=Column(
24
+ UUID(as_uuid=True),
25
+ ForeignKey("users.id", ondelete="CASCADE"),
26
+ nullable=False,
27
+ )
28
+ )
29
+
30
+ requested_at: datetime = Field(default_factory=datetime.now)
31
+
32
+ status: PayslipStatus = Field(default=PayslipStatus.PENDING)
33
+
34
+ refresh_token: Optional[str] = None
35
+
36
+ error_message: Optional[str] = None
src/payslip/router.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from src.payslip.schemas import PayslipRequestSchema
3
+ from src.payslip.service import process_payslip_request
4
+ from src.auth.utils import get_current_user
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from src.core.database import get_async_session
7
+ from src.core.models import Users
8
+
9
+ router = APIRouter(prefix="/payslips", tags=["Payslips"])
10
+
11
+
12
+ @router.post("/request")
13
+ def request_payslip(
14
+ payload: PayslipRequestSchema,
15
+ session: AsyncSession = Depends(get_async_session),
16
+ user: Users = Depends(get_current_user),
17
+ ):
18
+ entry = process_payslip_request(session, user, payload)
19
+ return {
20
+ "status": entry.status,
21
+ "requested_at": entry.requested_at,
22
+ }
src/payslip/schemas.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional, Literal
3
+
4
+
5
+ class PayslipRequestSchema(BaseModel):
6
+ mode: Literal["3_months", "6_months", "manual"]
7
+ start_month: Optional[str] = Field(default=None, description="YYYY-MM")
8
+ end_month: Optional[str] = Field(default=None, description="YYYY-MM")
src/payslip/service.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import Session, select
2
+ from fastapi import HTTPException
3
+ from src.payslip.models import PayslipRequest, PayslipStatus
4
+ from src.core.models import Users, Roles, UserTeamsRole
5
+
6
+ from src.payslip.utils import calculate_period, validate_join_date
7
+ from src.payslip.googleservice import (
8
+ refresh_google_access_token,
9
+ build_email,
10
+ send_gmail,
11
+ )
12
+
13
+
14
+ def get_latest_refresh_token(session: Session, user_id):
15
+ stmt = (
16
+ select(PayslipRequest)
17
+ .where(
18
+ PayslipRequest.user_id == user_id,
19
+ PayslipRequest.refresh_token.is_not(None),
20
+ )
21
+ .order_by(PayslipRequest.requested_at.desc())
22
+ )
23
+ entry = session.exec(stmt).first()
24
+ return entry.refresh_token if entry else None
25
+
26
+
27
+ def get_hr_email(session: Session) -> str:
28
+ role = session.exec(select(Roles).where(Roles.name == "HR Manager")).first()
29
+ if not role:
30
+ raise HTTPException(500, "HR Manager role missing")
31
+
32
+ mapping = session.exec(
33
+ select(UserTeamsRole).where(UserTeamsRole.role_id == role.id)
34
+ ).first()
35
+
36
+ if not mapping:
37
+ raise HTTPException(500, "No HR assigned")
38
+
39
+ hr_user = session.get(Users, mapping.user_id)
40
+ return hr_user.email_id
41
+
42
+
43
+ def process_payslip_request(session: Session, user: Users, payload):
44
+ # 1. Compute period
45
+ period_start, period_end = calculate_period(
46
+ payload.mode, payload.start_month, payload.end_month
47
+ )
48
+
49
+ # 2. Validate join date
50
+ validate_join_date(user.join_date, period_start)
51
+
52
+ # 3. Find refresh_token
53
+ refresh_token = get_latest_refresh_token(session, user.id)
54
+
55
+ if not refresh_token:
56
+ raise HTTPException(
57
+ 400, "Please connect your Gmail before requesting a payslip."
58
+ )
59
+
60
+ # 4. Get access token
61
+ access_token = refresh_google_access_token(refresh_token)
62
+
63
+ # 5. Find HR email
64
+ hr_email = get_hr_email(session)
65
+
66
+ # 6. Build message
67
+ subject = "Payslip Request"
68
+ body = (
69
+ f"Payslip request from {user.email_id}\n"
70
+ f"Period: {period_start} → {period_end}"
71
+ )
72
+ raw = build_email(user.email_id, hr_email, subject, body)
73
+
74
+ # 7. Send email
75
+ message_id = send_gmail(access_token, raw)
76
+
77
+ # 8. Create new DB record
78
+ entry = PayslipRequest(
79
+ user_id=user.id,
80
+ status=PayslipStatus.SENT,
81
+ refresh_token=refresh_token,
82
+ error_message=None,
83
+ )
84
+
85
+ session.add(entry)
86
+ session.commit()
87
+ session.refresh(entry)
88
+
89
+ return entry
src/payslip/utils.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date, datetime
2
+ from fastapi import HTTPException
3
+
4
+
5
+ def parse_month(month_str: str) -> date:
6
+ """Convert YYYY-MM to a date object representing first day of the month."""
7
+ try:
8
+ d = datetime.strptime(month_str, "%Y-%m")
9
+ return date(d.year, d.month, 1)
10
+ except:
11
+ raise HTTPException(status_code=400, detail="Invalid month format. Use YYYY-MM")
12
+
13
+
14
+ def calculate_period(mode: str, start: str | None, end: str | None):
15
+ today = date.today()
16
+ current_month = date(today.year, today.month, 1)
17
+
18
+ if mode == "3_months":
19
+ months = 3
20
+ elif mode == "6_months":
21
+ months = 6
22
+ else: # manual mode
23
+ if not start or not end:
24
+ raise HTTPException(400, "start_month and end_month required")
25
+ start_date = parse_month(start)
26
+ end_date = parse_month(end)
27
+
28
+ if end_date > current_month:
29
+ raise HTTPException(400, "Cannot request future payslips")
30
+ if start_date > end_date:
31
+ raise HTTPException(400, "start_month cannot be after end_month")
32
+
33
+ return start_date, end_date
34
+
35
+ # Auto period
36
+ end_date = current_month
37
+ year = end_date.year
38
+ mon = end_date.month - (months - 1)
39
+
40
+ while mon <= 0:
41
+ mon += 12
42
+ year -= 1
43
+
44
+ start_date = date(year, mon, 1)
45
+ return start_date, end_date
46
+
47
+
48
+ def validate_join_date(join_date_str: str, period_start: date):
49
+ """User cannot request payslips earlier than join date"""
50
+ try:
51
+ join = datetime.strptime(join_date_str, "%Y-%m-%d").date()
52
+ join_month = join.replace(day=1)
53
+ except:
54
+ raise HTTPException(500, "Invalid join_date format in DB")
55
+
56
+ if period_start < join_month:
57
+ raise HTTPException(400, "You cannot request payslips before your joining date")