Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,9 @@ class Settings:
REDIS_DB: int = _get_int_env("REDIS_DB", 0)
REDIS_CACHE_TTL: int = _get_int_env("REDIS_CACHE_TTL", 3600)

# Twilio (SMS OTP)
TWILIO_ACCOUNT_SID: str = os.getenv("TWILIO_ACCOUNT_SID", "")
TWILIO_AUTH_TOKEN: str = os.getenv("TWILIO_AUTH_TOKEN", "")
TWILIO_FROM_NUMBER: str = os.getenv("TWILIO_FROM_NUMBER", "")

settings = Settings()
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from routers import webhooks
from routers import search
from routers import user
from routers import phone_number
from config import settings
from services.notification_scheduler import NotificationScheduler
import logging
Expand Down Expand Up @@ -37,6 +38,7 @@
app.include_router(webhooks.router)
app.include_router(search.router)
app.include_router(user.router)
app.include_router(phone_number.router)

@app.get("/")
async def root():
Expand Down
175 changes: 175 additions & 0 deletions backend/routers/phone_number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import hashlib
import logging
import secrets
from datetime import datetime, timezone

import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field

from config import settings
from services.supabase_client import get_supabase_client
from utils.auth import get_current_user

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/user/phone", tags=["phone"])


class StartPhoneOtpRequest(BaseModel):
"""Request payload for starting a phone-number OTP verification flow."""

phone_number: str = Field(..., description="E.164 phone number (e.g. +15551234567).")


class ResendPhoneOtpRequest(BaseModel):
"""Request payload for resending a phone-number OTP."""

phone_number: str | None = Field(
default=None,
description="Optional E.164 phone number. If omitted, the stored pending phone number is used.",
)


def _generate_6_digit_otp() -> str:
"""
Generate a 6-digit numeric OTP as a string.

Returns:
str: A zero-padded 6-digit OTP (e.g. "042381").
"""
return str(secrets.randbelow(1_000_000)).zfill(6)


def _sha256_hex(value: str) -> str:
"""
Compute the SHA-256 hex digest for an input string.

Parameters:
value (str): Input string to hash.

Returns:
str: Lowercase hex SHA-256 digest.
"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()


async def _send_sms_otp(phone_number: str, otp: str) -> None:
"""
Send an OTP SMS to a phone number using Twilio's REST API.

Parameters:
phone_number (str): Destination phone number in E.164 format.
otp (str): 6-digit OTP to send.

Raises:
HTTPException: If Twilio credentials are missing or the API call fails.
"""
if not settings.TWILIO_ACCOUNT_SID or not settings.TWILIO_AUTH_TOKEN or not settings.TWILIO_FROM_NUMBER:
raise HTTPException(status_code=500, detail="Twilio is not configured on the backend")

url = (
f"https://api.twilio.com/2010-04-01/Accounts/{settings.TWILIO_ACCOUNT_SID}/Messages.json"
)
body = f"Your Keepsafe verification code is: {otp}"

async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
url,
data={"To": phone_number, "From": settings.TWILIO_FROM_NUMBER, "Body": body},
auth=(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN),
)

if response.status_code not in (200, 201):
logger.error("Twilio send failed", extra={"status": response.status_code, "body": response.text})
raise HTTPException(status_code=502, detail="Failed to send OTP SMS")


@router.post("/otp/start")
async def start_phone_otp(
payload: StartPhoneOtpRequest,
current_user=Depends(get_current_user),
supabase=Depends(get_supabase_client),
):
"""
Create or replace a pending `phone_number_updates` row and send an OTP SMS.

The row is upserted on `user_id` so each user has at most one pending verification record.
"""
user_id = current_user.user.id
otp = _generate_6_digit_otp()
otp_hash = _sha256_hex(otp)

# Upsert the verification record
now_iso = datetime.now(timezone.utc).isoformat()
try:
supabase.table("phone_number_updates").upsert(
{
"user_id": user_id,
"phone_number": payload.phone_number,
"otp_hash": otp_hash,
"created_at": now_iso,
},
on_conflict="user_id",
).execute()
except Exception as e:
logger.exception("Failed to upsert phone_number_updates row")
raise HTTPException(status_code=500, detail="Failed to create phone verification record") from e

await _send_sms_otp(payload.phone_number, otp)

return {"message": "OTP sent"}


@router.post("/otp/resend")
async def resend_phone_otp(
payload: ResendPhoneOtpRequest,
current_user=Depends(get_current_user),
supabase=Depends(get_supabase_client),
):
"""
Resend a phone-number OTP by regenerating the OTP hash and sending a new SMS.

