Skip to content

Commit d8a0ffe

Browse files
authored
Merge pull request #735 from JustAJobApp/feat/705-persist-tokens
Feat: Add Premium Tier for Auto Refresh
2 parents aaf4799 + 524ac70 commit d8a0ffe

36 files changed

+2056
-1305
lines changed

.github/actions/deploy-backend-to-lightsail/action.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ inputs:
4040
origin:
4141
description: Origin
4242
required: true
43+
token-encryption-key:
44+
description: Fernet key for encrypting OAuth tokens in DB
45+
required: true
4346

4447
runs:
4548
using: composite
@@ -59,6 +62,7 @@ runs:
5962
echo "::add-mask::${{ inputs.google-client-redirect-uri }}"
6063
echo "::add-mask::${{ inputs.ls-database-name }}"
6164
echo "::add-mask::${{ inputs.origin }}"
65+
echo "::add-mask::${{ inputs.token-encryption-key }}"
6266
6367
- name: Set up AWS CLI
6468
uses: aws-actions/configure-aws-credentials@v1
@@ -188,6 +192,7 @@ runs:
188192
APP_URL: ${{ inputs.app-url }}
189193
API_URL: ${{ inputs.api-url }}
190194
ORIGIN: ${{ inputs.origin }}
195+
TOKEN_ENCRYPTION_KEY: ${{ inputs.token-encryption-key }}
191196
shell: bash
192197
run: |
193198
# Mask env vars before use
@@ -201,6 +206,7 @@ runs:
201206
echo "::add-mask::${DATABASE_URL}"
202207
echo "::add-mask::${APP_URL}"
203208
echo "::add-mask::${API_URL}"
209+
echo "::add-mask::${TOKEN_ENCRYPTION_KEY}"
204210
205211
# Deploy to Lightsail using inline JSON
206212
aws lightsail create-container-service-deployment \
@@ -222,7 +228,8 @@ runs:
222228
\"API_URL\": \"${API_URL}\",
223229
\"ENV\": \"prod\",
224230
\"DATABASE_URL\": \"${DATABASE_URL}\",
225-
\"ORIGIN\": \"${ORIGIN}\"
231+
\"ORIGIN\": \"${ORIGIN}\",
232+
\"TOKEN_ENCRYPTION_KEY\": \"${TOKEN_ENCRYPTION_KEY}\"
226233
},
227234
\"ports\": {
228235
\"${CONTAINER_PORT}\": \"HTTP\"

.github/workflows/cd.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ jobs:
3434
google-client-secret: ${{ secrets.GOOGLE_CLIENT_SECRET }}
3535
google-client-redirect-uri: ${{ secrets.GOOGLE_CLIENT_REDIRECT_URI }}
3636
origin: ${{ secrets.ORIGIN }}
37+
token-encryption-key: ${{ secrets.TOKEN_ENCRYPTION_KEY }}
3738

