Hp137 commited on
Commit
7a4162b
·
1 Parent(s): 40cf27a

fix:remove fields

Browse files
.github/workflows/notify.yml CHANGED
@@ -2,8 +2,8 @@ name: Daily Emotion Check-In Notifications
2
 
3
  on:
4
  schedule:
5
- - cron: "30 3 * * *" # 9 AM IST
6
- - cron: "15 11 * * *" # 4:45 PM IST
7
  workflow_dispatch: {}
8
 
9
  jobs:
@@ -13,7 +13,7 @@ jobs:
13
  steps:
14
  - name: Send morning notification
15
  run: |
16
- curl -X POST "https://yuvabe-ai-yuvabe-app-backend.hf.space/home/notify/all" \
17
  -H "accept: application/json" \
18
  -H "Content-Type: application/json" \
19
  -d '{
@@ -31,7 +31,7 @@ jobs:
31
  steps:
32
  - name: Send evening notification
33
  run: |
34
- curl -X POST "https://yuvabe-ai-yuvabe-app-backend.hf.space/home/notify/all" \
35
  -H "accept: application/json" \
36
  -H "Content-Type: application/json" \
37
  -d '{
 
2
 
3
  on:
4
  schedule:
5
+ - cron: "30 3 * * *" # 9 AM IST
6
+ - cron: "15 11 * * *" # 4:45 PM IST
7
  workflow_dispatch: {}
8
 
9
  jobs:
 
13
  steps:
14
  - name: Send morning notification
15
  run: |
16
+ curl -X POST "${{secrets.NOTIFY_URL}}" \
17
  -H "accept: application/json" \
18
  -H "Content-Type: application/json" \
19
  -d '{
 
31
  steps:
32
  - name: Send evening notification
33
  run: |
34
+ curl -X POST "${{secrets.NOTIFY_URL}}" \
35
  -H "accept: application/json" \
36
  -H "Content-Type: application/json" \
37
  -d '{
alembic/versions/3380d93055a7_delete_posts_comments_likes_table.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """delete posts,comments,likes table
2
+
3
+ Revision ID: 3380d93055a7
4
+ Revises: 217db60578fa
5
+ Create Date: 2025-12-06 21:27:45.958044
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
+ from sqlalchemy.dialects import postgresql
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = '3380d93055a7'
17
+ down_revision: Union[str, Sequence[str], None] = '217db60578fa'
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("likes_post_id_fkey", "likes", type_="foreignkey")
26
+ op.drop_constraint("comments_post_id_fkey", "comments", type_="foreignkey")
27
+ op.drop_table("posts")
28
+ op.drop_table("likes")
29
+ op.drop_table("comments")
30
+ op.alter_column('app_version', 'apk_download_link',
31
+ existing_type=sa.VARCHAR(),
32
+ nullable=False)
33
+ # ### end Alembic commands ###
34
+
35
+
36
+ def downgrade() -> None:
37
+ """Downgrade schema."""
38
+ # ### commands auto generated by Alembic - please adjust! ###
39
+ op.alter_column('app_version', 'apk_download_link',
40
+ existing_type=sa.VARCHAR(),
41
+ nullable=True)
42
+ op.create_table('comments',
43
+ sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
44
+ sa.Column('post_id', sa.UUID(), autoincrement=False, nullable=False),
45
+ sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=False),
46
+ sa.Column('comment', sa.VARCHAR(), autoincrement=False, nullable=False),
47
+ sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
48
+ sa.ForeignKeyConstraint(['post_id'], ['posts.id'], name=op.f('comments_post_id_fkey'), ondelete='CASCADE'),
49
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('comments_user_id_fkey'), ondelete='CASCADE'),
50
+ sa.PrimaryKeyConstraint('id', name=op.f('comments_pkey'))
51
+ )
52
+ op.create_table('likes',
53
+ sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
54
+ sa.Column('post_id', sa.UUID(), autoincrement=False, nullable=False),
55
+ sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=False),
56
+ sa.Column('liked_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
57
+ sa.ForeignKeyConstraint(['post_id'], ['posts.id'], name=op.f('likes_post_id_fkey'), ondelete='CASCADE'),
58
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('likes_user_id_fkey'), ondelete='CASCADE'),
59
+ sa.PrimaryKeyConstraint('id', name=op.f('likes_pkey')),
60
+ sa.UniqueConstraint('user_id', 'post_id', name=op.f('likes_user_id_post_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
61
+ )
62
+ op.create_table('posts',
63
+ sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
64
+ sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
65
+ sa.Column('type', postgresql.ENUM('BIRTHDAY', 'NOTICE', 'BANNER', 'JOB_REQUEST', name='posttype'), autoincrement=False, nullable=False),
66
+ sa.Column('category', postgresql.ENUM('TEAM', 'GLOBAL', name='postcategory'), autoincrement=False, nullable=False),
67
+ sa.Column('caption', sa.VARCHAR(), autoincrement=False, nullable=True),
68
+ sa.Column('image', sa.VARCHAR(), autoincrement=False, nullable=True),
69
+ sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
70
+ sa.Column('edited_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
71
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('posts_user_id_fkey'), ondelete='SET NULL'),
72
+ sa.PrimaryKeyConstraint('id', name=op.f('posts_pkey'))
73
+ )
74
+ # ### end Alembic commands ###
alembic/versions/a34f13019598_drop_platform_model.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """drop platform model
2
+
3
+ Revision ID: a34f13019598
4
+ Revises: 3380d93055a7
5
+ Create Date: 2025-12-07 14:22:13.166768
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 = 'a34f13019598'
17
+ down_revision: Union[str, Sequence[str], None] = '3380d93055a7'
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_column('user_devices', 'platform')
26
+ op.drop_column('user_devices', 'device_model')
27
+ # ### end Alembic commands ###
28
+
29
+
30
+ def downgrade() -> None:
31
+ """Downgrade schema."""
32
+ # ### commands auto generated by Alembic - please adjust! ###
33
+ op.add_column('user_devices', sa.Column('device_model', sa.VARCHAR(), autoincrement=False, nullable=False))
34
+ op.add_column('user_devices', sa.Column('platform', sa.VARCHAR(), autoincrement=False, nullable=False))
35
+ # ### end Alembic commands ###
src/chatbot/embedding.py CHANGED
@@ -9,11 +9,8 @@ MODEL_ID = "onnx-community/embeddinggemma-300m-ONNX"
9
 
