Files
gw/backend/app/routers/members.py
T
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

1973 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 → 000000ffffff (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)