3839
deploy-frontend:
3940
name: Deploy Frontend
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""add always_open fields for background sync
2+
3+
Revision ID: add_always_open_fields
4+
Revises: add_applications_found
5+
Create Date: 2026-02-03
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
import sqlmodel
14+
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "add_always_open_fields"
18+
down_revision: Union[str, None] = "add_applications_found"
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+
"""Add always_open fields to users table for background sync feature."""
25+
26+
# Add sync_tier for tiered scheduling (none/premium)
27+
op.add_column(
28+
"users",
29+
sa.Column(
30+
"sync_tier",
31+
sa.String(20),
32+
nullable=False,
33+
server_default="none",
34+
),
35+
)
36+
37+
# Add last_background_sync_at timestamp
38+
op.add_column(
39+
"users",
40+
sa.Column(
41+
"last_background_sync_at",
42+
sa.DateTime(timezone=True),
43+
nullable=True,
44+
),
45+
)
46+
47+
# Create index on sync_tier for efficient batch queries
48+
op.create_index(
49+
"idx_users_sync_tier",
50+
"users",
51+
["sync_tier"],
52+
postgresql_where=sa.text("sync_tier = 'premium'"),
53+
)
54+
55+
# Set existing active users to onboarding_completed_at = now
56+
# This ensures existing beta users are not forced into the new onboarding flow
57+
print("Setting sync_tier = premium for all existing users with active coach link")
58+
op.execute(sa.text("UPDATE users SET sync_tier = 'premium' WHERE user_id in (" \
59+
"select client_id from coach_client_link where end_date is null)"))
60+
61+
# Drop payment_asks table - no longer used
62+
op.drop_index("idx_payment_asks_user_shown", table_name="payment_asks")
63+
op.drop_table("payment_asks")
64+
65+
66+
67+
def downgrade() -> None:
68+
"""Remove background sync fields from users table."""
69+
# Recreate payment_asks table
70+
op.create_table(
71+
"payment_asks",
72+
sa.Column("id", sa.UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")),
73+
sa.Column("user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
74+
sa.Column("trigger_type", sa.String(100), nullable=False),
75+
sa.Column("trigger_value", sa.String(255), nullable=True),
76+
sa.Column("shown_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
77+
sa.Column("action", sa.String(50), nullable=True),
78+
sa.Column("selected_amount_cents", sa.Integer(), nullable=True),
79+
sa.Column("action_at", sa.DateTime(timezone=True), nullable=True),
80+
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"], ondelete="CASCADE"),
81+
sa.PrimaryKeyConstraint("id"),
82+
)
83+
op.create_index("idx_payment_asks_user_shown", "payment_asks", ["user_id", "shown_at"])
84+
85+
op.drop_index("idx_users_sync_tier", table_name="users")
86+
op.drop_column("users", "last_background_sync_at")
87+
op.drop_column("users", "sync_tier")

backend/db/payment_asks.py

Lines changed: 0 additions & 25 deletions
This file was deleted.

backend/db/users.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ class Users(SQLModel, table=True):
3030
contribution_started_at: datetime | None = Field(default=None, nullable=True) # When they first paid
3131
total_contributed_cents: int = Field(default=0, nullable=False) # Lifetime contributions
3232
stripe_subscription_id: str | None = Field(default=None, nullable=True) # For subscription management
33+
# Background sync fields
34+
sync_tier: str = Field(default="none", nullable=False) # 'none' or 'premium'
35+
last_background_sync_at: datetime | None = Field(default=None, nullable=True) # Last background sync timestamp
3336

3437
class CoachClientLink(SQLModel, table=True):
3538
__tablename__ = "coach_client_link"

backend/main.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,31 @@
1111
from utils.config_utils import get_settings
1212
from contextlib import asynccontextmanager
1313
import database # noqa: F401 - used for dependency injection
14+
from scheduler.background_scheduler import start_scheduler, stop_scheduler
1415
# Import routes
1516
from routes import email_routes, auth_routes, file_routes, users_routes, start_date_routes, job_applications_routes, coach_routes, onboarding_routes, stripe_webhook_routes, payment_routes
1617

18+
# Configure logging early so it's available in lifespan
19+
logger = logging.getLogger(__name__)
20+
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s - %(message)s")
21+
1722
@asynccontextmanager
1823
async def lifespan(app: FastAPI):
19-
# App startup - no dev user seeding (use real OAuth flow for testing)
24+
# App startup
25+
settings = get_settings()
26+
27+
# Start background scheduler for Always Open email sync (production only)
28+
if settings.is_publicly_deployed:
29+
start_scheduler()
30+
logger.info("Background scheduler started for Always Open email sync")
31+
2032
yield
2133

34+
# App shutdown
35+
if settings.is_publicly_deployed:
36+
stop_scheduler()
37+
logger.info("Background scheduler stopped")
38+
2239
app = FastAPI(lifespan=lifespan)
2340
settings = get_settings()
2441
APP_URL = settings.APP_URL
@@ -75,10 +92,6 @@ async def lifespan(app: FastAPI):
7592
)
7693

7794

78-
logger = logging.getLogger(__name__)
79-
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s - %(message)s")
80-
81-
8295
# Rate limit exception handler
8396
@app.exception_handler(RateLimitExceeded)
8497
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
alembic==1.15.1
2+
APScheduler==3.10.4
23
altgraph==0.17.4
34
annotated-types==0.7.0
45
anyio==4.7.0

backend/routes/auth_routes.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from utils.config_utils import get_settings
1212
from utils.cookie_utils import set_conditional_cookie
1313
from utils.credential_service import save_credentials
14+
from utils.billing_utils import is_premium_eligible
1415
from utils.redirect_utils import Redirects
1516
from routes.email_routes import fetch_emails_to_db
1617
import database
@@ -46,8 +47,13 @@ async def login(
4647
)
4748
try:
4849
if not code:
49-
# Check if we have a refresh token in session
50-
has_refresh_token = get_refresh_token_status(request.session.get("creds"))
50+
# Check if we have a refresh token (DB first, then session fallback)
51+
session_user_id = request.session.get("user_id")
52+
has_refresh_token = get_refresh_token_status(
53+
session_creds=request.session.get("creds"),
54+
db_session=db_session,
55+
user_id=session_user_id,
56+
)
5157
authorization_url, state = get_google_authorization_url(
5258
flow, has_refresh_token
5359
)
@@ -87,11 +93,13 @@ async def login(
8793
request.session["access_token"] = creds.token
8894
request.session["creds"] = get_latest_refresh_token(old_creds=request.session.get("creds"), new_creds=creds)
8995

90-
# Persist encrypted credentials to database for background task support
91-
save_credentials(db_session, user.user_id, creds, credential_type="primary")
92-
9396
existing_user, last_fetched_date = user_exists(user, db_session)
94-
97+
98+
# Only persist credentials to DB for premium users (data minimization)
99+
if existing_user and is_premium_eligible(db_session, existing_user):
100+
save_credentials(db_session, user.user_id, creds, credential_type="primary")
101+
logger.info("Saved credentials for premium user %s", user.user_id)
102+
95103
# Default to False for existing users, will be overwritten if needed
96104
request.session["is_new_user"] = False
97105

@@ -231,7 +239,13 @@ async def signup(request: Request, db_session: database.DBSession):
231239
)
232240
try:
233241
if not code:
234-
has_refresh_token = get_refresh_token_status(request.session.get("creds"))
242+
# Check if we have a refresh token (DB first, then session fallback)
243+
session_user_id = request.session.get("user_id")
244+
has_refresh_token = get_refresh_token_status(
245+
session_creds=request.session.get("creds"),
246+
db_session=db_session,
247+
user_id=session_user_id,
248+
)
235249
authorization_url, state = get_google_authorization_url(
236250
flow, has_refresh_token
237251
)
@@ -337,7 +351,12 @@ async def email_sync_auth(
337351
logger.warning("Email sync auth attempted without session. Redirecting to login.")
338352
return Redirects.to_error("auth_required")
339353

340-
has_refresh_token = get_refresh_token_status(request.session.get("email_sync_creds"))
354+
# Check for email_sync credentials (DB first, then session fallback)
355+
has_refresh_token = get_refresh_token_status(
356+
session_creds=request.session.get("email_sync_creds"),
357+
db_session=db_session,
358+
user_id=user_id,
359+
)
341360
authorization_url, state = get_google_authorization_url(
342361
flow, has_refresh_token
343362
)
@@ -375,12 +394,14 @@ async def email_sync_auth(
375394
request.session["token_expiry"] = get_token_expiry(creds)
376395
request.session["access_token"] = creds.token
377396

378-
# Persist encrypted email_sync credentials to database for background task support
379-
save_credentials(db_session, user_id, creds, credential_type="email_sync")
380-
381397
# Update user record with email sync info
382398
user = db_session.exec(select(Users).where(Users.user_id == user_id)).first()
383399
if user:
400+
# Only persist credentials to DB for premium users (data minimization)
401+
if is_premium_eligible(db_session, user):
402+
save_credentials(db_session, user_id, creds, credential_type="email_sync")
403+
logger.info("Saved email_sync credentials for premium user %s", user_id)
404+
384405
user.has_email_sync_configured = True
385406
user.sync_email_address = sync_user.user_email
386407
db_session.add(user)

0 commit comments

Comments
 (0)