Skip to content

feat(tasks): wire up multi-account task sync - #55

Open
corrin wants to merge 4 commits into
mainfrom
feat/wire-task-sync
Open

feat(tasks): wire up multi-account task sync#55
corrin wants to merge 4 commits into
mainfrom
feat/wire-task-sync

Conversation

@corrin

@corrin corrin commented Jun 29, 2026

Copy link
Copy Markdown
Owner

What

Connects the one missing wire in the "combined task list from multiple accounts" feature. Everything around it already existed — providers, the multi-account ExternalAccount model, the combined GET /api/tasks list, the frontend Kanban board, and a complete content-hash sync service — but sync_provider_tasks() was never called and POST /api/tasks/sync returned "Sync not yet implemented.". So real Todoist/Google/Outlook tasks never reached the list.

Changes

  • sync_all_task_accounts() (services/task_sync.py) — loops a user's task-enabled accounts, maps DB provider → task provider (reusing the existing PROVIDER_TO_TASK), fetches via TaskManager, upserts via the existing content-hash sync. Resilient: one failing account is logged/counted, never aborts the batch. Returns {accounts, upserted, failed}.
  • POST /api/tasks/sync now runs it (manual "Sync now"; the frontend store already calls it and refetches).
  • task_sync_loop (providers/task_sync_loop.py) — background asyncio job, 15-min interval, mirroring the existing token_refresh_loop; started/cancelled in the app lifespan. Plus ExternalAccount.get_user_ids_with_task_accounts().
  • Latent fix: google_tasks.py imported the stubs-only googleapiclient._apis at runtime — harmless until now because nothing imported TaskManager at runtime. Moved under TYPE_CHECKING (same pattern as calendar/google.py). No suppressions.

Architecture note

In-process asyncio loop, not Celery — matches the only background pattern in the repo (token_refresh_loop), no broker dependency, single-instance deploy. sync_all_task_accounts is runtime-agnostic, so a future move to a queue would change only the caller. Known limit: >1 app instance would duplicate the loop's work.

Verification

  • 156 backend tests pass (new unit + integration sync tests); mypy --strict + ruff clean.
  • Live-confirmed through ngrok: POST /api/tasks/sync{"status":"ok","accounts":0,"upserted":0,"failed":0} (real orchestrator running, not the old stub).
  • Full "real tasks appear" run is pending a connected task provider — the dev DB currently has no Todoist token and the calendar accounts are calendar-only. Todoist connection (OAuth) is tracked as follow-up work.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added “use for tasks” enablement for connected external accounts (including a new endpoint and Settings UI controls).
    • Implemented manual task sync via /api/tasks/sync with real upsert results.
    • Added automatic background task synchronization to keep tasks up to date.
  • Bug Fixes

    • Sync is resilient: failures for one account no longer prevent others from syncing.
    • Re-syncing already-imported tasks now correctly becomes a no-op.
  • Tests

    • Added integration, unit, and end-to-end coverage for task synchronization behavior.

corrin and others added 2 commits June 30, 2026 09:45
POST /api/tasks/sync was a stub returning "Sync not yet implemented."
and the complete sync_provider_tasks() service was never called, so
real provider tasks never reached the combined list.

- Add sync_all_task_accounts() orchestrator: loops a user's task-enabled
  ExternalAccounts, maps DB provider -> task provider (reusing
  PROVIDER_TO_TASK), fetches via TaskManager, upserts via the existing
  content-hash sync, and is resilient (one bad account does not abort the
  batch). Returns {accounts, upserted, failed}.
- Implement POST /api/tasks/sync to run it (manual "Sync now"; the
  frontend store already calls this and refetches).
- Add task_sync_loop background job (asyncio, 15-min interval) mirroring
  token_refresh_loop, started/cancelled in the app lifespan; plus
  ExternalAccount.get_user_ids_with_task_accounts() to enumerate users.
- Fix a latent runtime-breaking import in google_tasks.py (move the
  stubs-only googleapiclient._apis import under TYPE_CHECKING), exposed
  now that TaskManager is imported at runtime.
- Tests: unit coverage for provider mapping / failure isolation / empty
  case, an integration test driving the endpoint end-to-end with
  idempotency, and an e2e tasks/sync test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It asserted task cards appear, which cannot pass until a task provider is
actually connected (no Todoist token; calendar accounts are calendar-only).
The sync endpoint is genuinely covered by the integration test
(tests/integration/test_tasks.py::TestSync). A real e2e test belongs with
the Todoist OAuth work, when there is data to run against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds provider task sync across users and accounts, wires a periodic background worker into app startup, introduces a task-enable action for external accounts and the settings UI control for it, and expands unit, integration, and end-to-end test coverage.