If `phone_number` is not provided in the request body, the stored pending phone number is used.
"""
user_id = current_user.user.id

phone_number = payload.phone_number
if not phone_number:
try:
res = (
supabase.table("phone_number_updates")
.select("phone_number")
.eq("user_id", user_id)
.execute()
)
except Exception as e:
logger.exception("Failed to fetch existing phone_number_updates row")
raise HTTPException(status_code=500, detail="Failed to fetch pending phone verification record") from e

if not res.data:
raise HTTPException(status_code=404, detail="No pending phone verification record found")

phone_number = res.data[0].get("phone_number")

otp = _generate_6_digit_otp()
otp_hash = _sha256_hex(otp)
now_iso = datetime.now(timezone.utc).isoformat()

try:
supabase.table("phone_number_updates").upsert(
{
"user_id": user_id,
"phone_number": phone_number,
"otp_hash": otp_hash,
"created_at": now_iso,
},
on_conflict="user_id",
).execute()
except Exception as e:
logger.exception("Failed to upsert phone_number_updates row for resend")
raise HTTPException(status_code=500, detail="Failed to recreate phone verification record") from e

await _send_sms_otp(phone_number, otp)
return {"message": "OTP resent"}

24 changes: 21 additions & 3 deletions backend/services/ingestion_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,25 @@ class IngestionService:
"""Service for ingesting entries into the vector database."""

def __init__(self):
self.pinecone_index = get_pinecone_index()
"""
Initialize the ingestion service.

Note:
Pinecone initialization is deferred until first use so unit tests and
environments without Pinecone credentials can still construct this service.
"""
self._pinecone_index = None

def _get_index(self):
"""
Lazily initialize and return the Pinecone index.

