inital commit, very early alpha stage
This commit is contained in:
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal 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
13
backend/README.md
Normal 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
2
backend/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""GroupHome backend package."""
|
||||
|
||||
2
backend/app/core/__init__.py
Normal file
2
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core configuration and security helpers."""
|
||||
|
||||
36
backend/app/core/config.py
Normal file
36
backend/app/core/config.py
Normal 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
|
||||
|
||||
44
backend/app/core/security.py
Normal file
44
backend/app/core/security.py
Normal 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"
|
||||
|
||||
2
backend/app/db/__init__.py
Normal file
2
backend/app/db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Database helpers."""
|
||||
|
||||
31
backend/app/db/base.py
Normal file
31
backend/app/db/base.py
Normal 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
320
backend/app/db/seed.py
Normal 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
71
backend/app/main.py
Normal 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)
|
||||
439
backend/app/models/__init__.py
Normal file
439
backend/app/models/__init__.py
Normal 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)
|
||||
|
||||
2
backend/app/routers/__init__.py
Normal file
2
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""API routers."""
|
||||
|
||||
321
backend/app/routers/auth.py
Normal file
321
backend/app/routers/auth.py
Normal 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
138
backend/app/routers/chat.py
Normal 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)}
|
||||
645
backend/app/routers/groups.py
Normal file
645
backend/app/routers/groups.py
Normal 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
167
backend/app/routers/home.py
Normal 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}
|
||||
119
backend/app/routers/remote.py
Normal file
119
backend/app/routers/remote.py
Normal 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}
|
||||
143
backend/app/schemas/__init__.py
Normal file
143
backend/app/schemas/__init__.py
Normal 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)
|
||||
2
backend/app/services/__init__.py
Normal file
2
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Application services."""
|
||||
|
||||
194
backend/app/services/auth.py
Normal file
194
backend/app/services/auth.py
Normal 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 {},
|
||||
)
|
||||
)
|
||||
341
backend/app/services/dashboard.py
Normal file
341
backend/app/services/dashboard.py
Normal 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,
|
||||
}
|
||||
39
backend/app/services/passkeys.py
Normal file
39
backend/app/services/passkeys.py
Normal 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()
|
||||
41
backend/app/services/permissions.py
Normal file
41
backend/app/services/permissions.py
Normal 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
|
||||
|
||||
175
backend/app/services/remote.py
Normal file
175
backend/app/services/remote.py
Normal 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
|
||||
|
||||
260
backend/app/services/serializers.py
Normal file
260
backend/app/services/serializers.py
Normal 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),
|
||||
}
|
||||
|
||||
172
backend/app/tests/test_acceptance.py
Normal file
172
backend/app/tests/test_acceptance.py
Normal 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
33
backend/pyproject.toml
Normal 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
|
||||
Reference in New Issue
Block a user