inital commit, very early alpha stage
This commit is contained in:
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}
|
||||
Reference in New Issue
Block a user