1973 lines
70 KiB
Python
1973 lines
70 KiB
Python
"""
|
||
Members router — all member-facing and admin-member endpoints.
|
||
|
||
Public (rate-limited):
|
||
POST /members/claim/request — send claim code to a pre-registered email
|
||
POST /members/claim/complete — verify code + set password → enter onboarding
|
||
POST /members/auth/login — password check → send 2FA code
|
||
POST /members/auth/login/verify — verify 2FA code → issue JWT pair
|
||
POST /members/auth/refresh — rotate refresh token
|
||
POST /members/auth/logout — revoke current session + log logout
|
||
|
||
Member-authenticated:
|
||
GET /members/onboarding — onboarding lifecycle + editable onboarding data
|
||
PUT /members/onboarding — update onboarding data and mark it complete
|
||
POST /members/onboarding/contract — sign the service agreement
|
||
GET /members/me — own profile
|
||
PUT /members/me — update contact details
|
||
GET /members/walks — completed walks
|
||
GET /members/bookings — all bookings
|
||
POST /members/bookings — request a new booking
|
||
GET /members/contract — onboarding form / contract data
|
||
GET /members/messages — admin messages (excludes soft-deleted)
|
||
PUT /members/messages/{id}/read — mark message as read
|
||
DELETE /members/messages/{id} — soft-delete (dismiss) a message
|
||
POST /members/messages/{id}/reply — send a reply to an admin message
|
||
|
||
Admin-authenticated (re-uses existing admin JWT):
|
||
POST /admin/members — create a new onboarding member
|
||
GET /admin/members — list all members
|
||
POST /admin/members/{id}/activate — activate a completed onboarding member
|
||
POST /admin/walks — record a completed walk
|
||
GET /admin/messages — list admin-sent messages with read status
|
||
POST /admin/messages — send a message to a member
|
||
GET /admin/notifications — recent actionable admin notifications
|
||
"""
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import secrets
|
||
import uuid
|
||
from datetime import date, datetime, time, timedelta, timezone
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||
from fastapi.responses import StreamingResponse
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.auth.jwt import (
|
||
create_access_token,
|
||
create_refresh_token,
|
||
hash_refresh_token,
|
||
get_token_expiry,
|
||
)
|
||
from app.auth.password import verify_password, hash_password
|
||
from app.auth.deps import get_current_user # admin guard
|
||
from app.auth.member_deps import get_authenticated_member, get_current_member # member guards
|
||
from app.database import get_db
|
||
from app.middleware.rate_limit import limiter
|
||
from app.models.member import (
|
||
Member,
|
||
MemberVerificationCode,
|
||
MemberRefreshToken,
|
||
MagicLinkToken,
|
||
Walk,
|
||
Booking,
|
||
AdminMessage,
|
||
)
|
||
from app.models.audit import AuditLog
|
||
from app.models.contact_lead import ContactLead
|
||
from app.models.user import User
|
||
from app.schemas.audit import AuditLogResponse
|
||
from app.schemas.member import (
|
||
MagicLinkVerifySchema,
|
||
MemberClaimVerifyCodeSchema,
|
||
ClaimRequestSchema,
|
||
ClaimCompleteSchema,
|
||
MemberLoginSchema,
|
||
MemberLoginVerifySchema,
|
||
MemberTokenResponse,
|
||
MemberRefreshSchema,
|
||
MemberLogoutSchema,
|
||
MemberProfileResponse,
|
||
MemberProfileUpdate,
|
||
MemberOnboardingResponse,
|
||
MemberOnboardingUpdate,
|
||
ContractSignSchema,
|
||
WalkResponse,
|
||
BookingCreate,
|
||
BookingAvailabilityResponse,
|
||
BookingAvailabilityDayResponse,
|
||
BookingResponse,
|
||
BookingSlotAvailabilityResponse,
|
||
MessageResponse,
|
||
MemberReplySchema,
|
||
AdminCreateMember,
|
||
AdminMemberResponse,
|
||
AdminMemberUpdate,
|
||
AdminBookingCreate,
|
||
AdminBookingResponse,
|
||
AdminBookingUpdate,
|
||
AdminRecordWalk,
|
||
AdminSendMessage,
|
||
AdminMemberToggleAction,
|
||
AdminNotificationSettingsResponse,
|
||
AdminNotificationSettingsUpdate,
|
||
AdminNotificationRunResponse,
|
||
AdminNotificationFeedItemResponse,
|
||
AdminNotificationsResponse,
|
||
AdminMessageHistoryResponse,
|
||
ContractResponse,
|
||
)
|
||
from app.services.audit import log_audit
|
||
from app.config import settings
|
||
from app.services.email import send_claim_code, send_login_2fa
|
||
from app.services.notifications import (
|
||
get_notification_settings_snapshot,
|
||
run_automatic_notifications,
|
||
send_account_activated_notification,
|
||
send_booking_rescheduled_notification,
|
||
send_booking_status_notification,
|
||
send_walk_completed_notification,
|
||
update_notification_settings_snapshot,
|
||
)
|
||
from app.services.pricing import normalize_service_pricing_overrides
|
||
from app.services.settings import get_feature_settings_snapshot
|
||
|
||
router = APIRouter(tags=["Members"])
|
||
|
||
_CODE_BYTES = 3 # 6 hex digits → 000000–ffffff (1-in-16M brute force)
|
||
MEMBER_STATUS_INVITED = "invited"
|
||
MEMBER_STATUS_ONBOARDING = "onboarding"
|
||
MEMBER_STATUS_PENDING_CONTRACT = "pending_contract"
|
||
MEMBER_STATUS_PENDING_REVIEW = "pending_review"
|
||
MEMBER_STATUS_ACTIVE = "active"
|
||
MEMBER_STATUS_ARCHIVED = "archived"
|
||
DEFAULT_CONTRACT_VERSION = "goodwalk-service-agreement-2026-03"
|
||
ADMIN_MEMBER_STATUSES = {
|
||
MEMBER_STATUS_INVITED,
|
||
MEMBER_STATUS_ONBOARDING,
|
||
MEMBER_STATUS_PENDING_CONTRACT,
|
||
MEMBER_STATUS_PENDING_REVIEW,
|
||
MEMBER_STATUS_ACTIVE,
|
||
MEMBER_STATUS_ARCHIVED,
|
||
}
|
||
BOOKING_SLOT_CAPACITY = 12
|
||
BOOKING_ACTIVE_STATUSES = {"pending", "confirmed", "assigned", "in_progress"}
|
||
BOOKING_TIMESLOT_HOURS = {"am": 7, "pm": 13}
|
||
NZ_TIMEZONE = ZoneInfo("Pacific/Auckland")
|
||
|
||
|
||
async def _require_bookings_enabled(db: AsyncSession) -> None:
|
||
feature_settings = await get_feature_settings_snapshot(db)
|
||
if not feature_settings.bookings_enabled:
|
||
raise HTTPException(status_code=404, detail="Bookings are currently disabled.")
|
||
|
||
|
||
async def _require_walks_enabled(db: AsyncSession) -> None:
|
||
feature_settings = await get_feature_settings_snapshot(db)
|
||
if not feature_settings.walks_enabled:
|
||
raise HTTPException(status_code=404, detail="Walks are currently disabled.")
|
||
|
||
|
||
async def _require_messages_enabled(db: AsyncSession) -> None:
|
||
feature_settings = await get_feature_settings_snapshot(db)
|
||
if not feature_settings.messages_enabled:
|
||
raise HTTPException(status_code=404, detail="Messages are currently disabled.")
|
||
|
||
|
||
async def _issue_member_tokens(db: AsyncSession, member: Member, *, now: datetime | None = None) -> MemberTokenResponse:
|
||
issued_at = now or datetime.now(timezone.utc)
|
||
access_token = create_access_token(data={"sub": str(member.id), "role": "member"})
|
||
plaintext_refresh, refresh_hash = create_refresh_token()
|
||
refresh_token_row = MemberRefreshToken(
|
||
member_id=member.id,
|
||
token_hash=refresh_hash,
|
||
expires_at=get_token_expiry(),
|
||
revoked=False,
|
||
created_at=issued_at,
|
||
)
|
||
db.add(refresh_token_row)
|
||
await db.flush()
|
||
return MemberTokenResponse(
|
||
access_token=access_token,
|
||
refresh_token=plaintext_refresh,
|
||
token_type="bearer",
|
||
)
|
||
|
||
|
||
def _generate_code() -> str:
|
||
"""Return a 6-character uppercase hex code."""
|
||
return secrets.token_hex(_CODE_BYTES).upper()
|
||
|
||
|
||
def _notification_settings_response(settings_snapshot) -> AdminNotificationSettingsResponse:
|
||
return AdminNotificationSettingsResponse(
|
||
automatic_member_notifications_enabled=settings_snapshot.automatic_member_notifications_enabled,
|
||
nz_public_holiday_notifications_enabled=settings_snapshot.nz_public_holiday_notifications_enabled,
|
||
invoice_reminder_notifications_enabled=settings_snapshot.invoice_reminder_notifications_enabled,
|
||
invoice_day_of_week=settings_snapshot.invoice_day_of_week,
|
||
)
|
||
|
||
|
||
def _hash_code(code: str) -> str:
|
||
return hashlib.sha256(code.encode()).hexdigest()
|
||
|
||
|
||
def _set_member_status(member: Member, next_status: str) -> None:
|
||
now = datetime.now(timezone.utc)
|
||
|
||
if next_status not in ADMIN_MEMBER_STATUSES:
|
||
raise HTTPException(status_code=422, detail="Invalid member status.")
|
||
|
||
if next_status in {
|
||
MEMBER_STATUS_ONBOARDING,
|
||
MEMBER_STATUS_PENDING_CONTRACT,
|
||
MEMBER_STATUS_PENDING_REVIEW,
|
||
MEMBER_STATUS_ACTIVE,
|
||
} and not member.is_claimed:
|
||
raise HTTPException(status_code=400, detail="Member must claim their account before entering this stage.")
|
||
|
||
if next_status == MEMBER_STATUS_INVITED:
|
||
member.member_status = MEMBER_STATUS_INVITED
|
||
member.onboarding_completed_at = None
|
||
member.contract_signed_at = None
|
||
member.contract_signer_name = None
|
||
member.contract_version = None
|
||
member.activated_at = None
|
||
return
|
||
|
||
if next_status == MEMBER_STATUS_ONBOARDING:
|
||
member.member_status = MEMBER_STATUS_ONBOARDING
|
||
member.onboarding_completed_at = None
|
||
member.contract_signed_at = None
|
||
member.contract_signer_name = None
|
||
member.contract_version = None
|
||
member.activated_at = None
|
||
return
|
||
|
||
if next_status == MEMBER_STATUS_PENDING_CONTRACT:
|
||
member.member_status = MEMBER_STATUS_PENDING_CONTRACT
|
||
member.onboarding_completed_at = member.onboarding_completed_at or now
|
||
member.contract_signed_at = None
|
||
member.contract_signer_name = None
|
||
member.contract_version = None
|
||
member.activated_at = None
|
||
return
|
||
|
||
if next_status == MEMBER_STATUS_PENDING_REVIEW:
|
||
if member.onboarding_completed_at is None:
|
||
raise HTTPException(status_code=400, detail="Member must complete onboarding before entering review.")
|
||
member.member_status = MEMBER_STATUS_PENDING_REVIEW
|
||
member.activated_at = None
|
||
return
|
||
|
||
if next_status == MEMBER_STATUS_ARCHIVED:
|
||
member.member_status = MEMBER_STATUS_ARCHIVED
|
||
member.is_active = False
|
||
return
|
||
|
||
if member.onboarding_completed_at is None:
|
||
raise HTTPException(status_code=400, detail="Member has not completed onboarding yet.")
|
||
if member.contract_signed_at is None:
|
||
raise HTTPException(status_code=400, detail="Member has not signed the contract yet.")
|
||
|
||
member.member_status = MEMBER_STATUS_ACTIVE
|
||
member.activated_at = member.activated_at or now
|
||
|
||
|
||
async def _get_member_or_404(db: AsyncSession, member_id: uuid.UUID) -> Member:
|
||
result = await db.execute(select(Member).where(Member.id == member_id))
|
||
member = result.scalars().first()
|
||
if member is None:
|
||
raise HTTPException(status_code=404, detail="Member not found.")
|
||
return member
|
||
|
||
|
||
def _normalize_timeslot(value: str | None, requested_date: datetime | None = None) -> str:
|
||
if value is None and requested_date is not None:
|
||
localized = requested_date if requested_date.tzinfo is None else requested_date.astimezone(NZ_TIMEZONE)
|
||
value = "pm" if localized.hour >= 12 else "am"
|
||
|
||
timeslot = (value or "am").strip().lower()
|
||
if timeslot not in BOOKING_TIMESLOT_HOURS:
|
||
raise HTTPException(status_code=422, detail="Timeslot must be AM or PM.")
|
||
return timeslot
|
||
|
||
|
||
def _requested_date_to_nz_date(value: datetime | None) -> date | None:
|
||
if value is None:
|
||
return None
|
||
if value.tzinfo is None:
|
||
return value.date()
|
||
return value.astimezone(NZ_TIMEZONE).date()
|
||
|
||
|
||
def _requested_day_or_date(requested_day: str | None, requested_date: datetime | None) -> date | None:
|
||
if requested_day:
|
||
try:
|
||
return datetime.strptime(requested_day, "%Y-%m-%d").date()
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="Requested day must use YYYY-MM-DD format.") from exc
|
||
return _requested_date_to_nz_date(requested_date)
|
||
|
||
|
||
def _booking_slot_bounds(target_date: date, timeslot: str) -> tuple[datetime, datetime]:
|
||
if timeslot == "am":
|
||
start_local = datetime.combine(target_date, time(hour=0, minute=0), NZ_TIMEZONE)
|
||
end_local = datetime.combine(target_date, time(hour=12, minute=0), NZ_TIMEZONE)
|
||
else:
|
||
start_local = datetime.combine(target_date, time(hour=12, minute=0), NZ_TIMEZONE)
|
||
end_local = datetime.combine(target_date + timedelta(days=1), time(hour=0, minute=0), NZ_TIMEZONE)
|
||
return start_local.astimezone(timezone.utc), end_local.astimezone(timezone.utc)
|
||
|
||
|
||
def _booking_slot_label(target_date: date) -> str:
|
||
return f"{target_date.strftime('%a')} {target_date.day} {target_date.strftime('%b')}"
|
||
|
||
|
||
async def _count_slot_bookings(db: AsyncSession, target_date: date, timeslot: str) -> int:
|
||
start_at, end_at = _booking_slot_bounds(target_date, timeslot)
|
||
result = await db.execute(
|
||
select(func.count(Booking.id)).where(
|
||
Booking.requested_date.is_not(None),
|
||
Booking.requested_date >= start_at,
|
||
Booking.requested_date < end_at,
|
||
Booking.status.in_(BOOKING_ACTIVE_STATUSES),
|
||
)
|
||
)
|
||
return int(result.scalar_one() or 0)
|
||
|
||
|
||
async def _build_booking_availability_day(
|
||
db: AsyncSession,
|
||
target_date: date,
|
||
) -> BookingAvailabilityDayResponse:
|
||
slots: list[BookingSlotAvailabilityResponse] = []
|
||
for slot, label in (("am", "AM"), ("pm", "PM")):
|
||
booked = await _count_slot_bookings(db, target_date, slot)
|
||
remaining = max(BOOKING_SLOT_CAPACITY - booked, 0)
|
||
slots.append(
|
||
BookingSlotAvailabilityResponse(
|
||
slot=slot,
|
||
label=label,
|
||
booked=booked,
|
||
capacity=BOOKING_SLOT_CAPACITY,
|
||
remaining=remaining,
|
||
is_available=booked < BOOKING_SLOT_CAPACITY,
|
||
)
|
||
)
|
||
|
||
return BookingAvailabilityDayResponse(
|
||
date=target_date.isoformat(),
|
||
label=_booking_slot_label(target_date),
|
||
slots=slots,
|
||
)
|
||
|
||
|
||
async def _build_booking_availability(
|
||
db: AsyncSession,
|
||
target_date: date,
|
||
) -> BookingAvailabilityResponse:
|
||
selected = await _build_booking_availability_day(db, target_date)
|
||
|
||
alternatives: list[BookingAvailabilityDayResponse] = []
|
||
candidate = target_date
|
||
while len(alternatives) < 4:
|
||
candidate += timedelta(days=1)
|
||
if candidate.weekday() >= 5:
|
||
continue
|
||
alternatives.append(await _build_booking_availability_day(db, candidate))
|
||
|
||
return BookingAvailabilityResponse(
|
||
requested_date=target_date.isoformat(),
|
||
selected=selected,
|
||
alternatives=alternatives,
|
||
)
|
||
|
||
|
||
def _slot_details_for_day(day: BookingAvailabilityDayResponse, timeslot: str) -> BookingSlotAvailabilityResponse:
|
||
for slot in day.slots:
|
||
if slot.slot == timeslot:
|
||
return slot
|
||
raise HTTPException(status_code=500, detail="Booking availability data is incomplete.")
|
||
|
||
|
||
async def _sync_lead_status_for_member(db: AsyncSession, member: Member) -> None:
|
||
lead_result = await db.execute(select(ContactLead).where(ContactLead.invited_member_id == member.id))
|
||
lead = lead_result.scalars().first()
|
||
if lead is None:
|
||
return
|
||
|
||
if member.member_status == MEMBER_STATUS_ACTIVE:
|
||
lead.status = "converted"
|
||
elif member.member_status == MEMBER_STATUS_ARCHIVED:
|
||
lead.status = "archived"
|
||
elif member.member_status == MEMBER_STATUS_INVITED:
|
||
lead.status = "invite"
|
||
lead.invited_at = None
|
||
else:
|
||
lead.status = "invited"
|
||
|
||
|
||
# ── Magic link authentication ──────────────────────────────────────────────────
|
||
|
||
@router.post("/members/auth/magic", response_model=MemberTokenResponse)
|
||
@limiter.limit("10/minute")
|
||
async def member_magic_auth(
|
||
request: Request,
|
||
response: Response,
|
||
data: MagicLinkVerifySchema,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
now = datetime.now(timezone.utc)
|
||
token_hash = hashlib.sha256(data.token.encode()).hexdigest()
|
||
result = await db.execute(
|
||
select(MagicLinkToken).where(
|
||
MagicLinkToken.token_hash == token_hash,
|
||
MagicLinkToken.used_at.is_(None),
|
||
MagicLinkToken.expires_at > now,
|
||
)
|
||
)
|
||
magic_token = result.scalars().first()
|
||
if magic_token is None:
|
||
raise HTTPException(status_code=400, detail="Invalid or expired invitation link.")
|
||
|
||
magic_token.used_at = now
|
||
|
||
result = await db.execute(select(Member).where(Member.id == magic_token.member_id))
|
||
member = result.scalars().first()
|
||
if member is None or not member.is_active:
|
||
raise HTTPException(status_code=400, detail="Invalid or expired invitation link.")
|
||
|
||
# Transition INVITED → ONBOARDING on first magic link use
|
||
if member.member_status == MEMBER_STATUS_INVITED:
|
||
member.member_status = MEMBER_STATUS_ONBOARDING
|
||
|
||
access_token = create_access_token(data={"sub": str(member.id), "role": "member"})
|
||
plaintext_refresh, refresh_hash = create_refresh_token()
|
||
mrt = MemberRefreshToken(
|
||
member_id=member.id,
|
||
token_hash=refresh_hash,
|
||
expires_at=get_token_expiry(),
|
||
revoked=False,
|
||
created_at=now,
|
||
)
|
||
db.add(mrt)
|
||
await db.flush()
|
||
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="magic_link_auth",
|
||
area="members/auth/magic",
|
||
description="Member authenticated via magic link.",
|
||
status="success",
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
|
||
return MemberTokenResponse(
|
||
access_token=access_token,
|
||
refresh_token=plaintext_refresh,
|
||
token_type="bearer",
|
||
)
|
||
|
||
|
||
# ── Claim ──────────────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/members/claim/request", status_code=200)
|
||
@limiter.limit("5/minute")
|
||
async def claim_request(
|
||
request: Request,
|
||
response: Response,
|
||
data: ClaimRequestSchema,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result = await db.execute(
|
||
select(Member).where(Member.email == data.email.lower())
|
||
)
|
||
member = result.scalars().first()
|
||
|
||
# Always return 200 — don't reveal whether the email exists
|
||
if member is None or member.is_claimed:
|
||
return {"message": "If this email is registered, a code has been sent."}
|
||
|
||
code = _generate_code()
|
||
expires = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||
vc = MemberVerificationCode(
|
||
member_id=member.id,
|
||
code_hash=_hash_code(code),
|
||
purpose="claim",
|
||
expires_at=expires,
|
||
)
|
||
db.add(vc)
|
||
await db.flush()
|
||
|
||
await send_claim_code(member.email, member.first_name, code)
|
||
return {"message": "If this email is registered, a code has been sent."}
|
||
|
||
|
||
@router.post("/members/claim/complete", status_code=200)
|
||
@limiter.limit("10/minute")
|
||
async def claim_complete(
|
||
request: Request,
|
||
response: Response,
|
||
data: ClaimCompleteSchema,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
if len(data.password) < 8:
|
||
raise HTTPException(status_code=422, detail="Password must be at least 8 characters.")
|
||
|
||
result = await db.execute(
|
||
select(Member).where(Member.email == data.email.lower())
|
||
)
|
||
member = result.scalars().first()
|
||
if member is None or member.is_claimed:
|
||
raise HTTPException(status_code=400, detail="Invalid or already claimed account.")
|
||
|
||
now = datetime.now(timezone.utc)
|
||
code_hash = _hash_code(data.code.upper().strip())
|
||
result = await db.execute(
|
||
select(MemberVerificationCode).where(
|
||
MemberVerificationCode.member_id == member.id,
|
||
MemberVerificationCode.code_hash == code_hash,
|
||
MemberVerificationCode.purpose == "claim",
|
||
MemberVerificationCode.used_at.is_(None),
|
||
MemberVerificationCode.expires_at > now,
|
||
)
|
||
)
|
||
vc = result.scalars().first()
|
||
if vc is None:
|
||
raise HTTPException(status_code=400, detail="Invalid or expired code.")
|
||
|
||
vc.used_at = now
|
||
member.hashed_password = hash_password(data.password)
|
||
member.is_claimed = True
|
||
member.claimed_at = now
|
||
if member.member_status == MEMBER_STATUS_INVITED:
|
||
member.member_status = MEMBER_STATUS_ONBOARDING
|
||
await db.flush()
|
||
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="account_claimed",
|
||
area="members/claim",
|
||
description="Member claimed their account and set a password.",
|
||
status="success",
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
|
||
return {
|
||
"message": "Account claimed successfully. Continue onboarding to activate your membership.",
|
||
"member_status": member.member_status,
|
||
}
|
||
|
||
|
||
@router.post("/members/claim/send-code", status_code=200)
|
||
@limiter.limit("5/minute")
|
||
async def claim_send_code(
|
||
request: Request,
|
||
response: Response,
|
||
member: Member = Depends(get_authenticated_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Send a claim code to the authenticated (but unclaimed) member's email."""
|
||
if member.is_claimed:
|
||
return {"message": "Account is already claimed."}
|
||
|
||
code = _generate_code()
|
||
expires = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||
vc = MemberVerificationCode(
|
||
member_id=member.id,
|
||
code_hash=_hash_code(code),
|
||
purpose="claim",
|
||
expires_at=expires,
|
||
)
|
||
db.add(vc)
|
||
await db.flush()
|
||
|
||
await send_claim_code(member.email, member.first_name, code)
|
||
return {"message": "A claim code has been sent to your email."}
|
||
|
||
|
||
@router.post("/members/claim/verify-code", status_code=200)
|
||
@limiter.limit("10/minute")
|
||
async def claim_verify_code(
|
||
request: Request,
|
||
response: Response,
|
||
data: MemberClaimVerifyCodeSchema,
|
||
member: Member = Depends(get_authenticated_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Verify a claim code for an authenticated magic-link user → marks account as claimed."""
|
||
if member.is_claimed:
|
||
return {"message": "Account is already claimed."}
|
||
|
||
now = datetime.now(timezone.utc)
|
||
code_hash = _hash_code(data.code.upper().strip())
|
||
result = await db.execute(
|
||
select(MemberVerificationCode).where(
|
||
MemberVerificationCode.member_id == member.id,
|
||
MemberVerificationCode.code_hash == code_hash,
|
||
MemberVerificationCode.purpose == "claim",
|
||
MemberVerificationCode.used_at.is_(None),
|
||
MemberVerificationCode.expires_at > now,
|
||
)
|
||
)
|
||
vc = result.scalars().first()
|
||
if vc is None:
|
||
raise HTTPException(status_code=400, detail="Invalid or expired code.")
|
||
|
||
if len(data.password) < 8:
|
||
raise HTTPException(status_code=422, detail="Password must be at least 8 characters.")
|
||
|
||
vc.used_at = now
|
||
member.is_claimed = True
|
||
member.claimed_at = now
|
||
member.hashed_password = hash_password(data.password)
|
||
await db.flush()
|
||
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="account_claimed",
|
||
area="members/claim/verify-code",
|
||
description="Member claimed their account via magic link flow.",
|
||
status="success",
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
|
||
return {"message": "Account claimed successfully.", "is_claimed": True}
|
||
|
||
|
||
# ── Auth ───────────────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/members/auth/login", status_code=200)
|
||
@limiter.limit("10/minute")
|
||
async def member_login(
|
||
request: Request,
|
||
response: Response,
|
||
data: MemberLoginSchema,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
feature_settings = await get_feature_settings_snapshot(db)
|
||
result = await db.execute(
|
||
select(Member).where(Member.email == data.email.lower())
|
||
)
|
||
member = result.scalars().first()
|
||
|
||
if (
|
||
member is None
|
||
or not member.is_claimed
|
||
or member.hashed_password is None
|
||
or not verify_password(data.password, member.hashed_password)
|
||
or not member.is_active
|
||
):
|
||
raise HTTPException(status_code=401, detail="Invalid email or password.")
|
||
|
||
requires_two_factor = feature_settings.two_factor_enabled or member.force_two_factor is True
|
||
|
||
if not requires_two_factor:
|
||
tokens = await _issue_member_tokens(db, member)
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="login",
|
||
area="members/login",
|
||
description="Member logged in successfully.",
|
||
status="success",
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
return {
|
||
"message": "Login successful.",
|
||
"two_factor_required": False,
|
||
"access_token": tokens.access_token,
|
||
"refresh_token": tokens.refresh_token,
|
||
"token_type": tokens.token_type,
|
||
}
|
||
|
||
code = _generate_code()
|
||
expires = datetime.now(timezone.utc) + timedelta(minutes=10)
|
||
vc = MemberVerificationCode(
|
||
member_id=member.id,
|
||
code_hash=_hash_code(code),
|
||
purpose="login_2fa",
|
||
expires_at=expires,
|
||
)
|
||
db.add(vc)
|
||
await db.flush()
|
||
|
||
await send_login_2fa(member.email, member.first_name, code)
|
||
return {"message": "A verification code has been sent to your email.", "two_factor_required": True}
|
||
|
||
|
||
@router.post("/members/auth/login/verify", response_model=MemberTokenResponse)
|
||
@limiter.limit("10/minute")
|
||
async def member_login_verify(
|
||
request: Request,
|
||
response: Response,
|
||
data: MemberLoginVerifySchema,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result = await db.execute(
|
||
select(Member).where(Member.email == data.email.lower())
|
||
)
|
||
member = result.scalars().first()
|
||
if member is None or not member.is_active:
|
||
raise HTTPException(status_code=401, detail="Invalid credentials.")
|
||
|
||
feature_settings = await get_feature_settings_snapshot(db)
|
||
requires_two_factor = feature_settings.two_factor_enabled or member.force_two_factor is True
|
||
if not requires_two_factor:
|
||
raise HTTPException(status_code=404, detail="Two-factor verification is currently disabled.")
|
||
|
||
now = datetime.now(timezone.utc)
|
||
code_hash = _hash_code(data.code.upper().strip())
|
||
result = await db.execute(
|
||
select(MemberVerificationCode).where(
|
||
MemberVerificationCode.member_id == member.id,
|
||
MemberVerificationCode.code_hash == code_hash,
|
||
MemberVerificationCode.purpose == "login_2fa",
|
||
MemberVerificationCode.used_at.is_(None),
|
||
MemberVerificationCode.expires_at > now,
|
||
)
|
||
)
|
||
vc = result.scalars().first()
|
||
if vc is None:
|
||
raise HTTPException(status_code=401, detail="Invalid or expired code.")
|
||
|
||
vc.used_at = now
|
||
|
||
tokens = await _issue_member_tokens(db, member, now=now)
|
||
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="login",
|
||
area="members/login",
|
||
description="Member logged in successfully.",
|
||
status="success",
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
|
||
return tokens
|
||
|
||
|
||
@router.post("/members/auth/refresh", response_model=MemberTokenResponse)
|
||
@limiter.limit("10/minute")
|
||
async def member_refresh(
|
||
request: Request,
|
||
response: Response,
|
||
data: MemberRefreshSchema,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
now = datetime.now(timezone.utc)
|
||
token_hash = hash_refresh_token(data.refresh_token)
|
||
result = await db.execute(
|
||
select(MemberRefreshToken).where(
|
||
MemberRefreshToken.token_hash == token_hash,
|
||
MemberRefreshToken.revoked == False,
|
||
MemberRefreshToken.expires_at > now,
|
||
)
|
||
)
|
||
mrt = result.scalars().first()
|
||
if mrt is None:
|
||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token.")
|
||
|
||
mrt.revoked = True
|
||
|
||
result = await db.execute(select(Member).where(Member.id == mrt.member_id))
|
||
member = result.scalars().first()
|
||
if member is None or not member.is_active:
|
||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token.")
|
||
|
||
access_token = create_access_token(data={"sub": str(member.id), "role": "member"})
|
||
plaintext_refresh, refresh_hash = create_refresh_token()
|
||
new_mrt = MemberRefreshToken(
|
||
member_id=member.id,
|
||
token_hash=refresh_hash,
|
||
expires_at=get_token_expiry(),
|
||
revoked=False,
|
||
created_at=now,
|
||
)
|
||
db.add(new_mrt)
|
||
await db.flush()
|
||
|
||
return MemberTokenResponse(
|
||
access_token=access_token,
|
||
refresh_token=plaintext_refresh,
|
||
token_type="bearer",
|
||
)
|
||
|
||
|
||
@router.post("/members/auth/logout", status_code=204)
|
||
async def member_logout(
|
||
request: Request,
|
||
data: MemberLogoutSchema,
|
||
member: Member = Depends(get_authenticated_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
if data.refresh_token:
|
||
now = datetime.now(timezone.utc)
|
||
token_hash = hash_refresh_token(data.refresh_token)
|
||
result = await db.execute(
|
||
select(MemberRefreshToken).where(
|
||
MemberRefreshToken.member_id == member.id,
|
||
MemberRefreshToken.token_hash == token_hash,
|
||
MemberRefreshToken.revoked == False,
|
||
MemberRefreshToken.expires_at > now,
|
||
)
|
||
)
|
||
refresh_token = result.scalars().first()
|
||
if refresh_token is not None:
|
||
refresh_token.revoked = True
|
||
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="logout",
|
||
area="members/logout",
|
||
description="Member ended their session.",
|
||
status="success",
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
|
||
|
||
# ── Member Profile ─────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/members/me", response_model=MemberProfileResponse)
|
||
async def get_my_profile(
|
||
member: Member = Depends(get_current_member),
|
||
):
|
||
return member
|
||
|
||
|
||
@router.put("/members/me", response_model=MemberProfileResponse)
|
||
async def update_my_profile(
|
||
request: Request,
|
||
data: MemberProfileUpdate,
|
||
member: Member = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
update = data.model_dump(exclude_unset=True)
|
||
for field, value in update.items():
|
||
setattr(member, field, value)
|
||
await db.flush()
|
||
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="profile_updated",
|
||
area="members/profile",
|
||
description=f"Member updated their profile ({', '.join(update.keys())}).",
|
||
status="success",
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
return member
|
||
|
||
|
||
# ── Onboarding ────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/members/onboarding", response_model=MemberOnboardingResponse)
|
||
async def get_my_onboarding(
|
||
member: Member = Depends(get_authenticated_member),
|
||
):
|
||
return member
|
||
|
||
|
||
@router.put("/members/onboarding", response_model=MemberOnboardingResponse)
|
||
async def update_my_onboarding(
|
||
request: Request,
|
||
data: MemberOnboardingUpdate,
|
||
member: Member = Depends(get_authenticated_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
update = data.model_dump(exclude_unset=True)
|
||
complete_onboarding = update.pop("complete_onboarding", False)
|
||
|
||
for field, value in update.items():
|
||
setattr(member, field, value)
|
||
|
||
if complete_onboarding:
|
||
now = datetime.now(timezone.utc)
|
||
member.onboarding_completed_at = now
|
||
member.member_status = (
|
||
MEMBER_STATUS_PENDING_REVIEW
|
||
if member.contract_signed_at is not None
|
||
else MEMBER_STATUS_PENDING_CONTRACT
|
||
)
|
||
elif member.member_status == MEMBER_STATUS_INVITED:
|
||
member.member_status = MEMBER_STATUS_ONBOARDING
|
||
|
||
await db.flush()
|
||
|
||
description = "Member completed onboarding." if complete_onboarding else "Member saved onboarding details."
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="onboarding_updated",
|
||
area="members/onboarding",
|
||
description=description,
|
||
status="success",
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
return member
|
||
|
||
|
||
@router.post("/members/onboarding/contract", response_model=MemberOnboardingResponse)
|
||
async def sign_my_contract(
|
||
request: Request,
|
||
data: ContractSignSchema,
|
||
member: Member = Depends(get_authenticated_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
if not data.agreed:
|
||
raise HTTPException(status_code=422, detail="You must agree before signing.")
|
||
|
||
if member.onboarding_completed_at is None:
|
||
raise HTTPException(status_code=400, detail="Complete onboarding details before signing the contract.")
|
||
|
||
now = datetime.now(timezone.utc)
|
||
member.contract_signed_at = now
|
||
member.contract_signer_name = data.signer_name.strip()
|
||
member.contract_version = data.contract_version or DEFAULT_CONTRACT_VERSION
|
||
member.member_status = MEMBER_STATUS_PENDING_REVIEW
|
||
await db.flush()
|
||
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="contract_signed",
|
||
area="members/contract",
|
||
description=f"Member signed the service agreement (version {member.contract_version}).",
|
||
status="success",
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
return member
|
||
|
||
|
||
# ── Walks ──────────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/members/walks", response_model=list[WalkResponse])
|
||
async def get_my_walks(
|
||
member: Member = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_walks_enabled(db)
|
||
result = await db.execute(
|
||
select(Walk)
|
||
.where(Walk.member_id == member.id)
|
||
.order_by(Walk.walked_at.desc())
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
# ── Bookings ───────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/members/bookings", response_model=list[BookingResponse])
|
||
async def get_my_bookings(
|
||
member: Member = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_bookings_enabled(db)
|
||
result = await db.execute(
|
||
select(Booking)
|
||
.where(Booking.member_id == member.id)
|
||
.order_by(Booking.created_at.desc())
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
@router.get("/members/bookings/availability", response_model=BookingAvailabilityResponse)
|
||
async def get_booking_availability(
|
||
date_value: str = Query(alias="date"),
|
||
member: Member = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
del member
|
||
await _require_bookings_enabled(db)
|
||
|
||
try:
|
||
target_date = datetime.strptime(date_value, "%Y-%m-%d").date()
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="Date must use YYYY-MM-DD format.") from exc
|
||
|
||
return await _build_booking_availability(db, target_date)
|
||
|
||
|
||
@router.post("/members/bookings", response_model=BookingResponse, status_code=201)
|
||
async def create_booking(
|
||
request: Request,
|
||
data: BookingCreate,
|
||
member: Member = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_bookings_enabled(db)
|
||
target_date = _requested_day_or_date(data.requested_day, data.requested_date)
|
||
requested_timeslot = _normalize_timeslot(data.requested_timeslot, data.requested_date)
|
||
|
||
if target_date is not None:
|
||
availability = await _build_booking_availability(db, target_date)
|
||
selected_slot = _slot_details_for_day(availability.selected, requested_timeslot)
|
||
if not selected_slot.is_available:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail=(
|
||
f"The {selected_slot.label} slot on {availability.selected.label} is fully booked "
|
||
f"({selected_slot.booked}/{selected_slot.capacity})."
|
||
),
|
||
headers={"X-Goodwalk-Booking-Capacity": "full"},
|
||
)
|
||
|
||
requested_date = datetime.combine(
|
||
target_date,
|
||
time(hour=BOOKING_TIMESLOT_HOURS[requested_timeslot], minute=0),
|
||
NZ_TIMEZONE,
|
||
).astimezone(timezone.utc)
|
||
else:
|
||
availability = None
|
||
requested_date = None
|
||
|
||
booking = Booking(
|
||
member_id=member.id,
|
||
service_type=data.service_type,
|
||
requested_date=requested_date,
|
||
status="pending",
|
||
notes=data.notes,
|
||
)
|
||
db.add(booking)
|
||
await db.flush()
|
||
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="booking_created",
|
||
area="members/book",
|
||
description=(
|
||
f"Member submitted a booking request for {data.service_type}"
|
||
f"{f' ({requested_timeslot.upper()})' if target_date else ''}."
|
||
),
|
||
status="success",
|
||
booking_id=booking.id,
|
||
extra={
|
||
"service_type": data.service_type,
|
||
"requested_timeslot": requested_timeslot if target_date else None,
|
||
"availability_checked": bool(availability),
|
||
},
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
return booking
|
||
|
||
|
||
# ── Contract ───────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/members/contract", response_model=ContractResponse)
|
||
async def get_my_contract(
|
||
member: Member = Depends(get_authenticated_member),
|
||
):
|
||
return ContractResponse(
|
||
onboarding_data=member.onboarding_data,
|
||
member_name=f"{member.first_name} {member.last_name}",
|
||
email=member.email,
|
||
member_status=member.member_status,
|
||
contract_signed_at=member.contract_signed_at,
|
||
contract_signer_name=member.contract_signer_name,
|
||
contract_version=member.contract_version,
|
||
activated_at=member.activated_at,
|
||
joined_at=member.created_at,
|
||
)
|
||
|
||
|
||
# ── Messages ───────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/members/messages", response_model=list[MessageResponse])
|
||
async def get_my_messages(
|
||
member: Member = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_messages_enabled(db)
|
||
result = await db.execute(
|
||
select(AdminMessage)
|
||
.where(AdminMessage.member_id == member.id, AdminMessage.deleted_at.is_(None))
|
||
.order_by(AdminMessage.created_at.desc())
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
@router.put("/members/messages/{message_id}/read", status_code=200)
|
||
async def mark_message_read(
|
||
message_id: uuid.UUID,
|
||
member: Member = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_messages_enabled(db)
|
||
result = await db.execute(
|
||
select(AdminMessage).where(
|
||
AdminMessage.id == message_id,
|
||
AdminMessage.member_id == member.id,
|
||
)
|
||
)
|
||
msg = result.scalars().first()
|
||
if msg is None:
|
||
raise HTTPException(status_code=404, detail="Message not found.")
|
||
if msg.read_at is None:
|
||
msg.read_at = datetime.now(timezone.utc)
|
||
await db.flush()
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="message_read",
|
||
area="members/messages",
|
||
description=f"Member read message: {msg.subject}",
|
||
status="success",
|
||
)
|
||
return {"message": "Marked as read."}
|
||
|
||
|
||
@router.delete("/members/messages/{message_id}", status_code=200)
|
||
async def delete_my_message(
|
||
message_id: uuid.UUID,
|
||
member: Member = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_messages_enabled(db)
|
||
result = await db.execute(
|
||
select(AdminMessage).where(
|
||
AdminMessage.id == message_id,
|
||
AdminMessage.member_id == member.id,
|
||
AdminMessage.deleted_at.is_(None),
|
||
)
|
||
)
|
||
msg = result.scalars().first()
|
||
if msg is None:
|
||
raise HTTPException(status_code=404, detail="Message not found.")
|
||
msg.deleted_at = datetime.now(timezone.utc)
|
||
await db.flush()
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="message_deleted",
|
||
area="members/messages",
|
||
description=f"Member deleted message: {msg.subject}",
|
||
status="success",
|
||
)
|
||
return {"message": "Message deleted."}
|
||
|
||
|
||
@router.post("/members/messages/{message_id}/reply", response_model=MessageResponse, status_code=201)
|
||
async def reply_to_message(
|
||
message_id: uuid.UUID,
|
||
data: MemberReplySchema,
|
||
member: Member = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_messages_enabled(db)
|
||
result = await db.execute(
|
||
select(AdminMessage).where(
|
||
AdminMessage.id == message_id,
|
||
AdminMessage.member_id == member.id,
|
||
AdminMessage.deleted_at.is_(None),
|
||
)
|
||
)
|
||
original = result.scalars().first()
|
||
if original is None:
|
||
raise HTTPException(status_code=404, detail="Message not found.")
|
||
|
||
body = data.body.strip()
|
||
if not body:
|
||
raise HTTPException(status_code=422, detail="Reply cannot be empty.")
|
||
|
||
reply = AdminMessage(
|
||
member_id=member.id,
|
||
subject=f"Re: {original.subject}",
|
||
body=body,
|
||
sent_by=f"{member.first_name} {member.last_name}",
|
||
direction="outbound",
|
||
reply_to_id=message_id,
|
||
read_at=datetime.now(timezone.utc), # auto-read own reply
|
||
)
|
||
db.add(reply)
|
||
await db.flush()
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="message_replied",
|
||
area="members/messages",
|
||
description=f"Member replied to message: {original.subject}",
|
||
status="success",
|
||
)
|
||
return reply
|
||
|
||
|
||
# ── Admin: Members ─────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/admin/members", response_model=AdminMemberResponse, status_code=201)
|
||
async def admin_create_member(
|
||
data: AdminCreateMember,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
existing = await db.execute(
|
||
select(Member).where(Member.email == data.email.lower())
|
||
)
|
||
if existing.scalars().first():
|
||
raise HTTPException(status_code=409, detail="A member with this email already exists.")
|
||
|
||
member = Member(
|
||
email=data.email.lower(),
|
||
first_name=data.first_name,
|
||
last_name=data.last_name,
|
||
phone=data.phone,
|
||
address=data.address,
|
||
emergency_contact=data.emergency_contact,
|
||
onboarding_data=data.onboarding_data,
|
||
is_claimed=False,
|
||
is_active=True,
|
||
notifications_enabled=True,
|
||
member_status=MEMBER_STATUS_INVITED,
|
||
service_pricing_overrides=normalize_service_pricing_overrides(data.service_pricing_overrides),
|
||
force_two_factor=True if data.force_two_factor else None,
|
||
)
|
||
db.add(member)
|
||
await db.flush()
|
||
return member
|
||
|
||
|
||
@router.get("/admin/members", response_model=list[AdminMemberResponse])
|
||
async def admin_list_members(
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result = await db.execute(
|
||
select(Member).order_by(Member.created_at.desc())
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
@router.get("/admin/members/{member_id}", response_model=AdminMemberResponse)
|
||
async def admin_get_member(
|
||
member_id: uuid.UUID,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
return await _get_member_or_404(db, member_id)
|
||
|
||
|
||
@router.put("/admin/members/{member_id}", response_model=AdminMemberResponse)
|
||
async def admin_update_member(
|
||
member_id: uuid.UUID,
|
||
data: AdminMemberUpdate,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
member = await _get_member_or_404(db, member_id)
|
||
|
||
updates = data.model_dump(exclude_unset=True)
|
||
next_status = updates.pop("member_status", None)
|
||
if "service_pricing_overrides" in updates:
|
||
updates["service_pricing_overrides"] = normalize_service_pricing_overrides(updates["service_pricing_overrides"])
|
||
if "force_two_factor" in updates:
|
||
updates["force_two_factor"] = True if updates["force_two_factor"] else None
|
||
|
||
for field, value in updates.items():
|
||
setattr(member, field, value)
|
||
|
||
if next_status is not None:
|
||
_set_member_status(member, next_status)
|
||
await _sync_lead_status_for_member(db, member)
|
||
|
||
await db.flush()
|
||
return member
|
||
|
||
|
||
@router.post("/admin/members/{member_id}/activate", response_model=AdminMemberResponse)
|
||
async def admin_activate_member(
|
||
member_id: uuid.UUID,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
member = await _get_member_or_404(db, member_id)
|
||
|
||
if not member.is_claimed:
|
||
raise HTTPException(status_code=400, detail="Member has not claimed their account yet.")
|
||
if member.onboarding_completed_at is None:
|
||
raise HTTPException(status_code=400, detail="Member has not completed onboarding yet.")
|
||
if member.contract_signed_at is None:
|
||
raise HTTPException(status_code=400, detail="Member has not signed the contract yet.")
|
||
|
||
_set_member_status(member, MEMBER_STATUS_ACTIVE)
|
||
await _sync_lead_status_for_member(db, member)
|
||
await send_account_activated_notification(db, member)
|
||
await db.flush()
|
||
return member
|
||
|
||
|
||
@router.post("/admin/members/{member_id}/reset-invite", response_model=AdminMemberResponse)
|
||
async def admin_reset_member_invite(
|
||
member_id: uuid.UUID,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Reset a member back to the pre-invited state so a fresh invite can be sent."""
|
||
member = await _get_member_or_404(db, member_id)
|
||
|
||
# Expire all outstanding magic link tokens so old invite links stop working
|
||
tokens_result = await db.execute(
|
||
select(MagicLinkToken).where(
|
||
MagicLinkToken.member_id == member_id,
|
||
MagicLinkToken.used_at.is_(None),
|
||
)
|
||
)
|
||
now = datetime.now(timezone.utc)
|
||
for token in tokens_result.scalars().all():
|
||
token.used_at = now
|
||
|
||
member.member_status = MEMBER_STATUS_INVITED
|
||
member.is_claimed = False
|
||
member.hashed_password = None
|
||
member.claimed_at = None
|
||
member.onboarding_completed_at = None
|
||
member.contract_signed_at = None
|
||
member.contract_signer_name = None
|
||
member.contract_version = None
|
||
member.activated_at = None
|
||
|
||
# Reset the associated lead back to "invite" so the invite button reappears
|
||
await _sync_lead_status_for_member(db, member)
|
||
|
||
await db.flush()
|
||
return member
|
||
|
||
|
||
@router.post("/admin/members/{member_id}/deactivate", response_model=AdminMemberResponse)
|
||
async def admin_deactivate_member(
|
||
member_id: uuid.UUID,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
member = await _get_member_or_404(db, member_id)
|
||
member.is_active = False
|
||
await db.flush()
|
||
return member
|
||
|
||
|
||
@router.post("/admin/members/{member_id}/archive", response_model=AdminMemberResponse)
|
||
async def admin_archive_member(
|
||
member_id: uuid.UUID,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
member = await _get_member_or_404(db, member_id)
|
||
_set_member_status(member, MEMBER_STATUS_ARCHIVED)
|
||
await _sync_lead_status_for_member(db, member)
|
||
await db.flush()
|
||
return member
|
||
|
||
|
||
@router.post("/admin/members/{member_id}/force-2fa", response_model=AdminMemberResponse)
|
||
async def admin_set_member_force_two_factor(
|
||
member_id: uuid.UUID,
|
||
data: AdminMemberToggleAction,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
member = await _get_member_or_404(db, member_id)
|
||
member.force_two_factor = True if data.enabled else None
|
||
await db.flush()
|
||
return member
|
||
|
||
|
||
@router.post("/admin/members/{member_id}/reset-password", response_model=AdminMemberResponse)
|
||
async def admin_reset_member_password(
|
||
member_id: uuid.UUID,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
member = await _get_member_or_404(db, member_id)
|
||
now = datetime.now(timezone.utc)
|
||
|
||
refresh_tokens = await db.execute(select(MemberRefreshToken).where(MemberRefreshToken.member_id == member_id))
|
||
for token in refresh_tokens.scalars().all():
|
||
token.revoked = True
|
||
|
||
verification_codes = await db.execute(
|
||
select(MemberVerificationCode).where(
|
||
MemberVerificationCode.member_id == member_id,
|
||
MemberVerificationCode.used_at.is_(None),
|
||
)
|
||
)
|
||
for verification_code in verification_codes.scalars().all():
|
||
verification_code.used_at = now
|
||
|
||
magic_tokens = await db.execute(
|
||
select(MagicLinkToken).where(
|
||
MagicLinkToken.member_id == member_id,
|
||
MagicLinkToken.used_at.is_(None),
|
||
)
|
||
)
|
||
for magic_token in magic_tokens.scalars().all():
|
||
magic_token.used_at = now
|
||
|
||
member.hashed_password = None
|
||
member.is_claimed = False
|
||
member.claimed_at = None
|
||
|
||
code = _generate_code()
|
||
db.add(
|
||
MemberVerificationCode(
|
||
member_id=member.id,
|
||
code_hash=_hash_code(code),
|
||
purpose="claim",
|
||
expires_at=now + timedelta(minutes=15),
|
||
)
|
||
)
|
||
await db.flush()
|
||
await send_claim_code(member.email, member.first_name, code)
|
||
return member
|
||
|
||
|
||
@router.get("/admin/members/{member_id}/walks", response_model=list[WalkResponse])
|
||
async def admin_get_member_walks(
|
||
member_id: uuid.UUID,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_walks_enabled(db)
|
||
result = await db.execute(
|
||
select(Walk).where(Walk.member_id == member_id).order_by(Walk.walked_at.desc())
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
@router.get("/admin/members/{member_id}/bookings", response_model=list[BookingResponse])
|
||
async def admin_get_member_bookings(
|
||
member_id: uuid.UUID,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_bookings_enabled(db)
|
||
result = await db.execute(
|
||
select(Booking).where(Booking.member_id == member_id).order_by(Booking.created_at.desc())
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
# ── Admin: Bookings ────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/admin/bookings", response_model=list[AdminBookingResponse])
|
||
async def admin_list_bookings(
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_bookings_enabled(db)
|
||
result = await db.execute(
|
||
select(Booking, Member)
|
||
.join(Member, Booking.member_id == Member.id)
|
||
.order_by(Booking.created_at.desc())
|
||
)
|
||
rows = result.all()
|
||
out = []
|
||
for booking, member in rows:
|
||
d = AdminBookingResponse.model_validate(booking)
|
||
d.member_first_name = member.first_name
|
||
d.member_last_name = member.last_name
|
||
d.member_email = member.email
|
||
od = member.onboarding_data or {}
|
||
d.member_dog_name = od.get('dog_name') or od.get('pet_name') or None
|
||
d.member_dog_breed = od.get('dog_breed') or od.get('breed') or None
|
||
out.append(d)
|
||
return out
|
||
|
||
|
||
async def _admin_bookings_stream_signature(db: AsyncSession) -> dict[str, str | int | None]:
|
||
result = await db.execute(
|
||
select(
|
||
func.count(Booking.id),
|
||
func.max(Booking.updated_at),
|
||
)
|
||
)
|
||
booking_count, latest_updated_at = result.one()
|
||
return {
|
||
"count": int(booking_count or 0),
|
||
"updated_at": latest_updated_at.isoformat() if latest_updated_at else None,
|
||
}
|
||
|
||
|
||
@router.get("/admin/bookings/events")
|
||
async def admin_bookings_event_stream(
|
||
request: Request,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_bookings_enabled(db)
|
||
|
||
async def event_generator():
|
||
signature = await _admin_bookings_stream_signature(db)
|
||
yield "retry: 5000\n\n"
|
||
yield f"event: ready\ndata: {json.dumps(signature)}\n\n"
|
||
|
||
while True:
|
||
if await request.is_disconnected():
|
||
break
|
||
|
||
await asyncio.sleep(5)
|
||
next_signature = await _admin_bookings_stream_signature(db)
|
||
|
||
if next_signature != signature:
|
||
signature = next_signature
|
||
yield f"event: bookings.changed\ndata: {json.dumps(signature)}\n\n"
|
||
else:
|
||
yield ": keep-alive\n\n"
|
||
|
||
return StreamingResponse(
|
||
event_generator(),
|
||
media_type="text/event-stream",
|
||
headers={
|
||
"Cache-Control": "no-cache",
|
||
"Connection": "keep-alive",
|
||
"X-Accel-Buffering": "no",
|
||
},
|
||
)
|
||
|
||
|
||
@router.post("/admin/bookings", response_model=AdminBookingResponse, status_code=201)
|
||
async def admin_create_booking(
|
||
request: Request,
|
||
data: AdminBookingCreate,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_bookings_enabled(db)
|
||
member = await _get_member_or_404(db, data.member_id)
|
||
|
||
booking = Booking(
|
||
member_id=member.id,
|
||
service_type=data.service_type,
|
||
requested_date=data.requested_date,
|
||
status=data.status or "confirmed",
|
||
notes=data.notes,
|
||
admin_notes=data.admin_notes,
|
||
)
|
||
db.add(booking)
|
||
await db.flush()
|
||
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type="booking_created",
|
||
area="admin/bookings",
|
||
description=f"Admin created a {data.service_type} booking.",
|
||
status="success",
|
||
booking_id=booking.id,
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
extra={"service_type": data.service_type, "booking_status": booking.status},
|
||
)
|
||
|
||
if booking.status != "pending":
|
||
await send_booking_status_notification(db, member, booking)
|
||
|
||
d = AdminBookingResponse.model_validate(booking)
|
||
d.member_first_name = member.first_name
|
||
d.member_last_name = member.last_name
|
||
d.member_email = member.email
|
||
od = member.onboarding_data or {}
|
||
d.member_dog_name = od.get('dog_name') or od.get('pet_name') or None
|
||
d.member_dog_breed = od.get('dog_breed') or od.get('breed') or None
|
||
return d
|
||
|
||
|
||
@router.put("/admin/bookings/{booking_id}", response_model=AdminBookingResponse)
|
||
async def admin_update_booking(
|
||
request: Request,
|
||
booking_id: uuid.UUID,
|
||
data: AdminBookingUpdate,
|
||
admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_bookings_enabled(db)
|
||
result = await db.execute(
|
||
select(Booking, Member)
|
||
.join(Member, Booking.member_id == Member.id)
|
||
.where(Booking.id == booking_id)
|
||
)
|
||
row = result.first()
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Booking not found.")
|
||
booking, member = row
|
||
updates = data.model_dump(exclude_unset=True)
|
||
previous_status = booking.status
|
||
previous_requested_date = booking.requested_date
|
||
previous_notes = booking.notes
|
||
previous_admin_notes = booking.admin_notes
|
||
for field, value in updates.items():
|
||
setattr(booking, field, value)
|
||
if booking.status != previous_status:
|
||
await send_booking_status_notification(db, member, booking)
|
||
elif (
|
||
booking.requested_date is not None
|
||
and booking.requested_date != previous_requested_date
|
||
and booking.status in {"confirmed", "assigned", "in_progress"}
|
||
):
|
||
await send_booking_rescheduled_notification(db, member, booking)
|
||
changed_fields = sorted(updates.keys())
|
||
status_changed = booking.status != previous_status
|
||
schedule_changed = booking.requested_date != previous_requested_date
|
||
customer_notes_changed = booking.notes != previous_notes
|
||
internal_notes_changed = booking.admin_notes != previous_admin_notes
|
||
if changed_fields:
|
||
action_type = "booking_updated"
|
||
description = "Admin updated booking details."
|
||
if status_changed and booking.status == "cancelled":
|
||
action_type = "booking_cancelled"
|
||
description = "Admin cancelled the booking."
|
||
elif status_changed and booking.status == "completed":
|
||
action_type = "booking_completed"
|
||
description = "Admin marked the booking as completed."
|
||
elif status_changed and booking.status in {"confirmed", "assigned", "in_progress"}:
|
||
action_type = "booking_confirmed"
|
||
description = "Admin confirmed the booking."
|
||
elif schedule_changed:
|
||
action_type = "booking_rescheduled"
|
||
description = "Admin rescheduled the booking."
|
||
elif customer_notes_changed and internal_notes_changed:
|
||
description = "Admin updated customer and internal booking notes."
|
||
elif customer_notes_changed:
|
||
description = "Admin updated customer-visible booking notes."
|
||
elif internal_notes_changed:
|
||
description = "Admin updated internal booking notes."
|
||
await log_audit(
|
||
db,
|
||
member_id=member.id,
|
||
member_email=member.email,
|
||
action_type=action_type,
|
||
area="admin/bookings",
|
||
description=description,
|
||
status="success",
|
||
booking_id=booking.id,
|
||
extra={
|
||
"changed_fields": changed_fields,
|
||
"admin_email": admin.email,
|
||
"previous_status": previous_status,
|
||
"new_status": booking.status,
|
||
"previous_requested_date": previous_requested_date.isoformat() if previous_requested_date else None,
|
||
"new_requested_date": booking.requested_date.isoformat() if booking.requested_date else None,
|
||
},
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("User-Agent"),
|
||
)
|
||
await db.flush()
|
||
d = AdminBookingResponse.model_validate(booking)
|
||
d.member_first_name = member.first_name
|
||
d.member_last_name = member.last_name
|
||
d.member_email = member.email
|
||
od = member.onboarding_data or {}
|
||
d.member_dog_name = od.get('dog_name') or od.get('pet_name') or None
|
||
d.member_dog_breed = od.get('dog_breed') or od.get('breed') or None
|
||
return d
|
||
|
||
|
||
@router.get("/admin/bookings/{booking_id}/audit", response_model=list[AuditLogResponse])
|
||
async def admin_get_booking_audit_log(
|
||
booking_id: uuid.UUID,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_bookings_enabled(db)
|
||
result = await db.execute(
|
||
select(AuditLog)
|
||
.where(AuditLog.booking_id == booking_id)
|
||
.order_by(AuditLog.timestamp.desc())
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
# ── Admin: Walks ───────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/admin/walks", response_model=WalkResponse, status_code=201)
|
||
async def admin_record_walk(
|
||
data: AdminRecordWalk,
|
||
admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_walks_enabled(db)
|
||
result = await db.execute(select(Member).where(Member.id == data.member_id))
|
||
member = result.scalars().first()
|
||
if member is None:
|
||
raise HTTPException(status_code=404, detail="Member not found.")
|
||
|
||
walk = Walk(
|
||
member_id=data.member_id,
|
||
walked_at=data.walked_at,
|
||
service_type=data.service_type,
|
||
duration_minutes=data.duration_minutes,
|
||
notes=data.notes,
|
||
recorded_by=admin.email,
|
||
)
|
||
db.add(walk)
|
||
await db.flush()
|
||
await send_walk_completed_notification(db, member, walk)
|
||
return walk
|
||
|
||
|
||
# ── Admin: Messages ────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/admin/messages", response_model=MessageResponse, status_code=201)
|
||
async def admin_send_message(
|
||
data: AdminSendMessage,
|
||
admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_messages_enabled(db)
|
||
result = await db.execute(select(Member).where(Member.id == data.member_id))
|
||
member = result.scalars().first()
|
||
if member is None:
|
||
raise HTTPException(status_code=404, detail="Member not found.")
|
||
|
||
msg = AdminMessage(
|
||
member_id=data.member_id,
|
||
subject=data.subject,
|
||
body=data.body,
|
||
sent_by=admin.email,
|
||
)
|
||
db.add(msg)
|
||
await db.flush()
|
||
return msg
|
||
|
||
|
||
@router.get("/admin/notifications/settings", response_model=AdminNotificationSettingsResponse)
|
||
async def admin_get_notification_settings(
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
settings = await get_notification_settings_snapshot(db)
|
||
return _notification_settings_response(settings)
|
||
|
||
|
||
@router.put("/admin/notifications/settings", response_model=AdminNotificationSettingsResponse)
|
||
async def admin_update_notification_settings(
|
||
data: AdminNotificationSettingsUpdate,
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
try:
|
||
settings = await update_notification_settings_snapshot(
|
||
db,
|
||
automatic_member_notifications_enabled=data.automatic_member_notifications_enabled,
|
||
nz_public_holiday_notifications_enabled=data.nz_public_holiday_notifications_enabled,
|
||
invoice_reminder_notifications_enabled=data.invoice_reminder_notifications_enabled,
|
||
invoice_day_of_week=data.invoice_day_of_week,
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail=str(exc))
|
||
return _notification_settings_response(settings)
|
||
|
||
|
||
@router.post("/admin/notifications/run", response_model=AdminNotificationRunResponse)
|
||
async def admin_run_notifications(
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
summary = await run_automatic_notifications(db)
|
||
return AdminNotificationRunResponse(
|
||
automatic_member_notifications_enabled=summary.automatic_member_notifications_enabled,
|
||
public_holiday_messages_sent=summary.public_holiday_messages_sent,
|
||
invoice_reminders_sent=summary.invoice_reminders_sent,
|
||
)
|
||
|
||
|
||
# ── Admin: Live notifications feed ────────────────────────────────────────────
|
||
|
||
async def _build_admin_notifications_response(db: AsyncSession) -> AdminNotificationsResponse:
|
||
"""Collect recent actionable items for the admin notification UI."""
|
||
from app.models.contact_lead import ContactLead as Lead
|
||
|
||
settings = await get_notification_settings_snapshot(db)
|
||
feature_settings = await get_feature_settings_snapshot(db)
|
||
cleared_before = settings.admin_notifications_cleared_before
|
||
|
||
bookings_result = None
|
||
if feature_settings.bookings_enabled:
|
||
bookings_result = await db.execute(
|
||
select(Booking, Member)
|
||
.join(Member, Booking.member_id == Member.id)
|
||
.where(Booking.status == "pending")
|
||
.order_by(Booking.created_at.desc())
|
||
.limit(10)
|
||
)
|
||
leads_result = await db.execute(
|
||
select(Lead)
|
||
.where(Lead.status == "invite")
|
||
.order_by(Lead.created_at.desc())
|
||
.limit(10)
|
||
)
|
||
pending_result = await db.execute(
|
||
select(Member)
|
||
.where(Member.member_status == MEMBER_STATUS_PENDING_REVIEW)
|
||
.order_by(Member.updated_at.desc())
|
||
.limit(10)
|
||
)
|
||
session_events_result = None
|
||
if feature_settings.audit_history_enabled:
|
||
session_events_result = await db.execute(
|
||
select(AuditLog)
|
||
.where(AuditLog.action_type.in_(("login", "logout")))
|
||
.order_by(AuditLog.timestamp.desc())
|
||
.limit(20)
|
||
)
|
||
|
||
items: list[AdminNotificationFeedItemResponse] = []
|
||
|
||
if bookings_result is not None:
|
||
for booking, member in bookings_result.all():
|
||
service = booking.service_type.replace("_", " ").title()
|
||
items.append(
|
||
AdminNotificationFeedItemResponse(
|
||
id=str(booking.id),
|
||
type="pending_booking",
|
||
title="Booking request",
|
||
description=f"{member.first_name} {member.last_name} - {service}",
|
||
created_at=booking.created_at,
|
||
href="/admin/bookings",
|
||
)
|
||
)
|
||
|
||
for lead in leads_result.scalars().all():
|
||
items.append(
|
||
AdminNotificationFeedItemResponse(
|
||
id=str(lead.id),
|
||
type="new_lead",
|
||
title="New contact lead",
|
||
description=f"{lead.full_name} ({lead.email})",
|
||
created_at=lead.created_at,
|
||
href="/admin/leads",
|
||
)
|
||
)
|
||
|
||
for member in pending_result.scalars().all():
|
||
items.append(
|
||
AdminNotificationFeedItemResponse(
|
||
id=str(member.id),
|
||
type="pending_review",
|
||
title="Pending review",
|
||
description=f"{member.first_name} {member.last_name} completed onboarding",
|
||
created_at=member.updated_at,
|
||
href=f"/admin/members/{member.id}",
|
||
)
|
||
)
|
||
|
||
if session_events_result is not None:
|
||
for event in session_events_result.scalars().all():
|
||
action_label = "logged in" if event.action_type == "login" else "ended their session"
|
||
member_label = event.member_email or "A client"
|
||
items.append(
|
||
AdminNotificationFeedItemResponse(
|
||
id=f"session-{event.id}",
|
||
type=f"member_{event.action_type}",
|
||
title="Client session activity",
|
||
description=f"{member_label} {action_label}.",
|
||
created_at=event.timestamp,
|
||
href=f"/admin/members/{event.member_id}" if event.member_id else "/admin/audit",
|
||
)
|
||
)
|
||
|
||
if cleared_before is not None:
|
||
items = [item for item in items if item.created_at > cleared_before]
|
||
|
||
items.sort(key=lambda item: item.created_at, reverse=True)
|
||
return AdminNotificationsResponse(
|
||
items=items[:20],
|
||
total=len(items),
|
||
settings=_notification_settings_response(settings),
|
||
)
|
||
|
||
|
||
@router.get("/admin/notifications", response_model=AdminNotificationsResponse)
|
||
async def admin_list_notifications(
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
return await _build_admin_notifications_response(db)
|
||
|
||
|
||
@router.get("/admin/notifications/feed", response_model=AdminNotificationsResponse)
|
||
async def admin_notifications_feed(
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
return await _build_admin_notifications_response(db)
|
||
|
||
|
||
@router.post("/admin/notifications/clear", response_model=AdminNotificationsResponse)
|
||
async def admin_clear_notifications(
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await update_notification_settings_snapshot(
|
||
db,
|
||
admin_notifications_cleared_before=datetime.now(timezone.utc),
|
||
)
|
||
return await _build_admin_notifications_response(db)
|
||
|
||
|
||
# ── Admin: Message history (with read status) ─────────────────────────────────
|
||
|
||
async def _load_admin_message_history(db: AsyncSession) -> list[AdminMessageHistoryResponse]:
|
||
"""All admin-sent messages with per-member read status."""
|
||
result = await db.execute(
|
||
select(AdminMessage, Member)
|
||
.join(Member, AdminMessage.member_id == Member.id)
|
||
.order_by(AdminMessage.created_at.desc())
|
||
.limit(100)
|
||
)
|
||
out: list[AdminMessageHistoryResponse] = []
|
||
for msg, member in result.all():
|
||
out.append(
|
||
AdminMessageHistoryResponse(
|
||
id=msg.id,
|
||
subject=msg.subject,
|
||
body=msg.body,
|
||
sent_by=msg.sent_by,
|
||
created_at=msg.created_at,
|
||
read_at=msg.read_at,
|
||
member_id=member.id,
|
||
member_name=f"{member.first_name} {member.last_name}",
|
||
member_email=member.email,
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
@router.get("/admin/messages", response_model=list[AdminMessageHistoryResponse])
|
||
async def admin_list_messages(
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_messages_enabled(db)
|
||
return await _load_admin_message_history(db)
|
||
|
||
|
||
@router.get("/admin/messages/history", response_model=list[AdminMessageHistoryResponse])
|
||
async def admin_messages_history(
|
||
_admin: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _require_messages_enabled(db)
|
||
return await _load_admin_message_history(db)
|