Changes

Task Sync and Task Enable Flow

Layer / File(s) Summary
ExternalAccount query and google_tasks import fix
backend/underway/models/external_account.py, backend/underway/providers/google_tasks.py
Adds get_user_ids_with_task_accounts and moves the Google Tasks resource import under TYPE_CHECKING.
sync_all_task_accounts service
backend/underway/services/task_sync.py
Adds sync_all_task_accounts, which maps providers, syncs provider tasks per account, and returns per-account counts.
Unit tests for sync_all_task_accounts
backend/tests/unit/test_task_sync.py
Adds a fake task manager, task-account helper, and tests for provider mapping, persistence, partial failure handling, and empty results.
task_sync_loop background module
backend/underway/providers/task_sync_loop.py
Defines the sync interval, per-user sync orchestration, and the repeating background loop.
Sync endpoint and app lifespan wiring
backend/underway/viewsets/tasks.py, backend/underway/app.py, backend/tests/integration/test_tasks.py
Replaces the /api/tasks/sync stub with the real sync call, starts the background loop with app startup, and verifies sync idempotency in integration tests.
Task enable endpoint and settings UI
backend/underway/viewsets/external_accounts.py, backend/tests/integration/test_external_accounts.py, frontend/src/views/SettingsView.vue
Adds the task-enable action, tests its success and error cases, and adds the settings control that invokes it.
Google Tasks e2e sandbox and sync test
backend/scripts/run-e2e.sh, backend/tests/e2e/conftest.py, backend/tests/e2e/test_tasks.py
Adds the e2e runner, Google Tasks sandbox fixture, and a Playwright sync test covering create, update, and delete reconciliation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 Hop-hop, the task list grew,
Syncing through the morning dew.
Buttons click and workers wake,
A rabbit grins at every stake.
Tasks appear, then vanish too —
tidy hops in code review!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: wiring up multi-account task synchronization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wire-task-sync

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
backend/underway/models/external_account.py (1)

136-140: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Mirror the actual syncable-account filters here.

sync_all_users() uses this query to decide which users get a background sync pass, but sync_all_task_accounts() later excludes needs_reauth and missing-credential accounts via get_task_accounts_for_user(). Users whose only task accounts are stale still get scanned every 15 minutes for a guaranteed no-op. Reusing the same predicates here keeps the fan-out set aligned with accounts that can really sync.

♻️ Proposed change
     `@classmethod`
     async def get_user_ids_with_task_accounts(cls, session: AsyncSession) -> list[uuid.UUID]:
         """Return distinct user ids that have at least one task-enabled account."""
