Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/jhonyes04/new-wayfy/llms.txt

Use this file to discover all available pages before exploring further.

The WayFy backend is a Python Flask application that follows a strict three-layer architecture. Every API feature is split across a routes file (HTTP parsing and response serialisation), a controller file (business logic and database queries), and a model file (SQLAlchemy ORM definition). This separation keeps route handlers thin, testable, and free of SQL — and keeps model files free of HTTP concerns.

Three-Layer Architecture

backend/src/api/
├── routes/<name>_routes.py        → HTTP layer only
├── controllers/<name>_controller.py → Business logic + DB queries
├── models/<name>_model.py         → SQLAlchemy ORM models with .serialize()
└── utils.py                       → APIException, Flask-Limiter instance
Each layer has one responsibility:
1

Routes layer

Parses the incoming HTTP request (query params, JSON body, JWT identity), delegates to the controller, and returns the JSON response. Contains no SQL and no business rules.
# api/routes/trip_routes.py
@trip_bp.route('/', methods=['POST'])
@jwt_required()
def handle_create_trip():
    data = request.get_json()
    user_id = int(get_jwt_identity())
    return TripController.create(user_id=user_id, data=data)
2

Controllers layer

Contains all business logic: validation, DB queries via SQLAlchemy, Cloudinary uploads, Groq API calls. Returns a Flask jsonify response.
# api/controllers/trip_controller.py
@staticmethod
def create(user_id: int, data: dict):
    title = data.get('title', '').strip()
    if not title:
        raise APIException('El título es obligatorio', 400)
    trip = Trip(user_id=user_id, title=title)
    db.session.add(trip)
    db.session.commit()
    return jsonify(trip.serialize()), 201
3

Models layer

Defines SQLAlchemy ORM models using the modern Mapped / mapped_column syntax. Every model exposes a .serialize() method that returns a plain dict safe for JSON serialisation.
# api/models/trip_model.py
class Trip(db.Model):
    __tablename__ = 'trips'
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    is_public: Mapped[bool] = mapped_column(Boolean, default=False)
    cover_image: Mapped[str] = mapped_column(String(200), nullable=True)

    def serialize(self, include_days: bool = False) -> dict:
        return { 'id': self.id, 'title': self.title, ... }

Flask Application Setup

app.py initialises every Flask extension and registers the single api Blueprint. The order of initialisation matters — extensions that depend on the app config (JWT, CORS, Limiter, SQLAlchemy) are configured before any Blueprint or command registration.
# backend/src/app.py (abridged)
app = Flask(__name__)

# CORS — origins from CORS_ALLOWED_ORIGINS env var (comma-separated)
CORS(app, origins=[...])

# Bcrypt and Flask-Limiter
bcrypt.init_app(app)
limiter.init_app(app)

# JWT — JWT_SECRET_KEY is required; app raises RuntimeError without it
app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY')  # RuntimeError if missing
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(
    hours=float(os.getenv('JWT_ACCESS_TOKEN_EXPIRES_HOURS', 1))
)
jwt = JWTManager(app)

# Database — auto-converts legacy postgres:// scheme
db_url = os.getenv('DATABASE_URL', 'sqlite:///project.db')
app.config['SQLALCHEMY_DATABASE_URI'] = db_url.replace('postgres://', 'postgresql://')
MIGRATE = Migrate(app, db, compare_type=True)
db.init_app(app)

# Flask-Admin and CLI commands
setup_admin(app)
setup_commands(app)

# Register the api Blueprint at /api
app.register_blueprint(api, url_prefix='/api')
JWT_SECRET_KEY is the only required environment variable that will halt startup. All others have safe defaults, but running without a real secret key is a security risk — never rely on a fallback in any non-development environment.

Production Static Serving

When FLASK_DEBUG is not "1", Flask serves the compiled Vite output from ../dist/ (relative to backend/src/). Any path that is not a file on disk returns index.html, enabling React Router’s client-side routing:
static_file_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../dist/')

@app.route('/<path:path>', methods=['GET'])
def serve_any_other_file(path):
    if not os.path.isfile(os.path.join(static_file_dir, path)):
        path = 'index.html'
    return send_from_directory(static_file_dir, path)

Route Blueprints

All blueprints are imported in api/routes/__init__.py and registered on the parent api Blueprint with their URL prefix. The parent Blueprint is then registered on the app at /api.

Blueprint Registry

PrefixBlueprint filePurpose
/api/usersuser_routes.pyRegistration, login, forgot-password, profile CRUD, avatar upload
/api/aiai_routes.pyGroq LLM queries for the AI assistant, geocoding, POI geocoding
/api/accessibilityaccessibility_routes.pyCommunity wheelchair reviews and photo uploads
/api/tripstrip_routes.pyTrip CRUD, days, places, cover images, forking, public listing
/api/placesplace_routes.pyCommunity-submitted custom pins (admin-moderated)
/api/utilsutils_routes.pyOG image scraper, Google Places photo proxy
# api/routes/__init__.py
api = Blueprint('api', __name__)

