From 32a4c50b234a1ce5a2cf0874326dd12c448aade5 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 12:04:14 +0000 Subject: [PATCH 1/5] feat: Sprint 1 foundation - FastAPI scaffold, DB schema, auth, seed data, mock WhatsApp, Docker --- .gitignore | 9 ++ AGENTS.md | 69 ++++++++ CLAUDE.md | 1 + Dockerfile | 24 +++ alembic.ini | 149 ++++++++++++++++++ alembic/README | 1 + alembic/env.py | 70 ++++++++ alembic/script.py.mako | 28 ++++ .../versions/795c6b95f637_initial_schema.py | 135 ++++++++++++++++ app/__init__.py | 0 app/core/__init__.py | 0 app/core/config.py | 38 +++++ app/core/database.py | 33 ++++ app/core/security.py | 115 ++++++++++++++ app/main.py | 55 +++++++ app/models/__init__.py | 7 + app/models/category.py | 37 +++++ app/models/ticket.py | 123 +++++++++++++++ app/models/unit.py | 21 +++ app/models/user.py | 32 ++++ app/models/whatsapp_log.py | 23 +++ app/routers/__init__.py | 0 app/routers/auth.py | 53 +++++++ app/routers/health.py | 14 ++ app/routers/whatsapp.py | 45 ++++++ app/schemas/__init__.py | 0 app/schemas/auth.py | 39 +++++ app/schemas/health.py | 8 + app/schemas/whatsapp.py | 28 ++++ app/services/__init__.py | 0 app/services/auth.py | 74 +++++++++ app/services/seed.py | 120 ++++++++++++++ docker-compose.yml | 17 ++ pyproject.toml | 22 +++ 34 files changed, 1390 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 Dockerfile create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/795c6b95f637_initial_schema.py create mode 100644 app/__init__.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/database.py create mode 100644 app/core/security.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/category.py create mode 100644 app/models/ticket.py create mode 100644 app/models/unit.py create mode 100644 app/models/user.py create mode 100644 app/models/whatsapp_log.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/health.py create mode 100644 app/routers/whatsapp.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/auth.py create mode 100644 app/schemas/health.py create mode 100644 app/schemas/whatsapp.py create mode 100644 app/services/__init__.py create mode 100644 app/services/auth.py create mode 100644 app/services/seed.py create mode 100644 docker-compose.yml create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be682c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.db +*.sqlite3 +.env +.venv/ +venv/ +*.egg-info/ +dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7a6fb68 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# Denya OneCare — Agent memory + +Denya OneCare is a centralized maintenance/issue tracking system for Pavilion Accra (FastAPI + SQLite + Alpine.js + Twilio). + +## Quick start + +```bash +# Start the app +docker-compose up + +# Or directly +uvicorn app.main:app --reload +``` + +## Project structure + +``` +app/ +├── core/ # config, database, security (JWT + bcrypt + RBAC) +├── models/ # SQLAlchemy ORM models +├── schemas/ # Pydantic request/response schemas +├── services/ # Business logic (auth, seed) +└── routers/ # FastAPI route handlers +alembic/ # Database migrations +``` + +## Commands + +- `alembic upgrade head` — apply migrations +- `alembic revision --autogenerate -m "msg"` — new migration + +## Seed data + +Users and units are auto-seeded on first startup via lifespan hook: +- 16 users covering all roles (Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher, Tech, CEO, Director) +- 120 apartment units (East/West, 10 floors × 6 apts per wing) +- Default password for all seed users: `denya123` +- Units load from `apartment_mapping.json` if present, else built-in fallback + +## Key API endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/health` | No | Health check | +| POST | `/api/auth/register` | No | Create user | +| POST | `/api/auth/login` | No | Get JWT tokens | +| POST | `/api/auth/refresh` | Token | Refresh tokens | +| GET | `/api/auth/me` | Bearer | Current user | +| POST | `/api/whatsapp/mock` | No | Mock WhatsApp | +| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions | + +## Auth + +- JWT access (30min) + refresh (7d) tokens +- Roles: CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director +- Use `require_roles("Admin/Jerome", "CEO")` dependency for RBAC +- `sub` claim holds string user ID + +## Database + +SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations. +Tables: users, units, categories, tickets, ticket_timeline, ticket_photos, escalations, whatsapp_log + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e190d78 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy project files +COPY pyproject.toml . +COPY alembic.ini . +COPY alembic/ alembic/ +COPY app/ app/ + +# Install Python dependencies +RUN pip install --no-cache-dir -e . && \ + pip install --no-cache-dir alembic aiosqlite + +# Expose port +EXPOSE 8000 + +# Run with uvicorn +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..df80d65 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..8821425 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,70 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Alembic Config object +config = context.config + +# Set up logging +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# ── Import all models so Alembic can detect them ───────────────────── +from app.core.database import Base # noqa: E402 +from app.models.user import User # noqa: E402, F401 +from app.models.unit import Unit # noqa: E402, F401 +from app.models.category import Category # noqa: E402, F401 +from app.models.ticket import Ticket, TicketTimeline, TicketPhoto, Escalation # noqa: E402, F401 +from app.models.whatsapp_log import WhatsAppLog # noqa: E402, F401 + +target_metadata = Base.metadata + +# ── Database URL from our settings ────────────────────────────────── +from app.core.config import settings as app_settings # noqa: E402 + +config.set_main_option("sqlalchemy.url", app_settings.DATABASE_URL) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/795c6b95f637_initial_schema.py b/alembic/versions/795c6b95f637_initial_schema.py new file mode 100644 index 0000000..a8ec3ba --- /dev/null +++ b/alembic/versions/795c6b95f637_initial_schema.py @@ -0,0 +1,135 @@ +"""initial schema + +Revision ID: 795c6b95f637 +Revises: +Create Date: 2026-07-23 12:01:33.135357 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '795c6b95f637' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('categories', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('type', sa.String(length=20), nullable=False, comment='maintenance, cs, emergency'), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('sla_urgency', sa.String(length=10), nullable=True, comment='urgent, high, medium, low'), + sa.ForeignKeyConstraint(['parent_id'], ['categories.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('units', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('property', sa.String(length=10), nullable=False, comment='East or West'), + sa.Column('apartment_code', sa.String(length=20), nullable=False), + sa.Column('building', sa.String(length=100), nullable=True), + sa.Column('floor', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_units_apartment_code'), 'units', ['apartment_code'], unique=True) + op.create_table('users', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('full_name', sa.String(length=255), nullable=False), + sa.Column('phone', sa.String(length=50), nullable=True), + sa.Column('role', sa.String(length=50), nullable=False, comment='CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director'), + sa.Column('active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('whatsapp_log', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('command', sa.Text(), nullable=False), + sa.Column('from_number', sa.String(length=50), nullable=True), + sa.Column('ticket_id', sa.Integer(), nullable=True), + sa.Column('received_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tickets', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('ticket_number', sa.String(length=20), nullable=False, comment='Format: PAV-YYYY-NNNNN'), + sa.Column('status', sa.String(length=30), nullable=False, comment='New, Logged, Triage, Assigned, Accepted, Travelling, On Site, In Progress, Waiting Parts, Escalated, Completed, On-Field Verification, Wahab Review, Closed, Reopened'), + sa.Column('unit_id', sa.Integer(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('priority', sa.String(length=10), nullable=True, comment='urgent, high, medium, low'), + sa.Column('reporter', sa.String(length=255), nullable=True), + sa.Column('reported_via', sa.String(length=20), nullable=True, comment='whatsapp, phone, walk-in, qr, agent'), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('assigned_to', sa.Integer(), nullable=True), + sa.Column('sla_deadline', sa.DateTime(), nullable=True), + sa.Column('eta', sa.DateTime(), nullable=True), + sa.Column('cost', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('parts_used', sa.Text(), nullable=True), + sa.Column('customer_rating', sa.Integer(), nullable=True), + sa.Column('reopen_count', sa.Integer(), nullable=False), + sa.Column('closed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.ForeignKeyConstraint(['unit_id'], ['units.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tickets_ticket_number'), 'tickets', ['ticket_number'], unique=True) + op.create_table('escalations', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('ticket_id', sa.Integer(), nullable=False), + sa.Column('escalated_to', sa.Integer(), nullable=False), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('resolved_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['escalated_to'], ['users.id'], ), + sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('ticket_photos', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('ticket_id', sa.Integer(), nullable=False), + sa.Column('photo_url', sa.String(length=500), nullable=False), + sa.Column('is_before', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('ticket_timeline', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('ticket_id', sa.Integer(), nullable=False), + sa.Column('from_status', sa.String(length=30), nullable=True), + sa.Column('to_status', sa.String(length=30), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('ticket_timeline') + op.drop_table('ticket_photos') + op.drop_table('escalations') + op.drop_index(op.f('ix_tickets_ticket_number'), table_name='tickets') + op.drop_table('tickets') + op.drop_table('whatsapp_log') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_units_apartment_code'), table_name='units') + op.drop_table('units') + op.drop_table('categories') + # ### end Alembic commands ### diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..3fd1605 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,38 @@ +"""Application configuration via Pydantic-settings environment variables.""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # ── App ────────────────────────────────────────────────────────── + APP_NAME: str = "Denya OneCare" + DEBUG: bool = False + + # ── Database ───────────────────────────────────────────────────── + DATABASE_URL: str = "sqlite+aiosqlite:///./denya_onecare.db" + + # ── Auth ───────────────────────────────────────────────────────── + SECRET_KEY: str = "change-me-in-production-use-a-real-secret" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + + # ── CORS ───────────────────────────────────────────────────────── + CORS_ORIGINS: str = "*" + + # ── Paths ──────────────────────────────────────────────────────── + BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent + + +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..bdf3d21 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,33 @@ +"""Database engine, session factory, and Base.""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: # type: ignore[misc] + """FastAPI dependency that yields an async DB session.""" + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..c93eff6 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,115 @@ +"""Password hashing, JWT token creation / verification, and RBAC.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from functools import wraps +from typing import Annotated, Any, Callable + +import bcrypt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from jose.exceptions import JWTClaimsError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.models.user import User + +bearer_scheme = HTTPBearer(auto_error=False) + + +# ── Password helpers ───────────────────────────────────────────────── +def hash_password(password: str) -> str: + """Return bcrypt hash of *password*.""" + salt = bcrypt.gensalt() + return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + """Return True if *plain* matches *hashed*.""" + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + + +# ── JWT helpers ────────────────────────────────────────────────────── +def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: + """Create a signed JWT access token.""" + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire, "type": "access"}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def create_refresh_token(data: dict[str, Any]) -> str: + """Create a signed JWT refresh token.""" + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire, "type": "refresh"}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def decode_token(token: str) -> dict[str, Any]: + """Decode and validate a JWT token. Raises 401 on failure.""" + try: + payload = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM], + options={"verify_sub": False}, + ) + return payload + except (JWTError, JWTClaimsError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + ) + + +# ── Dependencies ───────────────────────────────────────────────────── +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> User: + """Return the authenticated User from the Bearer token.""" + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + payload = decode_token(credentials.credentials) + raw_sub = payload.get("sub") + if raw_sub is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + try: + user_id = int(raw_sub) + except (ValueError, TypeError): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None or not user.active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive") + return user + + +def require_roles(*roles: str) -> Callable: + """Decorator for route dependencies that enforces one of the given roles. + + Usage:: + + @router.get("/admin-only") + async def admin_endpoint(current_user: Annotated[User, Depends(require_roles("Admin/Jerome"))]): + ... + """ + + async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User: + if current_user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Role '{current_user.role}' not in {roles}", + ) + return current_user + + return role_checker diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..65a81df --- /dev/null +++ b/app/main.py @@ -0,0 +1,55 @@ +"""Denya OneCare — FastAPI application entry point.""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import Base, async_session_factory, engine +from app.routers import auth, health, whatsapp +from app.services.seed import seed_units, seed_users + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Initialise database and seed data on startup.""" + logger.info("Starting Denya OneCare …") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with async_session_factory() as session: + await seed_users(session) + await session.commit() + await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json")) + await session.commit() + yield + await engine.dispose() + logger.info("Denya OneCare stopped.") + + +app = FastAPI( + title=settings.APP_NAME, + version="0.1.0", + lifespan=lifespan, +) + +# ── CORS ───────────────────────────────────────────────────────────── +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS != "*" else ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ── Routers ────────────────────────────────────────────────────────── +app.include_router(health.router) +app.include_router(auth.router) +app.include_router(whatsapp.router) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..046e266 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,7 @@ +"""Import all models so SQLAlchemy can resolve string-based relationships.""" + +from app.models.category import Category # noqa: F401 +from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline # noqa: F401 +from app.models.unit import Unit # noqa: F401 +from app.models.user import User # noqa: F401 +from app.models.whatsapp_log import WhatsAppLog # noqa: F401 diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..b836c79 --- /dev/null +++ b/app/models/category.py @@ -0,0 +1,37 @@ +"""Ticket category model with self-referencing parent for sub-categories.""" + +from __future__ import annotations + +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Category(Base): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + type: Mapped[str] = mapped_column( + String(20), + nullable=False, + comment="maintenance, cs, emergency", + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + parent_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("categories.id"), + nullable=True, + ) + sla_urgency: Mapped[str | None] = mapped_column( + String(10), + nullable=True, + comment="urgent, high, medium, low", + ) + + # self-referencing relationship + children: Mapped[list[Category]] = relationship("Category", back_populates="parent", cascade="all, delete-orphan") + parent: Mapped[Category | None] = relationship("Category", back_populates="children", remote_side="Category.id") + + def __repr__(self) -> str: + return f"" diff --git a/app/models/ticket.py b/app/models/ticket.py new file mode 100644 index 0000000..c270dcb --- /dev/null +++ b/app/models/ticket.py @@ -0,0 +1,123 @@ +"""Ticket, TicketTimeline, TicketPhoto, and Escalation models.""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Ticket(Base): + __tablename__ = "tickets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + ticket_number: Mapped[str] = mapped_column( + String(20), + unique=True, + nullable=False, + index=True, + comment="Format: PAV-YYYY-NNNNN", + ) + status: Mapped[str] = mapped_column( + String(30), + nullable=False, + default="New", + comment=( + "New, Logged, Triage, Assigned, Accepted, Travelling, On Site, " + "In Progress, Waiting Parts, Escalated, Completed, " + "On-Field Verification, Wahab Review, Closed, Reopened" + ), + ) + unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("units.id"), nullable=True) + category_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id"), nullable=True) + priority: Mapped[str | None] = mapped_column( + String(10), + nullable=True, + comment="urgent, high, medium, low", + ) + reporter: Mapped[str | None] = mapped_column(String(255), nullable=True) + reported_via: Mapped[str | None] = mapped_column( + String(20), + nullable=True, + comment="whatsapp, phone, walk-in, qr, agent", + ) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + assigned_to: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True) + sla_deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + eta: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + cost: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True) + parts_used: Mapped[str | None] = mapped_column(Text, nullable=True) + customer_rating: Mapped[int | None] = mapped_column(Integer, nullable=True) + reopen_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # relationships + unit = relationship("Unit") + category = relationship("Category") + assigned_technician = relationship("User", back_populates="assigned_tickets", foreign_keys=[assigned_to]) + timeline = relationship("TicketTimeline", back_populates="ticket", order_by="TicketTimeline.created_at") + photos = relationship("TicketPhoto", back_populates="ticket") + escalations = relationship("Escalation", back_populates="ticket") + + def __repr__(self) -> str: + return f"" + + +class TicketTimeline(Base): + __tablename__ = "ticket_timeline" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id"), nullable=False) + from_status: Mapped[str | None] = mapped_column(String(30), nullable=True) + to_status: Mapped[str | None] = mapped_column(String(30), nullable=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + + ticket = relationship("Ticket", back_populates="timeline") + user = relationship("User", back_populates="timeline_entries") + + def __repr__(self) -> str: + return f"" + + +class TicketPhoto(Base): + __tablename__ = "ticket_photos" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id"), nullable=False) + photo_url: Mapped[str] = mapped_column(String(500), nullable=False) + is_before: Mapped[bool] = mapped_column(nullable=False, default=True) + + ticket = relationship("Ticket", back_populates="photos") + + def __repr__(self) -> str: + return f"" + + +class Escalation(Base): + __tablename__ = "escalations" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id"), nullable=False) + escalated_to: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + + ticket = relationship("Ticket", back_populates="escalations") + escalated_to_user = relationship("User", back_populates="escalations", foreign_keys=[escalated_to]) + + def __repr__(self) -> str: + return f"" diff --git a/app/models/unit.py b/app/models/unit.py new file mode 100644 index 0000000..b733459 --- /dev/null +++ b/app/models/unit.py @@ -0,0 +1,21 @@ +"""Unit model — an apartment at Pavilion East/West.""" + +from __future__ import annotations + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class Unit(Base): + __tablename__ = "units" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + property: Mapped[str] = mapped_column(String(10), nullable=False, comment="East or West") + apartment_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True) + building: Mapped[str | None] = mapped_column(String(100), nullable=True) + floor: Mapped[int | None] = mapped_column(nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..f5fce85 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,32 @@ +"""User model.""" + +from __future__ import annotations + +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str] = mapped_column(String(255), nullable=False) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + role: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director", + ) + active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # relationships + assigned_tickets = relationship("Ticket", back_populates="assigned_technician", foreign_keys="Ticket.assigned_to") + timeline_entries = relationship("TicketTimeline", back_populates="user") + escalations = relationship("Escalation", back_populates="escalated_to_user", foreign_keys="Escalation.escalated_to") + + def __repr__(self) -> str: + return f"" diff --git a/app/models/whatsapp_log.py b/app/models/whatsapp_log.py new file mode 100644 index 0000000..510dbf7 --- /dev/null +++ b/app/models/whatsapp_log.py @@ -0,0 +1,23 @@ +"""WhatsApp log model for mock endpoint.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class WhatsAppLog(Base): + __tablename__ = "whatsapp_log" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + command: Mapped[str] = mapped_column(Text, nullable=False) + from_number: Mapped[str | None] = mapped_column(String(50), nullable=True) + ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..2903292 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,53 @@ +"""Authentication router — register, login, refresh, me.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User +from app.schemas.auth import ( + LoginRequest, + RefreshRequest, + RegisterRequest, + TokenResponse, + UserOut, +) +from app.services import auth as auth_service + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserOut, status_code=201) +async def register( + body: RegisterRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> User: + return await auth_service.register(db, body) + + +@router.post("/login", response_model=TokenResponse) +async def login( + body: LoginRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> TokenResponse: + access, refresh, _user = await auth_service.login(db, body.email, body.password) + return TokenResponse(access_token=access, refresh_token=refresh) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh( + body: RefreshRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> TokenResponse: + access, refresh = await auth_service.refresh_access_token(db, body.refresh_token) + return TokenResponse(access_token=access, refresh_token=refresh) + + +@router.get("/me", response_model=UserOut) +async def me(current_user: Annotated[User, Depends(get_current_user)]) -> User: + return current_user diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..b53ff8a --- /dev/null +++ b/app/routers/health.py @@ -0,0 +1,14 @@ +"""Health-check endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.schemas.health import HealthResponse + +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthResponse) +async def health_check() -> HealthResponse: + return HealthResponse() diff --git a/app/routers/whatsapp.py b/app/routers/whatsapp.py new file mode 100644 index 0000000..cfaa55b --- /dev/null +++ b/app/routers/whatsapp.py @@ -0,0 +1,45 @@ +"""Mock WhatsApp endpoint for testing command parsing.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.whatsapp_log import WhatsAppLog +from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse + +router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"]) + + +@router.post("/mock", response_model=MockWhatsAppResponse) +async def mock_whatsapp( + body: MockWhatsAppRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> MockWhatsAppResponse: + """Accept a mock WhatsApp command and log it.""" + log = WhatsAppLog( + command=body.command, + from_number=body.from_number, + ticket_id=body.ticket_id, + received_at=datetime.now(timezone.utc), + ) + db.add(log) + await db.flush() + return MockWhatsAppResponse() + + +@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry]) +async def mock_whatsapp_log( + db: Annotated[AsyncSession, Depends(get_db)], + limit: int = 50, +) -> list[MockWhatsAppLogEntry]: + """Return recent mock WhatsApp submissions.""" + result = await db.execute( + select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit) + ) + return list(result.scalars().all()) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..cf2b704 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,39 @@ +"""Pydantic schemas for authentication endpoints.""" + +from __future__ import annotations + +from pydantic import BaseModel, EmailStr + + +class RegisterRequest(BaseModel): + email: str + password: str + full_name: str + phone: str | None = None + role: str = "CS Rep" + + +class LoginRequest(BaseModel): + email: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class UserOut(BaseModel): + id: int + email: str + full_name: str + phone: str | None + role: str + active: bool + + model_config = {"from_attributes": True} diff --git a/app/schemas/health.py b/app/schemas/health.py new file mode 100644 index 0000000..62bfafd --- /dev/null +++ b/app/schemas/health.py @@ -0,0 +1,8 @@ +"""Health-check schema.""" + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str = "ok" + app: str = "Denya OneCare" diff --git a/app/schemas/whatsapp.py b/app/schemas/whatsapp.py new file mode 100644 index 0000000..0972168 --- /dev/null +++ b/app/schemas/whatsapp.py @@ -0,0 +1,28 @@ +"""Pydantic schemas for mock WhatsApp endpoint.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +class MockWhatsAppRequest(BaseModel): + command: str + from_number: str | None = None + ticket_id: int | None = None + + +class MockWhatsAppResponse(BaseModel): + status: str = "received" + message: str = "Command logged successfully" + + +class MockWhatsAppLogEntry(BaseModel): + id: int + command: str + from_number: str | None + ticket_id: int | None + received_at: datetime + + model_config = {"from_attributes": True} diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..de505a7 --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,74 @@ +"""Authentication service — register, login, refresh.""" + +from __future__ import annotations + +from fastapi import HTTPException, status +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.security import ( + create_access_token, + create_refresh_token, + decode_token, + hash_password, + verify_password, +) +from app.models.user import User +from app.schemas.auth import RegisterRequest + + +async def register(db: AsyncSession, body: RegisterRequest) -> User: + """Create a new user. Raises 409 if email already exists.""" + result = await db.execute(select(User).where(User.email == body.email)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered") + + user = User( + email=body.email, + password_hash=hash_password(body.password), + full_name=body.full_name, + phone=body.phone, + role=body.role, + ) + db.add(user) + await db.flush() + await db.refresh(user) + return user + + +async def login(db: AsyncSession, email: str, password: str) -> tuple[str, str, User]: + """Authenticate and return (access_token, refresh_token, user).""" + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + if user is None or not verify_password(password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + if not user.active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive") + + access_token = create_access_token({"sub": str(user.id)}) + refresh_token = create_refresh_token({"sub": str(user.id)}) + return access_token, refresh_token, user + + +async def refresh_access_token(db: AsyncSession, token: str) -> tuple[str, str]: + """Validate a refresh token and issue a new token pair.""" + try: + payload = decode_token(token) + if payload.get("type") != "refresh": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") + + user_id: int = int(payload["sub"]) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None or not user.active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive") + + new_access = create_access_token({"sub": str(user.id)}) + new_refresh = create_refresh_token({"sub": str(user.id)}) + return new_access, new_refresh diff --git a/app/services/seed.py b/app/services/seed.py new file mode 100644 index 0000000..d20f09b --- /dev/null +++ b/app/services/seed.py @@ -0,0 +1,120 @@ +"""Seed script — users and units. + +Called on first startup or via :func:`seed_all`. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import hash_password +from app.models.unit import Unit +from app.models.user import User + +logger = logging.getLogger(__name__) + +# ── Seed user data (dicts to avoid module-level model instantiation) ─ +SEED_USERS_DATA = [ + {"email": "jerome@denya.com", "full_name": "Jerome Tabiri", "phone": "+233000000001", "role": "Admin/Jerome"}, + {"email": "wahab@denya.com", "full_name": "Wahab", "phone": "+233000000002", "role": "Admin/Wahab"}, + {"email": "bella@denya.com", "full_name": "Bella", "phone": "+233000000003", "role": "CS Rep"}, + {"email": "akua@denya.com", "full_name": "Akua", "phone": "+233000000004", "role": "CS Manager"}, + {"email": "mercy@denya.com", "full_name": "Mercy Duah", "phone": "+233000000005", "role": "CS Manager"}, + {"email": "ama@denya.com", "full_name": "Ama", "phone": "+233000000006", "role": "CS Rep"}, + {"email": "nicholas@denya.com", "full_name": "Nicholas", "phone": "+233000000007", "role": "FM Dispatcher"}, + {"email": "collins@denya.com", "full_name": "Collins", "phone": "+233000000008", "role": "FM Dispatcher"}, + {"email": "prosper@denya.com", "full_name": "Prosper", "phone": "+233000000010", "role": "Tech"}, + {"email": "sam@denya.com", "full_name": "Sam", "phone": "+233000000011", "role": "Tech"}, + {"email": "steven@denya.com", "full_name": "Steven", "phone": "+233000000012", "role": "Tech"}, + {"email": "junior@denya.com", "full_name": "Junior (Samuel)", "phone": "+233000000013", "role": "Tech"}, + {"email": "francis@denya.com", "full_name": "Francis", "phone": "+233000000014", "role": "Tech"}, + {"email": "desmond@denya.com", "full_name": "Desmond Afful", "phone": "+233000000015", "role": "Tech"}, + {"email": "afful@denya.com", "full_name": "Afful", "phone": "+233000000016", "role": "Tech"}, + {"email": "scott@denya.com", "full_name": "Scott Murray", "phone": "+233000000020", "role": "CEO"}, + {"email": "director@denya.com", "full_name": "Director", "phone": "+233000000021", "role": "Director"}, +] + + +async def seed_users(db: AsyncSession, default_password: str = "denya123") -> list[User]: + """Insert seed users if they don't already exist.""" + hashed = hash_password(default_password) + created: list[User] = [] + for data in SEED_USERS_DATA: + result = await db.execute(select(User).where(User.email == data["email"])) + if result.scalar_one_or_none() is None: + user = User( + email=data["email"], + password_hash=hashed, + full_name=data["full_name"], + phone=data["phone"], + role=data["role"], + ) + db.add(user) + created.append(user) + if created: + await db.flush() + for u in created: + await db.refresh(u) + logger.info("Seeded %d users", len(created)) + else: + logger.info("All seed users already exist") + return created + + +async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> list[Unit]: + """Insert units from *apartment_mapping.json*. + + Falls back to a small built-in set if the file is not found. + """ + units_data: list[dict] = [] + + if json_path: + p = Path(json_path) + if p.exists(): + with open(p) as f: + raw = json.load(f) + units_data = raw if isinstance(raw, list) else raw.get("units", []) + logger.info("Loaded %d units from %s", len(units_data), p) + + if not units_data: + # Built-in fallback for Pavilion Accra + logger.warning("apartment_mapping.json not found; using built-in fallback") + for wing in ("East", "West"): + for floor in range(1, 11): + for num in range(1, 7): + code = f"{floor:02d}{num}{wing[0]}" + units_data.append({ + "apartment_code": code, + "property": wing, + "building": f"Pavilion {wing}", + "floor": floor, + }) + + created: list[Unit] = [] + for entry in units_data: + code = entry.get("apartment_code") or entry.get("unit") or "" + if not code: + continue + result = await db.execute(select(Unit).where(Unit.apartment_code == code)) + if result.scalar_one_or_none() is None: + unit = Unit( + property=entry.get("property", "East"), + apartment_code=code, + building=entry.get("building"), + floor=entry.get("floor"), + ) + db.add(unit) + created.append(unit) + if created: + await db.flush() + for u in created: + await db.refresh(u) + logger.info("Seeded %d units", len(created)) + else: + logger.info("All units already exist") + return created diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1fe37ba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + app: + build: . + container_name: denya-onecare + ports: + - "8000:8000" + environment: + - DATABASE_URL=sqlite+aiosqlite:///./data/denya_onecare.db + - SECRET_KEY=change-me-in-production + - CORS_ORIGINS=* + - DEBUG=false + volumes: + - app-data:/app/data + restart: unless-stopped + +volumes: + app-data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ba9b227 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "denya-onecare" +version = "0.1.0" +description = "Denya OneCare - Centralized issue tracking for Pavilion Accra" +requires-python = ">=3.11" +dependencies = [ + "fastapi[standard]>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.0", + "alembic>=1.13.0", + "pydantic-settings>=2.5.0", + "python-jose[cryptography]>=3.3.0", + "bcrypt>=4.0.0", + "python-multipart>=0.0.9", + "aiosqlite>=0.20.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "httpx>=0.27.0", +] From 1677498a8e3ae2471f4d8aafee6ab72ce3948b22 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 12:04:35 +0000 Subject: [PATCH 2/5] feat: add admin-only RBAC demo endpoint with require_roles --- app/routers/auth.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/routers/auth.py b/app/routers/auth.py index 2903292..27af060 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.core.security import get_current_user +from app.core.security import get_current_user, require_roles from app.models.user import User from app.schemas.auth import ( LoginRequest, @@ -51,3 +51,11 @@ async def refresh( @router.get("/me", response_model=UserOut) async def me(current_user: Annotated[User, Depends(get_current_user)]) -> User: return current_user + + +@router.get("/admin-only", response_model=UserOut) +async def admin_only( + current_user: Annotated[User, Depends(require_roles("Admin/Jerome", "Admin/Wahab"))], +) -> User: + """Example RBAC-protected endpoint — only Admins can access.""" + return current_user From 4b5d3e4ac199770bcf68966f43cbe62d3156dd52 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 12:12:43 +0000 Subject: [PATCH 3/5] no-mistakes(review): Remove redundant pip install for alembic and aiosqlite from Dockerfile --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e190d78..203871b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,7 @@ COPY alembic/ alembic/ COPY app/ app/ # Install Python dependencies -RUN pip install --no-cache-dir -e . && \ - pip install --no-cache-dir alembic aiosqlite +RUN pip install --no-cache-dir -e . # Expose port EXPOSE 8000 From a8745c79c91504dbb89e3f842b55ab07401dd8bb Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 12:16:56 +0000 Subject: [PATCH 4/5] no-mistakes(document): Fix AGENTS.md: 17 users, admin-only endpoint, correct RBAC example --- AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7a6fb68..02f8c04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ alembic/ # Database migrations ## Seed data Users and units are auto-seeded on first startup via lifespan hook: -- 16 users covering all roles (Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher, Tech, CEO, Director) +- 17 users covering all roles (Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher, Tech, CEO, Director) - 120 apartment units (East/West, 10 floors × 6 apts per wing) - Default password for all seed users: `denya123` - Units load from `apartment_mapping.json` if present, else built-in fallback @@ -46,6 +46,7 @@ Users and units are auto-seeded on first startup via lifespan hook: | POST | `/api/auth/login` | No | Get JWT tokens | | POST | `/api/auth/refresh` | Token | Refresh tokens | | GET | `/api/auth/me` | Bearer | Current user | +| GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint | | POST | `/api/whatsapp/mock` | No | Mock WhatsApp | | GET | `/api/whatsapp/mock-log` | No | Recent mock submissions | @@ -53,7 +54,7 @@ Users and units are auto-seeded on first startup via lifespan hook: - JWT access (30min) + refresh (7d) tokens - Roles: CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director -- Use `require_roles("Admin/Jerome", "CEO")` dependency for RBAC +- Use `require_roles("Admin/Jerome", "Admin/Wahab")` dependency for RBAC - `sub` claim holds string user ID ## Database From 955b59945c5d54e46ab14a5e8f64c27992d8f89a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 12:25:10 +0000 Subject: [PATCH 5/5] no-mistakes(test): Fix Docker build: add build-system config and scope package discovery to app* --- Dockerfile | 2 +- pyproject.toml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 203871b..00e3248 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ COPY alembic/ alembic/ COPY app/ app/ # Install Python dependencies -RUN pip install --no-cache-dir -e . +RUN pip install --no-cache-dir . # Expose port EXPOSE 8000 diff --git a/pyproject.toml b/pyproject.toml index ba9b227..224e9ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,13 @@ dependencies = [ "aiosqlite>=0.20.0", ] +[build-system] +requires = ["setuptools>=64.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] + [project.optional-dependencies] dev = [ "pytest>=8.0",