-        stmt = select(cls.user_id).where(cls.use_for_tasks.is_(True)).distinct()
+        stmt = (
+            select(cls.user_id)
+            .where(
+                cls.use_for_tasks.is_(True),
+                cls.needs_reauth.is_(False),
+                or_(
+                    and_(cls.provider == "todoist", cls.api_key.is_not(None)),
+                    and_(cls.provider.in_(["google", "o365"]), cls.token.is_not(None)),
+                ),
+            )
+            .distinct()
+        )
         result = await session.execute(stmt)
         return list(result.scalars().all())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/underway/models/external_account.py` around lines 136 - 140,
`ExternalAccount.get_user_ids_with_task_accounts()` is selecting users too
broadly because it only checks `use_for_tasks`, while `sync_all_task_accounts()`
later filters out accounts that cannot actually sync. Update this query to
mirror the same syncable-account predicates used by
`get_task_accounts_for_user()` so `sync_all_users()` only fans out to users with
at least one реально syncable task account. Keep the distinct user-id return
shape unchanged.
backend/tests/unit/test_task_sync.py (1)

141-172: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the persisted provider mapping, not just the TaskManager call mapping.

These tests prove get_tasks() receives "google_tasks"/"outlook", but the only DB assertion still exercises "todoist". A regression that writes Task.provider="google" or "o365" would pass here even though sync_all_task_accounts() is supposed to persist the mapped provider key.

Possible follow-up test
+    async def test_persists_mapped_provider_name(self, db_session: AsyncSession) -> None:
+        user = await _create_user(db_session)
+        await _create_task_account(db_session, user, "google", "g@example.com")
+        pt = _make_provider_task(title="Imported")
+
+        manager = _FakeTaskManager(tasks_by_email={"g@example.com": [pt]})
+        summary = await sync_all_task_accounts(db_session, user.id, manager)
+
+        assert summary == {"accounts": 1, "upserted": 1, "failed": 0}
+        result = await db_session.execute(select(Task).where(Task.user_id == user.id))
+        task = result.scalar_one()
+        assert task.provider == "google_tasks"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/unit/test_task_sync.py` around lines 141 - 172, The current
test only verifies the TaskManager provider mapping, but it does not assert that
sync_all_task_accounts persists the mapped provider key into Task.provider for
non-todoist accounts. Update test_syncs_provider_tasks_into_db in
TestSyncAllTaskAccounts to create a google or o365 account, sync a provider task
through _FakeTaskManager, and assert the saved Task.provider matches the mapped
key used by sync_all_task_accounts rather than the original DB provider value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/underway/services/task_sync.py`:
- Around line 53-60: The per-account write path in sync_provider_tasks is not
isolated, so a database failure can leave the shared AsyncSession in a bad state
and affect later accounts. Wrap the provider-specific fetch/write block in
task_sync inside session.begin_nested() so each account runs in a savepoint, and
keep the existing exception logging/failed counter handling around that nested
transaction.

---

Nitpick comments:
In `@backend/tests/unit/test_task_sync.py`:
- Around line 141-172: The current test only verifies the TaskManager provider
mapping, but it does not assert that sync_all_task_accounts persists the mapped
provider key into Task.provider for non-todoist accounts. Update
test_syncs_provider_tasks_into_db in TestSyncAllTaskAccounts to create a google
or o365 account, sync a provider task through _FakeTaskManager, and assert the
saved Task.provider matches the mapped key used by sync_all_task_accounts rather
than the original DB provider value.

In `@backend/underway/models/external_account.py`:
- Around line 136-140: `ExternalAccount.get_user_ids_with_task_accounts()` is
selecting users too broadly because it only checks `use_for_tasks`, while
`sync_all_task_accounts()` later filters out accounts that cannot actually sync.
Update this query to mirror the same syncable-account predicates used by
`get_task_accounts_for_user()` so `sync_all_users()` only fans out to users with
at least one реально syncable task account. Keep the distinct user-id return
shape unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 12f614e8-f89f-45a3-9129-31fadec1cccd

📥 Commits

Reviewing files that changed from the base of the PR and between eda59f6 and e2716e4.

📒 Files selected for processing (8)
  • backend/tests/integration/test_tasks.py
  • backend/tests/unit/test_task_sync.py
  • backend/underway/app.py
  • backend/underway/models/external_account.py
  • backend/underway/providers/google_tasks.py
  • backend/underway/providers/task_sync_loop.py
  • backend/underway/services/task_sync.py
  • backend/underway/viewsets/tasks.py

Comment on lines +53 to +60
try:
provider_tasks = await task_manager.get_tasks(session, user_id, account.external_email, task_provider)
upserted += await sync_provider_tasks(
session, user_id, account.external_email, task_provider, provider_tasks
)
except Exception:
logger.exception("Sync failed for account %s (%s)", account.external_email, task_provider)
failed += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files backend/underway/services/task_sync.py backend/underway/providers/task_sync_loop.py backend/underway/viewsets/tasks.py backend/underway/services | sed 's#^`#FILE` #'
echo '--- task_sync.py outline ---'
ast-grep outline backend/underway/services/task_sync.py --view expanded || true
echo '--- sync_provider_tasks references ---'
rg -n "sync_provider_tasks|begin_nested|AsyncSession|session.begin" backend/underway -S
echo '--- task_sync.py lines ---'
nl -ba backend/underway/services/task_sync.py | sed -n '1,220p'

Repository: corrin/underway

Length of output: 11122


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

for path in [
    Path("backend/underway/services/task_sync.py"),
    Path("backend/underway/providers/task_sync_loop.py"),
    Path("backend/underway/auth/dependencies.py"),
    Path("backend/underway/models/base.py"),
]:
    print(f"\n===== {path} =====")
    text = path.read_text()
    for i, line in enumerate(text.splitlines(), 1):
        if path.name == "task_sync.py" or path.name in {"task_sync_loop.py", "dependencies.py", "base.py"}:
            print(f"{i:4d}: {line}")
PY

Repository: corrin/underway

Length of output: 14081


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

for path in [
    Path("backend/underway/services/task_sync.py"),
    Path("backend/underway/providers/task_sync_loop.py"),
    Path("backend/underway/auth/dependencies.py"),
    Path("backend/underway/models/base.py"),
]:
    print(f"\n===== {path} =====")
    text = path.read_text()
    for i, line in enumerate(text.splitlines(), 1):
        print(f"{i:4d}: {line}")
