646 lines
28 KiB
Python
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}
|