Returns:
Any: Pinecone index instance.
"""
if self._pinecone_index is None:
self._pinecone_index = get_pinecone_index()
return self._pinecone_index

async def ingest_entry(self, entry: Dict[str, Any]) -> bool:
"""
Expand Down Expand Up @@ -99,7 +117,7 @@ async def ingest_entry(self, entry: Dict[str, Any]) -> bool:
logger.debug("Metadata: %s", metadata)
# Upsert to Pinecone
logger.info(f"Upserting entry {entry_id} to Pinecone")
self.pinecone_index.upsert(
self._get_index().upsert(
vectors=[{
"id": entry_id,
"values": embedding,
Expand Down Expand Up @@ -137,7 +155,7 @@ async def delete_entry(self, entry_id: str) -> bool:
True if successful, False otherwise
"""
try:
self.pinecone_index.delete(ids=[entry_id])
self._get_index().delete(ids=[entry_id])
logger.info(f"Successfully deleted entry {entry_id} from Pinecone")
return True
except Exception as e:
Expand Down
7 changes: 6 additions & 1 deletion backend/services/notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,12 @@ def _get_user_info_from_tokens(self, tokens: List[str]) -> Dict[str, Dict[str, O

try:
# Query push_tokens table to get user_ids for tokens
response = self.supabase.schema("public").table("push_tokens").select("token, user_id").in_("token", tokens).execute()
response = (
self.supabase.table("push_tokens")
.select("token, user_id")
.in_("token", tokens)
.execute()
)

token_to_user: Dict[str, str] = {}
if response.data:
Expand Down
8 changes: 8 additions & 0 deletions frontend/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* ESLint configuration for the Expo/React Native app.
*/
module.exports = {
root: true,
extends: ['expo'],
};

48 changes: 47 additions & 1 deletion frontend/app/capture/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import { Colors } from '@/lib/constants';
import AudioWaveVisualier from '@/components/audio/audio-wave-visualier';
import { useResponsive } from '@/hooks/use-responsive';
import { logger } from '@/lib/logger';
import PhoneNumberBottomSheet from '@/components/phone-number-bottom-sheet';
import { supabase } from '@/lib/supabase';
import { getPhonePromptState } from '@/services/phone-number-prompt-service';

const { height } = Dimensions.get('window');

Expand All @@ -46,7 +49,8 @@ export default function CaptureScreen() {
const [isCameraReady, setIsCameraReady] = useState(false);
const [cameraMode, setCameraMode] = useState<'picture' | 'video'>('picture');

const { profile } = useAuthContext();
const { profile, user } = useAuthContext();
const [showPhoneSheet, setShowPhoneSheet] = useState(false);

const {
isCapturing,
Expand Down Expand Up @@ -122,6 +126,43 @@ export default function CaptureScreen() {
};
}, []);

// Show the phone-number prompt bottom sheet when entering `/capture` if needed.
useEffect(() => {
let cancelled = false;

const checkShouldShowPhonePrompt = async () => {
if (!user?.id) return;
if (profile?.phone_number) {
if (!cancelled) setShowPhoneSheet(false);
return;
}

// If the user already has a pending OTP record, always show the sheet.
const { data: pendingRecord } = await supabase
.from('phone_number_updates')
.select('id')
.eq('user_id', user.id)
.maybeSingle();

if (pendingRecord?.id) {
if (!cancelled) setShowPhoneSheet(true);
return;
}

const state = await getPhonePromptState(user.id);
const now = Date.now();
const shouldShow =
!state.dontAskAgain && (!state.nextPromptAtMs || now >= state.nextPromptAtMs);

if (!cancelled) setShowPhoneSheet(shouldShow);
};

checkShouldShowPhonePrompt().catch(() => {});
Comment on lines +140 to +160
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find schema/migration files and phone_number_updates references
rg -l "phone_number_updates" --type ts --type tsx --type sql --type js --type jsx

Repository: fortune710/keepsafe

Length of output: 90


🏁 Script executed:

# Search for schema or migrations directory
fd -t d "schema|migration|supabase" | head -20

Repository: fortune710/keepsafe

Length of output: 121


🏁 Script executed:

# Look for phone_number_updates table definition
rg "phone_number_updates" -A 5 -B 2

Repository: fortune710/keepsafe

Length of output: 11654


Improve error handling for pending OTP record query.

The Supabase query uses maybeSingle() but doesn't destructure the error, and the catch block silently swallows errors. While a unique constraint on user_id guarantees at most one row per user, unhandled errors (e.g., RLS or network issues) can silently prevent the phone prompt from appearing. The hook usePhoneNumberUpdateRecord handles this correctly by checking the error.

Consider destructuring and logging errors for observability:

Suggested error handling
-      const { data: pendingRecord } = await supabase
+      const { data: pendingRecord, error: pendingError } = await supabase
         .from('phone_number_updates')
         .select('id')
         .eq('user_id', user.id)
         .maybeSingle();
+      if (pendingError) {
+        logger.warn('Failed to check pending phone updates', pendingError);
+      }
-    checkShouldShowPhonePrompt().catch(() => {});
+    checkShouldShowPhonePrompt().catch((error) => {
+      logger.warn('Failed to determine phone prompt visibility', error);
+    });
🤖 Prompt for AI Agents
In `@frontend/app/capture/index.tsx` around lines 140 - 160, The Supabase query
for pending OTPs in checkShouldShowPhonePrompt uses maybeSingle() but ignores
the returned error, and the final catch silently swallows failures; update the
supabase.from('phone_number_updates').select(...).maybeSingle() call to
destructure both { data: pendingRecord, error } and handle/log the error (e.g.,
processLogger / console.error) so RLS/network issues are observable, then only
proceed to check pendingRecord?.id and call setShowPhoneSheet(…) when not
cancelled; also avoid swallowing errors in the catch by logging or rethrowing to
aid debugging.

return () => {
cancelled = true;
};
}, [profile?.phone_number, user?.id]);

// Cleanup audio recording when component unmounts (navigating away)
useEffect(() => {
return () => {
Expand Down Expand Up @@ -614,6 +655,11 @@ export default function CaptureScreen() {
</ScrollView>
</GestureDetector>
</SafeAreaView>

<PhoneNumberBottomSheet
isVisible={showPhoneSheet}
onClose={() => setShowPhoneSheet(false)}
/>
</Animated.View>
);
}
Expand Down
Loading