PY

Repository: corrin/underway

Length of output: 14081


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

paths = [Path("tests"), Path("backend/underway")]
for base in paths:
    if base.exists():
        print(f"===== {base} =====")
        for p in sorted(base.rglob("*task*sync*")):
            if p.is_file():
                print(p)
PY

echo '--- test references ---'
rg -n "sync_all_task_accounts|sync_provider_tasks|begin_nested|rollback|savepoint|failed" tests backend/underway -S

Repository: corrin/underway

Length of output: 4315


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

for base in [Path("tests"), Path("backend/underway")]:
    if base.exists():
        print(f"===== {base} =====")
        for p in sorted(base.rglob("*task*sync*")):
            if p.is_file():
                print(p)
PY

echo '--- test references ---'
rg -n "sync_all_task_accounts|sync_provider_tasks|begin_nested|rollback|savepoint|failed" tests backend/underway -S

Repository: corrin/underway

Length of output: 4315


Wrap the per-account write path in a savepoint. In backend/underway/services/task_sync.py:53-60, a DB exception inside sync_provider_tasks() can poison the shared AsyncSession for later accounts and still leave partial work from that account pending for the user-level commit. Use session.begin_nested() around the write path so one failed account stays isolated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/underway/services/task_sync.py` around lines 53 - 60, The per-account
write path in sync_provider_tasks is not isolated, so a database failure can
leave the shared AsyncSession in a bad state and affect later accounts. Wrap the
provider-specific fetch/write block in task_sync inside session.begin_nested()
so each account runs in a savepoint, and keep the existing exception
logging/failed counter handling around that nested transaction.

corrin and others added 2 commits June 30, 2026 10:15
Make a real OAuth task provider possible so sync has something to pull
(the Todoist api-key path is being removed). The Google OAuth already
requests the tasks scope (GOOGLE_CALENDAR_SCOPES), but nothing could mark
a Google/O365 account use_for_tasks, so get_task_accounts_for_user never
returned them.

- Add POST /api/external-accounts/{id}/use-for-tasks (detail action on the
  read-only viewset) that sets use_for_tasks + is_primary_tasks, scoped to
  the current user. Integration tests: enable, cross-user 404, bad-id 400.
- Settings UI: a "Use for tasks" button on google/o365 account cards that
  calls it and refetches.

GoogleTaskProvider read path already works once the token carries the tasks
scope and the account is task-enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Recreates the deleted task-sync e2e test as a REAL one (not a placeholder):
it creates a sentinel task in a live Google Tasks account, clicks Sync in the
UI, and asserts the task appears in the combined list; then edits it upstream
and re-syncs (assert reflected), and deletes it upstream and re-syncs (assert
reconciled away). Drives both UI and API (via ngrok). Fails loudly if sync
does not perform correctly. Sentinel tasks are cleaned up on teardown.

- conftest: google_tasks fixture (reads the connected, task-enabled Google
  account from the app DB; skips with guidance if none) + GoogleTasksSandbox.
- scripts/run-e2e.sh: runs the suite with -p no:asyncio -p no:playwright
  (both plugins inject an event loop that breaks sync Playwright) and documents
  the close-Chrome / connect-Google prerequisites.

Not yet executed green — needs the full stack up and a Google account connected
for tasks (Settings: re-auth Google, then Use for tasks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/tests/e2e/conftest.py`:
- Around line 113-129: The Google task-account lookup is too broad because
`_load_google_tasks_account()` selects the first eligible `ExternalAccount`
across the entire database, which can target the wrong user during e2e runs.
Update this helper, and the call sites around the later sync assertions, to
scope the query to the authenticated e2e user’s account identity from the active
page/JWT fixture so only that user’s Google account is mutated and verified.
Keep the filtering tied to the existing `ExternalAccount` lookup and the user
context already available in the test setup.

In `@backend/underway/viewsets/external_accounts.py`:
- Around line 48-59: Reject unsupported providers before setting task-related
state in the external account flow. In the branch that updates
`account.use_for_tasks` and calls `ExternalAccount.set_as_primary`, add a
provider guard so only supported providers such as Google and O365 can be marked
as tasks-enabled/primary; otherwise raise an HTTP error and exit before
persisting anything. Use the existing `account`,
`ExternalAccount.set_as_primary`, and `use_for_tasks` logic in
`external_accounts.py` to place the check right before the assignment and flush.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ae71e26-c918-4c37-8cb7-dd5e4a2834c9

