Skip to content
Open
Show file tree
Hide file tree
Changes from 161 commits
Commits
Show all changes
170 commits
Select commit Hold shift + click to select a range
9da8845
Add focus analysis handler for desktop screen_frame messages (#5396)
beastoin Mar 7, 2026
4206abe
Add FocusResultEvent message type for desktop proactive AI (#5396)
beastoin Mar 7, 2026
dc9d765
Add screen_frame dispatcher to /v4/listen for desktop focus analysis …
beastoin Mar 7, 2026
f102156
Add 26 unit tests for desktop focus analysis (#5396)
beastoin Mar 7, 2026
e8690fa
Merge focus handler (kai backend) into #5396 trunk
beastoin Mar 7, 2026
616ca11
Add task extraction handler for desktop screen analysis
beastoin Mar 7, 2026
a0da068
Add memory extraction handler for desktop screen analysis
beastoin Mar 7, 2026
8d2b0f8
Add contextual advice handler for desktop screen analysis
beastoin Mar 7, 2026
51ed561
Add live notes handler for desktop transcript processing
beastoin Mar 7, 2026
4e3968f
Add user profile generation handler for desktop
beastoin Mar 7, 2026
a2138ca
Add task reranking and deduplication handlers for desktop
beastoin Mar 7, 2026
ecf3523
Add message event classes for all desktop handler types
beastoin Mar 7, 2026
3cac01d
Add full desktop dispatcher for screen_frame and text message types
beastoin Mar 7, 2026
5875ba7
Add unit tests for task extraction handler (18 tests)
beastoin Mar 7, 2026
aa330be
Add unit tests for memory extraction handler (14 tests)
beastoin Mar 7, 2026
8c0ebbb
Add unit tests for advice handler (14 tests)
beastoin Mar 7, 2026
5a32e04
Add unit tests for live notes handler (10 tests)
beastoin Mar 7, 2026
bbfe287
Add unit tests for profile handler (9 tests)
beastoin Mar 7, 2026
10112df
Add unit tests for task rerank and dedup handlers (16 tests)
beastoin Mar 7, 2026
7dd8fa5
Add all desktop handler tests to test.sh
beastoin Mar 7, 2026
cc43138
Add BackendProactiveService for server-side proactive AI (#5396)
beastoin Mar 8, 2026
056a352
Wire FocusAssistant to BackendProactiveService instead of GeminiClien…
beastoin Mar 8, 2026
a3a8dfa
Create BackendProactiveService in ProactiveAssistantsPlugin lifecycle…
beastoin Mar 8, 2026
01e323d
Update FocusTestRunnerWindow for new FocusAssistant init signature (#…
beastoin Mar 8, 2026
bc3abaf
Add all 8 message types to BackendProactiveService (#5396)
beastoin Mar 8, 2026
d1fcb80
Wire TaskAssistant thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
cc33cbd
Wire MemoryAssistant thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
0456a62
Wire AdviceAssistant thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
2bad746
Wire TaskDeduplicationService thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
0e0492b
Wire TaskPrioritizationService thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
289e41a
Wire AIUserProfileService thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
4c92d5b
Wire ProactiveAssistantsPlugin to pass backendService to all assistan…
beastoin Mar 8, 2026
aba6be4
Wire LiveNotesMonitor thin client for Phase 2 (#5396)
beastoin Mar 9, 2026
b985003
Wire LiveNotesMonitor in ProactiveAssistantsPlugin (#5396)
beastoin Mar 9, 2026
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
ea7021c
Add BackendTranscriptionService for /v4/listen WebSocket
beastoin Mar 6, 2026
8c28e58
Add mono output mode and fix single-source operation in AudioMixer
beastoin Mar 6, 2026
26a377f
Switch BleAudioService to closure-based audio sink
beastoin Mar 6, 2026
e02a820
Route desktop STT through backend /v4/listen in AppState
beastoin Mar 6, 2026
e80ec70
Use BackendTranscriptionService for push-to-talk
beastoin Mar 6, 2026
3824c4a
Remove old transcriptionService parameter from AudioSourceManager
beastoin Mar 6, 2026
2152e69
Remove DEEPGRAM_API_KEY from desktop .env.example
beastoin Mar 6, 2026
e2a8857
Add changelog entry for backend STT migration
beastoin Mar 6, 2026
2e76c8e
Add focus analysis handler for desktop screen_frame messages (#5396)
beastoin Mar 7, 2026
f636720
Add FocusResultEvent message type for desktop proactive AI (#5396)
beastoin Mar 7, 2026
beb5f6e
Add screen_frame dispatcher to /v4/listen for desktop focus analysis …
beastoin Mar 7, 2026
e3c970d
Add 26 unit tests for desktop focus analysis (#5396)
beastoin Mar 7, 2026
44248b0
Add task extraction handler for desktop screen analysis
beastoin Mar 7, 2026
2aefe84
Add memory extraction handler for desktop screen analysis
beastoin Mar 7, 2026
0da775a
Add contextual advice handler for desktop screen analysis
beastoin Mar 7, 2026
4dde2a5
Add live notes handler for desktop transcript processing
beastoin Mar 7, 2026
36a4a82
Add user profile generation handler for desktop
beastoin Mar 7, 2026
ef7154d
Add task reranking and deduplication handlers for desktop
beastoin Mar 7, 2026
24f9e9b
Add message event classes for all desktop handler types
beastoin Mar 7, 2026
2794289
Add full desktop dispatcher for screen_frame and text message types
beastoin Mar 7, 2026
4c5abcd
Add unit tests for task extraction handler (18 tests)
beastoin Mar 7, 2026
2d1d32a
Add unit tests for memory extraction handler (14 tests)
beastoin Mar 7, 2026
f3b20e3
Add unit tests for advice handler (14 tests)
beastoin Mar 7, 2026
be0a3b2
Add unit tests for live notes handler (10 tests)
beastoin Mar 7, 2026
4197646
Add unit tests for profile handler (9 tests)
beastoin Mar 7, 2026
daf72d0
Add unit tests for task rerank and dedup handlers (16 tests)
beastoin Mar 7, 2026
77da192
Add all desktop handler tests to test.sh
beastoin Mar 7, 2026
31b8100
Add BackendProactiveService for server-side proactive AI (#5396)
beastoin Mar 8, 2026
344a553
Wire FocusAssistant to BackendProactiveService instead of GeminiClien…
beastoin Mar 8, 2026
b29b882
Create BackendProactiveService in ProactiveAssistantsPlugin lifecycle…
beastoin Mar 8, 2026
1e876f1
Update FocusTestRunnerWindow for new FocusAssistant init signature (#…
beastoin Mar 8, 2026
c4b9f3e
Add all 8 message types to BackendProactiveService (#5396)
beastoin Mar 8, 2026
3010fe2
Wire TaskAssistant thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
e6155f3
Wire MemoryAssistant thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
f1b47a5
Wire AdviceAssistant thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
daefcaf
Wire TaskDeduplicationService thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
822c3c0
Wire TaskPrioritizationService thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
d7c6cf4
Wire AIUserProfileService thin client for Phase 2 (#5396)
beastoin Mar 8, 2026
3361229
Wire ProactiveAssistantsPlugin to pass backendService to all assistan…
beastoin Mar 8, 2026
e8e5820
Wire LiveNotesMonitor thin client for Phase 2 (#5396)
beastoin Mar 9, 2026
15bf1ec
Wire LiveNotesMonitor in ProactiveAssistantsPlugin (#5396)
beastoin Mar 9, 2026
61ee9c3
Swap dev plist to based-hardware-dev Firebase project
beastoin Mar 10, 2026
7796471
Read Firebase API key from plist at runtime instead of hardcoding
beastoin Mar 10, 2026
805d0df
Merge PR #5374: Desktop auth + base integration
beastoin Mar 10, 2026
bc2a2a3
Merge PR #5395: Desktop STT backend migration
beastoin Mar 10, 2026
54c2a6c
Merge PR #5413: Desktop proactive AI thin clients (resolve test.sh co…
beastoin Mar 10, 2026
e3cab73
Merge PR #5537: Dev Firebase config for dev builds (resolve test.sh c…
beastoin Mar 10, 2026
9e8c3a0
Fix dev.sh to copy dev Firebase plist instead of prod
beastoin Mar 10, 2026
e3cfbf1
Fix reset-and-run.sh to copy dev Firebase plist instead of prod
beastoin Mar 10, 2026
00ad7c8
Log fatal warning when dev build falls back to prod Firebase key
beastoin Mar 10, 2026
6d8b57e
Crash dev builds when Firebase plist is missing instead of falling ba…
beastoin Mar 10, 2026
1bb7195
Merge PR #5537 update: crash dev builds on missing plist (6d8b57e8e)
beastoin Mar 10, 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
0a7b431
Merge PR #5374 update: agent VM creation + status fields (40ae983af, …
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