"""Owner action links from proposal emails (§9.1-9.2): /a/{token}. Signed, single-use, no login required, idempotent (§15). All owner-facing copy is Bosnian. Reject shows a minimal form to edit the client SMS before sending; confirm re-checks free/busy and warns if the slot has been taken. """ from __future__ import annotations from fastapi import APIRouter, Depends, Form from fastapi.responses import HTMLResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from gogo.db import get_session from gogo.domain import BookingStatus, Slot from gogo.i18n import fmt_slot from gogo.models import ActionToken, BookingRequest, Tenant, utcnow from gogo.proposals import engine from gogo.proposals.tokens import unsign from gogo.sms.templates import render_sms router = APIRouter() _PAGE = """ Gogo Telefon
{body}

Gogo Telefon — virtuelni asistent

""" def page(body: str, status_code: int = 200) -> HTMLResponse: return HTMLResponse(_PAGE.format(body=body), status_code=status_code) STATUS_LABEL = { BookingStatus.pending.value: "na čekanju", BookingStatus.resolved_by_owner.value: "riješen — klijent kontaktiran", BookingStatus.confirmed.value: "potvrđen", BookingStatus.rejected.value: "odbijen", BookingStatus.expired.value: "istekao", } async def _load(token: str, session: AsyncSession): raw = unsign(token) if raw is None: return None, None, None, page( "

Nevažeći link

Link je oštećen ili nije ispravan.

", 400 ) at = ( await session.execute(select(ActionToken).where(ActionToken.token == raw)) ).scalar_one_or_none() if at is None: return None, None, None, page( "

Nepoznat link

Ovaj link više ne postoji.

", 404 ) req = ( await session.execute(select(BookingRequest).where(BookingRequest.id == at.request_id)) ).scalar_one() tenant = ( await session.execute(select(Tenant).where(Tenant.id == req.tenant_id)) ).scalar_one() return at, req, tenant, None def _already_done(req: BookingRequest) -> HTMLResponse: label = STATUS_LABEL.get(req.status, req.status) return page( f"

Zahtjev je već obrađen

" f"

Status zahtjeva klijenta {req.client_name}: {label}.

" "

Nije potrebna dodatna akcija.

" ) @router.get("/a/{token}", response_class=HTMLResponse) async def action_get(token: str, session: AsyncSession = Depends(get_session)): at, req, tenant, err = await _load(token, session) if err: return err if req.status != BookingStatus.pending.value: return _already_done(req) if at.action == "resolve": await engine.resolve_request(session, tenant, req) at.used_at = utcnow() await session.commit() return page( "

✓ Označeno kao riješeno

" f"

Zahtjev klijenta {req.client_name} je zatvoren. " "Klijentu nije poslan SMS — dogovorili ste se direktno.

" "

Ne zaboravite upisati termin u svoj kalendar (možete iskoristiti " "priloženi .ics iz emaila).

" ) if at.action == "confirm": slots = [Slot.model_validate(s) for s in (req.slots or [])] idx = at.slot_index or 0 if idx >= len(slots): return page("

Greška

Traženi termin ne postoji.

", 400) slot = slots[idx] ok = await engine.confirm_request(session, tenant, req, slot, recheck=True) if not ok: # slot taken since — warn, offer force (§9.2) return page( "

⚠ Termin je u međuvremenu zauzet

" f"

U kalendaru više nije slobodno: {fmt_slot(slot.start, tenant.timezone)}.

" "

Možete svejedno potvrditi (npr. ako ste sami upisali ovaj termin " "u kalendar), ili se javiti klijentu direktno.

" f"
" "
" ) at.used_at = utcnow() await session.commit() return page( "

✓ Termin potvrđen

" f"

Klijentu {req.client_name} je poslan SMS s potvrdom za " f"{fmt_slot(slot.start, tenant.timezone)}.

" "

Ne zaboravite upisati termin u svoj kalendar.

" ) if at.action == "reject": default_sms = render_sms(tenant, "rejection") return page( "

Odbij zahtjev

" f"

Klijent {req.client_name} ({req.client_phone}) će dobiti ovu poruku — " "možete je izmijeniti prije slanja:

" f"
" f"" "
" ) return page("

Nepoznata akcija

", 400) @router.post("/a/{token}/reject", response_class=HTMLResponse) async def action_reject( token: str, sms_body: str = Form(""), session: AsyncSession = Depends(get_session) ): at, req, tenant, err = await _load(token, session) if err: return err if at.action != "reject": return page("

Nepoznata akcija

", 400) if req.status != BookingStatus.pending.value: return _already_done(req) await engine.reject_request(session, tenant, req, custom_sms=sms_body.strip() or None) at.used_at = utcnow() await session.commit() return page( "

Zahtjev odbijen

" f"

Klijentu {req.client_name} je poslan SMS s obavještenjem.

" ) @router.post("/a/{token}/force-confirm", response_class=HTMLResponse) async def action_force_confirm(token: str, session: AsyncSession = Depends(get_session)): at, req, tenant, err = await _load(token, session) if err: return err if at.action != "confirm": return page("

Nepoznata akcija

", 400) if req.status != BookingStatus.pending.value: return _already_done(req) slots = [Slot.model_validate(s) for s in (req.slots or [])] idx = at.slot_index or 0 if idx >= len(slots): return page("

Greška

Traženi termin ne postoji.

", 400) slot = slots[idx] await engine.confirm_request(session, tenant, req, slot, recheck=False) at.used_at = utcnow() await session.commit() return page( "

✓ Termin potvrđen

" f"

Klijentu {req.client_name} je poslan SMS s potvrdom za " f"{fmt_slot(slot.start, tenant.timezone)}.

" )