api.register_blueprint(user_bp,          url_prefix='/users')
api.register_blueprint(ai_bp,            url_prefix='/ai')
api.register_blueprint(accessibility_bp, url_prefix='/accessibility')
api.register_blueprint(trip_bp,          url_prefix='/trips')
api.register_blueprint(place_bp,         url_prefix='/places')
api.register_blueprint(utils_bp,         url_prefix='/utils')

Blueprint Details

Handles the full user account lifecycle. Public endpoints (no JWT required): POST /register, POST /login, POST /forgot-password. All profile and avatar endpoints require @jwt_required().
  • POST /api/users/register — creates a new user with bcrypt-hashed password
  • POST /api/users/login — returns { token, expiresIn } on success
  • GET /api/users/ — lists all users (admin only, checked via JWT claims)
  • GET /api/users/<id> — returns a single user’s profile
  • PUT /api/users/<id> — updates profile fields; users can only update themselves
  • DELETE /api/users/<id> — soft or hard delete (admin or self)
  • POST /api/users/<id>/avatar — uploads avatar to Cloudinary, stores filename
  • GET /api/users/avatar/<filename> — serves avatar files from local storage fallback
All endpoints are rate-limited via Flask-Limiter. No JWT required — the AI assistant is accessible to anonymous visitors.
  • POST /api/ai/mapgpt — sends a user prompt to Groq/Llama; rate-limited to 20 per minute. Returns a structured response that the AI assistant module uses to pan the map or display information.
  • GET /api/ai/geocode?q=<query> — resolves a place name to coordinates using Nominatim/Geoapify; rate-limited to 30 per minute
  • GET /api/ai/geocode-poi?q=<query> — geocodes a POI name with more structured result; rate-limited to 30 per minute
Community members submit structured accessibility data that enriches the OSM baseline. One review per OSM place (osm_id is unique in accessibility_reviews).
  • GET /api/accessibility/<osm_id> — retrieves the community review for a place
  • POST /api/accessibility/ — creates or updates a review (@jwt_required())
  • POST /api/accessibility/<id>/photos — uploads a photo to Cloudinary; requires JWT
  • DELETE /api/accessibility/photos/<id> — deletes a review photo; requires JWT
  • GET /api/accessibility/photos/<filename> — serves locally stored photo files
Full CRUD for the trip planner feature. All endpoints require @jwt_required() except the public listing.
  • GET /api/trips/public — paginated listing of all public trips (no auth required)
  • GET /api/trips/public/<id> — detail view of a single public trip
  • GET /api/trips/ — lists the current user’s own trips
  • POST /api/trips/ — creates a new trip
  • GET /api/trips/<id> — full trip detail including days and places
  • PUT /api/trips/<id> — updates title, description, visibility, cover image
  • DELETE /api/trips/<id> — deletes a trip and all its days and places (cascade)
  • POST /api/trips/<id>/fork — duplicates a public trip to the current user’s account, setting original_trip_id
  • POST /api/trips/<id>/cover — uploads cover image to Cloudinary
  • GET /api/trips/cover/<filename> — serves cover image files
  • Day and place management sub-routes under /api/trips/<id>/days/ and /api/trips/<id>/days/<day_id>/places/
Users submit custom location pins that start with status = 'pending'. Admins review and approve them via the Flask-Admin panel, after which they appear on the map as the community-approved layer. The submitting user always sees their own pending pins via the community-pending layer.
  • GET /api/places/ — returns approved places + the current user’s own pending places
  • POST /api/places/ — submits a new pin (@jwt_required()); status defaults to 'pending'
  • PUT /api/places/<id> — updates a pending place (owner or admin only)
  • DELETE /api/places/<id> — deletes a place (owner or admin only)
Stateless proxy endpoints that keep API keys server-side and work around browser CORS restrictions.
  • GET /api/utils/og-image?url=<url> — scrapes the og:image or twitter:image meta tag from a given URL; rate-limited to 60 per minute
  • GET /api/utils/google-photo?name=<name>&lat=<lat>&lon=<lon> — proxies Google Places API v1 to retrieve a place photo URI; requires GOOGLE_PLACES_KEY env var; rate-limited to 60 per minute

Error Handling

WayFy uses a single custom exception class, APIException, for all expected API errors. It is defined in api/utils.py and caught globally in app.py.
# api/utils.py
class APIException(Exception):
    status_code = 400

    def __init__(self, message, status_code=None, payload=None):
        Exception.__init__(self)
        self.message = message
        if status_code is not None:
            self.status_code = status_code
        self.payload = payload

    def to_dict(self):
        rv = dict(self.payload or ())
        rv['message'] = self.message
        return rv
Raise it anywhere in a controller:
from api.utils import APIException

raise APIException('El viaje no existe', 404)
raise APIException('No tienes permiso para editar este viaje', 403)
raise APIException('El campo título es obligatorio', 400)
The global error handler in app.py converts every APIException to a consistent JSON response:
@app.errorhandler(APIException)
def handle_invalid_usage(error):
    return jsonify(error.to_dict()), error.status_code

# Response body:
# { "message": "El viaje no existe" }
The frontend’s handleResponse helper reads body.msg from error responses (Flask-JWT-Extended uses msg for its own errors). Controllers should keep the key consistent: use message in APIException payloads and let the frontend handle both shapes.

Authentication & Rate Limiting

All protected routes use Flask-JWT-Extended’s @jwt_required() decorator. The current user’s ID is extracted with get_jwt_identity(), which returns the string id embedded at token creation time. Additional claims (such as is_admin) are read from get_jwt():
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt

@trip_bp.route('/<int:trip_id>', methods=['DELETE'])
@jwt_required()
def handle_delete_trip(trip_id):
    user_id = int(get_jwt_identity())
    claims = get_jwt()
    is_admin = claims.get('is_admin', False)
    return TripController.delete(trip_id=trip_id, user_id=user_id, is_admin=is_admin)
Rate limiting is applied per-endpoint using the limiter instance from api/utils.py:
from api.utils import limiter

@ai_bp.route('/mapgpt', methods=['POST'])
@limiter.limit('20 per minute')
def handle_mapgpt():
    ...
limiter uses get_remote_address as the key function, so limits are enforced per client IP. The limiter is initialised lazily with limiter.init_app(app) in app.py.

Static File Serving

User-uploaded assets are stored in Cloudinary. Only the filename or Cloudinary public ID is persisted in the database. Each route blueprint that owns assets provides a GET endpoint to serve locally cached fallbacks:
Asset typeDB columnServing endpoint
User avatarsusers.avatarGET /api/users/avatar/<filename>
Accessibility photosaccessibility_photos.filenameGET /api/accessibility/photos/<filename>
Trip cover imagestrips.cover_imageGET /api/trips/cover/<filename>
The serialize() methods on the relevant models build the full URL path automatically:
# user_model.py — avatar URL built in serialize()
'avatar': f'/api/users/avatar/{self.avatar}'

# trip_model.py — cover image URL built in serialize()
'cover_image': f'/api/trips/cover/{self.cover_image}' if self.cover_image else None

Database Models

All models use SQLAlchemy 2.x Mapped / mapped_column annotations. osm_id is the shared identifier that links UserFavorite, AccessibilityReview, and TripDayPlace records — always stored as a string.
ModelTableKey fields
Userusersemail (unique), password (bcrypt hash), selected_mobility (JSON as Text), avatar, is_admin
UserFavoriteuser_favoritesosm_id + user_id unique together; wheelchair, all_tags (JSON as Text)
AccessibilityReviewaccessibility_reviewsosm_id unique — one review per place; wheelchair (‘yes’/‘limited’/‘no’); boolean flags: has_ramp, has_elevator, has_accessible_toilet, has_accessible_parking, automatic_door
AccessibilityPhotoaccessibility_photosfilename; FK to accessibility_reviews
Triptripsis_public, original_trip_id self-FK (forks), cover_image
TripDaytrip_daysday_number (ordered); places relationship ordered by order
TripDayPlacetrip_day_placesfavorite_id FK to UserFavorite (optional), osm_id, order, visit_time, visit_time_end
PlaceplacesCommunity pin; status = 'pending' or 'approved'; moderated via Flask-Admin

Alembic Migrations

Schema changes are managed with Flask-Migrate (Alembic). The migration workflow is:
# Run from backend/
pipenv run migrate     # flask db migrate — generates a new versioned migration file
pipenv run upgrade     # flask db upgrade — applies pending migrations to the DB
pipenv run downgrade   # flask db downgrade — reverts the last applied migration
Migrate(app, db, compare_type=True) is initialised in app.py with compare_type=True so Alembic detects column type changes (e.g. String(50)String(200)) in addition to structural changes.
After changing any model, always run pipenv run migrate to auto-generate the migration file, review the generated script in backend/migrations/versions/, and then run pipenv run upgrade. Never modify the database schema manually in production — all changes must go through migration files so they are reproducible.

Flask-Admin

setup_admin(app) in api/admin.py registers Flask-Admin views. The most important view is the Place model admin, which is the primary interface for moderating community-submitted pins. Admins change a pin’s status from 'pending' to 'approved' through the /admin/places panel, which immediately makes the pin visible on the map for all users. The admin panel is available at /admin/ and is protected by a custom admin check. It is not exposed to regular authenticated users — the AdminRoute guard on the frontend and a corresponding backend check ensure only users with is_admin = True can access it.

Build docs developers (and LLMs) love