Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""add contact us tables

Revision ID: j1k2l3m4n567
Revises: h1i2j3k4l567
Create Date: 2026-01-20

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "j1k2l3m4n567"
down_revision: Union[str, Sequence[str], None] = "h1i2j3k4l567"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"contact_submissions",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("first_name", sa.String(length=100), nullable=False),
sa.Column("last_name", sa.String(length=100), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("phone", sa.String(length=20), nullable=True),
sa.Column("company", sa.String(length=255), nullable=True),
sa.Column("subject", sa.String(length=50), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=20), server_default=sa.text("'new'"), nullable=False),
sa.Column("priority", sa.String(length=20), server_default=sa.text("'medium'"), nullable=False),
sa.Column("assigned_to", sa.Integer(), nullable=True),
sa.Column("source", sa.String(length=50), nullable=True),
sa.Column("ip_address", postgresql.INET(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("resolved_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["assigned_to"], ["user.id"]),
sa.PrimaryKeyConstraint("id"),
)

op.create_table(
"submission_notes",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("submission_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("admin_user_id", sa.Integer(), nullable=True),
sa.Column("note", sa.Text(), nullable=False),
sa.Column("is_internal", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["admin_user_id"], ["user.id"]),
sa.ForeignKeyConstraint(["submission_id"], ["contact_submissions.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)

op.create_table(
"submission_history",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("submission_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("admin_user_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(length=50), nullable=False),
sa.Column("field_name", sa.String(length=100), nullable=True),
sa.Column("old_value", sa.Text(), nullable=True),
sa.Column("new_value", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["admin_user_id"], ["user.id"]),
sa.ForeignKeyConstraint(["submission_id"], ["contact_submissions.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)


def downgrade() -> None:
op.drop_table("submission_history")
op.drop_table("submission_notes")
op.drop_table("contact_submissions")
271 changes: 271 additions & 0 deletions backend-api/app/api/v1/contact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
"""Contact Us API endpoints."""

from datetime import datetime
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.permissions import require_admin
from app.db.session import get_async_session
from app.models.contact import ContactSubmission, SubmissionHistory, SubmissionNote
from app.models.user import User
from app.schemas.contact import (
ContactSubmissionCreate,
ContactSubmissionRead,
ContactSubmissionUpdate,
SubmissionHistoryRead,
SubmissionNoteCreate,
SubmissionNoteRead,
)

router = APIRouter(prefix="/contact", tags=["Contact"])


def _build_history_entry(
submission_id: UUID,
admin_user_id: int | None,
action: str,
field_name: str | None,
old_value: str | None,
new_value: str | None,
) -> SubmissionHistory:
return SubmissionHistory(
submission_id=submission_id,
admin_user_id=admin_user_id,
action=action,
field_name=field_name,
old_value=old_value,
new_value=new_value,
)


@router.post("/", response_model=ContactSubmissionRead, status_code=status.HTTP_201_CREATED)
async def create_contact_submission(
payload: ContactSubmissionCreate,
request: Request,
db: AsyncSession = Depends(get_async_session),
) -> ContactSubmission:
"""Create a new Contact Us submission (public)."""
submission = ContactSubmission(
first_name=payload.first_name,
last_name=payload.last_name,
email=payload.email,
phone=payload.phone,
company=payload.company,
subject=payload.subject,
message=payload.message,
source=payload.source,
ip_address=request.client.host if request.client else None,
)
db.add(submission)
await db.flush()
db.add(
_build_history_entry(
submission_id=submission.id,
admin_user_id=None,
action="create",
field_name=None,
old_value=None,
new_value=None,
)
)
await db.commit()
await db.refresh(submission)
return submission


@router.get("/submissions", response_model=list[ContactSubmissionRead])
async def list_submissions(
_: User = Depends(require_admin),
db: AsyncSession = Depends(get_async_session),
) -> list[ContactSubmission]:
"""List all contact submissions (admin only)."""
result = await db.execute(
select(ContactSubmission).order_by(ContactSubmission.created_at.desc())
)
return list(result.scalars().all())


@router.get("/submissions/{submission_id}", response_model=ContactSubmissionRead)
async def get_submission(
submission_id: UUID,
_: User = Depends(require_admin),
db: AsyncSession = Depends(get_async_session),
) -> ContactSubmission:
"""Get a single submission (admin only)."""
result = await db.execute(
select(ContactSubmission).where(ContactSubmission.id == submission_id)
)
submission = result.scalar_one_or_none()
if not submission:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found")
return submission


@router.patch("/submissions/{submission_id}", response_model=ContactSubmissionRead)
async def update_submission(
submission_id: UUID,
payload: ContactSubmissionUpdate,
admin_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_async_session),
) -> ContactSubmission:
"""Update a submission (admin only)."""
result = await db.execute(
select(ContactSubmission).where(ContactSubmission.id == submission_id)
)
submission = result.scalar_one_or_none()
if not submission:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found")

history_entries: list[SubmissionHistory] = []

def track_change(field: str, old: str | None, new: str | None) -> None:
if old != new:
history_entries.append(
_build_history_entry(
submission_id=submission.id,
admin_user_id=admin_user.id,
action="update",
field_name=field,
old_value=old,
new_value=new,
)
)

if "status" in payload.model_fields_set:
if payload.status is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="status cannot be null")
track_change("status", submission.status, payload.status)
new_status = payload.status.lower()
submission.status = payload.status
if new_status == "resolved":
submission.resolved_at = datetime.utcnow()
elif submission.resolved_at is not None:
submission.resolved_at = None

if "priority" in payload.model_fields_set:
if payload.priority is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="priority cannot be null")
track_change("priority", submission.priority, payload.priority)
submission.priority = payload.priority

if "assigned_to" in payload.model_fields_set:
if payload.assigned_to is not None:
user_result = await db.execute(
select(User).where(User.id == payload.assigned_to)
)
assigned_user = user_result.scalar_one_or_none()
if not assigned_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="assigned_to user not found",
)
track_change(
"assigned_to",
str(submission.assigned_to) if submission.assigned_to is not None else None,
str(payload.assigned_to) if payload.assigned_to is not None else None,
)
submission.assigned_to = payload.assigned_to

if "resolved_at" in payload.model_fields_set:
track_change(
"resolved_at",
submission.resolved_at.isoformat() if submission.resolved_at else None,
payload.resolved_at.isoformat() if payload.resolved_at else None,
)
submission.resolved_at = payload.resolved_at

for entry in history_entries:
db.add(entry)

await db.commit()
await db.refresh(submission)
return submission


@router.delete("/submissions/{submission_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_submission(
submission_id: UUID,
_: User = Depends(require_admin),
db: AsyncSession = Depends(get_async_session),
) -> None:
"""Delete a submission (admin only)."""
result = await db.execute(
select(ContactSubmission).where(ContactSubmission.id == submission_id)
)
submission = result.scalar_one_or_none()
if not submission:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found")

await db.delete(submission)
await db.commit()


@router.get("/submissions/{submission_id}/notes", response_model=list[SubmissionNoteRead])
async def list_notes(
submission_id: UUID,
_: User = Depends(require_admin),
db: AsyncSession = Depends(get_async_session),
) -> list[SubmissionNote]:
result = await db.execute(
select(SubmissionNote)
.where(SubmissionNote.submission_id == submission_id)
.order_by(SubmissionNote.created_at.desc())
)
return list(result.scalars().all())


@router.post(
"/submissions/{submission_id}/notes",
response_model=SubmissionNoteRead,
status_code=status.HTTP_201_CREATED,
)
async def add_note(
submission_id: UUID,
payload: SubmissionNoteCreate,
admin_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_async_session),
) -> SubmissionNote:
result = await db.execute(
select(ContactSubmission).where(ContactSubmission.id == submission_id)
)
submission = result.scalar_one_or_none()
if not submission:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Submission not found")

note = SubmissionNote(
submission_id=submission_id,
admin_user_id=admin_user.id,
note=payload.note,
is_internal=payload.is_internal,
)
db.add(note)
db.add(
_build_history_entry(
submission_id=submission_id,
admin_user_id=admin_user.id,
action="note",
field_name="note",
old_value=None,
new_value=payload.note,
)
)
await db.commit()
await db.refresh(note)
return note


@router.get("/submissions/{submission_id}/history", response_model=list[SubmissionHistoryRead])
async def list_history(
submission_id: UUID,
_: User = Depends(require_admin),
db: AsyncSession = Depends(get_async_session),
) -> list[SubmissionHistory]:
result = await db.execute(
select(SubmissionHistory)
.where(SubmissionHistory.submission_id == submission_id)
.order_by(SubmissionHistory.created_at.desc())
)
return list(result.scalars().all())
5 changes: 4 additions & 1 deletion backend-api/app/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.v1 import auth, test, evidence, m365_connections, scans, benchmarks, platforms
from app.api.v1 import auth, test, evidence, m365_connections, scans, benchmarks, platforms, contact

api_router = APIRouter()

Expand All @@ -23,3 +23,6 @@

# Evidence routes
api_router.include_router(evidence.router)

# Contact routes
api_router.include_router(contact.router)
1 change: 0 additions & 1 deletion backend-api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from app.core.config import get_settings
from app.core.middleware import RequestLoggingMiddleware
from app.core.errors import not_found_handler, NotFound

settings = get_settings()


Expand Down
4 changes: 4 additions & 0 deletions backend-api/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app.models.scan_result import ScanResult
from app.models.compliance import Scan
from app.models.evidence_validation import EvidenceValidation
from app.models.contact import ContactSubmission, SubmissionNote, SubmissionHistory

__all__ = [
"User",
Expand All @@ -23,4 +24,7 @@
"ScanResult",
"Scan",
"EvidenceValidation",
"ContactSubmission",
"SubmissionNote",
"SubmissionHistory",
]
Loading
Loading