alembic/versions/6afe00260026_add_nick_name_to_users_table.py DELETED
@@ -1,35 +0,0 @@
1
- """add nick_name to users table
2
-
3
- Revision ID: 6afe00260026
4
- Revises: 0875ad9f64a9
5
- Create Date: 2026-02-03 12:08:55.758775
6
-
7
- """
8
-
9
- from typing import Sequence, Union
10
-
11
- from alembic import op
12
- import sqlalchemy as sa
13
- import sqlmodel.sql.sqltypes
14
- from sqlalchemy.dialects import postgresql
15
-
16
- # revision identifiers, used by Alembic.
17
- revision: str = "6afe00260026"
18
- down_revision: Union[str, Sequence[str], None] = "0875ad9f64a9"
19
- branch_labels: Union[str, Sequence[str], None] = None
20
- depends_on: Union[str, Sequence[str], None] = None
21
-
22
-
23
- def upgrade() -> None:
24
- """Upgrade schema."""
25
- # Only keep the column addition
26
- op.add_column(
27
- "users",
28
- sa.Column("nick_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
29
- )
30
-
31
-
32
- def downgrade() -> None:
33
- """Downgrade schema."""
34
- # Only keep the column removal
35
- op.drop_column("users", "nick_name")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
alembic/versions/f15af71aacb9_add_request_type_and_target_dates_to_.py DELETED
@@ -1,52 +0,0 @@
1
- """add request_type and target_dates to payslip_requests
2
-
3
- Revision ID: f15af71aacb9
4
- Revises: 6afe00260026
5
- Create Date: 2026-02-09 14:24:49.103516
6
-
7
- """
8
-
9
- from typing import Sequence, Union
10
-
11
- from alembic import op
12
- import sqlalchemy as sa
13
- import sqlmodel.sql.sqltypes
14
- from sqlalchemy.dialects import postgresql
15
-
16
- # revision identifiers, used by Alembic.
17
- revision: str = "f15af71aacb9"
18
- down_revision: Union[str, Sequence[str], None] = "6afe00260026"
19
- branch_labels: Union[str, Sequence[str], None] = None
20
- depends_on: Union[str, Sequence[str], None] = None
21
-
22
-
23
- def upgrade() -> None:
24
- """Upgrade schema."""
25
- # 1. Add request_type
26
- # We use server_default to fill existing rows with 'payslip' so the migration doesn't crash
27
- op.add_column(
28
- "payslip_requests",
29
- sa.Column(
30
- "request_type",
31
- sqlmodel.sql.sqltypes.AutoString(),
32
- nullable=False,
33
- server_default=sa.text("'payslip'"),
34
- ),
35
- )
36
-
37
- # 2. Optional: Remove the default constraint if you don't want it permanently (keeps schema clean)
38
- op.alter_column("payslip_requests", "request_type", server_default=None)
39
-
40
- # 3. Add target_dates (It is nullable, so no default needed)
41
- op.add_column(
42
- "payslip_requests",
43
- sa.Column(
44
- "target_dates", postgresql.JSON(astext_type=sa.Text()), nullable=True
45
- ),
46
- )
47
-
48
-
49
- def downgrade() -> None:
50
- """Downgrade schema."""
51
- op.drop_column("payslip_requests", "target_dates")
52
- op.drop_column("payslip_requests", "request_type")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/auth/router.py CHANGED
@@ -1,4 +1,3 @@
1
- from src.core.models import Teams
2
  import uuid
3
  from src.core.database import get_async_session
4
  from fastapi import APIRouter, Depends, HTTPException, status
@@ -114,26 +113,21 @@ async def get_home(
114
  user_id: str = Depends(get_current_user),
115
  session: AsyncSession = Depends(get_async_session),
116
  ):
117
- u_id = uuid.UUID(user_id)
118
- user = await session.get(Users, u_id)
 
 
119
  if not user:
120
  raise HTTPException(status_code=404, detail="User not found")
121
 
122
- # 1) Define the statement (Do NOT await here)
123
- statement = (
124
- select(Roles.name, Teams.name)
125
  .join(UserTeamsRole, UserTeamsRole.role_id == Roles.id)
126
- .join(Teams, Teams.id == UserTeamsRole.team_id)
127
- .where(UserTeamsRole.user_id == u_id)
128
  )
 
129
 
130
- # 2) Execute it ONCE
131
- exec_result = await session.exec(statement)
132
- row = exec_result.first()
133
-
134
- # Unpack values or provide defaults
135
- user_role, team_name = row if row else ("Member", "No Team")
136
-
137
  return {
138
  "code": 200,
139
  "data": {
@@ -146,8 +140,6 @@ async def get_home(
146
  "dob": user.dob.isoformat() if user.dob else None,
147
  "profile_picture": user.profile_picture,
148
  "role": user_role.lower(),
149
- "team_name": team_name,
150
- "nick_name": user.nick_name,
151
  },
152
  "home_data": {
153
  "announcements": ["Welcome!", "New protocol released"],
 
 
1
  import uuid
2
  from src.core.database import get_async_session
3
  from fastapi import APIRouter, Depends, HTTPException, status
 
113
  user_id: str = Depends(get_current_user),
114
  session: AsyncSession = Depends(get_async_session),
115
  ):
116
+ """
117
+ Protected home endpoint. Requires a valid access token (Bearer).
118
+ """
119
+ user = await session.get(Users, uuid.UUID(user_id))
120
  if not user:
121
  raise HTTPException(status_code=404, detail="User not found")
122
 
123
+ role_join = await session.exec(
124
+ select(Roles.name)
 
125
  .join(UserTeamsRole, UserTeamsRole.role_id == Roles.id)
126
+ .where(UserTeamsRole.user_id == uuid.UUID(user_id))
 
127
  )
128
+ user_role = role_join.first() or "Member"
129
 
130
+ # Example payload replace with your real app data
 
 
 
 
 
 
131
  return {
132
  "code": 200,
133
  "data": {
 
140
  "dob": user.dob.isoformat() if user.dob else None,
141
  "profile_picture": user.profile_picture,
142
  "role": user_role.lower(),
 
 
143
  },
144
  "home_data": {
145
  "announcements": ["Welcome!", "New protocol released"],
src/core/config.py CHANGED
@@ -38,8 +38,6 @@ class Settings(BaseSettings):
38
  GOOGLE_CLIENT_SECRET: str
39
  GOOGLE_REDIRECT_URI: str
40
 
41
- WEB_FRONTEND_URL: str
42
-
43
  FCM_SERVER_KEY: Optional[str] = None
44
  SICK_LEAVE_LIMIT: int = 10
45
  CASUAL_LEAVE_LIMIT: int = 10
@@ -60,6 +58,7 @@ class Settings(BaseSettings):
60
  FIREBASE_CLIENT_X509_CERT_URL: str
61
  FIREBASE_UNIVERSE_DOMAIN: str
62
 
 
63
  @computed_field
64
  @property
65
  def DATABASE_URL(self) -> PostgresDsn:
 
38
  GOOGLE_CLIENT_SECRET: str
39
  GOOGLE_REDIRECT_URI: str
40
 
 
 
41
  FCM_SERVER_KEY: Optional[str] = None
42
  SICK_LEAVE_LIMIT: int = 10
43
  CASUAL_LEAVE_LIMIT: int = 10
 
58
  FIREBASE_CLIENT_X509_CERT_URL: str
59
  FIREBASE_UNIVERSE_DOMAIN: str
60
 
61
+
62
  @computed_field
63
  @property
64
  def DATABASE_URL(self) -> PostgresDsn:
src/core/database.py CHANGED
@@ -12,9 +12,6 @@ async_engine = create_async_engine(
12
  pool_size=20,
13
  max_overflow=40,
14
  pool_timeout=30,
15
- pool_recycle=300,
16
- pool_pre_ping=True,
17
- pool_use_lifo=True,
18
  connect_args={"ssl": True},
19
  )
20
 
 
12
  pool_size=20,
13
  max_overflow=40,
14
  pool_timeout=30,
 
 
 
15
  connect_args={"ssl": True},
16
  )
17
 
src/core/models.py CHANGED
@@ -33,21 +33,18 @@ class LunchLocation(str, Enum):
33
  SARACON_CAMPUS = "Saracon Campus"
34
  SOLAR_KITCHEN = "Solar Kitchen"
35
 
36
-
37
  class AppVersion(SQLModel, table=True):
38
  __tablename__ = "app_version"
39
  version: str = Field(primary_key=True)
40
  apk_download_link: str = Field(nullable=False)
41
  ios_download_link: str = Field(nullable=False)
42
 
43
-
44
  class Users(SQLModel, table=True):
45
  __tablename__ = "users"
46
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
47
  email_id: str = Field(unique=True, nullable=False)
48
  password: str = Field(nullable=False)
49
  user_name: str = Field(nullable=False)
50
- nick_name: Optional[str] = Field(default=None, nullable=True)
51
  is_verified: bool = Field(
52
  default=False, sa_column_kwargs={"server_default": "false"}
53
  )
@@ -59,9 +56,7 @@ class Users(SQLModel, table=True):
59
  asset: List["Assets"] = Relationship(back_populates="user")
60
  water_logs: List["WaterLogs"] = Relationship(back_populates="user")
61
  journal_entries: List["JournalEntry"] = Relationship(back_populates="user")
62
- lunch_preference: LunchLocation = Field(
63
- default=None, sa_column_kwargs={"server_default": "SOLAR_KITCHEN"}
64
- )
65
 
66
 
67
  class Teams(SQLModel, table=True):
@@ -72,11 +67,7 @@ class Teams(SQLModel, table=True):
72
 
73
  class Roles(SQLModel, table=True):
74
  __tablename__ = "roles"
75
- id: uuid.UUID = Field(
76
- default_factory=uuid.uuid4,
77
- primary_key=True,
78
- sa_column_kwargs={"server_default": text("gen_random_uuid()")},
79
- )
80
  name: str = Field(unique=True, nullable=False)
81
 
82
 
 
33
  SARACON_CAMPUS = "Saracon Campus"
34
  SOLAR_KITCHEN = "Solar Kitchen"
35
 
 
36
  class AppVersion(SQLModel, table=True):
37
  __tablename__ = "app_version"
38
  version: str = Field(primary_key=True)
39
  apk_download_link: str = Field(nullable=False)
40
  ios_download_link: str = Field(nullable=False)
41
 
 
42
  class Users(SQLModel, table=True):
43
  __tablename__ = "users"
44
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
45
  email_id: str = Field(unique=True, nullable=False)
46
  password: str = Field(nullable=False)
47
  user_name: str = Field(nullable=False)
 
48
  is_verified: bool = Field(
49
  default=False, sa_column_kwargs={"server_default": "false"}
50
  )
 
56
  asset: List["Assets"] = Relationship(back_populates="user")
57
  water_logs: List["WaterLogs"] = Relationship(back_populates="user")
58
  journal_entries: List["JournalEntry"] = Relationship(back_populates="user")
59
+ lunch_preference: LunchLocation = Field(default= None,sa_column_kwargs={"server_default": "SOLAR_KITCHEN"})
 
 
60
 
61
 
62
  class Teams(SQLModel, table=True):
 
67
 
68
  class Roles(SQLModel, table=True):
69
  __tablename__ = "roles"
70
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, sa_column_kwargs={"server_default": text("gen_random_uuid()")})
 
 
 
 
71
  name: str = Field(unique=True, nullable=False)
72
 
73
 
src/core/utils.py CHANGED
@@ -22,8 +22,8 @@ async def send_mail_as_user(
22
  ):
23
  q = (
24
  select(PayslipRequest)
25
- .where(PayslipRequest.user_id == user_id,PayslipRequest.refresh_token.is_not(None))
26
- .order_by(PayslipRequest.requested_at.desc()).limit(1)
27
  )
28
  entry = (await session.execute(q)).scalar_one_or_none()
29
 
 
22
  ):
23
  q = (
24
  select(PayslipRequest)
25
+ .where(PayslipRequest.user_id == user_id)
26
+ .order_by(PayslipRequest.requested_at.desc())
27
  )
28
  entry = (await session.execute(q)).scalar_one_or_none()
29
 
src/foodcount/service.py CHANGED
@@ -7,15 +7,12 @@ from src.core.models import Users, Roles, UserTeamsRole
7
  from src.foodcount.schemas import LunchNotifyRequest
8
  from src.core.utils import send_mail_as_user
9
  from src.core.models import LunchLocation
10
- from src.payslip.models import PayslipRequest, PayslipStatus
11
- from datetime import timedelta, datetime
12
 
13
  LOCATION_ROLE_MAP = {
14
  LunchLocation.SOLAR_KITCHEN: "LM - Solar Kitchen",
15
  LunchLocation.SARACON_CAMPUS: "LM - Saracon Campus",
16
  }
17
 
18
-
19
  async def get_lunch_manager_by_location(
20
  session: AsyncSession,
21
  location: LunchLocation,
@@ -26,21 +23,20 @@ async def get_lunch_manager_by_location(
26
  raise HTTPException(400, "Invalid lunch location")
27
 
28
  role = (
29
- await session.execute(select(Roles).where(Roles.name == role_name))
 
 
30
  ).scalar_one_or_none()
31
 
32
  if not role:
33
  raise HTTPException(500, "Lunch manager role missing")
34
 
35
  mapping = (
36
- (
37
- await session.execute(
38
- select(UserTeamsRole).where(UserTeamsRole.role_id == role.id)
39
- )
40
  )
41
- .scalars()
42
- .first()
43
- )
44
 
45
  if not mapping:
46
  raise HTTPException(500, "No lunch manager assigned")
@@ -51,7 +47,6 @@ async def get_lunch_manager_by_location(
51
 
52
  return manager
53
 
54
-
55
  def build_lunch_mail_body(
56
  user_name: str,
57
  email: str,
@@ -72,42 +67,11 @@ def build_lunch_mail_body(
72
  f"The user has opted out of lunch."
73
  )
74
 
75
-
76
  async def process_lunch_notification(
77
  session: AsyncSession,
78
  user_id: uuid.UUID,
79
  payload: LunchNotifyRequest,
80
  ):
81
-
82
- requested_dates = []
83
- curr = payload.start_date
84
- while curr <= payload.end_date:
85
- requested_dates.append(curr.strftime("%Y-%m-%d"))
86
- curr += timedelta(days=1)
87
-
88
- # 2. CHECK DUPLICATES: Get all past lunch requests for this user
89
- # We fetch only the 'target_dates' column to make it fast
90
- stmt = select(PayslipRequest.target_dates).where(
91
- PayslipRequest.user_id == user_id,
92
- PayslipRequest.request_type == "lunch", # Only look at lunch rows
93
- PayslipRequest.status == PayslipStatus.SENT,
94
- )
95
- result = await session.execute(stmt)
96
-
97
- # Flatten the list of lists into a single set of existing dates
98
- existing_dates = set()
99
- for row in result.scalars().all():
100
- if row: # Check if row is not None
101
- existing_dates.update(row) # Add all dates from that request
102
-
103
- # Check if any requested date is already in the database
104
- duplicates = [d for d in requested_dates if d in existing_dates]
105
-
106
- if duplicates:
107
- raise HTTPException(
108
- status_code=400,
109
- detail=f"Already sent a request for: {', '.join(duplicates)}",
110
- )
111
  # fetch only needed user fields
112
  row = (
113
  await session.execute(
@@ -147,20 +111,11 @@ async def process_lunch_notification(
147
  body=body,
148
  )
149
  except HTTPException as e:
150
- if e.status_code == 428:
151
- raise HTTPException(
152
- status_code=428, detail="Please connect your Gmail account"
153
- )
154
- raise
155
-
156
- new_req = PayslipRequest(
157
- user_id=user_id,
158
- status=PayslipStatus.SENT,
159
- request_type="lunch", # Mark as lunch
160
- target_dates=requested_dates, # Save the dates ["2024-02-10"]
161
- requested_at=datetime.now(),
162
- )
163
- session.add(new_req)
164
- await session.commit()
165
 
166
  return manager.email_id
 
7
  from src.foodcount.schemas import LunchNotifyRequest
8
  from src.core.utils import send_mail_as_user
9
  from src.core.models import LunchLocation
 
 
10
 
11
  LOCATION_ROLE_MAP = {
12
  LunchLocation.SOLAR_KITCHEN: "LM - Solar Kitchen",
13
  LunchLocation.SARACON_CAMPUS: "LM - Saracon Campus",
14
  }
15
 
 
16
  async def get_lunch_manager_by_location(
17
  session: AsyncSession,
18
  location: LunchLocation,
 
23
  raise HTTPException(400, "Invalid lunch location")
24
 
25
  role = (
26
+ await session.execute(
27
+ select(Roles).where(Roles.name == role_name)
28
+ )
29
  ).scalar_one_or_none()
30
 
31
  if not role:
32
  raise HTTPException(500, "Lunch manager role missing")
33
 
34
  mapping = (
35
+ await session.execute(
36
+ select(UserTeamsRole)
37
+ .where(UserTeamsRole.role_id == role.id)
 
38
  )
39
+ ).scalars().first()
 
 
40
 
41
  if not mapping:
42
  raise HTTPException(500, "No lunch manager assigned")
 
47
 
48
  return manager
49
 
 
50
  def build_lunch_mail_body(
51
  user_name: str,
52
  email: str,
 
67
  f"The user has opted out of lunch."
68
  )
69
 
 
70
  async def process_lunch_notification(
71
  session: AsyncSession,
72
  user_id: uuid.UUID,
73
  payload: LunchNotifyRequest,
74
  ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  # fetch only needed user fields
76
  row = (
77
  await session.execute(
 
111
  body=body,
112
  )
113
  except HTTPException as e:
114
+ if e.status_code == 428:
115
+ raise HTTPException(
116
+ status_code=428,
117
+ detail="Please connect your Gmail account"
118
+ )
119
+ raise
 
 
 
 
 
 
 
 
 
120
 
121
  return manager.email_id
src/main.py CHANGED
@@ -17,16 +17,6 @@ from fastapi.staticfiles import StaticFiles
17
 
18
  app = FastAPI(title="Yuvabe App API")
19
 
20
- # app.add_middleware(
21
- # CORSMiddleware,
22
- # # REMOVE: allow_origins=["*"],
23
- # # ADD THIS: Regex to match localhost on ANY port
24
- # allow_origin_regex="https?://(?:localhost|127\.0\.0\.1)(?::\d+)?",
25
- # allow_credentials=True,
26
- # allow_methods=["*"],
27
- # allow_headers=["*"],
28
- # )
29
-
30
 
31
  @app.on_event("startup")
32
  async def on_startup():
 
17
 
18
  app = FastAPI(title="Yuvabe App API")
19
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  @app.on_event("startup")
22
  async def on_startup():
src/payslip/googleservice.py CHANGED
@@ -18,15 +18,8 @@ def exchange_code_for_tokens(code: str):
18
  "grant_type": "authorization_code",
19
  "redirect_uri": settings.GOOGLE_REDIRECT_URI,
20
  }
21
- print(f"🔄 Exchanging code with Redirect URI: {settings.GOOGLE_REDIRECT_URI}")
22
- response = requests.post(settings.TOKEN_URL, data=data)
23
- if response.status_code != 200:
24
- error_text = response.text
25
- print(
26
- f"❌ GOOGLE TOKEN ERROR: {error_text}"
27
- ) # This will show in your Hugging Face logs
28
- raise HTTPException(status_code=400, detail=f"Google Error: {error_text}")
29
 
 
30
  return response.json()
31
 
32
 
 
18
  "grant_type": "authorization_code",
19
  "redirect_uri": settings.GOOGLE_REDIRECT_URI,
20
  }
 
 
 
 
 
 
 
 
21
 
22
+ response = requests.post(settings.TOKEN_URL, data=data)
23
  return response.json()
24
 
25
 
src/payslip/models.py CHANGED
@@ -1,9 +1,9 @@
1
  import uuid
2
  from datetime import datetime
3
  from enum import Enum
4
- from typing import Optional, List
5
 
6
- from sqlalchemy.dialects.postgresql import UUID, JSON
7
  from sqlalchemy import Column
8
  from sqlmodel import SQLModel, Field, ForeignKey
9
 
@@ -14,11 +14,6 @@ class PayslipStatus(str, Enum):
14
  FAILED = "Failed"
15
 
16
 
17
- class RequestType(str, Enum):
18
- PAYSLIP = "payslip"
19
- LUNCH = "lunch"
20
-
21
-
22
  class PayslipRequest(SQLModel, table=True):
23
  __tablename__ = "payslip_requests"
24
 
@@ -39,9 +34,3 @@ class PayslipRequest(SQLModel, table=True):
39
  refresh_token: Optional[str] = None
40
 
41
  error_message: Optional[str] = None
42
-
43
- request_type: str = Field(default=RequestType.PAYSLIP)
44
-
45
- # Stores list of dates like ["2024-01-01", "2024-01-02"]
46
- # We use JSON type to store arrays in Postgres
47
- target_dates: Optional[List[str]] = Field(default=None, sa_column=Column(JSON))
 
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
 
 
14
  FAILED = "Failed"
15
 
16
 
 
 
 
 
 
17
  class PayslipRequest(SQLModel, table=True):
18
  __tablename__ = "payslip_requests"
19
 
 
34
  refresh_token: Optional[str] = None
35
 
36
  error_message: Optional[str] = None
 
 
 
 
 
 
src/payslip/router.py CHANGED
@@ -24,18 +24,11 @@ from src.payslip.utils import encrypt_token
24
  router = APIRouter(prefix="/payslips", tags=["Payslips & Gmail"])
25
 
26
 
27
- # Add platform parameter with default="mobile"
28
  @router.get("/gmail/connect-url")
29
- async def gmail_connect_url(
30
- user_id: uuid.UUID, platform: str = "mobile", redirect_path: str = "/payslip"
31
- ):
32
  """
33
- Returns the Google OAuth URL.
34
- State format: "user_id|platform" (e.g., "123e4567-e89b...|web")
35
  """
36
- # 1. Combine ID and Platform into one state string
37
- combined_state = f"{user_id}|{platform}|{redirect_path}"
38
-
39
  params = {
40
  "client_id": settings.GOOGLE_CLIENT_ID,
41
  "redirect_uri": settings.GOOGLE_REDIRECT_URI,
@@ -43,7 +36,7 @@ async def gmail_connect_url(
43
  "scope": "openid email profile https://www.googleapis.com/auth/gmail.send",
44
  "access_type": "offline",
45
  "prompt": "consent",
46
- "state": combined_state, # 👈 Send combined state
47
  }
48
  return {"auth_url": f"{settings.AUTH_BASE}?{urlencode(params)}"}
49
 
@@ -54,90 +47,34 @@ async def gmail_callback(
54
  ):
55
  from fastapi.responses import RedirectResponse
56
 
57
- # --- 1. PARSE STATE ---
58
- # Try to split "uuid|platform". If it fails (old app version), assume mobile.
59
- parts = state.split("|")
60
-
61
- if len(parts) >= 3:
62
- user_id_str = parts[0]
63
- platform = parts[1]
64
- redirect_path = parts[2]
65
- elif len(parts) == 2:
66
- # Backward compatibility (old mobile app versions)
67
- user_id_str = parts[0]
68
- platform = parts[1]
69
- redirect_path = "/payslip"
70
- else:
71
- # Fallback
72
- user_id_str = state
73
- platform = "mobile"
74
- redirect_path = "/payslip"
75
-
76
- try:
77
- user_id = uuid.UUID(user_id_str)
78
- except ValueError:
79
- return RedirectResponse(
80
- "yuvabe://gmail/callback?success=false&error=invalid_state"
81
- )
82
- # --- 2. DEFINE BASE URL BASED ON PLATFORM ---
83
- if platform == "web":
84
- # Point to your React Web App URL (Localhost or Production)
85
- # Ideally, put "http://localhost:5173" in your settings.py as WEB_BASE_URL
86
- base_redirect = f"{settings.WEB_FRONTEND_URL}{redirect_path}"
87
- else:
88
- # Default Mobile Deep Link
89
- base_redirect = "yuvabe://gmail/callback"
90
-
91
- # Helper to build the final URL cleanly
92
- def build_redirect(success: bool, error: str = None, message: str = None):
93
- params = {"success": str(success).lower()}
94
- if error:
95
- params["error"] = error
96
- if message:
97
- params["message"] = message
98
- return f"{base_redirect}?{urlencode(params)}"
99
-
100
- # --- 3. EXISTING LOGIC (Updated to use build_redirect) ---
101
  user = await session.get(Users, user_id)
 
102
  if not user:
103
  return RedirectResponse(
104
- build_redirect(False, "user_not_found", "No such user exists")
105
  )
106
 
107
  try:
108
  token_data = exchange_code_for_tokens(code)
109
  google_email = extract_email_from_id_token(token_data["id_token"])
110
- except Exception as e:
111
- # ---------------------------------------------------------
112
- # 🚩 NEW DEBUG LOGGING
113
- # ---------------------------------------------------------
114
- print("------------------------------------------------")
115
- print(f"❌ OAUTH CRITICAL FAILURE: {str(e)}")
116
- print("------------------------------------------------")
117
-
118
- # Pass the actual error message to the frontend for debugging
119
- error_msg = str(e).replace("400: ", "") # Clean up message slightly
120
- return RedirectResponse(build_redirect(False, "invalid_code", error_msg))
121
-
122
- # Gmail Mismatch
123
  if google_email.lower() != user.email_id.lower():
124
  return RedirectResponse(
125
- build_redirect(
126
- False,
127
- "email_mismatch",
128
- "Google account does not match registered email",
129
- )
130
  )
131
 
132
  refresh_token = token_data.get("refresh_token")
133
  if not refresh_token:
134
  return RedirectResponse(
135
- build_redirect(
136
- False, "no_refresh_token", "No refresh token returned from Google"
137
- )
138
  )
139
 
140
- # ... (Database saving logic stays exactly the same) ...
141
  q = (
142
  select(PayslipRequest)
143
  .where(PayslipRequest.user_id == user_id)
@@ -159,9 +96,8 @@ async def gmail_callback(
159
 
160
  await session.commit()
161
 
162
- # Success Redirect
163
  return RedirectResponse(
164
- build_redirect(True, message="gmail_connected_successfully")
165
  )
166
 
167
 
 
24
  router = APIRouter(prefix="/payslips", tags=["Payslips & Gmail"])
25
 
26
 
 
27
  @router.get("/gmail/connect-url")
28
+ async def gmail_connect_url(user_id: uuid.UUID):
 
 
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,
 
36
  "scope": "openid email profile https://www.googleapis.com/auth/gmail.send",
37
  "access_type": "offline",
38
  "prompt": "consent",
39
+ "state": str(user_id),
40
  }
41
  return {"auth_url": f"{settings.AUTH_BASE}?{urlencode(params)}"}
42
 
 
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)
 
96
 
97
  await session.commit()
98
 
 
99
  return RedirectResponse(
100
+ "yuvabe://gmail/callback?success=true&message=gmail_connected_successfully"
101
  )
102
 
103
 
src/payslip/service.py CHANGED
@@ -22,19 +22,13 @@ from src.payslip.utils import encrypt_token
22
  async def user_team_name(session: AsyncSession, user_id):
23
  """Return user's team name."""
24
  q = select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
25
- mappings = (await session.execute(q)).scalars().all()
26
 
27
- if not mappings:
28
- return "Unknown Team"
29
-
30
- team_ids = [m.team_id for m in mappings]
31
- q_teams = select(Teams.name).where(Teams.id.in_(team_ids))
32
- team_names = (await session.execute(q_teams)).scalars().all()
33
-
34
- if not team_names:
35
  return "Unknown Team"
36
 
37
- return ", ".join(team_names)
 
38
 
39
 
40
  async def one_request_per_day(session: AsyncSession, user_id):
@@ -49,7 +43,6 @@ async def one_request_per_day(session: AsyncSession, user_id):
49
  PayslipRequest.user_id == user_id,
50
  PayslipRequest.requested_at >= today_start,
51
  PayslipRequest.status != PayslipStatus.PENDING,
52
- PayslipRequest.request_type == "payslip"
53
  )
54
 
55
  result = await session.execute(q)
@@ -80,7 +73,7 @@ async def get_latest_payslip_row(session: AsyncSession, user_id):
80
  """
81
  q = (
82
  select(PayslipRequest)
83
- .where(PayslipRequest.user_id == user_id,PayslipRequest.request_type == "payslip")
84
  .order_by(PayslipRequest.requested_at.desc())
85
  )
86
  return (await session.execute(q)).scalar_one_or_none()
 
22
  async def user_team_name(session: AsyncSession, user_id):
23
  """Return user's team name."""
24
  q = select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
25
+ mapping = (await session.execute(q)).scalar_one_or_none()
26
 
27
+ if not mapping:
 
 
 
 
 
 
 
28
  return "Unknown Team"
29
 
30
+ team = await session.get(Teams, mapping.team_id)
31
+ return team.name if team else "Unknown Team"
32
 
33
 
34
  async def one_request_per_day(session: AsyncSession, user_id):
 
43
  PayslipRequest.user_id == user_id,
44
  PayslipRequest.requested_at >= today_start,
45
  PayslipRequest.status != PayslipStatus.PENDING,
 
46
  )
47
 
48
  result = await session.execute(q)
 
73
  """
74
  q = (
75
  select(PayslipRequest)
76
+ .where(PayslipRequest.user_id == user_id)
77
  .order_by(PayslipRequest.requested_at.desc())
78
  )
79
  return (await session.execute(q)).scalar_one_or_none()
src/profile/notify.py CHANGED
@@ -1,6 +1,5 @@
1
  from src.notifications.service import get_user_device_tokens
2
  from src.notifications.fcm import send_fcm
3
- from src.core.models import Users
4
 
5
 
6
  def ensure_list(value):
@@ -45,7 +44,6 @@ async def send_leave_request_notification(session, user, leave, mentor_ids, lead
45
  "type": "leave_request",
46
  "screen": "MentorApproval",
47
  "leave_id": str(leave.id),
48
- "url": f"/mentor-approval/{leave.id}",
49
  },
50
  priority="high",
51
  )
@@ -68,7 +66,6 @@ async def send_leave_request_notification(session, user, leave, mentor_ids, lead
68
  "type": "leave_request",
69
  "screen": "LeaveDetails",
70
  "leave_id": str(leave.id),
71
- "url": f"/leave-details/{leave.id}",
72
  },
73
  priority="high",
74
  )
@@ -78,10 +75,6 @@ async def send_leave_request_notification(session, user, leave, mentor_ids, lead
78
  # SEND TO USER + TEAM LEAD
79
  # -------------------------------
80
  async def send_leave_status_notification(session, leave, mentor_name, lead_ids):
81
-
82
- user = await session.get(Users, leave.user_id)
83
- # Fallback to "Unknown" if the user isn't found for some reason
84
- user_display_name = user.user_name if user else "Unknown User"
85
  title = "Leave status"
86
  body = f"Your leave was {leave.status.lower()} by {mentor_name}"
87
 
@@ -104,7 +97,6 @@ async def send_leave_status_notification(session, leave, mentor_name, lead_ids):
104
  "type": "leave_status",
105
  "screen": "LeaveDetails",
106
  "leave_id": str(leave.id),
107
- "url": f"/leave-details/{leave.id}",
108
  },
109
  priority="high",
110
  )
@@ -113,12 +105,11 @@ async def send_leave_status_notification(session, leave, mentor_name, lead_ids):
113
  await send_fcm(
114
  lead_tokens,
115
  title,
116
- f"Leave {leave.status} for user {user_display_name} by {mentor_name}",
117
  {
118
  "type": "lead_update",
119
  "screen": "LeaveDetails",
120
  "leave_id": str(leave.id),
121
- "url": f"/leave-details/{leave.id}",
122
  },
123
  priority="high",
124
  )
 
1
  from src.notifications.service import get_user_device_tokens
2
  from src.notifications.fcm import send_fcm
 
3
 
4
 
5
  def ensure_list(value):
 
44
  "type": "leave_request",
45
  "screen": "MentorApproval",
46
  "leave_id": str(leave.id),
 
47
  },
48
  priority="high",
49
  )
 
66
  "type": "leave_request",
67
  "screen": "LeaveDetails",
68
  "leave_id": str(leave.id),
 
69
  },
70
  priority="high",
71
  )
 
75
  # SEND TO USER + TEAM LEAD
76
  # -------------------------------
77
  async def send_leave_status_notification(session, leave, mentor_name, lead_ids):
 
 
 
 
78
  title = "Leave status"
79
  body = f"Your leave was {leave.status.lower()} by {mentor_name}"
80
 
 
97
  "type": "leave_status",
98
  "screen": "LeaveDetails",
99
  "leave_id": str(leave.id),
 
100
  },
101
  priority="high",
102
  )
 
105
  await send_fcm(
106
  lead_tokens,
107
  title,
108
+ f"Leave {leave.status} for user {leave.user_id}",
109
  {
110
  "type": "lead_update",
111
  "screen": "LeaveDetails",
112
  "leave_id": str(leave.id),
 
113
  },
114
  priority="high",
115
  )
src/profile/router.py CHANGED
@@ -119,8 +119,8 @@ async def get_leave_balance(
119
  )
120
  results = (await session.exec(stmt)).all()
121
 
122
- sick_used = sum(l.days for l in results if l.leave_type == LeaveType.SICK)
123
- casual_used = sum(l.days for l in results if l.leave_type == LeaveType.CASUAL)
124
 
125
  sick_remaining = SICK_LIMIT - sick_used
126
  casual_remaining = CASUAL_LIMIT - casual_used
@@ -145,10 +145,9 @@ async def get_leave_balance_for_user(
145
  )
146
  results = (await session.exec(stmt)).all()
147
 
148
- sick_used = sum(l.days for l in results if l.leave_type == LeaveType.SICK)
149
- casual_used = sum(l.days for l in results if l.leave_type == LeaveType.CASUAL)
150
 
151
-
152
  sick_remaining = SICK_LIMIT - sick_used
153
  casual_remaining = CASUAL_LIMIT - casual_used
154
 
@@ -175,7 +174,7 @@ async def list_notifications(
175
  | (Leave.mentor_id == user_id)
176
  | (Leave.lead_id == user_id)
177
  )
178
- .order_by(Leave.is_read.asc(), desc(Leave.updated_at))
179
  )
180
 
181
  results = (await session.exec(stmt)).all()
@@ -312,6 +311,8 @@ async def mentor_pending_leaves(
312
  ):
313
  mentor_uuid = uuid.UUID(mentor_id)
314
 
 
 
315
  mentor_team = (
316
  await session.exec(
317
  select(UserTeamsRole).where(UserTeamsRole.user_id == mentor_uuid)
@@ -363,9 +364,7 @@ async def mentor_pending_leaves(
363
  lead_id=str(leave.lead_id),
364
  user_name=user_name,
365
  updated_at=(leave.updated_at.isoformat() if leave.updated_at else None),
366
- requested_at=(
367
- leave.requested_at.isoformat() if leave.requested_at else None
368
- ),
369
  )
370
  )
371
 
@@ -574,7 +573,6 @@ async def get_profile_details(
574
  data={
575
  "name": user.user_name,
576
  "email": user.email_id,
577
- "nick_name": user.nick_name,
578
  "team_name": team.name,
579
  "lead_label": lead_label, # 🔥 Frontend uses this
580
  "lead_name": final_lead_name, # 🔥 Frontend uses this
@@ -709,9 +707,6 @@ async def update_profile(
709
  if body.email is not None:
710
  user.email_id = body.email
711
 
712
- if body.nick_name is not None:
713
- user.nick_name = body.nick_name
714
-
715
  if body.dob is not None:
716
  dob_str = body.dob.replace(".", "-") # convert dots to dashes
717
  try:
@@ -756,7 +751,6 @@ async def update_profile(
756
  "id": str(user.id),
757
  "name": user.user_name,
758
  "email": user.email_id,
759
- "nick_name": user.nick_name,
760
  "dob": getattr(user, "dob", None),
761
  "address": getattr(user, "address", None),
762
  }
 
119
  )
120
  results = (await session.exec(stmt)).all()
121
 
122
+ sick_used = sum(1 for l in results if l.leave_type == LeaveType.SICK)
123
+ casual_used = sum(1 for l in results if l.leave_type == LeaveType.CASUAL)
124
 
125
  sick_remaining = SICK_LIMIT - sick_used
126
  casual_remaining = CASUAL_LIMIT - casual_used
 
145
  )
146
  results = (await session.exec(stmt)).all()
147
 
148
+ sick_used = sum(1 for l in results if l.leave_type == LeaveType.SICK)
149
+ casual_used = sum(1 for l in results if l.leave_type == LeaveType.CASUAL)
150
 
 
151
  sick_remaining = SICK_LIMIT - sick_used
152
  casual_remaining = CASUAL_LIMIT - casual_used
153
 
 
174
  | (Leave.mentor_id == user_id)
175
  | (Leave.lead_id == user_id)
176
  )
177
+ .order_by(desc(Leave.updated_at))
178
  )
179
 
180
  results = (await session.exec(stmt)).all()
 
311
  ):
312
  mentor_uuid = uuid.UUID(mentor_id)
313
 
314
+
315
+
316
  mentor_team = (
317
  await session.exec(
318
  select(UserTeamsRole).where(UserTeamsRole.user_id == mentor_uuid)
 
364
  lead_id=str(leave.lead_id),
365
  user_name=user_name,
366
  updated_at=(leave.updated_at.isoformat() if leave.updated_at else None),
367
+ requested_at=leave.requested_at.isoformat() if leave.requested_at else None,
 
 
368
  )
369
  )
370
 
 
573
  data={
574
  "name": user.user_name,
575
  "email": user.email_id,
 
576
  "team_name": team.name,
577
  "lead_label": lead_label, # 🔥 Frontend uses this
578
  "lead_name": final_lead_name, # 🔥 Frontend uses this
 
707
  if body.email is not None:
708
  user.email_id = body.email
709
 
 
 
 
710
  if body.dob is not None:
711
  dob_str = body.dob.replace(".", "-") # convert dots to dashes
712
  try:
 
751
  "id": str(user.id),
752
  "name": user.user_name,
753
  "email": user.email_id,
 
754
  "dob": getattr(user, "dob", None),
755
  "address": getattr(user, "address", None),
756
  }
src/profile/schemas.py CHANGED
@@ -111,7 +111,7 @@ class UpdateProfileRequest(BaseModel):
111
  email: Optional[EmailStr] = None
112
  dob: Optional[str] = None
113
  address: Optional[str] = None
114
- nick_name: Optional[str] = None
115
  current_password: Optional[str] = None
116
  new_password: Optional[str] = None
117
 
 
111
  email: Optional[EmailStr] = None
112
  dob: Optional[str] = None
113
  address: Optional[str] = None
114
+
115
  current_password: Optional[str] = None
116
  new_password: Optional[str] = None
117
 
src/profile/service.py CHANGED
@@ -14,7 +14,6 @@ from src.profile.models import (
14
  Leave,
15
  UserDevices,
16
  )
17
- from sqlalchemy import select, and_, or_, not_
18
  from src.profile.notify import send_leave_request_notification
19
 
20
  from src.profile.schemas import CreateLeaveRequest, LeaveStatus, ApproveRejectRequest
@@ -39,7 +38,7 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
39
  await session.exec(
40
  select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
41
  )
42
- ).scalars().first()
43
 
44
  if not user_team:
45
  raise ValueError("User has no team mapping")
@@ -47,10 +46,10 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
47
  # 2) Get Mentor role
48
  mentor_role = (
49
  await session.exec(select(Roles).where(Roles.name == "Mentor"))
50
- ).scalars().first()
51
  sub_mentor_role = (
52
  await session.exec(select(Roles).where(Roles.name == "Sub Mentor"))
53
- ).scalars().first()
54
 
55
  if not mentor_role and not sub_mentor_role:
56
  raise ValueError("No Mentor / Sub Mentor roles defined in system")
@@ -64,7 +63,7 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
64
  # 3) Get Team Lead role
65
  lead_role = (
66
  await session.exec(select(Roles).where(Roles.name == "Team Lead"))
67
- ).scalars().first()
68
 
69
  # 4) Find mentor in same team
70
  mentor_users = (
@@ -74,7 +73,7 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
74
  .where(UserTeamsRole.team_id == user_team.team_id)
75
  .where(UserTeamsRole.role_id.in_(mentor_role_ids))
76
  )
77
- ).scalars().all()
78
 
79
  if not mentor_users:
80
  raise ValueError("No Mentor or Sub Mentor found in user's team")
@@ -89,7 +88,7 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
89
  .where(UserTeamsRole.team_id == user_team.team_id)
90
  .where(UserTeamsRole.role_id == lead_role.id)
91
  )
92
- ).scalars().all()
93
 
94
  return mentor_users, lead_users
95
 
@@ -112,26 +111,6 @@ async def _get_tokens_for_users(
112
 
113
 
114
  async def create_leave(session, user_id, body):
115
-
116
- overlap_query = select(Leave).where(
117
- and_(
118
- Leave.user_id == user_id,
119
- # We use a list of strings if LeaveStatus is an Enum/String
120
- Leave.status.notin_([LeaveStatus.REJECTED, LeaveStatus.CANCELLED]),
121
- Leave.from_date <= body.to_date,
122
- Leave.to_date >= body.from_date,
123
- )
124
- )
125
-
126
- # Use .exec() to match your existing style
127
- result = await session.exec(overlap_query)
128
- existing_overlap = result.scalars().first()
129
-
130
- if existing_overlap:
131
- raise HTTPException(
132
- status_code=400,
133
- detail=f"You have already submitted a leave request that covers {body.from_date} to {body.to_date}. Please check your leave history.",
134
- )
135
  # Get the user
136
  user = await session.get(Users, user_id)
137
 
 
14
  Leave,
15
  UserDevices,
16
  )
 
17
  from src.profile.notify import send_leave_request_notification
18
 
19
  from src.profile.schemas import CreateLeaveRequest, LeaveStatus, ApproveRejectRequest
 
38
  await session.exec(
39
  select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
40
  )
41
+ ).first()
42
 
43
  if not user_team:
44
  raise ValueError("User has no team mapping")
 
46
  # 2) Get Mentor role
47
  mentor_role = (
48
  await session.exec(select(Roles).where(Roles.name == "Mentor"))
49
+ ).first()
50
  sub_mentor_role = (
51
  await session.exec(select(Roles).where(Roles.name == "Sub Mentor"))
52
+ ).first()
53
 
54
  if not mentor_role and not sub_mentor_role:
55
  raise ValueError("No Mentor / Sub Mentor roles defined in system")
 
63
  # 3) Get Team Lead role
64
  lead_role = (
65
  await session.exec(select(Roles).where(Roles.name == "Team Lead"))
66
+ ).first()
67
 
68
  # 4) Find mentor in same team
69
  mentor_users = (
 
73
  .where(UserTeamsRole.team_id == user_team.team_id)
74
  .where(UserTeamsRole.role_id.in_(mentor_role_ids))
75
  )
76
+ ).all()
77
 
78
  if not mentor_users:
79
  raise ValueError("No Mentor or Sub Mentor found in user's team")
 
88
  .where(UserTeamsRole.team_id == user_team.team_id)
89
  .where(UserTeamsRole.role_id == lead_role.id)
90
  )
91
+ ).all()
92
 
93
  return mentor_users, lead_users
94
 
 
111
 
112
 
113
  async def create_leave(session, user_id, body):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  # Get the user
115
  user = await session.get(Users, user_id)
116