📥 Commits

Reviewing files that changed from the base of the PR and between e2716e4 and 875dd51.

📒 Files selected for processing (6)
  • backend/scripts/run-e2e.sh
  • backend/tests/e2e/conftest.py
  • backend/tests/e2e/test_tasks.py
  • backend/tests/integration/test_external_accounts.py
  • backend/underway/viewsets/external_accounts.py
  • frontend/src/views/SettingsView.vue
✅ Files skipped from review due to trivial changes (1)
  • backend/scripts/run-e2e.sh

Comment on lines +113 to +129
async def _load_google_tasks_account() -> tuple[str, str | None, str | None] | None:
"""Read a connected, task-enabled Google account from the app DB, or None."""
settings = get_settings()
engine = create_async_engine(settings.database_url)
try:
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
result = await session.execute(
select(ExternalAccount).where(
ExternalAccount.provider == "google",
ExternalAccount.use_for_tasks.is_(True),
ExternalAccount.needs_reauth.is_(False),
)
)
account = result.scalars().first()
if account is None:
return None
return account.external_email, account.token, account.refresh_token

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the Google sandbox account to the authenticated e2e user.

Line 126 picks the first eligible Google task account in the whole database, but the page/JWT under test can belong to a different user. As soon as multiple users have task-enabled Google accounts, this fixture starts mutating one account and asserting sync results against another.

Also applies to: 171-180

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/e2e/conftest.py` around lines 113 - 129, The Google
task-account lookup is too broad because `_load_google_tasks_account()` selects
the first eligible `ExternalAccount` across the entire database, which can
target the wrong user during e2e runs. Update this helper, and the call sites
around the later sync assertions, to scope the query to the authenticated e2e
user’s account identity from the active page/JWT fixture so only that user’s
Google account is mutated and verified. Keep the filtering tied to the existing
`ExternalAccount` lookup and the user context already available in the test
setup.

Comment on lines +48 to +59
if account is None:
raise HTTPException(status_code=404, detail="Account not found.")
else:
account.use_for_tasks = True
await ExternalAccount.set_as_primary(
session,
external_email=account.external_email,
provider=account.provider,
user_id=user.id,
account_type="tasks",
)
await session.flush()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject unsupported providers before persisting use_for_tasks.

The UI only offers this action for Google/O365, but Line 51 currently marks any owned external account as task-enabled and primary. That stores an invalid task-source state for providers the sync flow does not support.

Suggested fix
         account = result.scalar_one_or_none()
         if account is None:
             raise HTTPException(status_code=404, detail="Account not found.")
-        else:
-            account.use_for_tasks = True
-            await ExternalAccount.set_as_primary(
-                session,
-                external_email=account.external_email,
-                provider=account.provider,
-                user_id=user.id,
-                account_type="tasks",
-            )
-            await session.flush()
+        if account.provider not in {"google", "o365"}:
+            raise HTTPException(
+                status_code=400,
+                detail="Tasks are only supported for Google and Microsoft 365 accounts.",
+            )
+
+        account.use_for_tasks = True
+        await ExternalAccount.set_as_primary(
+            session,
+            external_email=account.external_email,
+            provider=account.provider,
+            user_id=user.id,
+            account_type="tasks",
+        )
+        await session.flush()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if account is None:
raise HTTPException(status_code=404, detail="Account not found.")
else:
account.use_for_tasks = True
await ExternalAccount.set_as_primary(
session,
external_email=account.external_email,
provider=account.provider,
user_id=user.id,
account_type="tasks",
)
await session.flush()
if account is None:
raise HTTPException(status_code=404, detail="Account not found.")
if account.provider not in {"google", "o365"}:
raise HTTPException(
status_code=400,
detail="Tasks are only supported for Google and Microsoft 365 accounts.",
)
account.use_for_tasks = True
await ExternalAccount.set_as_primary(
session,
external_email=account.external_email,
provider=account.provider,
user_id=user.id,
account_type="tasks",
)
await session.flush()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/underway/viewsets/external_accounts.py` around lines 48 - 59, Reject
unsupported providers before setting task-related state in the external account
flow. In the branch that updates `account.use_for_tasks` and calls
`ExternalAccount.set_as_primary`, add a provider guard so only supported
providers such as Google and O365 can be marked as tasks-enabled/primary;
otherwise raise an HTTP error and exit before persisting anything. Use the
existing `account`, `ExternalAccount.set_as_primary`, and `use_for_tasks` logic
in `external_accounts.py` to place the check right before the assignment and
flush.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant