feat: sistem de linkuri publice pentru proprietăți

Expune fluxul de rezervare anonimă printr-un link public partajabil
(/book/<slug>), cu calendar public, cod QR și proprietate demo publică.

Backend:
- slug + list_on_landing pe Property; utilitar slugify (diacritice RO,
  cuvinte rezervate, ban all-digit) + migrare idempotentă raw-DDL rulată
  din entrypoint; SQLite WAL + busy_timeout
- GET /public/properties/{slug_or_id}(+/spaces): rezolvare slug/ID, 404 uniform
- GET /public/spaces/{id}/busy: doar {start_time,end_time} (fără PII),
  pending+approved, tz-aware/interval>60z → 422, orfan/privat → 404
- availability: scrub user_name/title (fix scurgere PII)
- POST /public/bookings: overlap pending+approved, cap 5/email/spațiu/zi,
  rate-limit per-IP, suprimare notificări+email pe demo, email confirmare guest
- PATCH slug (409 duplicat / 422 format / 403 non-manager); guest_email EmailStr
- email guest la aprobare/respingere cu cod #RB-<id>
- teste noi: test_public.py + test_migrate_slug.py (33 teste)

Frontend:
- rută /book/:slug; PublicBooking rescris cu calendar grid manual
  (PublicCalendar + GuestBookingForm), 30min, 08-20, mobil day-view, RO
- card „Link public" în PropertyDetail: copy, edit slug, QR pe URL-ul cu ID
- secțiune proprietăți publice + CTA pe Landing; buton copy în Properties

Gate aprobat (U1-U3, G1 grid manual, G2 list_on_landing); G3-G5 în TODOS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-11 19:04:58 +00:00
parent e38d78ecb8
commit a15951c645
27 changed files with 3311 additions and 375 deletions

98
backend/app/core/slug.py Normal file
View File

@@ -0,0 +1,98 @@
"""Slug generation and validation for public property booking links.
Single source of truth for slug rules shared across the API, migrations,
and the demo seed script.
"""
import re
from sqlalchemy.orm import Session
# Paths/segments that must never be shadowed by a property slug.
RESERVED_SLUGS = {
"api", "admin", "book", "login", "register", "dashboard", "properties",
"public", "spaces", "organization", "users", "verify", "settings",
"reports", "confidentialitate", "termeni",
}
# Stable, deterministic slug reserved for the public demo property.
DEMO_SLUG = "proprietate-demo"
# Format accepted for user-supplied (PATCH) slugs: lowercase alnum + hyphen,
# 3-64 chars total, no leading/trailing/double hyphen (single run of `-`
# separating alnum groups), enforced by the regex plus extra checks below.
SLUG_REGEX = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,62}[a-z0-9])$")
# RO diacritics -> ASCII transliteration map.
_RO_CHAR_MAP = {
"ă": "a", "â": "a", "î": "i", "ș": "s", "ş": "s", "ț": "t", "ţ": "t",
"Ă": "a", "Â": "a", "Î": "i", "Ș": "s", "Ş": "s", "Ț": "t", "Ţ": "t",
}
_NON_ALNUM_RUN = re.compile(r"[^a-z0-9]+")
def slugify(name: str) -> str:
"""Transliterate RO diacritics, lowercase, and kebab-case a name.
Non `[a-z0-9]` runs collapse to a single `-`; leading/trailing/double
hyphens are stripped. Pure stdlib, no external dependency.
"""
if not name:
return ""
out = []
for ch in name:
out.append(_RO_CHAR_MAP.get(ch, ch))
text = "".join(out).lower()
text = _NON_ALNUM_RUN.sub("-", text)
return text.strip("-")
def _is_all_digit(slug: str) -> bool:
return slug.isdigit()
def validate_slug(slug: str) -> bool:
"""Validate a user-supplied slug against the PATCH format rules.
Rules: `[a-z0-9-]{3,64}`, no leading/trailing/double hyphen, not
all-digit (would shadow numeric property IDs), not reserved.
"""
if not slug:
return False
if len(slug) < 3 or len(slug) > 64:
return False
if not SLUG_REGEX.match(slug):
return False
if "--" in slug:
return False
if _is_all_digit(slug):
return False
if slug in RESERVED_SLUGS:
return False
return True
def generate_unique_slug(db: Session, name: str, exclude_id: int | None = None) -> str:
"""Generate a unique, safe slug for a property from its name.
Idempotent: given the same name and existing DB state, always returns
the same result. Empty/all-digit/reserved base slugs get a `p-` prefix.
Collisions are resolved by appending `-2`, `-3`, etc.
"""
from app.models.property import Property
base = slugify(name)
if not base or _is_all_digit(base) or base in RESERVED_SLUGS:
base = f"p-{base}" if base else "p"
candidate = base
suffix = 2
while True:
if candidate not in RESERVED_SLUGS:
query = db.query(Property).filter(Property.slug == candidate)
if exclude_id is not None:
query = query.filter(Property.id != exclude_id)
if query.first() is None:
return candidate
candidate = f"{base}-{suffix}"
suffix += 1