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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,13 @@ CORS_ORIGINS=http://localhost:3000,http://localhost:8000
# Server time zone (IANA zoneinfo key). Used for TZ-sensitive server-side
# logic such as EOD-local routine_checklist expiry. Validated at startup.
# BRAIN3_SERVER_TZ=America/Chicago

# Firebase Cloud Messaging (FCM) — push delivery to the companion app.
# BRAIN3_FCM_PROJECT_ID is the Firebase project identifier (NOT the project
# number). BRAIN3_FCM_SERVICE_ACCOUNT_JSON_PATH points at a service-account
# keyfile with the firebase.messaging scope. In production the keyfile is
# bind-mounted into the container at /run/secrets/fcm-service-account.json.
# Leave both unset in dev to skip FCM dispatch (a "not configured" result
# is returned to the caller; pending → delivered transitions still occur).
# BRAIN3_FCM_PROJECT_ID=
# BRAIN3_FCM_SERVICE_ACCOUNT_JSON_PATH=/run/secrets/fcm-service-account.json
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added
- Lightweight delivery promoter — asyncio background task spawned on app startup that polls `notification_queue` every 30s for `pending` rows whose `scheduled_at` has passed and transitions them to `delivered` (recomputing `expires_at` via `calculate_expires_at`). Cancelled cleanly on shutdown; per-tick exceptions are caught and logged so one bad row cannot kill the poller. Proto-Stream-D scaffolding for the v2.0.0 notification loop (#204)
- Device registration + FCM dispatch — `app_devices` table with unique `fcm_token`, platform check (`android | ios`), and registration timestamps. New `POST /api/app/devices` (upsert, 200/201), `GET /api/app/devices`, and `DELETE /api/app/devices/{id}` endpoints behind the bearer middleware. New `app/services/fcm.py` (FCM HTTP v1 client with cached `google-auth` service-account credentials and structured `FcmResult` / `FcmStatus` outcomes) and `app/services/delivery.py::dispatch_push` (broadcast data-only payload to every registered device, hard-delete on `UNREGISTERED` / `INVALID_ARGUMENT`). Wired into both the PATCH `pending → delivered` handler and the delivery promoter so background-promoted rows trigger pushes too. New env vars `BRAIN3_FCM_PROJECT_ID` and `BRAIN3_FCM_SERVICE_ACCOUNT_JSON_PATH`; both unset in dev skips dispatch with a `NOT_CONFIGURED` result. Adds `google-auth` to `requirements.txt`. (#205)

## [v1.3.0] — Rules Engine (Phase 2 Stream F)

Expand Down
70 changes: 70 additions & 0 deletions alembic/versions/023_create_app_devices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# BRAIN 3.0 — AI-powered personal operating system for ADHD
# Copyright (C) 2026 L (WilliM233)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""Create app_devices table.

Revision ID: 23a1b2c3d4e5
Revises: 22a1b2c3d4e5
Create Date: 2026-04-27

Stores FCM device registrations for the companion app. Phase 2 is
single-user, so the table has no user_id — every registered device
receives every push. ``platform`` is constrained to ``android | ios``
to future-proof the schema for APNs even though v2.0.0 ships
Android-only.
"""

from typing import Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "23a1b2c3d4e5"
down_revision: Union[str, None] = "22a1b2c3d4e5"
branch_labels: Union[str, None] = None
depends_on: Union[str, None] = None


def upgrade() -> None:
op.create_table(
"app_devices",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True),
sa.Column("fcm_token", sa.Text(), nullable=False),
sa.Column("platform", sa.String(), nullable=False),
sa.Column("label", sa.String(), nullable=True),
sa.Column(
"registered_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"last_seen_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"platform IN ('android', 'ios')",
name="ck_app_devices_platform",
),
sa.UniqueConstraint("fcm_token", name="uq_app_devices_fcm_token"),
)


def downgrade() -> None:
op.drop_table("app_devices")
12 changes: 12 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ class Settings(BaseSettings):
default="America/Chicago", validation_alias="BRAIN3_SERVER_TZ",
)

# Firebase Cloud Messaging (FCM) HTTP v1 — push delivery to the companion
# app. Both must be set in production. When either is unset, FCM dispatch
# is skipped with a startup warning (dev convenience, mirrors the
# APP_BEARER_TOKEN pattern). The service-account JSON path is loaded at
# call time, so rotation does not require a restart.
FCM_PROJECT_ID: str | None = Field(
default=None, validation_alias="BRAIN3_FCM_PROJECT_ID",
)
FCM_SERVICE_ACCOUNT_JSON_PATH: str | None = Field(
default=None, validation_alias="BRAIN3_FCM_SERVICE_ACCOUNT_JSON_PATH",
)

model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", populate_by_name=True,
)
Expand Down
4 changes: 4 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
activity,
artifacts,
checkins,
devices,
directives,
domains,
goals,
Expand Down Expand Up @@ -143,3 +144,6 @@ def app_health_check() -> dict:
app.include_router(
skills.skill_directives_router, prefix="/api/skills", tags=["Skill Directives"],
)
app.include_router(
devices.router, prefix="/api/app/devices", tags=["App Devices"],
)
40 changes: 40 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1285,3 +1285,43 @@ class Rule(Base):
Index("ix_rules_entity_lookup", "entity_type", "enabled"),
Index("ix_rules_entity_scoped", "entity_type", "entity_id"),
)


# ---------------------------------------------------------------------------
# App Devices — FCM registrations for the companion app
# ---------------------------------------------------------------------------


class AppDevice(Base):
"""FCM-registered companion-app device.

Phase 2 is single-user: there is no ``user_id`` and dispatch broadcasts
to every registered row. ``platform`` future-proofs for APNs (``ios``)
even though v2.0.0 only ships Android.
"""

__tablename__ = "app_devices"

id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid4,
)
fcm_token: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
platform: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str | None] = mapped_column(String)
registered_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)

__table_args__ = (
CheckConstraint(
"platform IN ('android', 'ios')",
name="ck_app_devices_platform",
),
)
103 changes: 103 additions & 0 deletions app/routers/devices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# BRAIN 3.0 — AI-powered personal operating system for ADHD
# Copyright (C) 2026 L (WilliM233)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""CRUD endpoints for app device (FCM) registration.

Mounted under ``/api/app/devices`` and therefore gated by the App API
bearer middleware (see ``app/auth.py``). Phase 2 is single-user, so the
table has no ``user_id`` — every registered device receives every push.
"""

from datetime import UTC, datetime
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session

from app.database import get_db
from app.models import AppDevice
from app.schemas.devices import (
DeviceListResponse,
DeviceRegisterRequest,
DeviceResponse,
)

router = APIRouter()


@router.post("", response_model=DeviceResponse)
def register_device(
payload: DeviceRegisterRequest,
response: Response,
db: Session = Depends(get_db),
) -> AppDevice:
"""Register or refresh a device by ``fcm_token``.

Upsert semantics: a new ``fcm_token`` inserts and returns 201; an
existing token updates ``last_seen_at`` (and ``platform`` / ``label``
when supplied) and returns 200.
"""
existing = (
db.query(AppDevice)
.filter(AppDevice.fcm_token == payload.fcm_token)
.first()
)
if existing is not None:
existing.platform = payload.platform
if payload.label is not None:
existing.label = payload.label
existing.last_seen_at = datetime.now(tz=UTC)
db.commit()
db.refresh(existing)
response.status_code = status.HTTP_200_OK
return existing

device = AppDevice(
fcm_token=payload.fcm_token,
platform=payload.platform,
label=payload.label,
)
db.add(device)
db.commit()
db.refresh(device)
response.status_code = status.HTTP_201_CREATED
return device


@router.get("", response_model=DeviceListResponse)
def list_devices(db: Session = Depends(get_db)) -> DeviceListResponse:
"""List all registered devices (debugging / admin)."""
rows = (
db.query(AppDevice)
.order_by(AppDevice.registered_at.desc())
.all()
)
return DeviceListResponse(
items=[DeviceResponse.model_validate(r) for r in rows],
count=len(rows),
)


@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
def unregister_device(
device_id: UUID, db: Session = Depends(get_db),
) -> None:
"""Unregister a device by id (used on logout / uninstall)."""
device = db.query(AppDevice).filter(AppDevice.id == device_id).first()
if device is None:
raise HTTPException(status_code=404, detail="Device not found")
db.delete(device)
db.commit()
22 changes: 21 additions & 1 deletion app/routers/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

"""CRUD endpoints for the Notification Queue."""

import logging
from datetime import UTC, date, datetime
from uuid import UUID

Expand All @@ -31,8 +32,11 @@
NotificationResponse,
NotificationUpdate,
)
from app.services.delivery import dispatch_push
from app.services.notification_defaults import calculate_expires_at, get_default_responses

logger = logging.getLogger(__name__)

router = APIRouter()

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -286,7 +290,8 @@ async def update_notification(
setattr(notification, field, value)

# Auto-populate expires_at on delivery
if updates.get("status") == "delivered":
delivered_now = updates.get("status") == "delivered"
if delivered_now:
delivered_at = datetime.now(tz=UTC)
notification.expires_at = calculate_expires_at(
notification.notification_type,
Expand All @@ -297,6 +302,21 @@ async def update_notification(

db.commit()
db.refresh(notification)

# Push to every registered device on the pending → delivered transition.
# Synchronous at Phase 2 single-user scale; async/queue is a Chunk 5
# concern (see ticket Out of Scope). Failures inside dispatch_push are
# logged per-device and never raised here.
if delivered_now:
try:
dispatch_push(notification, db)
except Exception as exc: # noqa: BLE001 — never fail the PATCH on dispatch
logger.warning(
"FCM dispatch failed for notification %s: %s",
notification.id,
exc,
)

return notification


Expand Down
55 changes: 55 additions & 0 deletions app/schemas/devices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# BRAIN 3.0 — AI-powered personal operating system for ADHD
# Copyright (C) 2026 L (WilliM233)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""Pydantic schemas for App Device registration."""

from __future__ import annotations

from datetime import datetime
from typing import Literal
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field

DevicePlatform = Literal["android", "ios"]


class DeviceRegisterRequest(BaseModel):
"""Body of POST /api/app/devices — register or refresh a device."""

fcm_token: str = Field(min_length=1, max_length=4096)
platform: DevicePlatform
label: str | None = Field(default=None, max_length=200)


class DeviceResponse(BaseModel):
"""Device row returned from the API."""

model_config = ConfigDict(from_attributes=True)

id: UUID
fcm_token: str
platform: str
label: str | None = None
registered_at: datetime
last_seen_at: datetime


class DeviceListResponse(BaseModel):
"""Envelope for GET /api/app/devices."""

items: list[DeviceResponse]
count: int
Loading
Loading