From 75a7b26695f79180e0c59de796ec830cc4988bf7 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 17 Mar 2026 17:24:55 -0400 Subject: [PATCH] March 17 2026: Mobile app phase 1 --- app/__init__.py | 4 + app/api/__init__.py | 60 +++++ app/api/auth.py | 319 +++++++++++++++++++++++ app/api/decorators.py | 108 ++++++++ app/api/errors.py | 87 +++++++ app/api/jwt_utils.py | 74 ++++++ app/models/__init__.py | 1 + app/models/api_token.py | 127 +++++++++ migrations/versions/phase7_mobile_api.py | 91 +++++++ requirements.txt | 3 +- 10 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 app/api/__init__.py create mode 100644 app/api/auth.py create mode 100644 app/api/decorators.py create mode 100644 app/api/errors.py create mode 100644 app/api/jwt_utils.py create mode 100644 app/models/api_token.py create mode 100644 migrations/versions/phase7_mobile_api.py diff --git a/app/__init__.py b/app/__init__.py index 35ff167..3993d0d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -131,6 +131,10 @@ def create_app(config_name='default'): app.register_blueprint(customers.bp) app.register_blueprint(scheduled_reports.bp) + # ── Mobile API ───────────────────────────────────────────── + from app.api import register_api + register_api(app) + # ── Error handler: 413 Request Entity Too Large ─────────────────────── # Nginx can return 413 before Flask sees the request; this handler covers # the Flask-side rejection and gives users a clear, actionable message diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..5871bef --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,60 @@ +""" +app/api/__init__.py +------------------- +Registers the /api/v1 blueprint group. + +All mobile API routes live under the prefix /api/v1/. +This module is imported once from app/__init__.py — see the integration +instructions at the bottom of this file. + +Blueprint layout +---------------- +/api/v1/auth/login → api_auth.login +/api/v1/auth/refresh → api_auth.refresh +/api/v1/auth/logout → api_auth.logout +/api/v1/auth/me → api_auth.me +/api/v1/devices/register → api_auth.register_device + +Future phases will add: +/api/v1/facilities → api_facilities.* +/api/v1/inspections → api_inspections.* +/api/v1/issues → api_issues.* +/api/v1/notifications → api_notifications.* +/api/v1/photos/upload → api_photos.* +""" + +from flask import Blueprint +from app.api.errors import register_error_handlers + +# Parent blueprint — all sub-blueprints registered under this prefix +api_bp = Blueprint('api', __name__, url_prefix='/api/v1') + +# Register JSON error handlers so Flask exceptions within /api/v1/ +# return JSON instead of HTML error pages. +register_error_handlers(api_bp) + + +def register_api(app): + """ + Import and register all API sub-blueprints onto api_bp, then + register api_bp on the Flask app. + + Called once from create_app() in app/__init__.py. + """ + # ── Phase 1: Auth ──────────────────────────────────────────────────── + from app.api.auth import bp as auth_bp + api_bp.register_blueprint(auth_bp) + + # ── Phase 2+: Additional blueprints registered here as phases complete + # from app.api.facilities import bp as facilities_bp + # from app.api.inspections import bp as inspections_bp + # from app.api.issues import bp as issues_bp + # from app.api.notifications import bp as notifications_bp + # from app.api.photos import bp as photos_bp + # api_bp.register_blueprint(facilities_bp) + # api_bp.register_blueprint(inspections_bp) + # api_bp.register_blueprint(issues_bp) + # api_bp.register_blueprint(notifications_bp) + # api_bp.register_blueprint(photos_bp) + + app.register_blueprint(api_bp) diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..c514b2c --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,319 @@ +""" +app/api/auth.py +--------------- +Authentication endpoints for the JQC mobile app. + +POST /api/v1/auth/login + Accepts username + password. + Returns a short-lived access token (JWT) and a long-lived refresh token + (opaque, stored in DB). The app stores both in the iOS Keychain. + +POST /api/v1/auth/refresh + Accepts a refresh token. + Returns a new access token. The refresh token is rotated — the old one + is revoked and a new one is issued, preventing replay attacks. + +POST /api/v1/auth/logout + Accepts a refresh token. + Revokes it so it can no longer be used to issue new access tokens. + The app should discard both tokens from the Keychain after this call. + +POST /api/v1/devices/register + Registers or updates the APNs device token for push notifications. + Called on every app launch after the user has already authenticated. + Requires a valid access token (JWT). + +GET /api/v1/auth/me + Returns the current user's profile from the access token. + Useful for the app to verify the token is still valid on launch. +""" + +import logging + +from flask import Blueprint, request, g +from flask_wtf.csrf import csrf_exempt + +from app import db +from app.models.user import User +from app.models.api_token import RefreshToken, DeviceToken +from app.api.errors import api_ok, api_error +from app.api.jwt_utils import generate_access_token +from app.api.decorators import jwt_required +from app.utils.audit import log_action, ACTION_LOGIN, ACTION_LOGOUT +from app.utils.time_utils import now_eastern + +logger = logging.getLogger(__name__) + +bp = Blueprint('api_auth', __name__) + + +def _user_payload(user: User) -> dict: + """Serialize a User to the dict returned in auth responses.""" + return { + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'role': user.role, + 'created_at': user.created_at.isoformat() if user.created_at else None, + } + + +# ── Login ───────────────────────────────────────────────────────────────────── + +@bp.route('/auth/login', methods=['POST']) +@csrf_exempt +def login(): + """ + Authenticate with username + password. + + Request JSON + ------------ + { + "username": "john", + "password": "secret", + "device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional) + "device_name": "John's iPhone" // (optional) + } + + Response 200 + ------------ + { + "ok": true, + "data": { + "access_token": "", + "refresh_token": "", + "token_type": "Bearer", + "expires_in": 3600, + "user": { id, username, email, role, created_at } + } + } + """ + data = request.get_json(silent=True) or {} + + username = (data.get('username') or '').strip() + password = data.get('password') or '' + + if not username or not password: + return api_error('username and password are required', 400) + + user = User.query.filter_by(username=username).first() + + # Generic message — never reveal whether the username exists + if user is None or not user.check_password(password): + logger.warning('API login failed | username=%s | ip=%s', + username, request.remote_addr) + return api_error('Invalid credentials', 401) + + if not user.active: + return api_error('Account is disabled. Please contact an administrator.', 401) + + device_id = (data.get('device_id') or '')[:64] or None + device_name = (data.get('device_name') or '')[:100] or None + + # Issue tokens + access_token = generate_access_token(user) + raw_refresh, rt_row = RefreshToken.create_for( + user, + device_id=device_id, + device_name=device_name, + ) + db.session.commit() + + log_action(ACTION_LOGIN, 'User', user.id, user.username, + f'source=mobile_api; device_id={device_id}') + + logger.info('API LOGIN | user=%s | role=%s | device_id=%s', + user.username, user.role, device_id) + + return api_ok({ + 'access_token': access_token, + 'refresh_token': raw_refresh, + 'token_type': 'Bearer', + 'expires_in': 3600, # seconds — matches ACCESS_TOKEN_LIFETIME_MINUTES * 60 + 'user': _user_payload(user), + }) + + +# ── Refresh ─────────────────────────────────────────────────────────────────── + +@bp.route('/auth/refresh', methods=['POST']) +@csrf_exempt +def refresh(): + """ + Exchange a valid refresh token for a new access token. + + The refresh token is rotated on every call — the submitted token is + revoked and a brand new one is issued. This limits the damage window + if a token is ever stolen. + + Request JSON + ------------ + { "refresh_token": "" } + + Response 200 + ------------ + { + "ok": true, + "data": { + "access_token": "", + "refresh_token": "", + "token_type": "Bearer", + "expires_in": 3600 + } + } + """ + data = request.get_json(silent=True) or {} + raw_token = (data.get('refresh_token') or '').strip() + + if not raw_token: + return api_error('refresh_token is required', 400) + + rt_row = RefreshToken.verify(raw_token) + if rt_row is None: + logger.warning('API refresh rejected | invalid/expired token | ip=%s', + request.remote_addr) + return api_error('Refresh token is invalid or expired', 401) + + user = User.query.get(rt_row.user_id) + if user is None or not user.active: + rt_row.revoke() + db.session.commit() + return api_error('Account not available', 401) + + # Rotate: revoke old token, issue new pair + device_id = rt_row.device_id + device_name = rt_row.device_name + rt_row.revoke() + + new_access = generate_access_token(user) + new_raw_refresh, new_rt = RefreshToken.create_for( + user, + device_id=device_id, + device_name=device_name, + ) + db.session.commit() + + logger.info('API TOKEN REFRESH | user=%s | device_id=%s', + user.username, device_id) + + return api_ok({ + 'access_token': new_access, + 'refresh_token': new_raw_refresh, + 'token_type': 'Bearer', + 'expires_in': 3600, + }) + + +# ── Logout ──────────────────────────────────────────────────────────────────── + +@bp.route('/auth/logout', methods=['POST']) +@csrf_exempt +@jwt_required +def logout(): + """ + Revoke the current session's refresh token. + + The app should call this when the user taps "Log out" and then discard + both the access token and refresh token from the Keychain. + + Request JSON + ------------ + { "refresh_token": "" } + + Response 200 + ------------ + { "ok": true, "data": { "message": "Logged out" } } + """ + data = request.get_json(silent=True) or {} + raw_token = (data.get('refresh_token') or '').strip() + + if raw_token: + rt_row = RefreshToken.verify(raw_token) + if rt_row and rt_row.user_id == g.api_user.id: + rt_row.revoke() + db.session.commit() + + log_action(ACTION_LOGOUT, 'User', g.api_user.id, g.api_user.username, + 'source=mobile_api') + logger.info('API LOGOUT | user=%s', g.api_user.username) + + return api_ok({'message': 'Logged out successfully'}) + + +# ── Current user ────────────────────────────────────────────────────────────── + +@bp.route('/auth/me', methods=['GET']) +@jwt_required +def me(): + """ + Return the authenticated user's profile. + + Called by the app on launch to verify the stored access token is still + valid and to refresh the local user record. + + Response 200 + ------------ + { "ok": true, "data": { "user": { id, username, email, role, ... } } } + """ + return api_ok({'user': _user_payload(g.api_user)}) + + +# ── Device token registration ───────────────────────────────────────────────── + +@bp.route('/devices/register', methods=['POST']) +@csrf_exempt +@jwt_required +def register_device(): + """ + Register or update the APNs device token for the authenticated user. + + Called on every app launch after authentication so the server always + has the current token (APNs rotates tokens periodically). + + Request JSON + ------------ + { + "device_id": "", + "apns_token": "", + "device_name": "John's iPhone", // optional + "app_version": "1.0.3" // optional + } + + Response 200 + ------------ + { "ok": true, "data": { "registered": true } } + """ + data = request.get_json(silent=True) or {} + device_id = (data.get('device_id') or '').strip()[:64] + apns_token = (data.get('apns_token') or '').strip()[:200] + device_name = (data.get('device_name') or '').strip()[:100] or None + app_version = (data.get('app_version') or '').strip()[:20] or None + + if not device_id or not apns_token: + return api_error('device_id and apns_token are required', 400) + + # Upsert: update existing row or insert new one + existing = DeviceToken.query.filter_by( + user_id=g.api_user.id, + device_id=device_id, + ).first() + + if existing: + existing.apns_token = apns_token + existing.device_name = device_name + existing.app_version = app_version + existing.registered_at = now_eastern() + else: + db.session.add(DeviceToken( + user_id = g.api_user.id, + device_id = device_id, + apns_token = apns_token, + device_name = device_name, + app_version = app_version, + )) + + db.session.commit() + logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | apns_token=...%s', + g.api_user.username, device_id, apns_token[-6:]) + + return api_ok({'registered': True}) diff --git a/app/api/decorators.py b/app/api/decorators.py new file mode 100644 index 0000000..916a8cc --- /dev/null +++ b/app/api/decorators.py @@ -0,0 +1,108 @@ +""" +app/api/decorators.py +--------------------- +Request-level guards for all /api/v1/ endpoints. + +@jwt_required + Validates the Bearer token in the Authorization header. + On success, sets flask.g.api_user to the authenticated User instance + so any route can access it without a second DB query. + +@api_role_required(*roles) + Must be applied AFTER @jwt_required. + Rejects callers whose role is not in the allowed set. + +Usage +----- + @bp.route('/inspections') + @jwt_required + def list_inspections(): + user = g.api_user + ... + + @bp.route('/admin/users') + @jwt_required + @api_role_required('admin') + def admin_only(): + ... +""" + +import logging +from functools import wraps + +from flask import request, g, abort + +from app.api.jwt_utils import decode_access_token +from app.api.errors import api_error +from app.models.user import User + +logger = logging.getLogger(__name__) + + +def jwt_required(f): + """ + Validate the JWT Bearer token and load the user into flask.g.api_user. + + Returns 401 if: + - Authorization header is missing or malformed + - Token is expired or invalid + - User referenced by the token no longer exists + - User account has been disabled (active=False) + """ + @wraps(f) + def decorated(*args, **kwargs): + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return api_error('Missing or malformed Authorization header', 401) + + raw_token = auth_header[len('Bearer '):] + payload = decode_access_token(raw_token) + + if payload is None: + return api_error('Access token is invalid or expired', 401) + + user_id = int(payload.get('sub', 0)) + user = User.query.get(user_id) + + if user is None: + return api_error('User not found', 401) + + if not user.active: + return api_error('Account is disabled', 401) + + # Make the user available to the route without re-querying + g.api_user = user + return f(*args, **kwargs) + + return decorated + + +def api_role_required(*roles): + """ + Restrict an endpoint to users whose role is in the provided list. + + Must be stacked BELOW @jwt_required so that g.api_user is already set. + + Example + ------- + @jwt_required + @api_role_required('admin', 'supervisor') + def supervisor_only_route(): + ... + """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + user = getattr(g, 'api_user', None) + if user is None: + # Defensive: jwt_required should always run first + return api_error('Authentication required', 401) + if user.role not in roles: + logger.warning( + 'API role denied | user=%s role=%s required=%s endpoint=%s', + user.username, user.role, roles, request.endpoint, + ) + return api_error('Insufficient permissions', 403) + return f(*args, **kwargs) + return decorated + return decorator diff --git a/app/api/errors.py b/app/api/errors.py new file mode 100644 index 0000000..8934648 --- /dev/null +++ b/app/api/errors.py @@ -0,0 +1,87 @@ +""" +app/api/errors.py +----------------- +Consistent JSON error responses for every API endpoint. + +Every response — success or failure — uses the same envelope: + + { + "ok": true | false, + "data": { ... } | null, + "error": null | "Human-readable message" + } + +Usage +----- + from app.api.errors import api_error, api_ok + + return api_ok({'inspection': {...}}) + return api_error('Inspection not found', 404) +""" + +from flask import jsonify + + +def api_ok(data=None, status=200): + """Return a successful JSON response.""" + return jsonify({ + 'ok': True, + 'data': data, + 'error': None, + }), status + + +def api_error(message: str, status: int = 400): + """Return an error JSON response.""" + return jsonify({ + 'ok': False, + 'data': None, + 'error': message, + }), status + + +# ── Registered error handlers (attached to the api blueprint) ───────────────── + +def register_error_handlers(bp): + """ + Attach JSON error handlers to the given blueprint so that Flask + exceptions (404, 405, 500, etc.) return JSON instead of HTML within + the /api/v1/ prefix. + """ + from werkzeug.exceptions import HTTPException + + @bp.errorhandler(400) + def bad_request(e): + return api_error(str(e.description) if hasattr(e, 'description') else 'Bad request', 400) + + @bp.errorhandler(401) + def unauthorized(e): + return api_error('Authentication required', 401) + + @bp.errorhandler(403) + def forbidden(e): + return api_error('Access denied', 403) + + @bp.errorhandler(404) + def not_found(e): + return api_error('Resource not found', 404) + + @bp.errorhandler(405) + def method_not_allowed(e): + return api_error('Method not allowed', 405) + + @bp.errorhandler(413) + def payload_too_large(e): + return api_error('Uploaded file is too large', 413) + + @bp.errorhandler(422) + def unprocessable(e): + return api_error('Unprocessable request', 422) + + @bp.errorhandler(500) + def internal_error(e): + return api_error('Internal server error', 500) + + @bp.errorhandler(HTTPException) + def generic_http(e): + return api_error(e.description or e.name, e.code) diff --git a/app/api/jwt_utils.py b/app/api/jwt_utils.py new file mode 100644 index 0000000..bde55b2 --- /dev/null +++ b/app/api/jwt_utils.py @@ -0,0 +1,74 @@ +""" +app/api/jwt_utils.py +-------------------- +Thin wrappers around PyJWT for signing and verifying access tokens. + +Access tokens are short-lived JWTs (default 60 minutes) signed with +HMAC-SHA256 using the app's SECRET_KEY. They carry only the minimum +claims needed to identify the caller: + + { + "sub": "42", # user.id as string + "role": "inspector", # user.role + "iat": 1710000000, # issued-at (UTC epoch) + "exp": 1710003600, # expiry (UTC epoch, 60 min later) + } + +Refresh tokens are opaque random strings stored in the DB +(see app/models/api_token.py). This module only handles JWTs. +""" + +import logging +from datetime import datetime, timezone, timedelta + +import jwt +from flask import current_app + +logger = logging.getLogger(__name__) + +ACCESS_TOKEN_LIFETIME_MINUTES = 60 + + +def _secret(): + return current_app.config['SECRET_KEY'] + + +def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MINUTES) -> str: + """ + Create and sign a new access token for the given user. + + Parameters + ---------- + user : User ORM instance + lifetime_minutes : Token validity window (default 60 min) + + Returns + ------- + str + Signed JWT string ready to include in Authorization header. + """ + now = datetime.now(timezone.utc) + payload = { + 'sub': str(user.id), + 'role': user.role, + 'iat': now, + 'exp': now + timedelta(minutes=lifetime_minutes), + } + return jwt.encode(payload, _secret(), algorithm='HS256') + + +def decode_access_token(token: str) -> dict | None: + """ + Decode and verify a JWT access token. + + Returns the payload dict on success, or None if the token is invalid, + expired, or tampered with. Logs the failure reason at DEBUG level. + """ + try: + return jwt.decode(token, _secret(), algorithms=['HS256']) + except jwt.ExpiredSignatureError: + logger.debug('JWT decode failed: token expired') + return None + except jwt.InvalidTokenError as exc: + logger.debug('JWT decode failed: %s', exc) + return None diff --git a/app/models/__init__.py b/app/models/__init__.py index c82256d..8d827d5 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -4,3 +4,4 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem, Inspection, InspectionResult) from app.models.issue import Issue from app.models.project import Project, CustomerAssignment +from app.models.api_token import RefreshToken, DeviceToken \ No newline at end of file diff --git a/app/models/api_token.py b/app/models/api_token.py new file mode 100644 index 0000000..58ef417 --- /dev/null +++ b/app/models/api_token.py @@ -0,0 +1,127 @@ +""" +app/models/api_token.py +----------------------- +Persistent storage for JWT refresh tokens and APNs device tokens. + +RefreshToken + One row per active mobile session. When the access token expires the + app presents its refresh token here; a new access token is issued and + the refresh token is rotated (old one deleted, new one inserted). + Revocation is instant: delete the row. + +DeviceToken + One row per (user, device) pair. Stores the APNs token so the server + can push notifications to the device. Updated on every app launch + because APNs tokens can rotate. +""" + +import secrets +from app import db +from app.utils.time_utils import now_eastern +from datetime import timedelta + + +class RefreshToken(db.Model): + """ + Opaque refresh token stored server-side. + + The token value itself is a 64-character hex string generated with + secrets.token_hex(32). Only the SHA-256 hash is stored so that a DB + breach does not expose live tokens. + """ + __tablename__ = 'api_refresh_tokens' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, + index=True, + ) + # SHA-256 hex digest of the raw token — never store the raw value + token_hash = db.Column(db.String(64), nullable=False, unique=True, index=True) + # Device identifier supplied by the app (UIDevice.identifierForVendor) + device_id = db.Column(db.String(64), nullable=True) + device_name = db.Column(db.String(100), nullable=True) # e.g. "John's iPhone" + created_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + expires_at = db.Column(db.DateTime, nullable=False) + revoked = db.Column(db.Boolean, nullable=False, default=False) + + user = db.relationship('User', foreign_keys=[user_id], + backref=db.backref('refresh_tokens', lazy='dynamic', + cascade='all, delete-orphan')) + + @classmethod + def create_for(cls, user, device_id=None, device_name=None, + lifetime_days=30): + """ + Generate a new refresh token, persist it, and return the raw token + string (only time it is ever available in plaintext). + """ + import hashlib + raw = secrets.token_hex(32) # 64-char hex, 256 bits entropy + hashed = hashlib.sha256(raw.encode()).hexdigest() + token = cls( + user_id = user.id, + token_hash = hashed, + device_id = device_id, + device_name = device_name, + expires_at = now_eastern() + timedelta(days=lifetime_days), + ) + db.session.add(token) + return raw, token # caller must db.session.commit() + + @classmethod + def verify(cls, raw_token): + """ + Look up a refresh token by its raw value. + Returns the RefreshToken row if valid and unexpired, else None. + """ + import hashlib + hashed = hashlib.sha256(raw_token.encode()).hexdigest() + row = cls.query.filter_by(token_hash=hashed, revoked=False).first() + if row is None: + return None + if row.expires_at < now_eastern(): + return None + return row + + def revoke(self): + self.revoked = True + + def __repr__(self): + return f'' + + +class DeviceToken(db.Model): + """ + APNs device token for push notification delivery. + + One row per (user, device_id) pair — upserted on every app launch. + The apns_token is the hex string returned by the iOS SDK. + """ + __tablename__ = 'api_device_tokens' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, + index=True, + ) + device_id = db.Column(db.String(64), nullable=False) # UIDevice.identifierForVendor + apns_token = db.Column(db.String(200), nullable=False) + device_name = db.Column(db.String(100), nullable=True) + app_version = db.Column(db.String(20), nullable=True) + registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'device_id', name='uq_device_token_user_device'), + ) + + user = db.relationship('User', foreign_keys=[user_id], + backref=db.backref('device_tokens', lazy='dynamic', + cascade='all, delete-orphan')) + + def __repr__(self): + return f'' diff --git a/migrations/versions/phase7_mobile_api.py b/migrations/versions/phase7_mobile_api.py new file mode 100644 index 0000000..db99aac --- /dev/null +++ b/migrations/versions/phase7_mobile_api.py @@ -0,0 +1,91 @@ +""" +migrations/versions/phase7_mobile_api.py +----------------------------------------- +Phase 7 — Mobile API: JWT refresh tokens and APNs device tokens. + +Revision ID : phase7_mobile_api +Revises : phase6_features +Create Date : 2026-03-17 + +New tables +---------- +api_refresh_tokens + Stores server-side refresh token hashes for mobile sessions. + Enables instant revocation by deleting the row. + +api_device_tokens + Stores APNs device tokens for push notification delivery. + One row per (user_id, device_id) — upserted on every app launch. + +All columns include safe IF NOT EXISTS / IF EXISTS guards so the +migration is idempotent and safe to re-run. +""" + +from alembic import op +import sqlalchemy as sa + +revision = 'phase7_mobile_api' +down_revision = 'phase6_features' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = inspector.get_table_names() + + # ── 1. api_refresh_tokens ───────────────────────────────────────────── + if 'api_refresh_tokens' not in tables: + op.create_table( + 'api_refresh_tokens', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), + sa.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False), + sa.Column('token_hash', sa.String(64), nullable=False, unique=True), + sa.Column('device_id', sa.String(64), nullable=True), + sa.Column('device_name', sa.String(100), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False, + server_default='0'), + ) + op.create_index('ix_api_refresh_tokens_user_id', + 'api_refresh_tokens', ['user_id']) + op.create_index('ix_api_refresh_tokens_token_hash', + 'api_refresh_tokens', ['token_hash'], unique=True) + + # ── 2. api_device_tokens ────────────────────────────────────────────── + if 'api_device_tokens' not in tables: + op.create_table( + 'api_device_tokens', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), + sa.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False), + sa.Column('device_id', sa.String(64), nullable=False), + sa.Column('apns_token', sa.String(200), nullable=False), + sa.Column('device_name', sa.String(100), nullable=True), + sa.Column('app_version', sa.String(20), nullable=True), + sa.Column('registered_at', sa.DateTime(), nullable=False), + ) + op.create_index('ix_api_device_tokens_user_id', + 'api_device_tokens', ['user_id']) + op.create_unique_constraint( + 'uq_device_token_user_device', + 'api_device_tokens', + ['user_id', 'device_id'], + ) + + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = inspector.get_table_names() + + if 'api_device_tokens' in tables: + op.drop_table('api_device_tokens') + + if 'api_refresh_tokens' in tables: + op.drop_table('api_refresh_tokens') diff --git a/requirements.txt b/requirements.txt index 884c4da..a9dc21a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,5 @@ WTForms email-validator gunicorn reportlab -pytz \ No newline at end of file +pytz +pyJWT \ No newline at end of file