-
Notifications
You must be signed in to change notification settings - Fork 30
Feature/contact us backend : backend apis implemented for contact us form along with admin management page. #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
2196965
contact us schemas and apis added
nikhil-3210 2a32b9d
main branch merged and conflic resolved
nikhil-3210 077defd
older api flow and schemas diagram removed
nikhil-3210 2c0aaea
Avoid runtime DDL on every app startup issue addressed
nikhil-3210 a6cc080
Validate assigned_to before persisting and Reject null status/priorit…
nikhil-3210 d94511e
Refresh history after updates review addressed
nikhil-3210 bd37874
note refresh review addressed
nikhil-3210 244fcd6
review addressed
nikhil-3210 c536307
review addressed
nikhil-3210 fcfd0a7
review addressed
nikhil-3210 abb97f4
review addressed
nikhil-3210 0c3b699
admin page for contact us form details is updated
nikhil-3210 1642f33
refresh icon removed, emojis removed
nikhil-3210 c6710b0
drop down menu design aligned with project theme
nikhil-3210 9d03231
Admin success banner for note clears in 5 sec
nikhil-3210 9b5a087
exsiting error state used for showing error on contact us form
nikhil-3210 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
78 changes: 78 additions & 0 deletions
78
backend-api/alembic/versions/j1k2l3m4n567_add_contact_us_tables.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.