Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
0db4d3d
Use OMI_API_URL for auth instead of dedicated Cloud Run service
beastoin Mar 4, 2026
fe6d87a
Use dynamic redirect_uri in auth callback template
beastoin Mar 4, 2026
05f43bf
Pass redirect_uri from session to auth callback template
beastoin Mar 4, 2026
103ef6f
Validate redirect_uri against allowed app URL schemes
beastoin Mar 4, 2026
b2faf43
Add client-side redirect scheme validation and safe serialization
beastoin Mar 4, 2026
53318e0
Add auth endpoint tests for redirect_uri validation and template rend…
beastoin Mar 4, 2026
1e2ca51
Add auth route tests to test.sh
beastoin Mar 4, 2026
f8ad416
Add Admin SDK fallback for custom token generation
beastoin Mar 5, 2026
a2dafb0
Add POST /v1/conversations/from-segments with user auth
beastoin Mar 5, 2026
38599d1
Add tests for from-segments endpoint (16 tests)
beastoin Mar 5, 2026
29cdee6
Add chat session list/update and message save/rating DB functions
beastoin Mar 5, 2026
0acc57d
Add desktop chat sessions CRUD + message rating endpoints
beastoin Mar 5, 2026
e485cf4
Register desktop_chat router in main.py
beastoin Mar 5, 2026
112cfab
Add tests for desktop chat endpoints (18 tests)
beastoin Mar 5, 2026
e5eae14
Fix chat sessions list — client-side sort to avoid composite index
beastoin Mar 5, 2026
af49fd9
Fix reviewer issues in database/chat.py — remove duplicate update_mes…
beastoin Mar 5, 2026
842626a
Fix PATCH response to return full ChatSessionResponse, add cascade de…
beastoin Mar 5, 2026
a8248b4
Update tests for reviewer fixes — verify cascade delete and full sess…
beastoin Mar 5, 2026
28c1cda
Add endpoint-level tests for from-segments boundary cases and error p…
beastoin Mar 5, 2026
08f9328
Add desktop chat tests for not-found, session link failure, query bou…
beastoin Mar 5, 2026
00e91d2
Add POST /v1/screen-activity/sync endpoint
beastoin Mar 5, 2026
bdc4d2c
Register screen_activity router in main.py
beastoin Mar 5, 2026
c0b4185
Add assistant_settings and ai_user_profile database functions
beastoin Mar 5, 2026
9c34e9e
Add assistant-settings and ai-profile endpoints to users router
beastoin Mar 5, 2026
129f5e1
Add explicit Firestore error handling in screen-activity sync
beastoin Mar 5, 2026
fcc55c9
Use update() for ai_user_profile full replacement
beastoin Mar 5, 2026
0ef223f
Strict RFC3339 validation and timestamp storage for ai-profile
beastoin Mar 5, 2026
cb68247
Use regex for strict RFC3339 validation on ai-profile timestamp
beastoin Mar 5, 2026
eec3e24
Narrow ai_user_profile fallback to NotFound only
beastoin Mar 5, 2026
420fea0
Use code-based not-found check instead of importing NotFound
beastoin Mar 5, 2026
6fbbb67
Consolidate desktop chat endpoints into existing chat router
beastoin Mar 5, 2026
e6df530
Remove desktop_chat.py — endpoints consolidated into chat.py
beastoin Mar 5, 2026
1c006b3
Remove desktop_chat import from main.py
beastoin Mar 5, 2026
40aba05
Update desktop chat tests to import from routers.chat
beastoin Mar 5, 2026
a171b89
Update Swift saveMessage to use /v2/messages/save endpoint
beastoin Mar 5, 2026
f7376d1
Add unit tests for screen-activity sync, assistant-settings, and ai-p…
beastoin Mar 5, 2026
4ac4ad9
Address tester gaps: add 11 more tests for coverage
beastoin Mar 5, 2026
1625094
Return 404 on rating when message not found
beastoin Mar 5, 2026
5786f3b
Update session metadata when saving messages
beastoin Mar 5, 2026
3031442
Remove unused timezone and input_device_name from from-segments request
beastoin Mar 5, 2026
b9d9032
Update tests for reviewer round 2 fixes
beastoin Mar 5, 2026
b416664
Remove timezone and inputDeviceName from Swift from-segments request
beastoin Mar 5, 2026
ffde130
Remove inputDeviceName param from AppState conversation upload
beastoin Mar 5, 2026
535db37
Remove timezone and inputDeviceName from retry service upload
beastoin Mar 5, 2026
fee7fcb
Add tester-requested boundary and filter tests
beastoin Mar 5, 2026
fd847a6
Add Firestore CRUD for focus sessions
beastoin Mar 5, 2026
278f970
Add Firestore CRUD for advice
beastoin Mar 5, 2026
338a6f4
Add focus sessions router with 4 endpoints
beastoin Mar 5, 2026
53a352a
Add advice router with 5 endpoints
beastoin Mar 5, 2026
f66136b
Register focus_sessions and advice routers in main.py
beastoin Mar 5, 2026
fbb4249
Add 45 unit tests for focus sessions and advice endpoints
beastoin Mar 5, 2026
b20aa9a
Fix advice update not-found and focus date filter cutoff
beastoin Mar 5, 2026
da4ed82
Fix Rust parity issues from CP7 review
beastoin Mar 5, 2026
3d91685
Add staged tasks database layer for desktop migration
beastoin Mar 5, 2026
761b5d4
Add staged tasks + daily scores endpoints for desktop
beastoin Mar 5, 2026
c694212
Wire staged_tasks router into main.py
beastoin Mar 5, 2026
08c44d5
Add 30 tests for staged tasks and daily scores endpoints
beastoin Mar 5, 2026
837dfd7
Fix reviewer issues: dedup on create, filter completed/deleted, weekl…
beastoin Mar 5, 2026
2c1bedd
Make delete endpoint idempotent to match Rust behavior
beastoin Mar 5, 2026
62daa99
Add tests for dedup create, idempotent delete, weekly created_at sema…
beastoin Mar 5, 2026
899acc5
Add created_at DESC tie-break ordering for staged tasks query
beastoin Mar 5, 2026
aede924
Move in-function imports to module top level per CLAUDE.md
beastoin Mar 5, 2026
b67873c
Add 18 tests: DB-layer dedup/filter/scoring, [screen] normalization, …
beastoin Mar 5, 2026
c84e4a2
Fix focus_sessions test fixture: use sys.modules mock + isolated rout…
beastoin Mar 5, 2026
c2e0bdf
Fix advice test fixture: use sys.modules mock + isolated router import
beastoin Mar 5, 2026
9414efb
Add POST /v2/chat/generate-title endpoint for desktop session naming
beastoin Mar 5, 2026
21204a5
Add GET /v1/conversations/count endpoint with Firestore aggregation
beastoin Mar 5, 2026
967f47b
Add unit tests for generate-title and conversations count endpoints
beastoin Mar 5, 2026
e1e2c10
Fix count endpoint: validate statuses limit, use stream fallback
beastoin Mar 5, 2026
a4f9a72
Add tests for statuses validation and stream fallback
beastoin Mar 5, 2026
dbb1dbb
Fix mutable default argument in count/stream_conversations
beastoin Mar 5, 2026
2922c4d
Add boundary tests for fallback truncation and message text limit
beastoin Mar 5, 2026
1e1a42c
Add tests for status whitespace normalization and fallback parity
beastoin Mar 5, 2026
c74570a
Swift desktop: path updates, decoder hardening, no-ops, remove migrat…
beastoin Mar 5, 2026
78d15d2
Fix auth fallback: verify JWT signature, sanitize email PII
beastoin Mar 9, 2026
4ec7217
Add GCE VM creation logic for new users (port from Rust agent.rs)
beastoin Mar 10, 2026
4587490
Add unit tests for /v1/agent/vm-ensure and /v1/agent/vm-status (13 te…
beastoin Mar 10, 2026
23d88ee
Add test_agent_vm.py to test.sh
beastoin Mar 10, 2026
4b8704e
Fix reviewer issues: move imports to top-level, fail-fast on GCE time…
beastoin Mar 10, 2026
f1a912d
Fix test in-function imports per CLAUDE.md style rule
beastoin Mar 10, 2026
c9640aa
Move time import to module level per CLAUDE.md style rule
beastoin Mar 10, 2026
03ddcd8
Add boundary, background-error, and incomplete-payload tests (8 new, …
beastoin Mar 10, 2026
40ae983
Fix test isolation: mock GCE status in incomplete-payload tests
beastoin Mar 10, 2026
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
115 changes: 115 additions & 0 deletions backend/database/advice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import logging
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional

from google.cloud import firestore

from ._client import db

logger = logging.getLogger(__name__)

USERS_COLLECTION = 'users'
ADVICE_SUBCOLLECTION = 'advice'


def _collection_ref(uid: str):
return db.collection(USERS_COLLECTION).document(uid).collection(ADVICE_SUBCOLLECTION)


def create_advice(uid: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new advice document. Returns the created document with id."""
advice_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)

doc_data = {
'content': data['content'],
'category': data.get('category', 'other'),
'confidence': data.get('confidence', 0.5),
'is_read': False,
'is_dismissed': False,
'created_at': now,
}
for optional_field in ('reasoning', 'source_app', 'context_summary', 'current_activity'):
if data.get(optional_field) is not None:
doc_data[optional_field] = data[optional_field]

_collection_ref(uid).document(advice_id).set(doc_data)

doc_data['id'] = advice_id
return doc_data


def get_advice(
uid: str,
limit: int = 100,
offset: int = 0,
category: Optional[str] = None,
include_dismissed: bool = False,
) -> List[Dict[str, Any]]:
"""Query advice, ordered by created_at DESC."""
query = _collection_ref(uid).order_by('created_at', direction=firestore.Query.DESCENDING)

if not include_dismissed:
query = query.where(filter=firestore.FieldFilter('is_dismissed', '==', False))
if category:
query = query.where(filter=firestore.FieldFilter('category', '==', category))

query = query.offset(offset).limit(limit)

results = []
for doc in query.stream():
data = doc.to_dict()
data['id'] = doc.id
results.append(data)
return results


def update_advice(uid: str, advice_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update an advice document (is_read, is_dismissed). Returns updated doc."""
doc_ref = _collection_ref(uid).document(advice_id)

update_data = {'updated_at': datetime.now(timezone.utc)}
if 'is_read' in data:
update_data['is_read'] = data['is_read']
if 'is_dismissed' in data:
update_data['is_dismissed'] = data['is_dismissed']

try:
doc_ref.update(update_data)
except Exception as e:
if hasattr(e, 'code') and e.code == 404:
return None
raise

doc = doc_ref.get()
if doc.exists:
result = doc.to_dict()
result['id'] = doc.id
return result
return None


def delete_advice(uid: str, advice_id: str) -> bool:
"""Delete an advice document. Returns True on success."""
_collection_ref(uid).document(advice_id).delete()
return True


def mark_all_advice_read(uid: str) -> int:
"""Mark all unread, non-dismissed advice as read. Returns count of marked items."""
query = _collection_ref(uid).where(
filter=firestore.FieldFilter('is_dismissed', '==', False)
).where(
filter=firestore.FieldFilter('is_read', '==', False)
).limit(1000)

count = 0
now = datetime.now(timezone.utc)
for doc in query.stream():
try:
doc.reference.update({'is_read': True, 'updated_at': now})
count += 1
except Exception:
logger.warning('Failed to mark advice %s as read for uid=%s', doc.id, uid)
return count
63 changes: 61 additions & 2 deletions backend/database/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,10 +468,69 @@ def delete_chat_session(uid, chat_session_id):
session_ref.delete()


def add_message_to_chat_session(uid: str, chat_session_id: str, message_id: str):
def get_chat_sessions(
uid: str, app_id: Optional[str] = None, limit: int = 50, offset: int = 0, starred: Optional[bool] = None
):
"""List chat sessions with optional filters.

Note: Client-side sort + slice because Firestore composite indexes would be
needed for every filter combination. Acceptable for desktop users (low session
counts). Revisit with server-side ordering if session counts grow large.
"""
sessions_ref = db.collection('users').document(uid).collection('chat_sessions')
if app_id is not None:
sessions_ref = sessions_ref.where(filter=FieldFilter('plugin_id', '==', app_id))
if starred is not None:
sessions_ref = sessions_ref.where(filter=FieldFilter('starred', '==', starred))
sessions = [doc.to_dict() for doc in sessions_ref.stream()]
sessions.sort(key=lambda s: s.get('updated_at', s.get('created_at', datetime.min)), reverse=True)
return sessions[offset : offset + limit]


def update_chat_session(uid: str, chat_session_id: str, update_data: dict):
"""Partial update of a chat session."""
user_ref = db.collection('users').document(uid)
session_ref = user_ref.collection('chat_sessions').document(chat_session_id)
session_ref.update(update_data)


@set_data_protection_level(data_arg_name='message_data')
@prepare_for_write(data_arg_name='message_data', prepare_func=_prepare_data_for_write)
def save_message(uid: str, message_data: dict):
"""Save a message directly by document ID (for desktop CRUD)."""
user_ref = db.collection('users').document(uid)
user_ref.collection('messages').document(message_data['id']).set(message_data)
return message_data


def delete_chat_session_messages(uid: str, chat_session_id: str):
"""Delete all messages belonging to a chat session."""
user_ref = db.collection('users').document(uid)
messages_ref = user_ref.collection('messages').where(filter=FieldFilter('chat_session_id', '==', chat_session_id))
batch = db.batch()
count = 0
for doc in messages_ref.stream():
batch.delete(doc.reference)
count += 1
if count % 400 == 0:
batch.commit()
batch = db.batch()
if count % 400 != 0:
batch.commit()
logger.info(f"Deleted {count} messages for session {chat_session_id}")


def add_message_to_chat_session(uid: str, chat_session_id: str, message_id: str, preview: str = None):
user_ref = db.collection('users').document(uid)
session_ref = user_ref.collection('chat_sessions').document(chat_session_id)
session_ref.update({"message_ids": firestore.ArrayUnion([message_id])})
update_data = {
"message_ids": firestore.ArrayUnion([message_id]),
"updated_at": datetime.now(timezone.utc),
"message_count": firestore.Increment(1),
}
if preview:
update_data["preview"] = preview[:200]
session_ref.update(update_data)


def add_files_to_chat_session(uid: str, chat_session_id: str, file_ids: List[str]):
Expand Down
24 changes: 24 additions & 0 deletions backend/database/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,30 @@ def get_conversations(
return conversations


def count_conversations(uid: str, statuses: Optional[List[str]] = None) -> int:
"""Count conversations matching status filters without fetching full documents."""
if statuses is None:
statuses = []
conversations_ref = db.collection('users').document(uid).collection(conversations_collection)
conversations_ref = conversations_ref.where(filter=FieldFilter('discarded', '==', False))
if statuses:
conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses))
count_query = conversations_ref.count()
results = count_query.get()
return results[0][0].value


def stream_conversations(uid: str, statuses: Optional[List[str]] = None):
"""Yield conversation docs as a stream for counting without loading all into memory."""
if statuses is None:
statuses = []
conversations_ref = db.collection('users').document(uid).collection(conversations_collection)
conversations_ref = conversations_ref.where(filter=FieldFilter('discarded', '==', False))
if statuses:
conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses))
yield from conversations_ref.stream()