10
  class EmbeddingModel:
11
  def __init__(self):
12
- print("Loading tokenizer…")
13
  self.tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
14
 
15
- print("Downloading ONNX model files…")
16
-
17
  self.model_path = hf_hub_download(
18
  repo_id=MODEL_ID,
19
  filename="onnx/model.onnx"
@@ -25,7 +22,6 @@ class EmbeddingModel:
25
 
26
  model_dir = os.path.dirname(self.model_path)
27
 
28
- print("Creating inference session…")
29
  self.session = ort.InferenceSession(
30
  self.model_path,
31
  providers=["CPUExecutionProvider"],
 
9
 
10
  class EmbeddingModel:
11
  def __init__(self):
 
12
  self.tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
13
 
 
 
14
  self.model_path = hf_hub_download(
15
  repo_id=MODEL_ID,
16
  filename="onnx/model.onnx"
 
22
 
23
  model_dir = os.path.dirname(self.model_path)
24
 
 
25
  self.session = ort.InferenceSession(
26
  self.model_path,
27
  providers=["CPUExecutionProvider"],
src/chatbot/router.py CHANGED
@@ -2,11 +2,13 @@ import os
2
  import shutil
3
  import tempfile
4
  from typing import Optional
 
5
 
6
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
7
  from sqlalchemy import text
8
  from sqlmodel.ext.asyncio.session import AsyncSession
9
 
 
10
  from src.core.database import get_async_session
11
  from .schemas import ManualTextRequest
12
  from .service import store_manual_text
@@ -23,7 +25,7 @@ from .service import process_pdf_and_store
23
  router = APIRouter(prefix="/chatbot", tags=["chatbot"])
24
 
25
  @router.post("/tokenize", response_model=TokenizeResponse)
26
- async def tokenize_text(payload: TokenizeRequest):
27
  try:
28
  encoded = embedding_model.tokenizer(
29
  payload.text,
@@ -44,7 +46,7 @@ async def tokenize_text(payload: TokenizeRequest):
44
 
45
  @router.post("/semantic-search", response_model=list[SemanticSearchResult])
46
  async def semantic_search(
47
- payload: SemanticSearchRequest, session: AsyncSession = Depends(get_async_session)
48
  ):
49
 
50
  if len(payload.embedding) == 0:
 
2
  import shutil
3
  import tempfile
4
  from typing import Optional
5
+ from uuid import UUID
6
 
7
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
8
  from sqlalchemy import text
9
  from sqlmodel.ext.asyncio.session import AsyncSession
10
 
11
+ from src.auth.utils import get_current_user
12
  from src.core.database import get_async_session
13
  from .schemas import ManualTextRequest
14
  from .service import store_manual_text
 
25
  router = APIRouter(prefix="/chatbot", tags=["chatbot"])
26
 
27
  @router.post("/tokenize", response_model=TokenizeResponse)
28
+ async def tokenize_text(payload: TokenizeRequest,user_id: UUID = Depends(get_current_user)):
29
  try:
30
  encoded = embedding_model.tokenizer(
31
  payload.text,
 
46
 
47
  @router.post("/semantic-search", response_model=list[SemanticSearchResult])
48
  async def semantic_search(
49
+ payload: SemanticSearchRequest, session: AsyncSession = Depends(get_async_session), user_id: UUID = Depends(get_current_user)
50
  ):
51
 
52
  if len(payload.embedding) == 0:
src/core/__init__.py CHANGED
@@ -1,7 +1,6 @@
1
  from src.auth import models as auth_models
2
  from src.chatbot import models as chatbot_models
3
  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
 
1
  from src.auth import models as auth_models
2
  from src.chatbot import models as chatbot_models
3
  from src.core import models as core_models
 
4
  from src.home import models as home_models
5
  from src.profile import models as profile_models
6
  from src.wellbeing import models as wellbeing_models
src/core/models.py CHANGED
@@ -26,6 +26,11 @@ class Emotion(str, Enum):
26
  ANXIOUS = "anxious"
27
  SAD = "sad"
28
  FRUSTRATED = "frustrated"
 
 
 
 
 
29
 
30
  class Users(SQLModel, table=True):
31
  __tablename__ = "users"
 
26
  ANXIOUS = "anxious"
27
  SAD = "sad"
28
  FRUSTRATED = "frustrated"
29
+
30
+ class AppVersion(SQLModel, table=True):
31
+ __tablename__ = "app_version"
32
+ version: str = Field(primary_key=True)
33
+ apk_download_link: str
34
 
35
  class Users(SQLModel, table=True):
36
  __tablename__ = "users"
src/core/router.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlmodel.ext.asyncio.session import AsyncSession
3
+ from sqlmodel import select
4
+
5
+ from src.core.database import get_async_session
6
+ from src.core.schemas import BaseResponse
7
+ from src.core.models import AppVersion
8
+ from .schemas import AppConfigResponse
9
+
10
+ router = APIRouter(prefix="/app", tags=["AppConfig"])
11
+
12
+
13
+ @router.get("/config", response_model=BaseResponse[AppConfigResponse])
14
+ async def get_app_config(
15
+ session: AsyncSession = Depends(get_async_session),
16
+ ):
17
+ result = await session.exec(select(AppVersion))
18
+ row = result.first()
19
+
20
+ min_version = row.version if row else "0.0.0"
21
+ apk_url = row.apk_download_link if row else ""
22
+
23
+ return BaseResponse(
24
+ status_code=200,
25
+ data=AppConfigResponse(
26
+ version=min_version,
27
+ apk_download_link=apk_url
28
+ )
29
+ )
30
+
src/core/schemas.py CHANGED
@@ -1,5 +1,5 @@
1
  from typing import Generic, TypeVar
2
-
3
  from pydantic import BaseModel
4
 
5
  T = TypeVar("T")
@@ -8,3 +8,7 @@ T = TypeVar("T")
8
  class BaseResponse(BaseModel, Generic[T]):
9
  status_code: int
10
  data: T
 
 
 
 
 
1
  from typing import Generic, TypeVar
2
+ from sqlmodel import SQLModel
3
  from pydantic import BaseModel
4
 
5
  T = TypeVar("T")
 
8
  class BaseResponse(BaseModel, Generic[T]):
9
  status_code: int
10
  data: T
11
+
12
+ class AppConfigResponse(SQLModel):
13
+ version: str
14
+ apk_download_link: str
src/home/router.py CHANGED
@@ -26,7 +26,8 @@ async def fetch_home_data(
26
 
27
  @router.post("/emotion", response_model=BaseResponse[EmotionLogResponse])
28
  async def create_or_update_emotion(
29
- data: EmotionLogCreate, session: AsyncSession = Depends(get_async_session)
 
30
  ):
31
  record = await add_or_update_emotion(data, session)
32
  return {
 
26
 
27
  @router.post("/emotion", response_model=BaseResponse[EmotionLogResponse])
28
  async def create_or_update_emotion(
29
+ data: EmotionLogCreate, session: AsyncSession = Depends(get_async_session),
30
+ user_id: str = Depends(get_current_user),
31
  ):
32
  record = await add_or_update_emotion(data, session)
33
  return {
src/main.py CHANGED
@@ -10,6 +10,7 @@ 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
 
@@ -23,7 +24,7 @@ async def on_startup():
23
 
24
  app.include_router(home_router, prefix="/home", tags=["Home"])
25
 
26
- # init_db()
27
 
28
  app.include_router(profile)
29
 
 
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 src.core.router import router as app_config
14
  from fastapi.staticfiles import StaticFiles
15
 
16
 
 
24
 
25
  app.include_router(home_router, prefix="/home", tags=["Home"])
26
 
27
+ app.include_router(app_config)
28
 
29
  app.include_router(profile)
30
 
src/notifications/schemas.py CHANGED
@@ -1,6 +1,4 @@
1
  from pydantic import BaseModel
2
 
3
  class RegisterDeviceRequest(BaseModel):
4
- device_token: str
5
- platform: str
6
- device_model: str
 
1
  from pydantic import BaseModel
2
 
3
  class RegisterDeviceRequest(BaseModel):
4
+ device_token: str
 
 
src/notifications/service.py CHANGED
@@ -18,8 +18,6 @@ async def register_device(
18
  device = result.scalar_one_or_none()
19
 
20
  if device:
21
- device.platform = body.platform
22
- device.device_model = body.device_model
23
  device.last_seen = datetime.utcnow()
24
  device.updated_at = datetime.utcnow()
25
 
@@ -31,8 +29,6 @@ async def register_device(
31
  new_device = UserDevices(
32
  user_id=user_id,
33
  device_token=body.device_token,
34
- platform=body.platform,
35
- device_model=body.device_model,
36
  )
37
 
38
  session.add(new_device)
@@ -40,9 +36,11 @@ async def register_device(
40
  await session.refresh(new_device)
41
  return new_device
42
 
 
43
  from sqlalchemy import select
44
  from src.profile.models import UserDevices
45
 
 
46
  async def get_user_device_tokens(session, user_id):
47
  stmt = select(UserDevices.device_token).where(UserDevices.user_id == user_id)
48
  rows = (await session.execute(stmt)).all()
 
18
  device = result.scalar_one_or_none()
19
 
20
  if device:
 
 
21
  device.last_seen = datetime.utcnow()
22
  device.updated_at = datetime.utcnow()
23
 
 
29
  new_device = UserDevices(
30
  user_id=user_id,
31
  device_token=body.device_token,
 
 
32
  )
33
 
34
  session.add(new_device)
 
36
  await session.refresh(new_device)
37
  return new_device
38
 
39
+
40
  from sqlalchemy import select
41
  from src.profile.models import UserDevices
42
 
43
+
44
  async def get_user_device_tokens(session, user_id):
45
  stmt = select(UserDevices.device_token).where(UserDevices.user_id == user_id)
46
  rows = (await session.execute(stmt)).all()
src/payslip/router.py CHANGED
@@ -1,5 +1,5 @@
1
  # src/payslip/router.py
2
- from fastapi import APIRouter, Depends, HTTPException, Request
3
  from fastapi.responses import HTMLResponse
4
  from urllib.parse import urlencode
5
  import uuid
@@ -18,6 +18,8 @@ from src.payslip.googleservice import (
18
  )
19
  from src.payslip.utils import get_current_user_model
20
  from src.payslip.models import PayslipRequest, PayslipStatus
 
 
21
 
22
  router = APIRouter(prefix="/payslips", tags=["Payslips & Gmail"])
23
 
@@ -27,11 +29,6 @@ async def gmail_connect_url(user_id: uuid.UUID):
27
  """
28
  Returns the Google OAuth URL for the frontend to open in InAppBrowser.
29
  """
30
-
31
- print("\n🚀 GOOGLE CONNECT URL CALLED")
32
- print("Using redirect URI:", settings.GOOGLE_REDIRECT_URI)
33
- print("Client ID:", settings.GOOGLE_CLIENT_ID)
34
-
35
  params = {
36
  "client_id": settings.GOOGLE_CLIENT_ID,
37
  "redirect_uri": settings.GOOGLE_REDIRECT_URI,
@@ -41,102 +38,65 @@ async def gmail_connect_url(user_id: uuid.UUID):
41
  "prompt": "consent",
42
  "state": str(user_id),
43
  }
44
-
45
- url = f"{settings.AUTH_BASE}?{urlencode(params)}"
46
-
47
- print("Generated Google URL:", url)
48
-
49
- return {"auth_url": url}
50
 
51
 
52
  @router.get("/gmail/callback")
53
  async def gmail_callback(
54
- request: Request, # REQUIRED to read URL + headers
55
- code: str,
56
- state: str,
57
- session: AsyncSession = Depends(get_async_session)
58
  ):
59
-
60
  from fastapi.responses import RedirectResponse
61
 
62
- print("\n🔔 CALLBACK HIT")
63
- print("➡ URL seen by backend:", request.url)
64
- print("➡ Headers:", dict(request.headers))
65
- print("➡ Query params:", request.query_params)
66
-
67
  user_id = uuid.UUID(state)
68
- print("➡ Extracted user_id from state:", user_id)
69
-
70
- # LOAD USER
71
  user = await session.get(Users, user_id)
72
- print("➡ User found:", user.email_id if user else "None")
73
 
74
  if not user:
75
- print("❌ User not found")
76
  return RedirectResponse(
77
  "yuvabe://gmail/callback?success=false&error=user_not_found&message=No such user exists"
78
  )
79
 
80
- # EXCHANGE CODE
81
  try:
82
- print("➡ Exchanging code for tokens...")
83
  token_data = exchange_code_for_tokens(code)
84
- print("➡ Token Data:", token_data)
85
-
86
  google_email = extract_email_from_id_token(token_data["id_token"])
87
- print("➡ Email from Google:", google_email)
88
-
89
- except Exception as e:
90
- print("❌ OAuth Code Exchange Failed:", e)
91
  return RedirectResponse(
92
  "yuvabe://gmail/callback?success=false&error=invalid_code&message=OAuth code exchange failed"
93
  )
94
 
95
- # EMAIL MISMATCH
96
  if google_email.lower() != user.email_id.lower():
97
- print("❌ Email mismatch! Google:", google_email, "| App:", user.email_id)
98
  return RedirectResponse(
99
  "yuvabe://gmail/callback?success=false&error=email_mismatch&message=Google account does not match registered email"
100
  )
101
 
102
- # GET REFRESH TOKEN
103
  refresh_token = token_data.get("refresh_token")
104
- print("➡ Refresh Token:", refresh_token)
105
-
106
  if not refresh_token:
107
- print("❌ Google DID NOT send refresh token")
108
  return RedirectResponse(
109
  "yuvabe://gmail/callback?success=false&error=no_refresh_token&message=No refresh token returned from Google"
110
  )
111
 
112
- # SAVE TOKEN
113
  q = (
114
  select(PayslipRequest)
115
  .where(PayslipRequest.user_id == user_id)
116
  .order_by(PayslipRequest.requested_at.desc())
117
  )
118
  existing = (await session.execute(q)).scalar_one_or_none()
119
- print("➡ Existing request:", existing)
120
 
121
  if existing:
122
- existing.refresh_token = refresh_token
123
  session.add(existing)
124
- print("➡ Updated existing request with token")
125
  else:
126
  session.add(
127
  PayslipRequest(
128
  user_id=user_id,
129
- refresh_token=refresh_token,
130
  status=PayslipStatus.PENDING,
131
  )
132
  )
133
- print("➡ Created new PayslipRequest row")
134
 
135
  await session.commit()
136
- print("➡ DB Commit Successful!")
137
 
138
- # SUCCESS REDIRECT
139
- print("🎉 SUCCESS — Gmail Connected!")
140
  return RedirectResponse(
141
  "yuvabe://gmail/callback?success=true&message=gmail_connected_successfully"
142
  )
@@ -148,14 +108,17 @@ async def request_payslip(
148
  session: AsyncSession = Depends(get_async_session),
149
  user: Users = Depends(get_current_user_model),
150
  ):
151
- print("\n📩 PAYSLIP REQUEST HIT")
152
- print(" User:", user.email_id)
153
- print("➡ Payload:", payload)
154
-
 
 
 
 
 
 
155
  entry = await process_payslip_request(session, user, payload)
156
-
157
- print("➡ Payslip Entry Created:", entry)
158
-
159
  return {
160
  "status": entry.status,
161
  "requested_at": entry.requested_at,
 
1
  # src/payslip/router.py
2
+ from fastapi import APIRouter, Depends, HTTPException
3
  from fastapi.responses import HTMLResponse
4
  from urllib.parse import urlencode
5
  import uuid
 
18
  )
19
  from src.payslip.utils import get_current_user_model
20
  from src.payslip.models import PayslipRequest, PayslipStatus
21
+ from src.payslip.utils import encrypt_token
22
+
23
 
24
  router = APIRouter(prefix="/payslips", tags=["Payslips & Gmail"])
25
 
 
29
  """
30
  Returns the Google OAuth URL for the frontend to open in InAppBrowser.
31
  """
 
 
 
 
 
32
  params = {
33
  "client_id": settings.GOOGLE_CLIENT_ID,
34
  "redirect_uri": settings.GOOGLE_REDIRECT_URI,
 
38
  "prompt": "consent",
39
  "state": str(user_id),
40
  }
41
+ return {"auth_url": f"{settings.AUTH_BASE}?{urlencode(params)}"}
 
 
 
 
 
42
 
43
 
44
  @router.get("/gmail/callback")
45
  async def gmail_callback(
46
+ code: str, state: str, session: AsyncSession = Depends(get_async_session)
 
 
 
47
  ):
 
48
  from fastapi.responses import RedirectResponse
49
 
 
 
 
 
 
50
  user_id = uuid.UUID(state)
 
 
 
51
  user = await session.get(Users, user_id)
 
52
 
53
  if not user:
 
54
  return RedirectResponse(
55
  "yuvabe://gmail/callback?success=false&error=user_not_found&message=No such user exists"
56
  )
57
 
 
58
  try:
 
59
  token_data = exchange_code_for_tokens(code)
 
 
60
  google_email = extract_email_from_id_token(token_data["id_token"])
61
+ except Exception:
 
 
 
62
  return RedirectResponse(
63
  "yuvabe://gmail/callback?success=false&error=invalid_code&message=OAuth code exchange failed"
64
  )
65
 
66
+ # --- GMAIL MISMATCH ERROR ---
67
  if google_email.lower() != user.email_id.lower():
 
68
  return RedirectResponse(
69
  "yuvabe://gmail/callback?success=false&error=email_mismatch&message=Google account does not match registered email"
70
  )
71
 
 
72
  refresh_token = token_data.get("refresh_token")
 
 
73
  if not refresh_token:
 
74
  return RedirectResponse(
75
  "yuvabe://gmail/callback?success=false&error=no_refresh_token&message=No refresh token returned from Google"
76
  )
77
 
 
78
  q = (
79
  select(PayslipRequest)
80
  .where(PayslipRequest.user_id == user_id)
81
  .order_by(PayslipRequest.requested_at.desc())
82
  )
83
  existing = (await session.execute(q)).scalar_one_or_none()
 
84
 
85
  if existing:
86
+ existing.refresh_token = encrypt_token(refresh_token)
87
  session.add(existing)
 
88
  else:
89
  session.add(
90
  PayslipRequest(
91
  user_id=user_id,
92
+ refresh_token=encrypt_token(refresh_token),
93
  status=PayslipStatus.PENDING,
94
  )
95
  )
 
96
 
97
  await session.commit()
 
98
 
99
+ # --- SUCCESS MESSAGE ---
 
100
  return RedirectResponse(
101
  "yuvabe://gmail/callback?success=true&message=gmail_connected_successfully"
102
  )
 
108
  session: AsyncSession = Depends(get_async_session),
109
  user: Users = Depends(get_current_user_model),
110
  ):
111
+ """
112
+ User hits this when pressing "Request Payslip" in the app.
113
+ We:
114
+ - enforce 1 request per day
115
+ - compute period
116
+ - validate join date
117
+ - load refresh_token from payslip_requests table
118
+ - send email
119
+ - update or create row in payslip_requests
120
+ """
121
  entry = await process_payslip_request(session, user, payload)
 
 
 
122
  return {
123
  "status": entry.status,
124
  "requested_at": entry.requested_at,
src/payslip/service.py CHANGED
@@ -15,6 +15,9 @@ from src.payslip.googleservice import (
15
  send_gmail,
16
  )
17
 
 
 
 
18
 
19
  async def user_team_name(session: AsyncSession, user_id):
20
  """Return user's team name."""
@@ -95,7 +98,7 @@ async def process_payslip_request(
95
  # 4. Get refresh_token from latest payslip row (DB)
96
  latest = await get_latest_payslip_row(session, user.id)
97
 
98
- refresh_token = latest.refresh_token if latest else None
99
 
100
  if not refresh_token:
101
  # No token stored yet
@@ -134,7 +137,7 @@ async def process_payslip_request(
134
  latest.status = PayslipStatus.SENT
135
  latest.requested_at = now
136
  latest.error_message = None
137
- latest.refresh_token = refresh_token # keep token
138
  session.add(latest)
139
  await session.commit()
140
  await session.refresh(latest)
 
15
  send_gmail,
16
  )
17
 
18
+ from src.payslip.utils import decrypt_token
19
+ from src.payslip.utils import encrypt_token
20
+
21
 
22
  async def user_team_name(session: AsyncSession, user_id):
23
  """Return user's team name."""
 
98
  # 4. Get refresh_token from latest payslip row (DB)
99
  latest = await get_latest_payslip_row(session, user.id)
100
 
101
+ refresh_token = decrypt_token(latest.refresh_token) if latest else None
102
 
103
  if not refresh_token:
104
  # No token stored yet
 
137
  latest.status = PayslipStatus.SENT
138
  latest.requested_at = now
139
  latest.error_message = None
140
+ latest.refresh_token = encrypt_token(refresh_token) # keep token
141
  session.add(latest)
142
  await session.commit()
143
  await session.refresh(latest)
src/payslip/utils.py CHANGED
@@ -13,6 +13,23 @@ from src.core.database import get_async_session
13
  from src.core.models import Users
14
  from src.core.config import settings
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  bearer_scheme = HTTPBearer()
17
 
18
  SECRET_KEY = settings.SECRET_KEY
 
13
  from src.core.models import Users
14
  from src.core.config import settings
15
 
16
+
17
+ from cryptography.fernet import Fernet
18
+ from src.core.config import settings
19
+
20
+
21
+ fernet = Fernet(settings.FERNET_KEY.encode())
22
+
23
+
24
+ def encrypt_token(token: str) -> str:
25
+ """Encrypts a refresh token before saving to DB."""
26
+ return fernet.encrypt(token.encode()).decode()
27
+
28
+
29
+ def decrypt_token(token: str) -> str:
30
+ """Decrypts a stored refresh token when needed."""
31
+ return fernet.decrypt(token.encode()).decode()
32
+
33
  bearer_scheme = HTTPBearer()
34
 
35
  SECRET_KEY = settings.SECRET_KEY
src/profile/models.py CHANGED
@@ -63,6 +63,4 @@ class UserDevices(SQLModel, table=True):
63
  )
64
  device_token: str
65
  last_seen: datetime = Field(default_factory=datetime.now)
66
- device_model: str
67
- platform: str
68
  updated_at: datetime = Field(default_factory=datetime.now)
 
63
  )
64
  device_token: str
65
  last_seen: datetime = Field(default_factory=datetime.now)
 
 
66
  updated_at: datetime = Field(default_factory=datetime.now)
src/profile/utils.py CHANGED
@@ -12,17 +12,27 @@ from datetime import date
12
  from typing import Tuple, Optional, List
13
  from sqlmodel import select
14
  from sqlmodel.ext.asyncio.session import AsyncSession
15
- from src.core.models import UserTeamsRole, Roles, Users, Teams # adjust import path if differs
 
 
 
 
 
16
  from src.core.config import settings # for FCM key if needed
17
  import httpx
18
- import math
19
 
20
- def calculate_days(from_date: date, to_date: date, include_weekends: bool = True) -> int:
 
 
 
21
  """Calculate inclusive days. If you want to exclude weekends, add logic."""
22
  delta = (to_date - from_date).days + 1
23
  return max(0, delta)
24
 
25
- async def find_mentor_and_lead(session: AsyncSession, user_id) -> Tuple[Optional[dict], Optional[dict]]:
 
 
 
26
  """
27
  Return (mentor_user, lead_user) as dicts or None.
28
  Uses your existing UserTeamsRole and Roles tables to find role members in same team.
@@ -34,41 +44,51 @@ async def find_mentor_and_lead(session: AsyncSession, user_id) -> Tuple[Optional
34
  return None, None
35
 
36
  # 2) find Mentor role id
37
- mentor_role = (await session.exec(select(Roles).where(Roles.name == "Mentor"))).first()
38
- lead_role = (await session.exec(select(Roles).where(Roles.name == "Team Lead"))).first()
 
 
 
 
39
 
40
  mentor_user = None
41
  lead_user = None
42
 
43
  if mentor_role:
44
- mentor_user = (await session.exec(
45
- select(Users)
46
- .join(UserTeamsRole)
47
- .where(UserTeamsRole.team_id == user_team.team_id)
48
- .where(UserTeamsRole.role_id == mentor_role.id)
49
- )).first()
 
 
50
 
51
  if lead_role:
52
- lead_user = (await session.exec(
53
- select(Users)
54
- .join(UserTeamsRole)
55
- .where(UserTeamsRole.team_id == user_team.team_id)
56
- .where(UserTeamsRole.role_id == lead_role.id)
57
- )).first()
 
 
58
 
59
  return mentor_user, lead_user
60
 
61
 
62
-
63
  async def get_tokens_for_user(session: AsyncSession, user_id) -> list[str]:
64
  user = await session.get(Users, user_id)
65
  if not user:
66
  return []
67
  return user.device_tokens or []
68
 
 
69
  # Simple FCM send using legacy HTTP API (server key).
70
  # In production prefer FCM HTTP v1 (OAuth) or firebase-admin SDK.
71
- async def send_push_to_tokens(tokens: list[str], title: str, body: str, data: dict = None):
 
 
72
  if not tokens:
73
  return
74
 
@@ -97,8 +117,6 @@ async def send_push_to_tokens(tokens: list[str], title: str, body: str, data: di
97
  print("FCM send failed:", r.status_code, r.text)
98
 
99
 
100
-
101
-
102
  SMTP_HOST = settings.EMAIL_SERVER
103
  SMTP_PORT = settings.EMAIL_PORT
104
  SMTP_USER = settings.EMAIL_USERNAME
 
12
  from typing import Tuple, Optional, List
13
  from sqlmodel import select
14
  from sqlmodel.ext.asyncio.session import AsyncSession
15
+ from src.core.models import (
16
+ UserTeamsRole,
17
+ Roles,
18
+ Users,
19
+ Teams,
20
+ ) # adjust import path if differs
21
  from src.core.config import settings # for FCM key if needed
22
  import httpx
 
23
 
24
+
25
+ def calculate_days(
26
+ from_date: date, to_date: date, include_weekends: bool = True
27
+ ) -> int:
28
  """Calculate inclusive days. If you want to exclude weekends, add logic."""
29
  delta = (to_date - from_date).days + 1
30
  return max(0, delta)
31
 
32
+
33
+ async def find_mentor_and_lead(
34
+ session: AsyncSession, user_id
35
+ ) -> Tuple[Optional[dict], Optional[dict]]:
36
  """
37
  Return (mentor_user, lead_user) as dicts or None.
38
  Uses your existing UserTeamsRole and Roles tables to find role members in same team.
 
44
  return None, None
45
 
46
  # 2) find Mentor role id
47
+ mentor_role = (
48
+ await session.exec(select(Roles).where(Roles.name == "Mentor"))
49
+ ).first()
50
+ lead_role = (
51
+ await session.exec(select(Roles).where(Roles.name == "Team Lead"))
52
+ ).first()
53
 
54
  mentor_user = None
55
  lead_user = None
56
 
57
  if mentor_role:
58
+ mentor_user = (
59
+ await session.exec(
60
+ select(Users)
61
+ .join(UserTeamsRole)
62
+ .where(UserTeamsRole.team_id == user_team.team_id)
63
+ .where(UserTeamsRole.role_id == mentor_role.id)
64
+ )
65
+ ).first()
66
 
67
  if lead_role:
68
+ lead_user = (
69
+ await session.exec(
70
+ select(Users)
71
+ .join(UserTeamsRole)
72
+ .where(UserTeamsRole.team_id == user_team.team_id)
73
+ .where(UserTeamsRole.role_id == lead_role.id)
74
+ )
75
+ ).first()
76
 
77
  return mentor_user, lead_user
78
 
79
 
 
80
  async def get_tokens_for_user(session: AsyncSession, user_id) -> list[str]:
81
  user = await session.get(Users, user_id)
82
  if not user:
83
  return []
84
  return user.device_tokens or []
85
 
86
+
87
  # Simple FCM send using legacy HTTP API (server key).
88
  # In production prefer FCM HTTP v1 (OAuth) or firebase-admin SDK.
89
+ async def send_push_to_tokens(
90
+ tokens: list[str], title: str, body: str, data: dict = None
91
+ ):
92
  if not tokens:
93
  return
94
 
 
117
  print("FCM send failed:", r.status_code, r.text)
118
 
119
 
 
 
120
  SMTP_HOST = settings.EMAIL_SERVER
121
  SMTP_PORT = settings.EMAIL_PORT
122
  SMTP_USER = settings.EMAIL_USERNAME