inital commit, very early alpha stage

This commit is contained in:
2026-06-30 13:38:24 +02:00
parent f5530ad336
commit 70cf1a84ca
72 changed files with 14074 additions and 2 deletions

8
backend/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM python:3.13-slim
WORKDIR /app
COPY pyproject.toml README.md ./
RUN pip install --no-cache-dir -e .
COPY app ./app
EXPOSE 8000

13
backend/README.md Normal file
View File

@@ -0,0 +1,13 @@
# GroupHome Backend
FastAPI, SQLAlchemy, and SQLite backend for the GroupHome coordination platform.
## Commands
```bash
python -m app.db.seed
uvicorn app.main:app --reload
pytest
```
The seed command resets the local database and prints a demo invite URL plus a demo remote connection code.

2
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""GroupHome backend package."""

View File

@@ -0,0 +1,2 @@
"""Core configuration and security helpers."""

View File

@@ -0,0 +1,36 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "GroupHome"
environment: str = "development"
dev_mode: bool = True
server_name: str = "GroupHome Local"
server_origin: str = "http://localhost:8000"
api_base_url: str = "http://localhost:8000/api"
frontend_origin: str = "http://localhost:5173"
database_url: str = "sqlite:///./grouphome.db"
session_secret: str = "dev-change-me"
session_cookie_name: str = "grouphome_session"
cookie_secure: bool = False
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173"
upload_dir: Path = Path("./storage/uploads")
max_upload_bytes: int = 10 * 1024 * 1024
remote_request_timeout_seconds: float = 8.0
@property
def allowed_origins(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@lru_cache
def get_settings() -> Settings:
settings = Settings()
settings.upload_dir.mkdir(parents=True, exist_ok=True)
return settings

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import hashlib
import hmac
import re
import secrets
from datetime import UTC, datetime, timedelta
from pathlib import Path
from app.core.config import get_settings
def utc_now() -> datetime:
return datetime.now(UTC)
def token_urlsafe(length: int = 32) -> str:
return secrets.token_urlsafe(length)
def short_code(length: int = 6) -> str:
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
return "".join(secrets.choice(alphabet) for _ in range(length))
def hash_token(raw_token: str) -> str:
settings = get_settings()
return hmac.new(settings.session_secret.encode("utf-8"), raw_token.encode("utf-8"), hashlib.sha256).hexdigest()
def constant_time_equal(left: str, right: str) -> bool:
return hmac.compare_digest(left, right)
def session_expiry(days: int = 30) -> datetime:
return utc_now() + timedelta(days=days)
def sanitize_filename(filename: str) -> str:
cleaned = Path(filename).name.strip().replace("\x00", "")
cleaned = re.sub(r"[^A-Za-z0-9._ -]+", "_", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip(" .")
return cleaned[:180] or "upload.bin"

View File

@@ -0,0 +1,2 @@
"""Database helpers."""

31
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,31 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db() -> None:
import app.models # noqa: F401
Base.metadata.create_all(bind=engine)

320
backend/app/db/seed.py Normal file
View File

@@ -0,0 +1,320 @@
from __future__ import annotations
from datetime import timedelta
from sqlalchemy import select
from app.core.config import get_settings
from app.core.security import hash_token, token_urlsafe, utc_now
import app.db.base as db_base
from app.db.base import Base, init_db
from app.models import (
Announcement,
ConnectionToken,
Event,
FileAsset,
Group,
HomeProfile,
Member,
MemberInvite,
Message,
MigrationState,
Notification,
NotificationPreference,
Poll,
PollOption,
Task,
Thread,
)
DEMO_INVITE_TOKEN = "demo-fc-invite"
DEMO_REMOTE_CONNECTION_CODE = "demo-remote-sync-code"
def _add_member(db, group: Group, name: str, role: str = "member", profile: HomeProfile | None = None, status: str = "joined") -> Member:
member = Member(
group_id=group.id,
home_profile_id=profile.id if profile else None,
display_name=name,
role=role,
status=status,
joined_at=utc_now() - timedelta(days=9) if status in {"joined", "verified"} else None,
last_seen_at=utc_now() - timedelta(days=5),
notification_enabled_at=utc_now() - timedelta(days=3) if name == "Anna Müller" else None,
)
db.add(member)
db.flush()
return member
def _add_file(db, group: Group, member: Member, filename: str, description: str, requires_ack: bool = False) -> None:
db.add(
FileAsset(
group_id=group.id,
uploaded_by_member_id=member.id,
filename_original=filename,
filename_stored=f"seed-{filename.replace(' ', '_')}",
content_type="text/plain",
size_bytes=len(description.encode("utf-8")),
storage_path=f"seed://{filename}",
description=description,
requires_ack=requires_ack,
)
)
def seed() -> None:
settings = get_settings()
Base.metadata.drop_all(bind=db_base.engine)
init_db()
db = db_base.SessionLocal()
try:
anna = HomeProfile(primary_display_name="Anna Müller", last_seen_at=utc_now() - timedelta(days=5))
db.add(anna)
db.flush()
groups = [
Group(
server_origin=settings.server_origin,
name="FC Kreuzberg U12 Parents",
description="Planning, matches, drivers, files, and official team announcements.",
visibility="private",
legacy_channel_status="transition",
transition_deadline=(utc_now() + timedelta(days=21)).date(),
),
Group(
server_origin=settings.server_origin,
name="Class 4B Parents",
description="School forms, parent evenings, votes, and classroom coordination.",
visibility="private",
legacy_channel_status="transition",
transition_deadline=(utc_now() + timedelta(days=28)).date(),
),
Group(
server_origin=settings.server_origin,
name="Tenant Association",
description="Building updates, maintenance actions, meeting minutes, and votes.",
visibility="private",
legacy_channel_status="transition",
transition_deadline=(utc_now() + timedelta(days=14)).date(),
),
Group(
server_origin=settings.server_origin,
name="Food Bank Volunteers",
description="Volunteer shifts, supply lists, files, and announcements.",
visibility="private",
legacy_channel_status="transition",
transition_deadline=(utc_now() + timedelta(days=30)).date(),
),
]
db.add_all(groups)
db.flush()
fc, school, tenants, food = groups
anna_fc = _add_member(db, fc, "Anna Müller", "admin", anna)
coach = _add_member(db, fc, "Coach Mark", "owner")
lisa_fc = _add_member(db, fc, "Lisa Becker")
samir_fc = _add_member(db, fc, "Samir Khan")
_add_member(db, fc, "Priya N.", status="invited")
anna_school = _add_member(db, school, "Anna Müller", "member", anna)
lisa_school = _add_member(db, school, "Lisa Becker", "admin")
samir_school = _add_member(db, school, "Samir Khan")
anna_tenant = _add_member(db, tenants, "Anna Müller", "member", anna)
tenant_admin = _add_member(db, tenants, "Tenant admin", "owner")
_add_member(db, tenants, "Priya N.", "moderator")
anna_food = _add_member(db, food, "Anna Müller", "member", anna)
priya_food = _add_member(db, food, "Priya N.", "admin")
_add_member(db, food, "Samir Khan")
db.add(
MemberInvite(
group_id=fc.id,
created_by_member_id=anna_fc.id,
label="Parent open invite",
scope="open_seat",
permission_role="member",
token_hash=hash_token(DEMO_INVITE_TOKEN),
max_uses=100,
)
)
now = utc_now()
db.add_all(
[
Announcement(
group_id=fc.id,
author_member_id=coach.id,
title="Official move: match details live here",
body="Schedules, RSVP, driver planning, and files are now handled in GroupHome.",
priority="urgent",
official=True,
requires_ack=True,
),
Announcement(
group_id=school.id,
author_member_id=lisa_school.id,
title="School form due Friday",
body="Please download the excursion form and return it by Friday morning.",
official=True,
requires_ack=False,
),
Announcement(
group_id=food.id,
author_member_id=priya_food.id,
title="Saturday shift checklist",
body="Bring gloves if you have them. New shelf labels are attached in files.",
official=True,
),
]
)
db.add_all(
[
Event(
group_id=fc.id,
created_by_member_id=coach.id,
title="League match vs. Neukölln",
description="Please RSVP and add a note if you can drive.",
starts_at=now + timedelta(days=2, hours=3),
ends_at=now + timedelta(days=2, hours=5),
location_name="Willi-Boos-Sportplatz",
location_address="Gneisenaustr. 36, Berlin",
rsvp_required=True,
),
Event(
group_id=fc.id,
created_by_member_id=coach.id,
title="Training moved to Pitch 2",
description="The usual pitch is closed for maintenance.",
starts_at=now + timedelta(days=1, hours=2),
ends_at=now + timedelta(days=1, hours=4),
location_name="Pitch 2",
rsvp_required=False,
changed_at=now - timedelta(days=1),
),
Event(
group_id=school.id,
created_by_member_id=lisa_school.id,
title="Parent evening",
description="Agenda: class trip, reading groups, and summer project.",
starts_at=now + timedelta(days=5, hours=1),
location_name="Classroom 4B",
rsvp_required=True,
),
Event(
group_id=food.id,
created_by_member_id=priya_food.id,
title="Volunteer shift",
description="Sorting and front desk support.",
starts_at=now + timedelta(days=3, hours=4),
location_name="Food Bank Hall",
rsvp_required=True,
),
]
)
db.add_all(
[
Task(
group_id=fc.id,
created_by_member_id=coach.id,
assigned_to_member_id=anna_fc.id,
title="Confirm one more driver",
description="We need one extra car for Saturday.",
due_at=now + timedelta(days=1),
),
Task(
group_id=food.id,
created_by_member_id=priya_food.id,
assigned_to_member_id=anna_food.id,
title="Bring label printer",
description="Use it for shelf relabeling before the morning shift.",
due_at=now + timedelta(days=2),
),
]
)
poll = Poll(
group_id=tenants.id,
created_by_member_id=tenant_admin.id,
title="Vote: courtyard repair appointment",
description="Choose the appointment that works for your household.",
closes_at=now + timedelta(days=4),
status="open",
)
db.add(poll)
db.flush()
db.add_all(
[
PollOption(poll_id=poll.id, label="Tuesday morning", position=1),
PollOption(poll_id=poll.id, label="Thursday afternoon", position=2),
PollOption(poll_id=poll.id, label="Either is fine", position=3),
]
)
_add_file(db, fc, coach, "Season schedule.txt", "Full U12 season schedule and match locations.", True)
_add_file(db, fc, coach, "Emergency contacts.txt", "Emergency contacts and consent notes.", False)
_add_file(db, tenants, tenant_admin, "Meeting minutes.txt", "Tenant association meeting notes.", False)
_add_file(db, school, lisa_school, "School excursion form.txt", "Please print and return this form.", True)
for group, creator, title, body in [
(fc, lisa_fc, "Snack coordination", "I can bring oranges. Who can bring water?"),
(fc, samir_fc, "Carpool planning", "I have two seats from Kottbusser Tor."),
(tenants, tenant_admin, "Maintenance issue", "Elevator inspection is scheduled next week."),
(food, priya_food, "Volunteer supplies", "We are low on tape and shelf labels."),
]:
thread = Thread(group_id=group.id, created_by_member_id=creator.id, title=title, kind="discussion")
db.add(thread)
db.flush()
db.add(Message(thread_id=thread.id, author_member_id=creator.id, body=body))
db.add(
Notification(
home_profile_id=anna.id,
member_id=anna_fc.id,
title="Coach Mark mentioned you",
body="Can you confirm the last driver for Saturday?",
category="direct_mentions",
)
)
for owner_member in [anna_fc, anna_school, anna_tenant, anna_food]:
for category, delivery in [
("direct_mentions", "immediate"),
("event_changes", "immediate"),
("urgent_announcements", "immediate"),
("tasks_assigned", "immediate"),
("discussions", "digest"),
("new_files", "digest"),
("general_chatter", "digest"),
("reactions", "muted"),
("off_topic", "muted"),
]:
db.add(NotificationPreference(home_profile_id=anna.id, member_id=owner_member.id, category=category, delivery=delivery))
for group in groups:
db.add(MigrationState(group_id=group.id))
db.add(
ConnectionToken(
created_by_member_id=anna_fc.id,
label="Demo remote sync code",
token_hash=hash_token(DEMO_REMOTE_CONNECTION_CODE),
scopes_json=["sync:read"],
)
)
db.commit()
print("Seed complete.")
print(f"Demo invite URL: {settings.frontend_origin}/join/{DEMO_INVITE_TOKEN}")
print(f"Demo remote connection code: {DEMO_REMOTE_CONNECTION_CODE}")
finally:
db.close()
if __name__ == "__main__":
seed()

71
backend/app/main.py Normal file
View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import constant_time_equal, hash_token
from app.db.base import SessionLocal, init_db
from app.models import AppSession
from app.routers import auth, chat, groups, home, remote
settings = get_settings()
app = FastAPI(title=settings.app_name, version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
def on_startup() -> None:
init_db()
@app.middleware("http")
async def csrf_protection(request: Request, call_next):
if not settings.dev_mode and request.method not in {"GET", "HEAD", "OPTIONS"}:
session_id = request.cookies.get(settings.session_cookie_name)
if session_id:
header_token = request.headers.get("x-csrf-token") or request.cookies.get("grouphome_csrf")
if not header_token:
return JSONResponse(
status_code=403,
content={"error": {"code": "csrf_required", "message": "Security check failed.", "details": {}}},
)
db: Session = SessionLocal()
try:
session = db.get(AppSession, session_id)
if not session or not constant_time_equal(session.csrf_token_hash, hash_token(header_token)):
return JSONResponse(
status_code=403,
content={"error": {"code": "csrf_invalid", "message": "Security check failed.", "details": {}}},
)
finally:
db.close()
return await call_next(request)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
if settings.dev_mode:
raise exc
return JSONResponse(
status_code=500,
content={"error": {"code": "server_error", "message": "Something went wrong.", "details": {}}},
)
app.include_router(auth.router)
app.include_router(home.router)
app.include_router(chat.router)
app.include_router(groups.router)
app.include_router(remote.api_router)
app.include_router(remote.well_known_router)

View File

@@ -0,0 +1,439 @@
from __future__ import annotations
from datetime import date, datetime
from uuid import uuid4
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.security import utc_now
from app.db.base import Base
def uuid_str() -> str:
return str(uuid4())
class HomeProfile(Base):
__tablename__ = "home_profiles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
primary_display_name: Mapped[str] = mapped_column(String(160))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="active")
members: Mapped[list["Member"]] = relationship(back_populates="home_profile")
devices: Mapped[list["HomeDevice"]] = relationship(back_populates="home_profile")
class HomeDevice(Base):
__tablename__ = "home_devices"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
label: Mapped[str] = mapped_column(String(160))
user_agent_summary: Mapped[str] = mapped_column(String(255), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
trust_level: Mapped[str] = mapped_column(String(32), default="claimed_browser")
home_profile: Mapped[HomeProfile | None] = relationship(back_populates="devices")
class RecoveryMethod(Base):
__tablename__ = "recovery_methods"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
home_profile_id: Mapped[str] = mapped_column(ForeignKey("home_profiles.id"))
kind: Mapped[str] = mapped_column(String(32))
value_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
display_hint: Mapped[str] = mapped_column(String(160), default="")
recovery_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
recovery_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class AppSession(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
home_device_id: Mapped[str | None] = mapped_column(ForeignKey("home_devices.id"), nullable=True)
member_device_id: Mapped[str | None] = mapped_column(ForeignKey("member_devices.id"), nullable=True)
csrf_token_hash: Mapped[str] = mapped_column(String(128))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Group(Base):
__tablename__ = "groups"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
server_origin: Mapped[str] = mapped_column(String(255))
name: Mapped[str] = mapped_column(String(180))
description: Mapped[str] = mapped_column(Text, default="")
visibility: Mapped[str] = mapped_column(String(32), default="private")
default_permissions_json: Mapped[dict] = mapped_column(JSON, default=dict)
legacy_channel_status: Mapped[str] = mapped_column(String(32), default="transition")
transition_deadline: Mapped[date | None] = mapped_column(Date, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
members: Mapped[list["Member"]] = relationship(back_populates="group")
class Member(Base):
__tablename__ = "members"
__table_args__ = (UniqueConstraint("group_id", "home_profile_id", name="uq_member_group_profile"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
display_name: Mapped[str] = mapped_column(String(160))
role: Mapped[str] = mapped_column(String(32), default="member")
status: Mapped[str] = mapped_column(String(32), default="invited")
joined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
notification_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
group: Mapped[Group] = relationship(back_populates="members")
home_profile: Mapped[HomeProfile | None] = relationship(back_populates="members")
devices: Mapped[list["MemberDevice"]] = relationship(back_populates="member")
class MemberDevice(Base):
__tablename__ = "member_devices"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
label: Mapped[str] = mapped_column(String(160))
user_agent_summary: Mapped[str] = mapped_column(String(255), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
trust_level: Mapped[str] = mapped_column(String(32), default="claimed_browser")
member: Mapped[Member] = relationship(back_populates="devices")
class MemberInvite(Base):
__tablename__ = "member_invites"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
label: Mapped[str] = mapped_column(String(160))
scope: Mapped[str] = mapped_column(String(32), default="open_seat")
permission_role: Mapped[str] = mapped_column(String(32), default="member")
token_hash: Mapped[str] = mapped_column(String(128), index=True, unique=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
max_uses: Mapped[int] = mapped_column(Integer, default=50)
use_count: Mapped[int] = mapped_column(Integer, default=0)
opened_count: Mapped[int] = mapped_column(Integer, default=0)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Announcement(Base):
__tablename__ = "announcements"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
author_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
title: Mapped[str] = mapped_column(String(220))
body: Mapped[str] = mapped_column(Text, default="")
priority: Mapped[str] = mapped_column(String(32), default="normal")
official: Mapped[bool] = mapped_column(Boolean, default=True)
requires_ack: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Event(Base):
__tablename__ = "events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
title: Mapped[str] = mapped_column(String(220))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
location_name: Mapped[str | None] = mapped_column(String(220), nullable=True)
location_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
rsvp_required: Mapped[bool] = mapped_column(Boolean, default=False)
changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class EventRsvp(Base):
__tablename__ = "event_rsvps"
__table_args__ = (UniqueConstraint("event_id", "member_id", name="uq_event_member_rsvp"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
event_id: Mapped[str] = mapped_column(ForeignKey("events.id"))
member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
status: Mapped[str] = mapped_column(String(32), default="unknown")
note: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Task(Base):
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
assigned_to_member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
title: Mapped[str] = mapped_column(String(220))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="open")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Poll(Base):
__tablename__ = "polls"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
title: Mapped[str] = mapped_column(String(220))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
closes_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="open")
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class PollOption(Base):
__tablename__ = "poll_options"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
poll_id: Mapped[str] = mapped_column(ForeignKey("polls.id"))
label: Mapped[str] = mapped_column(String(220))
position: Mapped[int] = mapped_column(Integer, default=0)
class PollVote(Base):
__tablename__ = "poll_votes"
__table_args__ = (UniqueConstraint("poll_id", "member_id", name="uq_poll_member_vote"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
poll_id: Mapped[str] = mapped_column(ForeignKey("polls.id"))
option_id: Mapped[str] = mapped_column(ForeignKey("poll_options.id"))
member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class FileAsset(Base):
__tablename__ = "file_assets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
uploaded_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
filename_original: Mapped[str] = mapped_column(String(255))
filename_stored: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(160), default="application/octet-stream")
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
storage_path: Mapped[str] = mapped_column(String(500))
visibility: Mapped[str] = mapped_column(String(32), default="members")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
requires_ack: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Thread(Base):
__tablename__ = "threads"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
title: Mapped[str] = mapped_column(String(220))
kind: Mapped[str] = mapped_column(String(32), default="discussion")
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Message(Base):
__tablename__ = "messages"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
thread_id: Mapped[str] = mapped_column(ForeignKey("threads.id"))
author_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
body: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class NotificationPreference(Base):
__tablename__ = "notification_preferences"
__table_args__ = (UniqueConstraint("home_profile_id", "member_id", "category", name="uq_preference_owner_category"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
category: Mapped[str] = mapped_column(String(80))
delivery: Mapped[str] = mapped_column(String(32), default="immediate")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Notification(Base):
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
title: Mapped[str] = mapped_column(String(220))
body: Mapped[str] = mapped_column(Text, default="")
category: Mapped[str] = mapped_column(String(80), default="general")
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class ActionItem(Base):
__tablename__ = "action_items"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
remote_connection_id: Mapped[str | None] = mapped_column(ForeignKey("remote_server_connections.id"), nullable=True)
source_type: Mapped[str] = mapped_column(String(32), default="local")
source_server_origin: Mapped[str] = mapped_column(String(255))
source_group_id: Mapped[str] = mapped_column(String(80))
source_group_name: Mapped[str] = mapped_column(String(180))
type: Mapped[str] = mapped_column(String(80))
status: Mapped[str] = mapped_column(String(32), default="open")
priority: Mapped[str] = mapped_column(String(32), default="normal")
title: Mapped[str] = mapped_column(String(220))
summary: Mapped[str] = mapped_column(Text, default="")
object_type: Mapped[str] = mapped_column(String(80))
object_id: Mapped[str] = mapped_column(String(80))
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class MigrationState(Base):
__tablename__ = "migration_states"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), unique=True)
invited_count: Mapped[int] = mapped_column(Integer, default=0)
opened_count: Mapped[int] = mapped_column(Integer, default=0)
joined_count: Mapped[int] = mapped_column(Integer, default=0)
verified_count: Mapped[int] = mapped_column(Integer, default=0)
notification_enabled_count: Mapped[int] = mapped_column(Integer, default=0)
not_reached_count: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
actor_member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
actor_home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
action: Mapped[str] = mapped_column(String(120))
resource_type: Mapped[str] = mapped_column(String(80), default="")
resource_id: Mapped[str] = mapped_column(String(80), default="")
details_json: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class RemoteServerConnection(Base):
__tablename__ = "remote_server_connections"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
home_profile_id: Mapped[str] = mapped_column(ForeignKey("home_profiles.id"))
server_origin: Mapped[str] = mapped_column(String(255))
server_name: Mapped[str] = mapped_column(String(180))
api_base: Mapped[str] = mapped_column(String(255))
protocol_version: Mapped[str] = mapped_column(String(32), default="0.1")
capabilities_json: Mapped[dict] = mapped_column(JSON, default=dict)
access_token_encrypted: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="active")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class RemoteMembership(Base):
__tablename__ = "remote_memberships"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
remote_connection_id: Mapped[str] = mapped_column(ForeignKey("remote_server_connections.id"))
remote_group_id: Mapped[str] = mapped_column(String(80))
remote_member_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
group_name: Mapped[str] = mapped_column(String(180), default="")
role: Mapped[str] = mapped_column(String(32), default="member")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class RemoteSyncCursor(Base):
__tablename__ = "remote_sync_cursors"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
remote_connection_id: Mapped[str] = mapped_column(ForeignKey("remote_server_connections.id"))
cursor: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class RemoteCachedObject(Base):
__tablename__ = "remote_cached_objects"
__table_args__ = (UniqueConstraint("remote_connection_id", "object_type", "remote_id", name="uq_remote_object"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
remote_connection_id: Mapped[str] = mapped_column(ForeignKey("remote_server_connections.id"))
object_type: Mapped[str] = mapped_column(String(80))
remote_id: Mapped[str] = mapped_column(String(120))
group_remote_id: Mapped[str] = mapped_column(String(120))
group_name: Mapped[str] = mapped_column(String(180), default="")
payload_json: Mapped[dict] = mapped_column(JSON, default=dict)
updated_at_remote: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cached_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class ConnectionToken(Base):
__tablename__ = "connection_tokens"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
created_by_member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id"), nullable=True)
label: Mapped[str] = mapped_column(String(160), default="Remote connection")
token_hash: Mapped[str] = mapped_column(String(128), index=True, unique=True)
scopes_json: Mapped[list[str]] = mapped_column(JSON, default=list)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class DeviceLinkCode(Base):
__tablename__ = "device_link_codes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
code_hash: Mapped[str] = mapped_column(String(128), index=True, unique=True)
requested_device_label: Mapped[str] = mapped_column(String(160))
requested_user_agent: Mapped[str] = mapped_column(String(255), default="")
approved_by_home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
approved_by_member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)

View File

@@ -0,0 +1,2 @@
"""API routers."""

321
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,321 @@
from __future__ import annotations
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import hash_token, short_code, token_urlsafe, utc_now
from app.db.base import get_db
from app.models import (
DeviceLinkCode,
Group,
HomeDevice,
HomeProfile,
Member,
MemberDevice,
MemberInvite,
RecoveryMethod,
)
from app.schemas import DeviceLinkCodeIn, DeviceLinkComplete, DeviceLinkStart, InviteClaim, PasskeyStub, RecoveryConsume, RecoveryRequest
from app.services.auth import (
CurrentContext,
audit,
create_session,
ensure_home_profile,
get_current_context,
get_members_for_context,
get_optional_context,
set_session_cookies,
)
from app.services.passkeys import passkey_provider
from app.services.serializers import device_dict, group_dict, member_dict, profile_dict
router = APIRouter(prefix="/api", tags=["auth"])
def _invite_or_404(db: Session, raw_token: str) -> MemberInvite:
invite = db.scalar(select(MemberInvite).where(MemberInvite.token_hash == hash_token(raw_token)))
now = utc_now()
expired = invite and invite.expires_at and (invite.expires_at < (now if invite.expires_at.tzinfo else now.replace(tzinfo=None)))
if not invite or invite.revoked_at or expired or invite.use_count >= invite.max_uses:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "invite_unavailable", "message": "This invite is no longer available.", "details": {}}},
)
return invite
@router.get("/health")
def health() -> dict:
return {"ok": True, "name": get_settings().app_name}
@router.get("/me")
def me(ctx: CurrentContext = Depends(get_optional_context), db: Session = Depends(get_db)) -> dict:
memberships = []
for member in get_members_for_context(db, ctx):
group = member.group
memberships.append({"member": member_dict(member), "group": group_dict(group)})
return {
"authenticated": ctx.authenticated,
"profile": profile_dict(ctx.home_profile, ctx.member),
"member": member_dict(ctx.member) if ctx.member else None,
"memberships": memberships,
"dev_mode": get_settings().dev_mode,
}
@router.post("/auth/dev/demo-session")
def dev_demo_session(response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
settings = get_settings()
if not settings.dev_mode:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Not found.", "details": {}}})
profile = db.scalar(select(HomeProfile).where(HomeProfile.primary_display_name == "Anna Müller"))
if not profile:
raise HTTPException(status_code=409, detail={"error": {"code": "seed_missing", "message": "Run python -m app.db.seed first.", "details": {}}})
device = HomeDevice(
home_profile_id=profile.id,
label="Demo browser",
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="verified",
)
db.add(device)
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
audit(db, action="dev_demo_session", resource_type="home_profile", resource_id=profile.id)
db.commit()
set_session_cookies(response, session, csrf_token)
return {"profile": profile_dict(profile), "csrf_token": csrf_token}
@router.get("/join/{token}/preview")
def invite_preview(token: str, db: Session = Depends(get_db)) -> dict:
invite = _invite_or_404(db, token)
group = invite and db.get(Group, invite.group_id)
if invite.member_id:
member = db.get(Member, invite.member_id)
if member and member.status == "invited":
member.status = "opened"
invite.opened_count += 1
from app.models import Announcement, Event
from app.services.serializers import announcement_dict, event_dict
announcements = [
announcement_dict(item, group)
for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id, Announcement.official.is_(True)).limit(3)).all()
]
events = [event_dict(item, group) for item in db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at).limit(3)).all()]
db.commit()
return {
"group": group_dict(group),
"invite": {"label": invite.label, "expires_at": invite.expires_at.isoformat() if invite.expires_at else None, "role": invite.permission_role},
"preview": {"announcements": announcements, "events": events},
}
@router.post("/auth/invite/{token}/claim")
def claim_invite(token: str, payload: InviteClaim, response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
invite = _invite_or_404(db, token)
group = db.get(Group, invite.group_id)
member = db.get(Member, invite.member_id) if invite.member_id else None
if member is None:
member = Member(
group_id=group.id,
display_name=payload.display_name,
role=invite.permission_role,
status="joined",
joined_at=utc_now(),
last_seen_at=utc_now(),
)
db.add(member)
db.flush()
else:
member.display_name = payload.display_name
member.role = invite.permission_role if member.role == "guest" else member.role
member.status = "joined"
member.joined_at = member.joined_at or utc_now()
invite.use_count += 1
if invite.use_count >= invite.max_uses:
invite.consumed_at = utc_now()
device = MemberDevice(
member_id=member.id,
label=payload.device_label,
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="claimed_browser",
)
db.add(device)
session, csrf_token = create_session(db, home_profile=member.home_profile, member=member, member_device=device)
audit(db, action="invite_claimed", resource_type="invite", resource_id=invite.id, details={"group_id": group.id})
db.commit()
set_session_cookies(response, session, csrf_token)
return {
"member": member_dict(member),
"group": group_dict(group),
"csrf_token": csrf_token,
"next_steps": ["save_access", "enable_notifications"],
}
@router.post("/auth/recovery/request")
def recovery_request(payload: RecoveryRequest, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
profile = ensure_home_profile(db, ctx)
raw = token_urlsafe(32)
method = RecoveryMethod(
home_profile_id=profile.id,
kind="email",
value_hash=hash_token(payload.email.lower()),
display_hint=payload.email[:2] + "***" + payload.email[payload.email.rfind("@") :],
recovery_token_hash=hash_token(raw),
recovery_expires_at=utc_now() + timedelta(minutes=30),
)
db.add(method)
audit(db, ctx=ctx, action="recovery_requested", resource_type="home_profile", resource_id=profile.id)
db.commit()
result = {"ok": True, "message": "Check your email for a recovery link."}
if get_settings().dev_mode:
result.update({"dev_code": raw, "dev_link": f"{get_settings().frontend_origin}/me?recover={raw}"})
print(f"[dev recovery] {payload.email}: {raw}")
return result
@router.post("/auth/recovery/consume")
def recovery_consume(payload: RecoveryConsume, response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
method = db.scalar(select(RecoveryMethod).where(RecoveryMethod.recovery_token_hash == hash_token(payload.recovery_code)))
now = utc_now()
expired = method and method.recovery_expires_at and method.recovery_expires_at < (
now if method.recovery_expires_at.tzinfo else now.replace(tzinfo=None)
)
if not method or method.revoked_at or expired:
raise HTTPException(status_code=400, detail={"error": {"code": "recovery_invalid", "message": "This recovery link has expired.", "details": {}}})
profile = db.get(HomeProfile, method.home_profile_id)
method.verified_at = method.verified_at or utc_now()
method.recovery_token_hash = None
device = HomeDevice(
home_profile_id=profile.id,
label=payload.device_label,
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="verified",
)
db.add(device)
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
audit(db, action="recovery_consumed", resource_type="home_profile", resource_id=profile.id)
db.commit()
set_session_cookies(response, session, csrf_token)
return {"profile": profile_dict(profile), "csrf_token": csrf_token}
@router.post("/auth/device-link/start")
def device_link_start(payload: DeviceLinkStart, request: Request, db: Session = Depends(get_db)) -> dict:
code = short_code()
pending = DeviceLinkCode(
code_hash=hash_token(code),
requested_device_label=payload.device_label,
requested_user_agent=request.headers.get("user-agent", "")[:255],
expires_at=utc_now() + timedelta(minutes=10),
)
db.add(pending)
db.commit()
return {"pairing_id": pending.id, "code": code, "expires_at": pending.expires_at.isoformat()}
@router.post("/auth/device-link/approve")
def device_link_approve(payload: DeviceLinkCodeIn, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
pending = db.scalar(select(DeviceLinkCode).where(DeviceLinkCode.code_hash == hash_token(payload.code.upper().replace(" ", ""))))
now = utc_now()
expired = pending and pending.expires_at < (now if pending.expires_at.tzinfo else now.replace(tzinfo=None))
if not pending or expired or pending.completed_at:
raise HTTPException(status_code=400, detail={"error": {"code": "pairing_invalid", "message": "That link code is not active.", "details": {}}})
pending.approved_by_home_profile_id = ctx.home_profile.id if ctx.home_profile else None
pending.approved_by_member_id = ctx.member.id if ctx.member else None
pending.approved_at = utc_now()
audit(db, ctx=ctx, action="device_link_approved", resource_type="device_link", resource_id=pending.id)
db.commit()
return {"ok": True, "device_label": pending.requested_device_label}
@router.post("/auth/device-link/complete")
def device_link_complete(payload: DeviceLinkComplete, response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
pending = db.scalar(select(DeviceLinkCode).where(DeviceLinkCode.code_hash == hash_token(payload.code.upper().replace(" ", ""))))
now = utc_now()
expired = pending and pending.expires_at < (now if pending.expires_at.tzinfo else now.replace(tzinfo=None))
if not pending or expired or not pending.approved_at or pending.completed_at:
raise HTTPException(status_code=400, detail={"error": {"code": "pairing_not_ready", "message": "This link code has not been approved yet.", "details": {}}})
profile = db.get(HomeProfile, pending.approved_by_home_profile_id) if pending.approved_by_home_profile_id else None
member = db.get(Member, pending.approved_by_member_id) if pending.approved_by_member_id else None
if profile:
device = HomeDevice(
home_profile_id=profile.id,
label=payload.device_label or pending.requested_device_label,
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="claimed_browser",
)
db.add(device)
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
elif member:
member_device = MemberDevice(
member_id=member.id,
label=payload.device_label or pending.requested_device_label,
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="claimed_browser",
)
db.add(member_device)
session, csrf_token = create_session(db, member=member, member_device=member_device)
else:
raise HTTPException(status_code=400, detail={"error": {"code": "pairing_invalid", "message": "No approving device was found.", "details": {}}})
pending.completed_at = utc_now()
audit(db, action="device_link_completed", resource_type="device_link", resource_id=pending.id)
db.commit()
set_session_cookies(response, session, csrf_token)
return {"ok": True, "csrf_token": csrf_token}
@router.get("/me/devices")
def devices(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
items = []
current_id = ctx.home_device.id if ctx.home_device else (ctx.member_device.id if ctx.member_device else None)
if ctx.home_profile:
items.extend([device_dict(item, current_id) for item in db.scalars(select(HomeDevice).where(HomeDevice.home_profile_id == ctx.home_profile.id)).all()])
member_ids = [member.id for member in get_members_for_context(db, ctx)]
if member_ids:
items.extend([device_dict(item, current_id) for item in db.scalars(select(MemberDevice).where(MemberDevice.member_id.in_(member_ids))).all()])
elif ctx.member:
items.extend([device_dict(item, current_id) for item in db.scalars(select(MemberDevice).where(MemberDevice.member_id == ctx.member.id)).all()])
return {"devices": items}
@router.delete("/me/devices/{device_id}")
def revoke_device(device_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
device = None
if ctx.home_profile:
device = db.scalar(select(HomeDevice).where(HomeDevice.id == device_id, HomeDevice.home_profile_id == ctx.home_profile.id))
if not device and ctx.member:
device = db.scalar(select(MemberDevice).where(MemberDevice.id == device_id, MemberDevice.member_id == ctx.member.id))
if not device:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Device not found.", "details": {}}})
device.revoked_at = utc_now()
audit(db, ctx=ctx, action="device_revoked", resource_type="device", resource_id=device_id)
db.commit()
return {"ok": True}
@router.post("/auth/passkeys/register/options")
def passkey_register_options(payload: PasskeyStub) -> dict:
return passkey_provider.registration_options(payload.display_name)
@router.post("/auth/passkeys/register/verify")
def passkey_register_verify(payload: dict) -> dict:
return passkey_provider.verify_registration(payload)
@router.post("/auth/passkeys/login/options")
def passkey_login_options() -> dict:
return passkey_provider.login_options()
@router.post("/auth/passkeys/login/verify")
def passkey_login_verify(payload: dict) -> dict:
return passkey_provider.verify_login(payload)

138
backend/app/routers/chat.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.models import Group, Member, Message, Thread
from app.schemas import MessageCreate, ThreadCreate
from app.services.auth import CurrentContext, audit, get_current_context, get_member_for_group, get_members_for_context
from app.services.serializers import group_dict, iso, member_dict
router = APIRouter(prefix="/api", tags=["chat"])
LOW_SIGNAL_PHRASES = {"ok", "okay", "yes", "no", "thanks", "thank you", "great", "👍", "+1", "fine", "done"}
def low_signal(body: str) -> bool:
normalized = body.strip().lower()
return len(normalized) <= 24 and (normalized in LOW_SIGNAL_PHRASES or normalized.replace("!", "") in LOW_SIGNAL_PHRASES)
def message_chat_dict(message: Message, members_by_id: dict[str, Member], current_member_id: str | None) -> dict:
author = members_by_id.get(message.author_member_id)
return {
"id": message.id,
"thread_id": message.thread_id,
"author_member_id": message.author_member_id,
"author_name": author.display_name if author else "Member",
"body": message.body,
"created_at": iso(message.created_at),
"mine": message.author_member_id == current_member_id,
"low_signal": low_signal(message.body),
}
def thread_chat_dict(db: Session, thread: Thread, group: Group, current_member_id: str | None, include_messages: bool = True) -> dict:
members = db.scalars(select(Member).where(Member.group_id == group.id)).all()
members_by_id = {member.id: member for member in members}
messages = []
latest_message = None
if include_messages:
rows = list(db.scalars(select(Message).where(Message.thread_id == thread.id).order_by(Message.created_at)).all())
messages = [message_chat_dict(message, members_by_id, current_member_id) for message in rows]
latest_message = messages[-1] if messages else None
else:
row = db.scalar(select(Message).where(Message.thread_id == thread.id).order_by(desc(Message.created_at)).limit(1))
latest_message = message_chat_dict(row, members_by_id, current_member_id) if row else None
return {
"id": thread.id,
"group_id": group.id,
"group_name": group.name,
"title": thread.title,
"kind": thread.kind,
"created_at": iso(thread.created_at),
"updated_at": iso(thread.updated_at),
"latest_message": latest_message,
"messages": messages,
}
@router.get("/chat")
def chat_home(
group_id: str | None = None,
thread_id: str | None = None,
ctx: CurrentContext = Depends(get_current_context),
db: Session = Depends(get_db),
) -> dict:
memberships = get_members_for_context(db, ctx)
if not memberships:
return {"groups": [], "threads": [], "active_group": None, "active_thread": None, "current_member_id": None}
groups = [db.get(Group, member.group_id) for member in memberships]
groups = [group for group in groups if group is not None and not group.archived_at]
active_group = next((group for group in groups if group.id == group_id), groups[0])
active_member = get_member_for_group(db, ctx, active_group.id)
threads = list(db.scalars(select(Thread).where(Thread.group_id == active_group.id).order_by(desc(Thread.updated_at))).all())
if not threads:
thread = Thread(group_id=active_group.id, title="General", kind="discussion", created_by_member_id=active_member.id)
db.add(thread)
db.flush()
db.commit()
threads = [thread]
active_thread = next((thread for thread in threads if thread.id == thread_id), threads[0])
members = list(db.scalars(select(Member).where(Member.group_id == active_group.id, Member.status.in_(["joined", "verified"]))).all())
return {
"groups": [
{"group": group_dict(group), "member": member_dict(next(member for member in memberships if member.group_id == group.id))}
for group in groups
],
"active_group": group_dict(active_group),
"active_member": member_dict(active_member) if active_member else None,
"members": [member_dict(member) for member in members],
"threads": [thread_chat_dict(db, thread, active_group, active_member.id if active_member else None, include_messages=False) for thread in threads],
"active_thread": thread_chat_dict(db, active_thread, active_group, active_member.id if active_member else None, include_messages=True),
"current_member_id": active_member.id if active_member else None,
}
@router.post("/chat/threads")
def create_chat_thread(payload: ThreadCreate, group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Group not found.", "details": {}}})
member = get_member_for_group(db, ctx, group.id)
if not member:
raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "Join this group to continue.", "details": {}}})
thread = Thread(group_id=group.id, title=payload.title, kind=payload.kind, created_by_member_id=member.id)
db.add(thread)
audit(db, ctx=ctx, action="chat_thread_created", resource_type="thread", resource_id=thread.id)
db.commit()
return {"thread": thread_chat_dict(db, thread, group, member.id)}
@router.post("/chat/threads/{thread_id}/messages")
def create_chat_message(thread_id: str, payload: MessageCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
thread = db.get(Thread, thread_id)
if not thread:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Thread not found.", "details": {}}})
group = db.get(Group, thread.group_id)
member = get_member_for_group(db, ctx, group.id)
if not member:
raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "Join this group to continue.", "details": {}}})
if thread.kind == "archive":
raise HTTPException(status_code=403, detail={"error": {"code": "archive_read_only", "message": "Imported archive threads are read-only.", "details": {}}})
from app.core.security import utc_now
now = utc_now()
message = Message(thread_id=thread.id, author_member_id=member.id, body=payload.body, created_at=now)
thread.updated_at = now
db.add(message)
audit(db, ctx=ctx, action="chat_message_created", resource_type="thread", resource_id=thread.id)
db.commit()
members = {item.id: item for item in db.scalars(select(Member).where(Member.group_id == group.id)).all()}
return {"message": message_chat_dict(message, members, member.id)}

View File

@@ -0,0 +1,645 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
from fastapi.responses import FileResponse, PlainTextResponse
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import hash_token, sanitize_filename, token_urlsafe, utc_now
from app.db.base import get_db
from app.models import (
Announcement,
Event,
EventRsvp,
FileAsset,
Group,
HomeDevice,
HomeProfile,
Member,
MemberInvite,
Message,
Poll,
PollOption,
PollVote,
Task,
Thread,
)
from app.schemas import (
AnnouncementCreate,
EventCreate,
GroupCreate,
GroupPatch,
InviteCreate,
MessageCreate,
MigrationReminderRequest,
PollCreate,
PollVoteCreate,
RsvpCreate,
TaskCreate,
TaskPatch,
ThreadCreate,
)
from app.services.auth import (
CurrentContext,
audit,
create_session,
get_current_context,
get_member_for_group,
get_optional_context,
set_session_cookies,
)
from app.services.dashboard import group_dashboard
from app.services.permissions import can, require_role
from app.services.serializers import (
announcement_dict,
event_dict,
file_dict,
group_dict,
member_dict,
message_dict,
poll_dict,
task_dict,
thread_dict,
)
router = APIRouter(prefix="/api", tags=["groups"])
def _group_or_404(db: Session, group_id: str) -> Group:
group = db.get(Group, group_id)
if not group or group.archived_at:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Group not found.", "details": {}}})
return group
def _member_or_403(db: Session, ctx: CurrentContext, group_id: str) -> Member:
member = get_member_for_group(db, ctx, group_id)
if not member:
raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "Join this group to continue.", "details": {}}})
return member
@router.get("/groups")
def list_groups(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
if ctx.home_profile:
members = db.scalars(select(Member).where(Member.home_profile_id == ctx.home_profile.id, Member.status.in_(["joined", "verified"]))).all()
elif ctx.member:
members = [ctx.member]
else:
members = []
items = []
for member in members:
group = db.get(Group, member.group_id)
if group:
items.append({"group": group_dict(group), "member": member_dict(member), "dashboard": group_dashboard(db, group, member)})
return {"groups": items}
@router.post("/groups")
def create_group(
payload: GroupCreate,
response: Response,
request: Request,
ctx: CurrentContext = Depends(get_optional_context),
db: Session = Depends(get_db),
) -> dict:
settings = get_settings()
profile = ctx.home_profile
if not profile:
profile = HomeProfile(primary_display_name=payload.owner_display_name)
db.add(profile)
db.flush()
group = Group(
server_origin=settings.server_origin,
name=payload.name,
description=payload.description,
visibility=payload.visibility,
legacy_channel_status="transition",
)
db.add(group)
db.flush()
owner = Member(
group_id=group.id,
home_profile_id=profile.id,
display_name=payload.owner_display_name,
role="owner",
status="joined",
joined_at=utc_now(),
)
db.add(owner)
if not ctx.authenticated:
device = HomeDevice(
home_profile_id=profile.id,
label="Admin browser",
user_agent_summary=request.headers.get("user-agent", "")[:255],
trust_level="claimed_browser",
)
db.add(device)
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
set_session_cookies(response, session, csrf_token)
audit(db, ctx=ctx, action="group_created", resource_type="group", resource_id=group.id)
db.commit()
return {"group": group_dict(group), "member": member_dict(owner)}
@router.get("/groups/{group_id}")
def get_group(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
members = []
if can(member, "manage_members"):
members = [member_dict(item) for item in db.scalars(select(Member).where(Member.group_id == group.id)).all()]
return {"group": group_dict(group), "member": member_dict(member), "dashboard": group_dashboard(db, group, member), "members": members}
@router.patch("/groups/{group_id}")
def patch_group(group_id: str, payload: GroupPatch, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
require_role(member, "admin")
if payload.name is not None:
group.name = payload.name
if payload.description is not None:
group.description = payload.description
if payload.visibility is not None:
group.visibility = payload.visibility
if payload.legacy_channel_status is not None:
group.legacy_channel_status = payload.legacy_channel_status
if payload.transition_deadline is not None:
group.transition_deadline = date.fromisoformat(payload.transition_deadline) if payload.transition_deadline else None
group.updated_at = utc_now()
audit(db, ctx=ctx, action="group_updated", resource_type="group", resource_id=group.id)
db.commit()
return {"group": group_dict(group)}
@router.post("/groups/{group_id}/invites")
def create_invite(group_id: str, payload: InviteCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
actor = _member_or_403(db, ctx, group.id)
require_role(actor, "admin")
raw_token = token_urlsafe(24)
invite = MemberInvite(
group_id=group.id,
member_id=payload.member_id,
created_by_member_id=actor.id,
label=payload.label,
scope=payload.scope,
permission_role=payload.permission_role,
token_hash=hash_token(raw_token),
expires_at=payload.expires_at,
max_uses=payload.max_uses,
)
db.add(invite)
audit(db, ctx=ctx, action="invite_created", resource_type="group", resource_id=group.id, details={"invite_id": invite.id})
db.commit()
frontend_origin = get_settings().frontend_origin.rstrip("/")
return {"invite": {"id": invite.id, "label": invite.label, "max_uses": invite.max_uses}, "token_display_once": raw_token, "invite_url": f"{frontend_origin}/join/{raw_token}"}
@router.get("/groups/{group_id}/members")
def group_members(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
require_role(member, "admin")
return {"members": [member_dict(item) for item in db.scalars(select(Member).where(Member.group_id == group.id)).all()]}
@router.patch("/groups/{group_id}/members/{member_id}")
def patch_member(group_id: str, member_id: str, payload: dict, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
actor = _member_or_403(db, ctx, group.id)
require_role(actor, "admin")
member = db.get(Member, member_id)
if not member or member.group_id != group.id:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Member not found.", "details": {}}})
if "role" in payload:
member.role = payload["role"]
if "status" in payload:
member.status = payload["status"]
audit(db, ctx=ctx, action="member_updated", resource_type="member", resource_id=member.id)
db.commit()
return {"member": member_dict(member)}
@router.get("/groups/{group_id}/announcements")
def list_announcements(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
_member_or_403(db, ctx, group.id)
return {"announcements": [announcement_dict(item, group) for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id)).all()]}
@router.post("/groups/{group_id}/announcements")
def create_announcement(group_id: str, payload: AnnouncementCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
if payload.official:
require_role(member, "moderator")
announcement = Announcement(
group_id=group.id,
author_member_id=member.id,
title=payload.title,
body=payload.body,
priority=payload.priority,
official=payload.official,
requires_ack=payload.requires_ack,
)
db.add(announcement)
audit(db, ctx=ctx, action="announcement_created", resource_type="announcement", resource_id=announcement.id)
db.commit()
return {"announcement": announcement_dict(announcement, group)}
@router.get("/announcements/{announcement_id}")
def announcement_detail(announcement_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
announcement = db.get(Announcement, announcement_id)
if not announcement:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Announcement not found.", "details": {}}})
group = db.get(Group, announcement.group_id)
_member_or_403(db, ctx, group.id)
return {"announcement": announcement_dict(announcement, group)}
@router.get("/groups/{group_id}/events")
def list_events(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
events = db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at)).all()
return {
"events": [
event_dict(item, group, (db.scalar(select(EventRsvp).where(EventRsvp.event_id == item.id, EventRsvp.member_id == member.id)) or EventRsvp(status="unknown")).status)
for item in events
]
}
@router.post("/groups/{group_id}/events")
def create_event(group_id: str, payload: EventCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
require_role(member, "moderator")
event = Event(
group_id=group.id,
created_by_member_id=member.id,
title=payload.title,
description=payload.description,
starts_at=payload.starts_at,
ends_at=payload.ends_at,
location_name=payload.location_name,
location_address=payload.location_address,
rsvp_required=payload.rsvp_required,
)
db.add(event)
audit(db, ctx=ctx, action="event_created", resource_type="event", resource_id=event.id)
db.commit()
return {"event": event_dict(event, group)}
@router.get("/events/{event_id}")
def event_detail(event_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
event = db.get(Event, event_id)
if not event:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Event not found.", "details": {}}})
group = db.get(Group, event.group_id)
member = _member_or_403(db, ctx, group.id)
rsvp = db.scalar(select(EventRsvp).where(EventRsvp.event_id == event.id, EventRsvp.member_id == member.id))
return {"event": event_dict(event, group, rsvp.status if rsvp else "unknown")}
@router.post("/events/{event_id}/rsvp")
def rsvp(event_id: str, payload: RsvpCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
event = db.get(Event, event_id)
if not event:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Event not found.", "details": {}}})
group = db.get(Group, event.group_id)
member = _member_or_403(db, ctx, group.id)
existing = db.scalar(select(EventRsvp).where(EventRsvp.event_id == event.id, EventRsvp.member_id == member.id))
if existing:
existing.status = payload.status
existing.note = payload.note
existing.updated_at = utc_now()
rsvp_row = existing
else:
rsvp_row = EventRsvp(event_id=event.id, member_id=member.id, status=payload.status, note=payload.note, updated_at=utc_now())
db.add(rsvp_row)
audit(db, ctx=ctx, action="event_rsvp", resource_type="event", resource_id=event.id, details={"status": payload.status})
db.commit()
return {"rsvp": {"id": rsvp_row.id, "event_id": event.id, "member_id": member.id, "status": rsvp_row.status, "note": rsvp_row.note, "updated_at": rsvp_row.updated_at.isoformat()}, "event": event_dict(event, group, rsvp_row.status)}
@router.get("/groups/{group_id}/tasks")
def list_tasks(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
_member_or_403(db, ctx, group.id)
return {"tasks": [task_dict(item, group) for item in db.scalars(select(Task).where(Task.group_id == group.id)).all()]}
@router.post("/groups/{group_id}/tasks")
def create_task(group_id: str, payload: TaskCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
require_role(member, "moderator")
task = Task(
group_id=group.id,
created_by_member_id=member.id,
assigned_to_member_id=payload.assigned_to_member_id,
title=payload.title,
description=payload.description,
due_at=payload.due_at,
)
db.add(task)
audit(db, ctx=ctx, action="task_created", resource_type="task", resource_id=task.id)
db.commit()
return {"task": task_dict(task, group)}
@router.get("/tasks/{task_id}")
def task_detail(task_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
task = db.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Task not found.", "details": {}}})
group = db.get(Group, task.group_id)
_member_or_403(db, ctx, group.id)
return {"task": task_dict(task, group)}
@router.patch("/tasks/{task_id}")
def patch_task(task_id: str, payload: TaskPatch, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
task = db.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Task not found.", "details": {}}})
group = db.get(Group, task.group_id)
member = _member_or_403(db, ctx, group.id)
if task.assigned_to_member_id != member.id:
require_role(member, "moderator")
for field in ["title", "description", "assigned_to_member_id", "due_at", "status"]:
value = getattr(payload, field)
if value is not None:
setattr(task, field, value)
task.updated_at = utc_now()
audit(db, ctx=ctx, action="task_updated", resource_type="task", resource_id=task.id)
db.commit()
return {"task": task_dict(task, group)}
@router.get("/groups/{group_id}/polls")
def list_polls(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
_member_or_403(db, ctx, group.id)
polls = []
for poll in db.scalars(select(Poll).where(Poll.group_id == group.id).order_by(desc(Poll.created_at))).all():
options = list(db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all())
votes = list(db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all())
polls.append(poll_dict(poll, options, votes, group))
return {"polls": polls}
@router.post("/groups/{group_id}/polls")
def create_poll(group_id: str, payload: PollCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
poll = Poll(group_id=group.id, created_by_member_id=member.id, title=payload.title, description=payload.description, closes_at=payload.closes_at)
db.add(poll)
db.flush()
for index, option in enumerate(payload.options):
db.add(PollOption(poll_id=poll.id, label=option.label, position=index))
audit(db, ctx=ctx, action="poll_created", resource_type="poll", resource_id=poll.id)
db.commit()
options = list(db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all())
return {"poll": poll_dict(poll, options, [], group)}
@router.get("/polls/{poll_id}")
def poll_detail(poll_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
poll = db.get(Poll, poll_id)
if not poll:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Poll not found.", "details": {}}})
group = db.get(Group, poll.group_id)
_member_or_403(db, ctx, group.id)
options = list(db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all())
votes = list(db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all())
return {"poll": poll_dict(poll, options, votes, group)}
@router.post("/polls/{poll_id}/vote")
def vote_poll(poll_id: str, payload: PollVoteCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
poll = db.get(Poll, poll_id)
if not poll:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Poll not found.", "details": {}}})
group = db.get(Group, poll.group_id)
member = _member_or_403(db, ctx, group.id)
option = db.get(PollOption, payload.option_id)
if not option or option.poll_id != poll.id:
raise HTTPException(status_code=400, detail={"error": {"code": "invalid_option", "message": "Choose one of the poll options.", "details": {}}})
existing = db.scalar(select(PollVote).where(PollVote.poll_id == poll.id, PollVote.member_id == member.id))
if existing:
existing.option_id = option.id
else:
db.add(PollVote(poll_id=poll.id, option_id=option.id, member_id=member.id))
audit(db, ctx=ctx, action="poll_voted", resource_type="poll", resource_id=poll.id)
db.commit()
options = list(db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all())
votes = list(db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all())
return {"poll": poll_dict(poll, options, votes, group)}
@router.get("/groups/{group_id}/files")
def list_group_files(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
_member_or_403(db, ctx, group.id)
return {"files": [file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id)).all()]}
@router.post("/groups/{group_id}/files")
async def upload_file(
group_id: str,
upload: UploadFile = File(...),
description: str = Form(""),
requires_ack: bool = Form(False),
ctx: CurrentContext = Depends(get_current_context),
db: Session = Depends(get_db),
) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
if not can(member, "upload_file"):
raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "You cannot upload files here.", "details": {}}})
content = await upload.read()
settings = get_settings()
if len(content) > settings.max_upload_bytes:
raise HTTPException(status_code=413, detail={"error": {"code": "file_too_large", "message": "This file is too large.", "details": {}}})
original = sanitize_filename(upload.filename or "upload.bin")
stored = f"{token_urlsafe(8)}-{original}"
group_dir = Path(settings.upload_dir) / group.id
group_dir.mkdir(parents=True, exist_ok=True)
path = group_dir / stored
path.write_bytes(content)
file_asset = FileAsset(
group_id=group.id,
uploaded_by_member_id=member.id,
filename_original=original,
filename_stored=stored,
content_type=upload.content_type or "application/octet-stream",
size_bytes=len(content),
storage_path=str(path),
description=description,
requires_ack=requires_ack,
)
db.add(file_asset)
audit(db, ctx=ctx, action="file_uploaded", resource_type="file", resource_id=file_asset.id)
db.commit()
return {"file": file_dict(file_asset, group)}
@router.get("/files/{file_id}/download")
def download_file(file_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)):
file_asset = db.get(FileAsset, file_id)
if not file_asset:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "File not found.", "details": {}}})
group = db.get(Group, file_asset.group_id)
_member_or_403(db, ctx, group.id)
if file_asset.storage_path.startswith("seed://"):
return PlainTextResponse(file_asset.description or file_asset.filename_original, media_type="text/plain")
return FileResponse(file_asset.storage_path, media_type=file_asset.content_type, filename=file_asset.filename_original)
@router.get("/groups/{group_id}/threads")
def list_threads(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
_member_or_403(db, ctx, group.id)
threads = []
for thread in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at))).all():
messages = list(db.scalars(select(Message).where(Message.thread_id == thread.id).order_by(Message.created_at)).all())
threads.append(thread_dict(thread, messages, group))
return {"threads": threads}
@router.post("/groups/{group_id}/threads")
def create_thread(group_id: str, payload: ThreadCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
thread = Thread(group_id=group.id, created_by_member_id=member.id, title=payload.title, kind=payload.kind)
db.add(thread)
audit(db, ctx=ctx, action="thread_created", resource_type="thread", resource_id=thread.id)
db.commit()
return {"thread": thread_dict(thread, group=group)}
@router.post("/threads/{thread_id}/messages")
def create_message(thread_id: str, payload: MessageCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
thread = db.get(Thread, thread_id)
if not thread:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Thread not found.", "details": {}}})
group = db.get(Group, thread.group_id)
member = _member_or_403(db, ctx, group.id)
if thread.kind == "archive":
raise HTTPException(status_code=403, detail={"error": {"code": "archive_read_only", "message": "Imported archive threads are read-only.", "details": {}}})
message = Message(thread_id=thread.id, author_member_id=member.id, body=payload.body)
thread.updated_at = utc_now()
db.add(message)
audit(db, ctx=ctx, action="message_created", resource_type="thread", resource_id=thread.id)
db.commit()
return {"message": message_dict(message)}
@router.get("/groups/{group_id}/migration")
def migration_dashboard(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
require_role(member, "admin")
members = list(db.scalars(select(Member).where(Member.group_id == group.id)).all())
invites = list(db.scalars(select(MemberInvite).where(MemberInvite.group_id == group.id)).all())
stats = {
"invited": len([item for item in members if item.status == "invited"]) + sum(item.max_uses for item in invites),
"opened": len([item for item in members if item.status == "opened"]) + sum(item.opened_count for item in invites),
"joined": len([item for item in members if item.status in {"joined", "verified"}]),
"verified": len([item for item in members if item.status == "verified" or item.home_profile_id]),
"notification_enabled": len([item for item in members if item.notification_enabled_at]),
"not_reached": len([item for item in members if item.status == "invited"]),
}
return {
"group": group_dict(group),
"stats": stats,
"members": [member_dict(item) for item in members],
"invites": [
{
"id": item.id,
"label": item.label,
"scope": item.scope,
"role": item.permission_role,
"max_uses": item.max_uses,
"use_count": item.use_count,
"opened_count": item.opened_count,
"expires_at": item.expires_at.isoformat() if item.expires_at else None,
"revoked_at": item.revoked_at.isoformat() if item.revoked_at else None,
}
for item in invites
],
}
@router.post("/groups/{group_id}/migration/reminder-copy")
def migration_reminder_copy(
group_id: str,
payload: MigrationReminderRequest,
ctx: CurrentContext = Depends(get_current_context),
db: Session = Depends(get_db),
) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
require_role(member, "admin")
raw_token = token_urlsafe(24)
invite = MemberInvite(
group_id=group.id,
created_by_member_id=member.id,
label="Migration reminder invite",
scope="open_seat",
permission_role="member",
token_hash=hash_token(raw_token),
max_uses=250,
)
db.add(invite)
db.flush()
origin = (payload.frontend_origin or get_settings().frontend_origin).rstrip("/")
link = f"{origin}/join/{raw_token}"
stats = migration_dashboard(group_id, ctx, db)["stats"]
deadline = group.transition_deadline.isoformat() if group.transition_deadline else "the transition date"
copy = (
f"{stats['joined']} people have joined our new group space.\n"
f"The schedule, RSVP, files, and official updates are now here: {link}\n"
f"From {deadline}, official announcements will only be posted there."
)
audit(db, ctx=ctx, action="migration_reminder_created", resource_type="group", resource_id=group.id)
db.commit()
return {"copy": copy, "invite_url": link, "token_display_once": raw_token}
@router.post("/groups/{group_id}/migration/import-whatsapp-export")
async def import_whatsapp_export(
group_id: str,
upload: UploadFile = File(...),
ctx: CurrentContext = Depends(get_current_context),
db: Session = Depends(get_db),
) -> dict:
group = _group_or_404(db, group_id)
member = _member_or_403(db, ctx, group.id)
require_role(member, "admin")
if not (upload.filename or "").lower().endswith(".txt"):
raise HTTPException(status_code=400, detail={"error": {"code": "unsupported_file", "message": "Upload a .txt chat export.", "details": {}}})
content = (await upload.read()).decode("utf-8", errors="replace")
thread = Thread(group_id=group.id, created_by_member_id=member.id, title="Imported chat archive", kind="archive")
db.add(thread)
db.flush()
imported = 0
for line in content.splitlines()[:500]:
body = line.strip()
if not body:
continue
db.add(Message(thread_id=thread.id, author_member_id=member.id, body=body))
imported += 1
audit(db, ctx=ctx, action="whatsapp_export_imported", resource_type="group", resource_id=group.id, details={"messages": imported})
db.commit()
return {"thread": thread_dict(thread, group=group), "imported_messages": imported, "read_only": True}

167
backend/app/routers/home.py Normal file
View File

@@ -0,0 +1,167 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security import utc_now
from app.db.base import get_db
from app.models import Notification, NotificationPreference
from app.schemas import NotificationPreferencePatch
from app.services.auth import CurrentContext, get_current_context
from app.services.dashboard import calendar_items, file_items, home_dashboard, local_actions_for_context
from app.services.serializers import profile_dict
router = APIRouter(prefix="/api", tags=["home"])
DEFAULT_PREFERENCES = {
"direct_mentions": "immediate",
"event_changes": "immediate",
"urgent_announcements": "immediate",
"tasks_assigned": "immediate",
"discussions": "digest",
"new_files": "digest",
"general_chatter": "digest",
"reactions": "muted",
"off_topic": "muted",
}
@router.get("/home")
def home(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
dashboard = home_dashboard(db, ctx)
return {
"profile": profile_dict(ctx.home_profile, ctx.member),
"sections": dashboard["sections"],
"connections": dashboard["connections"],
}
@router.get("/home/actions")
def home_actions(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
return {"actions": home_dashboard(db, ctx)["sections"]["needs_me"]}
@router.get("/home/calendar")
def home_calendar(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
return {"events": calendar_items(db, ctx)}
@router.get("/home/files")
def home_files(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
return {"files": file_items(db, ctx)}
@router.get("/home/official-updates")
def official_updates(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
return {"official_updates": home_dashboard(db, ctx)["sections"]["official_updates"]}
@router.get("/home/catch-up")
def catch_up(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
return {"catch_up": home_dashboard(db, ctx)["sections"]["catch_up"]}
def _preference_owner(ctx: CurrentContext) -> tuple[str | None, str | None]:
if ctx.home_profile:
return ctx.home_profile.id, None
if ctx.member:
return None, ctx.member.id
return None, None
@router.get("/me/notification-preferences")
def get_notification_preferences(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
home_profile_id, member_id = _preference_owner(ctx)
rows = db.scalars(
select(NotificationPreference).where(
NotificationPreference.home_profile_id == home_profile_id,
NotificationPreference.member_id == member_id,
)
).all()
result = dict(DEFAULT_PREFERENCES)
for row in rows:
result[row.category] = row.delivery
return {
"headline": "Mute the noise, not the group.",
"preferences": result,
"groups": {
"Immediate": ["direct_mentions", "event_changes", "urgent_announcements", "tasks_assigned"],
"Quiet / digest": ["discussions", "new_files", "general_chatter"],
"Mute": ["reactions", "off_topic"],
},
}
@router.patch("/me/notification-preferences")
def patch_notification_preferences(
payload: NotificationPreferencePatch,
ctx: CurrentContext = Depends(get_current_context),
db: Session = Depends(get_db),
) -> dict:
home_profile_id, member_id = _preference_owner(ctx)
if home_profile_id is None and member_id is None:
raise HTTPException(status_code=401, detail={"error": {"code": "not_authenticated", "message": "Open an invite first.", "details": {}}})
for category, delivery in payload.preferences.items():
row = db.scalar(
select(NotificationPreference).where(
NotificationPreference.home_profile_id == home_profile_id,
NotificationPreference.member_id == member_id,
NotificationPreference.category == category,
)
)
if row:
row.delivery = delivery
row.enabled = delivery != "muted"
row.updated_at = utc_now()
else:
db.add(
NotificationPreference(
home_profile_id=home_profile_id,
member_id=member_id,
category=category,
delivery=delivery,
enabled=delivery != "muted",
)
)
db.commit()
return get_notification_preferences(ctx, db)
@router.get("/me/notifications")
def notifications(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
query = select(Notification)
if ctx.home_profile:
query = query.where(Notification.home_profile_id == ctx.home_profile.id)
elif ctx.member:
query = query.where(Notification.member_id == ctx.member.id)
rows = db.scalars(query.order_by(Notification.created_at.desc()).limit(50)).all()
return {
"notifications": [
{
"id": item.id,
"title": item.title,
"body": item.body,
"category": item.category,
"read_at": item.read_at.isoformat() if item.read_at else None,
"created_at": item.created_at.isoformat(),
}
for item in rows
]
}
@router.patch("/me/notifications/{notification_id}/read")
def mark_notification_read(notification_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
query = select(Notification).where(Notification.id == notification_id)
if ctx.home_profile:
query = query.where(Notification.home_profile_id == ctx.home_profile.id)
elif ctx.member:
query = query.where(Notification.member_id == ctx.member.id)
notification = db.scalar(query)
if not notification:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Notification not found.", "details": {}}})
notification.read_at = utc_now()
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security import hash_token, token_urlsafe
from app.db.base import get_db
from app.models import ConnectionToken, Group, RemoteServerConnection
from app.schemas import ConnectionTokenCreate, RemoteConnect
from app.services.auth import CurrentContext, audit, ensure_home_profile, get_current_context, get_member_for_group
from app.services.permissions import require_role
from app.services.remote import fetch_manifest, manifest, mask_store_token, sync_connection, sync_payload_for_token, validate_connection_token
from app.services.serializers import remote_connection_dict
api_router = APIRouter(prefix="/api", tags=["remote"])
well_known_router = APIRouter(tags=["remote"])
@well_known_router.get("/.well-known/group-platform.json")
def well_known_manifest() -> dict:
return manifest()
@api_router.get("/sync")
def sync(since: str | None = None, authorization: str | None = Header(default=None), db: Session = Depends(get_db)) -> dict:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail={"error": {"code": "token_required", "message": "Connection code required.", "details": {}}})
raw_token = authorization.split(" ", 1)[1].strip()
token = validate_connection_token(db, raw_token)
if not token or "sync:read" not in token.scopes_json:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"error": {"code": "permission_denied", "message": "This connection cannot sync.", "details": {}}})
return sync_payload_for_token(db, token)
@api_router.post("/connection-tokens")
def create_connection_token(payload: ConnectionTokenCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
actor = None
if payload.group_id:
group = db.get(Group, payload.group_id)
if not group:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Group not found.", "details": {}}})
actor = get_member_for_group(db, ctx, group.id)
require_role(actor, "admin")
elif ctx.member:
actor = ctx.member
raw = token_urlsafe(32)
token = ConnectionToken(
created_by_member_id=actor.id if actor else None,
group_id=payload.group_id,
label=payload.label,
token_hash=hash_token(raw),
scopes_json=payload.scopes,
expires_at=payload.expires_at,
)
db.add(token)
audit(db, ctx=ctx, action="connection_token_created", resource_type="connection_token", resource_id=token.id)
db.commit()
return {"connection_code_display_once": raw, "token": {"id": token.id, "label": token.label, "scopes": token.scopes_json, "expires_at": token.expires_at.isoformat() if token.expires_at else None}}
@api_router.get("/remote/servers")
def remote_servers(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
if not ctx.home_profile:
return {"servers": []}
rows = db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
return {"servers": [remote_connection_dict(item) for item in rows]}
@api_router.post("/remote/servers/connect")
def connect_remote(payload: RemoteConnect, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
profile = ensure_home_profile(db, ctx)
server_url = str(payload.server_url).rstrip("/")
try:
remote_manifest = fetch_manifest(server_url)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail={"error": {"code": "remote_unreachable", "message": "Could not read that server's group manifest.", "details": {"reason": str(exc)}}}) from exc
connection = RemoteServerConnection(
home_profile_id=profile.id,
server_origin=server_url,
server_name=remote_manifest.get("server_name", server_url),
api_base=remote_manifest.get("api_base", f"{server_url}/api"),
protocol_version=remote_manifest.get("protocol_version", "0.1"),
capabilities_json=remote_manifest.get("capabilities", {}),
access_token_encrypted=mask_store_token(payload.connection_code),
status="active",
)
db.add(connection)
db.flush()
audit(db, ctx=ctx, action="remote_server_connected", resource_type="remote_connection", resource_id=connection.id)
sync_connection(db, connection)
db.commit()
return {"server": remote_connection_dict(connection)}
@api_router.post("/remote/servers/{connection_id}/sync")
def sync_remote_server(connection_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
if not ctx.home_profile:
raise HTTPException(status_code=403, detail={"error": {"code": "profile_required", "message": "Save access before connecting servers.", "details": {}}})
connection = db.scalar(select(RemoteServerConnection).where(RemoteServerConnection.id == connection_id, RemoteServerConnection.home_profile_id == ctx.home_profile.id))
if not connection:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Connected server not found.", "details": {}}})
sync_connection(db, connection)
audit(db, ctx=ctx, action="remote_server_synced", resource_type="remote_connection", resource_id=connection.id)
db.commit()
return {"server": remote_connection_dict(connection)}
@api_router.delete("/remote/servers/{connection_id}")
def delete_remote_server(connection_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
if not ctx.home_profile:
raise HTTPException(status_code=403, detail={"error": {"code": "profile_required", "message": "Save access before managing servers.", "details": {}}})
connection = db.scalar(select(RemoteServerConnection).where(RemoteServerConnection.id == connection_id, RemoteServerConnection.home_profile_id == ctx.home_profile.id))
if not connection:
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Connected server not found.", "details": {}}})
connection.status = "revoked"
audit(db, ctx=ctx, action="remote_server_removed", resource_type="remote_connection", resource_id=connection.id)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,143 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, HttpUrl
class InviteClaim(BaseModel):
display_name: str = Field(min_length=1, max_length=160)
device_label: str = Field(default="Browser", max_length=160)
class RecoveryRequest(BaseModel):
email: str = Field(min_length=3, max_length=255)
class RecoveryConsume(BaseModel):
recovery_code: str = Field(min_length=6, max_length=300)
device_label: str = Field(default="Recovered browser", max_length=160)
class DeviceLinkStart(BaseModel):
device_label: str = Field(default="New browser", max_length=160)
class DeviceLinkCodeIn(BaseModel):
code: str = Field(min_length=6, max_length=32)
class DeviceLinkComplete(BaseModel):
code: str = Field(min_length=6, max_length=32)
device_label: str = Field(default="Linked browser", max_length=160)
class GroupCreate(BaseModel):
name: str = Field(min_length=2, max_length=180)
description: str = Field(default="", max_length=4000)
visibility: Literal["private", "public", "listed"] = "private"
owner_display_name: str = Field(default="Group admin", max_length=160)
class GroupPatch(BaseModel):
name: str | None = Field(default=None, max_length=180)
description: str | None = Field(default=None, max_length=4000)
visibility: Literal["private", "public", "listed"] | None = None
legacy_channel_status: Literal["none", "transition", "legacy"] | None = None
transition_deadline: str | None = None
class InviteCreate(BaseModel):
label: str = Field(default="Group invite", max_length=160)
scope: Literal["specific_member", "open_seat", "admin_invite"] = "open_seat"
permission_role: Literal["guest", "member", "admin"] = "member"
max_uses: int = Field(default=50, ge=1, le=500)
expires_at: datetime | None = None
member_id: str | None = None
class AnnouncementCreate(BaseModel):
title: str = Field(min_length=2, max_length=220)
body: str = Field(default="", max_length=12000)
priority: Literal["normal", "urgent"] = "normal"
official: bool = True
requires_ack: bool = False
class EventCreate(BaseModel):
title: str = Field(min_length=2, max_length=220)
description: str | None = Field(default=None, max_length=8000)
starts_at: datetime
ends_at: datetime | None = None
location_name: str | None = Field(default=None, max_length=220)
location_address: str | None = Field(default=None, max_length=255)
rsvp_required: bool = True
class RsvpCreate(BaseModel):
status: Literal["yes", "no", "maybe", "unknown"]
note: str | None = Field(default=None, max_length=1000)
class TaskCreate(BaseModel):
title: str = Field(min_length=2, max_length=220)
description: str | None = Field(default=None, max_length=8000)
assigned_to_member_id: str | None = None
due_at: datetime | None = None
class TaskPatch(BaseModel):
title: str | None = Field(default=None, max_length=220)
description: str | None = Field(default=None, max_length=8000)
assigned_to_member_id: str | None = None
due_at: datetime | None = None
status: Literal["open", "done", "cancelled"] | None = None
class PollOptionIn(BaseModel):
label: str = Field(min_length=1, max_length=220)
class PollCreate(BaseModel):
title: str = Field(min_length=2, max_length=220)
description: str | None = Field(default=None, max_length=8000)
closes_at: datetime | None = None
options: list[PollOptionIn] = Field(min_length=2, max_length=12)
class PollVoteCreate(BaseModel):
option_id: str
class ThreadCreate(BaseModel):
title: str = Field(min_length=2, max_length=220)
kind: Literal["discussion", "question", "archive"] = "discussion"
class MessageCreate(BaseModel):
body: str = Field(min_length=1, max_length=12000)
class NotificationPreferencePatch(BaseModel):
preferences: dict[str, Literal["immediate", "digest", "muted"]]
class MigrationReminderRequest(BaseModel):
frontend_origin: str | None = None
class ConnectionTokenCreate(BaseModel):
label: str = Field(default="Home server connection", max_length=160)
group_id: str | None = None
scopes: list[str] = Field(default_factory=lambda: ["sync:read"])
expires_at: datetime | None = None
class RemoteConnect(BaseModel):
server_url: HttpUrl
connection_code: str = Field(min_length=10, max_length=300)
class PasskeyStub(BaseModel):
display_name: str | None = Field(default=None, max_length=160)

View File

@@ -0,0 +1,2 @@
"""Application services."""

View File

@@ -0,0 +1,194 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import hash_token, session_expiry, token_urlsafe, utc_now
from app.db.base import get_db
from app.models import (
AppSession,
AuditLog,
HomeDevice,
HomeProfile,
Member,
MemberDevice,
)
@dataclass
class CurrentContext:
session: AppSession | None
home_profile: HomeProfile | None
member: Member | None
home_device: HomeDevice | None
member_device: MemberDevice | None
@property
def authenticated(self) -> bool:
return self.session is not None
def _expired(dt: datetime) -> bool:
now = utc_now()
if dt.tzinfo is None:
return dt < now.replace(tzinfo=None)
return dt < now
def create_session(
db: Session,
*,
home_profile: HomeProfile | None = None,
member: Member | None = None,
home_device: HomeDevice | None = None,
member_device: MemberDevice | None = None,
) -> tuple[AppSession, str]:
csrf_token = token_urlsafe(24)
session = AppSession(
home_profile_id=home_profile.id if home_profile else None,
member_id=member.id if member else None,
home_device_id=home_device.id if home_device else None,
member_device_id=member_device.id if member_device else None,
csrf_token_hash=hash_token(csrf_token),
expires_at=session_expiry(),
)
db.add(session)
db.flush()
return session, csrf_token
def set_session_cookies(response: Response, session: AppSession, csrf_token: str) -> None:
settings = get_settings()
response.set_cookie(
settings.session_cookie_name,
session.id,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
max_age=60 * 60 * 24 * 30,
path="/",
)
response.set_cookie(
"grouphome_csrf",
csrf_token,
httponly=False,
secure=settings.cookie_secure,
samesite="lax",
max_age=60 * 60 * 24 * 30,
path="/",
)
def clear_session_cookies(response: Response) -> None:
settings = get_settings()
response.delete_cookie(settings.session_cookie_name, path="/")
response.delete_cookie("grouphome_csrf", path="/")
def load_context_from_request(request: Request, db: Session) -> CurrentContext:
settings = get_settings()
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
return CurrentContext(None, None, None, None, None)
session = db.get(AppSession, session_id)
if not session or session.revoked_at or _expired(session.expires_at):
return CurrentContext(None, None, None, None, None)
home_profile = db.get(HomeProfile, session.home_profile_id) if session.home_profile_id else None
member = db.get(Member, session.member_id) if session.member_id else None
home_device = db.get(HomeDevice, session.home_device_id) if session.home_device_id else None
member_device = db.get(MemberDevice, session.member_device_id) if session.member_device_id else None
now = utc_now()
if home_device and not home_device.revoked_at:
home_device.last_seen_at = now
if member_device and not member_device.revoked_at:
member_device.last_seen_at = now
db.flush()
return CurrentContext(session, home_profile, member, home_device, member_device)
def get_optional_context(request: Request, db: Session = Depends(get_db)) -> CurrentContext:
return load_context_from_request(request, db)
def get_current_context(request: Request, db: Session = Depends(get_db)) -> CurrentContext:
ctx = load_context_from_request(request, db)
if not ctx.authenticated:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "not_authenticated", "message": "Open an invite or recover access to continue.", "details": {}}},
)
return ctx
def get_members_for_context(db: Session, ctx: CurrentContext) -> list[Member]:
if ctx.home_profile:
return list(
db.scalars(
select(Member).where(
Member.home_profile_id == ctx.home_profile.id,
Member.status.in_(["joined", "verified"]),
)
).all()
)
if ctx.member:
return [ctx.member]
return []
def get_member_for_group(db: Session, ctx: CurrentContext, group_id: str) -> Member | None:
if ctx.member and ctx.member.group_id == group_id and ctx.member.status != "left":
return ctx.member
if ctx.home_profile:
return db.scalar(
select(Member).where(
Member.home_profile_id == ctx.home_profile.id,
Member.group_id == group_id,
Member.status.in_(["joined", "verified"]),
)
)
return None
def ensure_home_profile(db: Session, ctx: CurrentContext, display_name: str | None = None) -> HomeProfile:
if ctx.home_profile:
return ctx.home_profile
name = display_name or (ctx.member.display_name if ctx.member else "GroupHome member")
profile = HomeProfile(primary_display_name=name)
db.add(profile)
db.flush()
if ctx.member and ctx.member.home_profile_id is None:
ctx.member.home_profile_id = profile.id
if ctx.session:
ctx.session.home_profile_id = profile.id
ctx.home_profile = profile
db.flush()
return profile
def audit(
db: Session,
*,
ctx: CurrentContext | None = None,
action: str,
resource_type: str = "",
resource_id: str = "",
details: dict | None = None,
) -> None:
db.add(
AuditLog(
actor_member_id=ctx.member.id if ctx and ctx.member else None,
actor_home_profile_id=ctx.home_profile.id if ctx and ctx.home_profile else None,
action=action,
resource_type=resource_type,
resource_id=resource_id,
details_json=details or {},
)
)

View File

@@ -0,0 +1,341 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.core.security import utc_now
from app.models import (
Announcement,
Event,
EventRsvp,
FileAsset,
Group,
Member,
Poll,
PollOption,
PollVote,
RemoteCachedObject,
RemoteServerConnection,
Task,
Thread,
)
from app.services.auth import CurrentContext, get_members_for_context
from app.services.serializers import (
action_dict,
announcement_dict,
event_dict,
file_dict,
poll_dict,
remote_connection_dict,
task_dict,
thread_dict,
)
PRIORITY_ORDER = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
def _db_now():
return utc_now().replace(tzinfo=None)
def _member_rsvp_status(db: Session, event_id: str, member_id: str) -> str:
rsvp = db.scalar(select(EventRsvp).where(EventRsvp.event_id == event_id, EventRsvp.member_id == member_id))
return rsvp.status if rsvp else "unknown"
def _local_actions_for_member(db: Session, member: Member) -> list[dict[str, Any]]:
group = db.get(Group, member.group_id)
if not group:
return []
now = _db_now()
actions: list[dict[str, Any]] = []
events = db.scalars(select(Event).where(Event.group_id == group.id, Event.starts_at >= now - timedelta(days=1))).all()
for event in events:
status = _member_rsvp_status(db, event.id, member.id)
if event.rsvp_required and status == "unknown":
actions.append(
action_dict(
id=f"local:rsvp:{member.id}:{event.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="rsvp_required",
priority="urgent" if event.starts_at <= now + timedelta(days=2) else "high",
title=f"RSVP: {event.title}",
summary=event.location_name or "Let the group know if you can make it.",
object_type="event",
object_id=event.id,
due_at=event.starts_at,
)
)
if event.changed_at and (not member.last_seen_at or event.changed_at > member.last_seen_at):
actions.append(
action_dict(
id=f"local:event_changed:{member.id}:{event.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="event_changed",
priority="high",
title=f"Changed: {event.title}",
summary="Time or location changed since your last visit.",
object_type="event",
object_id=event.id,
due_at=event.starts_at,
)
)
tasks = db.scalars(select(Task).where(Task.assigned_to_member_id == member.id, Task.status == "open")).all()
for task in tasks:
actions.append(
action_dict(
id=f"local:task:{member.id}:{task.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="task_assigned",
priority="high" if task.due_at and task.due_at <= now + timedelta(days=3) else "normal",
title=task.title,
summary=task.description or "Task assigned to you.",
object_type="task",
object_id=task.id,
due_at=task.due_at,
)
)
polls = db.scalars(select(Poll).where(Poll.group_id == group.id, Poll.status == "open")).all()
for poll in polls:
existing_vote = db.scalar(select(PollVote).where(PollVote.poll_id == poll.id, PollVote.member_id == member.id))
if not existing_vote:
actions.append(
action_dict(
id=f"local:poll:{member.id}:{poll.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="vote_required",
priority="high" if poll.closes_at and poll.closes_at <= now + timedelta(days=2) else "normal",
title=poll.title,
summary=poll.description or "Add your vote.",
object_type="poll",
object_id=poll.id,
due_at=poll.closes_at,
)
)
files = db.scalars(select(FileAsset).where(FileAsset.group_id == group.id, FileAsset.requires_ack.is_(True))).all()
for file in files:
actions.append(
action_dict(
id=f"local:file_ack:{member.id}:{file.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="file_ack",
priority="normal",
title=f"Read: {file.filename_original}",
summary=file.description or "Please review this file.",
object_type="file",
object_id=file.id,
due_at=None,
)
)
announcements = db.scalars(
select(Announcement).where(Announcement.group_id == group.id, Announcement.requires_ack.is_(True), Announcement.official.is_(True))
).all()
for announcement in announcements:
actions.append(
action_dict(
id=f"local:announcement_ack:{member.id}:{announcement.id}",
source_type="local",
source_server_origin=group.server_origin,
source_group_id=group.id,
source_group_name=group.name,
type="admin_request",
priority="urgent" if announcement.priority == "urgent" else "normal",
title=announcement.title,
summary=announcement.body[:180],
object_type="announcement",
object_id=announcement.id,
due_at=None,
)
)
return actions
def local_actions_for_context(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
actions: list[dict[str, Any]] = []
for member in get_members_for_context(db, ctx):
actions.extend(_local_actions_for_member(db, member))
return sort_actions(actions)
def sort_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(
actions,
key=lambda item: (
PRIORITY_ORDER.get(item.get("priority", "normal"), 9),
item.get("due_at") or "9999-12-31T00:00:00",
item.get("title") or "",
),
)
def remote_items_for_context(db: Session, ctx: CurrentContext, object_type: str | None = None) -> list[dict[str, Any]]:
if not ctx.home_profile:
return []
connections = db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
connection_ids = [connection.id for connection in connections]
if not connection_ids:
return []
query = select(RemoteCachedObject).where(RemoteCachedObject.remote_connection_id.in_(connection_ids))
if object_type:
query = query.where(RemoteCachedObject.object_type == object_type)
rows = db.scalars(query).all()
connection_by_id = {connection.id: connection for connection in connections}
items: list[dict[str, Any]] = []
for row in rows:
payload = dict(row.payload_json or {})
connection = connection_by_id.get(row.remote_connection_id)
payload.setdefault("source_type", "remote")
payload.setdefault("source_server_origin", connection.server_origin if connection else "")
payload.setdefault("source_group_id", row.group_remote_id)
payload.setdefault("source_group_name", row.group_name)
payload.setdefault("remote_connection_id", row.remote_connection_id)
items.append(payload)
return items
def home_dashboard(db: Session, ctx: CurrentContext) -> dict[str, Any]:
members = get_members_for_context(db, ctx)
member_group_ids = [member.group_id for member in members]
now = _db_now()
local_actions = local_actions_for_context(db, ctx)
remote_actions = remote_items_for_context(db, ctx, "action")
needs_me = sort_actions(local_actions + remote_actions)
today: list[dict[str, Any]] = []
if member_group_ids:
events = db.scalars(
select(Event)
.where(Event.group_id.in_(member_group_ids), Event.starts_at >= now - timedelta(hours=6))
.order_by(Event.starts_at)
.limit(50)
).all()
member_by_group = {member.group_id: member for member in members}
for event in events:
group = db.get(Group, event.group_id)
member = member_by_group.get(event.group_id)
today.append(event_dict(event, group, _member_rsvp_status(db, event.id, member.id) if member else None))
today.extend(remote_items_for_context(db, ctx, "event"))
today = sorted(today, key=lambda item: item.get("starts_at") or "9999-12-31T00:00:00")[:50]
changed = [action for action in needs_me if action["type"] in {"event_changed", "admin_request"}][:20]
official_updates: list[dict[str, Any]] = []
if member_group_ids:
announcements = db.scalars(
select(Announcement)
.where(Announcement.group_id.in_(member_group_ids), Announcement.official.is_(True))
.order_by(desc(Announcement.created_at))
.limit(20)
).all()
for announcement in announcements:
official_updates.append(announcement_dict(announcement, db.get(Group, announcement.group_id)))
official_updates.extend(remote_items_for_context(db, ctx, "announcement"))
discussion_count = 0
if member_group_ids:
discussion_count = db.scalar(select(func.count()).select_from(Thread).where(Thread.group_id.in_(member_group_ids), Thread.kind == "discussion")) or 0
catch_up = [
{"label": "official announcements", "count": len(official_updates)},
{"label": "changed events", "count": len([item for item in changed if item["type"] == "event_changed"])},
{"label": "open actions", "count": len(needs_me)},
{"label": "discussion threads", "count": discussion_count + len(remote_items_for_context(db, ctx, "thread"))},
]
connections = []
if ctx.home_profile:
connections = [
remote_connection_dict(connection)
for connection in db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
]
return {
"sections": {
"needs_me": needs_me,
"today": today,
"changed": changed,
"official_updates": official_updates,
"catch_up": catch_up,
},
"connections": connections,
}
def calendar_items(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
items = home_dashboard(db, ctx)["sections"]["today"]
return sorted(items, key=lambda item: item.get("starts_at") or "9999-12-31T00:00:00")
def file_items(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
members = get_members_for_context(db, ctx)
group_ids = [member.group_id for member in members]
files: list[dict[str, Any]] = []
if group_ids:
for file in db.scalars(select(FileAsset).where(FileAsset.group_id.in_(group_ids)).order_by(desc(FileAsset.created_at))).all():
files.append(file_dict(file, db.get(Group, file.group_id)))
files.extend(remote_items_for_context(db, ctx, "file"))
return files
def group_dashboard(db: Session, group: Group, member: Member | None = None) -> dict[str, Any]:
rsvp_member_id = member.id if member else ""
announcements = [
announcement_dict(item, group)
for item in db.scalars(
select(Announcement).where(Announcement.group_id == group.id).order_by(desc(Announcement.created_at)).limit(20)
).all()
]
events = [
event_dict(item, group, _member_rsvp_status(db, item.id, rsvp_member_id) if rsvp_member_id else None)
for item in db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at).limit(30)).all()
]
tasks = [task_dict(item, group) for item in db.scalars(select(Task).where(Task.group_id == group.id).order_by(Task.status, Task.due_at)).all()]
files = [file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id).order_by(desc(FileAsset.created_at))).all()]
polls = []
for poll in db.scalars(select(Poll).where(Poll.group_id == group.id).order_by(desc(Poll.created_at))).all():
options = db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all()
votes = db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all()
polls.append(poll_dict(poll, list(options), list(votes), group))
threads = [
thread_dict(item, group=group)
for item in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at)).limit(20)).all()
]
important_now = _local_actions_for_member(db, member) if member else []
return {
"important_now": sort_actions(important_now)[:8],
"upcoming": events[:8],
"open_actions": sort_actions(important_now),
"announcements": announcements,
"tasks": tasks,
"polls": polls,
"files": files,
"discussions": threads,
}

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from app.core.security import token_urlsafe
class PasskeyProvider:
def registration_options(self, display_name: str | None = None) -> dict:
raise NotImplementedError
def verify_registration(self, payload: dict) -> dict:
raise NotImplementedError
def login_options(self) -> dict:
raise NotImplementedError
def verify_login(self, payload: dict) -> dict:
raise NotImplementedError
class DevelopmentPasskeyProvider(PasskeyProvider):
"""Development-only passkey-shaped adapter.
It preserves the API contract and UI flow without claiming production WebAuthn.
"""
def registration_options(self, display_name: str | None = None) -> dict:
return {"challenge": token_urlsafe(24), "display_name": display_name, "development_only": True}
def verify_registration(self, payload: dict) -> dict:
return {"verified": True, "trust_level": "passkey_ready", "development_only": True}
def login_options(self) -> dict:
return {"challenge": token_urlsafe(24), "development_only": True}
def verify_login(self, payload: dict) -> dict:
return {"verified": True, "development_only": True}
passkey_provider: PasskeyProvider = DevelopmentPasskeyProvider()

View File

@@ -0,0 +1,41 @@
from fastapi import HTTPException, status
from app.models import Member
ROLE_ORDER = {
"guest": 0,
"member": 10,
"moderator": 20,
"admin": 30,
"owner": 40,
}
def has_role(member: Member | None, min_role: str) -> bool:
if member is None:
return False
return ROLE_ORDER.get(member.role, -1) >= ROLE_ORDER.get(min_role, 999)
def require_role(member: Member | None, min_role: str = "admin") -> None:
if not has_role(member, min_role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": {"code": "permission_denied", "message": "You do not have permission to do that.", "details": {}}},
)
def can(member: Member | None, action: str, resource: object | None = None) -> bool:
if member is None:
return False
if action in {"rsvp", "vote", "comment", "upload_file", "view_group"}:
return ROLE_ORDER.get(member.role, -1) >= ROLE_ORDER["member"]
if action == "create_official_announcement":
return has_role(member, "moderator")
if action in {"create_invite", "view_migration", "manage_members", "create_connection_token"}:
return has_role(member, "admin")
if action in {"create_event", "create_task"}:
return has_role(member, "moderator")
return False

View File

@@ -0,0 +1,175 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import hash_token, utc_now
from app.models import ConnectionToken, RemoteCachedObject, RemoteServerConnection, RemoteSyncCursor
from app.services.dashboard import home_dashboard
def mask_store_token(raw_token: str) -> str:
return f"dev:{raw_token}"
def unmask_store_token(stored: str) -> str:
if stored.startswith("dev:"):
return stored[4:]
return stored
def validate_connection_token(db: Session, raw_token: str) -> ConnectionToken | None:
token = db.scalar(select(ConnectionToken).where(ConnectionToken.token_hash == hash_token(raw_token)))
if not token or token.revoked_at:
return None
if token.expires_at:
now = utc_now()
expires = token.expires_at if token.expires_at.tzinfo else token.expires_at.replace(tzinfo=now.tzinfo)
if expires < now:
return None
return token
def manifest() -> dict[str, Any]:
settings = get_settings()
return {
"server_name": settings.server_name,
"api_base": settings.api_base_url,
"protocol_version": "0.1",
"capabilities": {
"events": True,
"tasks": True,
"files": True,
"chat": True,
"polls": True,
"federation": False,
},
}
def sync_payload_for_token(db: Session, token: ConnectionToken | None) -> dict[str, Any]:
settings = get_settings()
fake_ctx = type("SyncContext", (), {"home_profile": None, "member": None, "session": None})()
payload = home_dashboard(db, fake_ctx) if False else None
actions: list[dict[str, Any]] = []
events: list[dict[str, Any]] = []
announcements: list[dict[str, Any]] = []
files: list[dict[str, Any]] = []
threads: list[dict[str, Any]] = []
from sqlalchemy import desc
from app.models import Announcement, Event, FileAsset, Group, Poll, Task, Thread
from app.services.dashboard import _local_actions_for_member
from app.services.serializers import announcement_dict, event_dict, file_dict, thread_dict
group_filter = [token.group_id] if token and token.group_id else [group.id for group in db.scalars(select(Group)).all()]
groups = [db.get(Group, group_id) for group_id in group_filter]
for group in [item for item in groups if item]:
for member in group.members:
if member.status in {"joined", "verified"}:
actions.extend(_local_actions_for_member(db, member))
break
events.extend([event_dict(item, group) for item in db.scalars(select(Event).where(Event.group_id == group.id)).all()])
announcements.extend(
[announcement_dict(item, group) for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id, Announcement.official.is_(True))).all()]
)
files.extend([file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id)).all()])
threads.extend(
[thread_dict(item, group=group) for item in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at))).all()]
)
for collection in (actions, events, announcements, files, threads):
for item in collection:
item["source_type"] = "remote"
item["source_server_origin"] = settings.server_origin
return {
"cursor": utc_now().isoformat(),
"server_time": utc_now().isoformat(),
"actions": actions,
"events": events,
"announcements": announcements,
"files": files,
"threads": threads,
}
def fetch_manifest(server_url: str) -> dict[str, Any]:
settings = get_settings()
with httpx.Client(timeout=settings.remote_request_timeout_seconds, follow_redirects=True) as client:
response = client.get(f"{server_url.rstrip('/')}/.well-known/group-platform.json")
response.raise_for_status()
return response.json()
def sync_connection(db: Session, connection: RemoteServerConnection) -> RemoteServerConnection:
settings = get_settings()
cursor = db.scalar(select(RemoteSyncCursor).where(RemoteSyncCursor.remote_connection_id == connection.id))
since = cursor.cursor if cursor else None
params = {"since": since} if since else {}
raw_token = unmask_store_token(connection.access_token_encrypted)
try:
with httpx.Client(timeout=settings.remote_request_timeout_seconds, follow_redirects=True) as client:
response = client.get(f"{connection.api_base.rstrip('/')}/sync", params=params, headers={"Authorization": f"Bearer {raw_token}"})
response.raise_for_status()
payload = response.json()
except Exception as exc: # noqa: BLE001
connection.status = "error"
connection.last_error = str(exc)
connection.updated_at = utc_now()
db.flush()
return connection
for object_type, collection_name in [
("action", "actions"),
("event", "events"),
("announcement", "announcements"),
("file", "files"),
("thread", "threads"),
]:
for item in payload.get(collection_name, []):
remote_id = str(item.get("id") or item.get("object_id") or f"{object_type}:{len(item)}")
group_remote_id = str(item.get("source_group_id") or item.get("group_id") or "remote")
group_name = str(item.get("source_group_name") or item.get("group_name") or connection.server_name)
existing = db.scalar(
select(RemoteCachedObject).where(
RemoteCachedObject.remote_connection_id == connection.id,
RemoteCachedObject.object_type == object_type,
RemoteCachedObject.remote_id == remote_id,
)
)
if existing:
existing.group_remote_id = group_remote_id
existing.group_name = group_name
existing.payload_json = item
existing.cached_at = utc_now()
else:
db.add(
RemoteCachedObject(
remote_connection_id=connection.id,
object_type=object_type,
remote_id=remote_id,
group_remote_id=group_remote_id,
group_name=group_name,
payload_json=item,
)
)
next_cursor = payload.get("cursor")
if cursor:
cursor.cursor = next_cursor
cursor.updated_at = utc_now()
else:
db.add(RemoteSyncCursor(remote_connection_id=connection.id, cursor=next_cursor))
connection.status = "active"
connection.last_error = None
connection.last_sync_at = utc_now()
connection.updated_at = utc_now()
db.flush()
return connection

View File

@@ -0,0 +1,260 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from app.models import (
Announcement,
Event,
FileAsset,
Group,
HomeDevice,
HomeProfile,
Member,
MemberDevice,
Message,
Poll,
PollOption,
PollVote,
RemoteServerConnection,
Task,
Thread,
)
def iso(value: datetime | date | None) -> str | None:
if value is None:
return None
return value.isoformat()
def group_dict(group: Group) -> dict[str, Any]:
return {
"id": group.id,
"server_origin": group.server_origin,
"name": group.name,
"description": group.description,
"visibility": group.visibility,
"legacy_channel_status": group.legacy_channel_status,
"transition_deadline": iso(group.transition_deadline),
"created_at": iso(group.created_at),
"updated_at": iso(group.updated_at),
}
def member_dict(member: Member) -> dict[str, Any]:
return {
"id": member.id,
"group_id": member.group_id,
"home_profile_id": member.home_profile_id,
"display_name": member.display_name,
"role": member.role,
"status": member.status,
"joined_at": iso(member.joined_at),
"last_seen_at": iso(member.last_seen_at),
"notification_enabled_at": iso(member.notification_enabled_at),
}
def profile_dict(profile: HomeProfile | None, member: Member | None = None) -> dict[str, Any] | None:
if profile:
return {
"id": profile.id,
"primary_display_name": profile.primary_display_name,
"status": profile.status,
"last_seen_at": iso(profile.last_seen_at),
}
if member:
return {
"id": None,
"primary_display_name": member.display_name,
"status": "membership_only",
"last_seen_at": iso(member.last_seen_at),
}
return None
def announcement_dict(announcement: Announcement, group: Group | None = None) -> dict[str, Any]:
return {
"id": announcement.id,
"group_id": announcement.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"author_member_id": announcement.author_member_id,
"title": announcement.title,
"body": announcement.body,
"priority": announcement.priority,
"official": announcement.official,
"requires_ack": announcement.requires_ack,
"created_at": iso(announcement.created_at),
"updated_at": iso(announcement.updated_at),
}
def event_dict(event: Event, group: Group | None = None, rsvp_status: str | None = None) -> dict[str, Any]:
return {
"id": event.id,
"group_id": event.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"created_by_member_id": event.created_by_member_id,
"title": event.title,
"description": event.description,
"starts_at": iso(event.starts_at),
"ends_at": iso(event.ends_at),
"location_name": event.location_name,
"location_address": event.location_address,
"rsvp_required": event.rsvp_required,
"rsvp_status": rsvp_status,
"changed_at": iso(event.changed_at),
"created_at": iso(event.created_at),
"updated_at": iso(event.updated_at),
}
def task_dict(task: Task, group: Group | None = None) -> dict[str, Any]:
return {
"id": task.id,
"group_id": task.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"created_by_member_id": task.created_by_member_id,
"assigned_to_member_id": task.assigned_to_member_id,
"title": task.title,
"description": task.description,
"due_at": iso(task.due_at),
"status": task.status,
"created_at": iso(task.created_at),
"updated_at": iso(task.updated_at),
}
def poll_dict(poll: Poll, options: list[PollOption], votes: list[PollVote], group: Group | None = None) -> dict[str, Any]:
counts: dict[str, int] = {option.id: 0 for option in options}
for vote in votes:
counts[vote.option_id] = counts.get(vote.option_id, 0) + 1
return {
"id": poll.id,
"group_id": poll.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"title": poll.title,
"description": poll.description,
"closes_at": iso(poll.closes_at),
"status": poll.status,
"created_by_member_id": poll.created_by_member_id,
"created_at": iso(poll.created_at),
"options": [{"id": option.id, "label": option.label, "position": option.position, "vote_count": counts.get(option.id, 0)} for option in options],
}
def file_dict(file: FileAsset, group: Group | None = None) -> dict[str, Any]:
return {
"id": file.id,
"group_id": file.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"uploaded_by_member_id": file.uploaded_by_member_id,
"filename_original": file.filename_original,
"content_type": file.content_type,
"size_bytes": file.size_bytes,
"visibility": file.visibility,
"description": file.description,
"requires_ack": file.requires_ack,
"created_at": iso(file.created_at),
"download_url": f"/api/files/{file.id}/download",
}
def thread_dict(thread: Thread, messages: list[Message] | None = None, group: Group | None = None) -> dict[str, Any]:
return {
"id": thread.id,
"group_id": thread.group_id,
"group_name": group.name if group else None,
"source_type": "local",
"source_server_origin": group.server_origin if group else None,
"title": thread.title,
"kind": thread.kind,
"created_by_member_id": thread.created_by_member_id,
"created_at": iso(thread.created_at),
"updated_at": iso(thread.updated_at),
"messages": [message_dict(message) for message in messages or []],
}
def message_dict(message: Message) -> dict[str, Any]:
return {
"id": message.id,
"thread_id": message.thread_id,
"author_member_id": message.author_member_id,
"body": message.body,
"created_at": iso(message.created_at),
"edited_at": iso(message.edited_at),
"deleted_at": iso(message.deleted_at),
}
def action_dict(
*,
id: str,
source_type: str,
source_server_origin: str,
source_group_id: str,
source_group_name: str,
type: str,
priority: str,
title: str,
summary: str,
object_type: str,
object_id: str,
due_at: datetime | None = None,
status: str = "open",
) -> dict[str, Any]:
return {
"id": id,
"source_type": source_type,
"source_server_origin": source_server_origin,
"source_group_id": source_group_id,
"source_group_name": source_group_name,
"type": type,
"status": status,
"priority": priority,
"title": title,
"summary": summary,
"object_type": object_type,
"object_id": object_id,
"due_at": iso(due_at),
}
def device_dict(device: HomeDevice | MemberDevice, current_id: str | None = None) -> dict[str, Any]:
return {
"id": device.id,
"label": device.label,
"created_at": iso(device.created_at),
"last_seen_at": iso(device.last_seen_at),
"revoked_at": iso(device.revoked_at),
"trust_level": device.trust_level,
"current": device.id == current_id,
}
def remote_connection_dict(connection: RemoteServerConnection) -> dict[str, Any]:
return {
"id": connection.id,
"server_origin": connection.server_origin,
"server_name": connection.server_name,
"api_base": connection.api_base,
"protocol_version": connection.protocol_version,
"capabilities": connection.capabilities_json,
"status": connection.status,
"last_sync_at": iso(connection.last_sync_at),
"last_error": connection.last_error,
"created_at": iso(connection.created_at),
}

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
import os
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
@pytest.fixture()
def client(tmp_path, monkeypatch):
db_path = tmp_path / "test.db"
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_path}")
monkeypatch.setenv("SESSION_SECRET", "test-secret")
monkeypatch.setenv("DEV_MODE", "true")
monkeypatch.setenv("UPLOAD_DIR", str(tmp_path / "uploads"))
monkeypatch.setenv("FRONTEND_ORIGIN", "http://testserver")
monkeypatch.setenv("SERVER_ORIGIN", "http://testserver")
monkeypatch.setenv("API_BASE_URL", "http://testserver/api")
from app.core.config import get_settings
get_settings.cache_clear()
import app.db.base as base
from app.core.config import get_settings as refreshed_settings
from app.db.seed import seed
from app.main import app
settings = refreshed_settings()
base.settings = settings
base.engine.dispose()
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
base.engine = create_engine(settings.database_url, connect_args={"check_same_thread": False}, future=True)
base.SessionLocal = sessionmaker(bind=base.engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True)
seed()
with TestClient(app) as test_client:
yield test_client
def test_invite_is_hashed_and_claim_creates_session(client):
from app.core.security import hash_token
from app.db.base import SessionLocal
from app.db.seed import DEMO_INVITE_TOKEN
from app.models import MemberInvite
db = SessionLocal()
try:
invite = db.scalar(select(MemberInvite).where(MemberInvite.token_hash == hash_token(DEMO_INVITE_TOKEN)))
assert invite is not None
assert invite.token_hash != DEMO_INVITE_TOKEN
finally:
db.close()
preview = client.get(f"/api/join/{DEMO_INVITE_TOKEN}/preview")
assert preview.status_code == 200
assert preview.json()["group"]["name"] == "FC Kreuzberg U12 Parents"
claimed = client.post(f"/api/auth/invite/{DEMO_INVITE_TOKEN}/claim", json={"display_name": "New Parent", "device_label": "Pytest browser"})
assert claimed.status_code == 200
assert "grouphome_session" in claimed.cookies
assert claimed.json()["member"]["status"] == "joined"
def test_limited_invite_blocks_after_max_use(client):
client.post("/api/auth/dev/demo-session", json={})
groups = client.get("/api/groups").json()["groups"]
group_id = groups[0]["group"]["id"]
created = client.post(f"/api/groups/{group_id}/invites", json={"label": "Once", "max_uses": 1})
token = created.json()["token_display_once"]
first = TestClient(client.app)
assert first.post(f"/api/auth/invite/{token}/claim", json={"display_name": "First", "device_label": "Browser"}).status_code == 200
second = TestClient(client.app)
assert second.post(f"/api/auth/invite/{token}/claim", json={"display_name": "Second", "device_label": "Browser"}).status_code == 404
def test_home_dashboard_contains_required_sections_and_actions(client):
client.post("/api/auth/dev/demo-session", json={})
response = client.get("/api/home")
assert response.status_code == 200
sections = response.json()["sections"]
assert set(sections) == {"needs_me", "today", "changed", "official_updates", "catch_up"}
types = {item["type"] for item in sections["needs_me"]}
assert {"rsvp_required", "task_assigned", "vote_required"}.issubset(types)
def test_permissions_and_rsvp_flow(client):
client.post("/api/auth/dev/demo-session", json={})
groups = client.get("/api/groups").json()["groups"]
school = next(item for item in groups if item["group"]["name"] == "Class 4B Parents")
group_id = school["group"]["id"]
denied = client.post(f"/api/groups/{group_id}/announcements", json={"title": "Official", "body": "No", "official": True})
assert denied.status_code == 403
fc = next(item for item in groups if item["group"]["name"] == "FC Kreuzberg U12 Parents")
event = next(item for item in fc["dashboard"]["upcoming"] if item["rsvp_required"])
rsvp = client.post(f"/api/events/{event['id']}/rsvp", json={"status": "yes"})
assert rsvp.status_code == 200
home = client.get("/api/home").json()
assert event["id"] not in [item["object_id"] for item in home["sections"]["needs_me"] if item["type"] == "rsvp_required"]
def test_device_link_flow(client):
client.post("/api/auth/dev/demo-session", json={})
start = client.post("/api/auth/device-link/start", json={"device_label": "Laptop"})
code = start.json()["code"]
assert client.post("/api/auth/device-link/approve", json={"code": code}).status_code == 200
other = TestClient(client.app)
complete = other.post("/api/auth/device-link/complete", json={"code": code, "device_label": "Laptop"})
assert complete.status_code == 200
devices = client.get("/api/me/devices").json()["devices"]
assert len(devices) >= 2
def test_recovery_flow(client):
client.post("/api/auth/dev/demo-session", json={})
requested = client.post("/api/auth/recovery/request", json={"email": "anna@example.test"})
code = requested.json()["dev_code"]
other = TestClient(client.app)
consumed = other.post("/api/auth/recovery/consume", json={"recovery_code": code, "device_label": "Recovered"})
assert consumed.status_code == 200
def test_remote_manifest_token_and_sync(client):
client.post("/api/auth/dev/demo-session", json={})
manifest = client.get("/.well-known/group-platform.json")
assert manifest.status_code == 200
assert manifest.json()["capabilities"]["federation"] is False
token_response = client.post("/api/connection-tokens", json={"label": "Test sync", "scopes": ["sync:read"]})
raw = token_response.json()["connection_code_display_once"]
denied = client.get("/api/sync")
assert denied.status_code == 401
synced = client.get("/api/sync", headers={"Authorization": f"Bearer {raw}"})
assert synced.status_code == 200
assert synced.json()["events"]
def test_chat_home_and_message_metadata(client):
client.post("/api/auth/dev/demo-session", json={})
chat = client.get("/api/chat")
assert chat.status_code == 200
payload = chat.json()
assert payload["groups"]
assert payload["threads"]
thread_id = payload["active_thread"]["id"]
sent = client.post(f"/api/chat/threads/{thread_id}/messages", json={"body": "ok"})
assert sent.status_code == 200
message = sent.json()["message"]
assert message["author_name"] == "Anna Müller"
assert message["mine"] is True
assert message["low_signal"] is True
def test_file_upload_requires_auth_and_sanitizes_filename(client, tmp_path):
no_auth = TestClient(client.app)
assert no_auth.post("/api/groups/not-real/files", files={"upload": ("bad.txt", b"x", "text/plain")}).status_code == 401
client.post("/api/auth/dev/demo-session", json={})
group_id = client.get("/api/groups").json()["groups"][0]["group"]["id"]
uploaded = client.post(
f"/api/groups/{group_id}/files",
data={"description": "test"},
files={"upload": ("../../unsafe name.txt", b"hello", "text/plain")},
)
assert uploaded.status_code == 200
assert uploaded.json()["file"]["filename_original"] == "unsafe name.txt"

33
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,33 @@
[project]
name = "grouphome-backend"
version = "0.1.0"
description = "FastAPI backend for the GroupHome browser-first group coordination platform."
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy>=2.0.30",
"pydantic-settings>=2.4.0",
"python-multipart>=0.0.9",
"httpx>=0.27.0",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]
[project.optional-dependencies]
test = [
"pytest>=8.2.0",
]
[tool.pytest.ini_options]
testpaths = ["app/tests"]
pythonpath = ["."]
[tool.ruff]
line-length = 140