@prepare_for_read(decrypt_func=_prepare_conversation_for_read)
def get_conversations_without_photos(
uid: str,
Expand Down
75 changes: 75 additions & 0 deletions backend/database/focus_sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional

from google.cloud import firestore

from ._client import db

logger = logging.getLogger(__name__)

USERS_COLLECTION = 'users'
FOCUS_SESSIONS_SUBCOLLECTION = 'focus_sessions'


def _collection_ref(uid: str):
return db.collection(USERS_COLLECTION).document(uid).collection(FOCUS_SESSIONS_SUBCOLLECTION)


def create_focus_session(uid: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new focus session document. Returns the created document with id."""
session_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)

doc_data = {
'status': data['status'],
'app_or_site': data['app_or_site'],
'description': data['description'],
'created_at': now,
}
if data.get('message') is not None:
doc_data['message'] = data['message']
if data.get('duration_seconds') is not None:
doc_data['duration_seconds'] = data['duration_seconds']

_collection_ref(uid).document(session_id).set(doc_data)

doc_data['id'] = session_id
return doc_data


def get_focus_sessions(
uid: str,
limit: int = 100,
offset: int = 0,
date: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Query focus sessions, ordered by created_at DESC. Optional date filter (YYYY-MM-DD)."""
query = _collection_ref(uid).order_by('created_at', direction=firestore.Query.DESCENDING)

if date:
day_start = datetime.strptime(date, '%Y-%m-%d').replace(tzinfo=timezone.utc)
next_day_start = day_start + timedelta(days=1)
query = query.where(filter=firestore.FieldFilter('created_at', '>=', day_start))
query = query.where(filter=firestore.FieldFilter('created_at', '<', next_day_start))

query = query.offset(offset).limit(limit)

results = []
for doc in query.stream():
data = doc.to_dict()
data['id'] = doc.id
results.append(data)
return results


def delete_focus_session(uid: str, session_id: str) -> bool:
"""Delete a focus session document. Returns True on success."""
_collection_ref(uid).document(session_id).delete()
return True


def get_focus_sessions_for_stats(uid: str, date: str) -> List[Dict[str, Any]]:
"""Get up to 1000 sessions for a date, for stats computation."""
return get_focus_sessions(uid, limit=1000, offset=0, date=date)
Loading