Files
comiaunicaty/backend/app/routers/groups.py

646 lines
28 KiB
Python

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}