inital commit, very early alpha stage

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

View File

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

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

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

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

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

View File

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

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

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

View File

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