diff --git a/.env b/.env index 1d44286e25..84782451fa 100644 --- a/.env +++ b/.env @@ -18,9 +18,9 @@ STACK_NAME=full-stack-fastapi-project # Backend BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis +SECRET_KEY=kjadhflkafaskjdfhakjfsd +FIRST_SUPERUSER=marcelomizuno@me.com +FIRST_SUPERUSER_PASSWORD=admin123 # Emails SMTP_HOST= @@ -32,11 +32,11 @@ SMTP_SSL=False SMTP_PORT=587 # Postgres -POSTGRES_SERVER=localhost +POSTGRES_SERVER=db POSTGRES_PORT=5432 POSTGRES_DB=app POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis +POSTGRES_PASSWORD=univesp SENTRY_DSN= diff --git a/backend/Dockerfile b/backend/Dockerfile index 44c53f0365..9c73b3c3de 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -40,4 +40,5 @@ COPY ./app /app/app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync -CMD ["fastapi", "run", "--workers", "4", "app/main.py"] +# Run migrations before starting the server +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] diff --git a/backend/app/alembic/versions/b54d6e812a9c_add_category_to_ticket.py b/backend/app/alembic/versions/b54d6e812a9c_add_category_to_ticket.py new file mode 100644 index 0000000000..1df647fe34 --- /dev/null +++ b/backend/app/alembic/versions/b54d6e812a9c_add_category_to_ticket.py @@ -0,0 +1,24 @@ +""" +Add category to ticket table +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'b54d6e812a9c' +down_revision = 'f23a9c45d178' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add category column to ticket table + op.add_column('ticket', sa.Column('category', sqlmodel.sql.sqltypes.AutoString(), nullable=False, + server_default="Suporte")) # Default to "Suporte" for existing tickets + + +def downgrade(): + # Remove category column from ticket table + op.drop_column('ticket', 'category') diff --git a/backend/app/alembic/versions/f23a9c45d178_add_ticket_and_comment.py b/backend/app/alembic/versions/f23a9c45d178_add_ticket_and_comment.py new file mode 100644 index 0000000000..7dfd9794fc --- /dev/null +++ b/backend/app/alembic/versions/f23a9c45d178_add_ticket_and_comment.py @@ -0,0 +1,68 @@ +""" +Add ticket and comment tables +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects.postgresql import UUID +from uuid import uuid4 + + +# revision identifiers, used by Alembic. +revision = 'f23a9c45d178' +down_revision = '1a31ce608336' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create ticket table + op.create_table( + 'ticket', + sa.Column("id", UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("priority", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("owner_id", UUID(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["owner_id"], ["user.id"], ondelete="CASCADE" + ), + ) + + # Create index for ticket lookup by owner + op.create_index(op.f('ix_ticket_owner_id'), 'ticket', ['owner_id'], unique=False) + + # Create comment table + op.create_table( + 'comment', + sa.Column("id", UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")), + sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("ticket_id", UUID(), nullable=False), + sa.Column("user_id", UUID(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["ticket_id"], ["ticket.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user_id"], ["user.id"], ondelete="CASCADE" + ), + ) + + # Create indexes for faster comment lookups + op.create_index(op.f('ix_comment_ticket_id'), 'comment', ['ticket_id'], unique=False) + op.create_index(op.f('ix_comment_user_id'), 'comment', ['user_id'], unique=False) + + +def downgrade(): + # Drop tables in reverse order (comments first, then tickets) + op.drop_index(op.f('ix_comment_user_id'), table_name='comment') + op.drop_index(op.f('ix_comment_ticket_id'), table_name='comment') + op.drop_table('comment') + + op.drop_index(op.f('ix_ticket_owner_id'), table_name='ticket') + op.drop_table('ticket') diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..33862ee8b6 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import items, login, private, users, utils, tickets from app.core.config import settings api_router = APIRouter() @@ -8,6 +8,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(tickets.router) # Add tickets router if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/tickets.py b/backend/app/api/routes/tickets.py new file mode 100644 index 0000000000..e89114e141 --- /dev/null +++ b/backend/app/api/routes/tickets.py @@ -0,0 +1,172 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from sqlmodel import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Ticket, + TicketCreate, + TicketUpdate, + TicketPublic, + TicketsPublic, + TicketDetailPublic, + Comment, + CommentCreate, + CommentPublic, + Message, + TicketCategory, + TicketPriority, + TicketStatus +) + +router = APIRouter(prefix="/tickets", tags=["tickets"]) + + +@router.get("/", response_model=TicketsPublic) +def read_tickets( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100, + page: int = Query(1, ge=1), + category: TicketCategory = None, + priority: TicketPriority = None, + status: TicketStatus = None +) -> Any: + """ + Listar todos os tickets (com filtros e paginação). + """ + skip = (page - 1) * limit + + # Base query + query = select(Ticket) + count_query = select(func.count()).select_from(Ticket) + + # Apply filters + if category: + query = query.where(Ticket.category == category) + count_query = count_query.where(Ticket.category == category) + + if priority: + query = query.where(Ticket.priority == priority) + count_query = count_query.where(Ticket.priority == priority) + + if status: + query = query.where(Ticket.status == status) + count_query = count_query.where(Ticket.status == status) + + # Apply user filter if not superuser + if not current_user.is_superuser: + query = query.where(Ticket.user_id == current_user.id) + count_query = count_query.where(Ticket.user_id == current_user.id) + + # Get count and tickets + count = session.exec(count_query).one() + tickets = session.exec(query.offset(skip).limit(limit)).all() + + return TicketsPublic(data=tickets, count=count, page=page) + + +@router.get("/{id}", response_model=TicketDetailPublic) +def read_ticket(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Retorna detalhes de um ticket. + """ + ticket = session.get(Ticket, id) + if not ticket: + raise HTTPException(status_code=404, detail="Ticket não encontrado") + + if not current_user.is_superuser and (ticket.user_id != current_user.id): + raise HTTPException(status_code=400, detail="Permissões insuficientes") + + return ticket + + +@router.post("/", response_model=TicketPublic) +def create_ticket( + *, session: SessionDep, current_user: CurrentUser, ticket_in: TicketCreate +) -> Any: + """ + Criar um novo ticket. + """ + ticket = Ticket.model_validate(ticket_in, update={"user_id": current_user.id}) + session.add(ticket) + session.commit() + session.refresh(ticket) + return ticket + + +@router.put("/{id}", response_model=TicketPublic) +def update_ticket( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + ticket_in: TicketUpdate, +) -> Any: + """ + Atualizar um ticket existente. + """ + ticket = session.get(Ticket, id) + if not ticket: + raise HTTPException(status_code=404, detail="Ticket não encontrado") + + if not current_user.is_superuser and (ticket.user_id != current_user.id): + raise HTTPException(status_code=400, detail="Permissões insuficientes") + + update_dict = ticket_in.model_dump(exclude_unset=True) + ticket.sqlmodel_update(update_dict) + ticket.updated_at = func.now() # Update the updated_at field + + session.add(ticket) + session.commit() + session.refresh(ticket) + return ticket + + +@router.delete("/{id}") +def delete_ticket( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Deletar um ticket. + """ + ticket = session.get(Ticket, id) + if not ticket: + raise HTTPException(status_code=404, detail="Ticket não encontrado") + + if not current_user.is_superuser and (ticket.user_id != current_user.id): + raise HTTPException(status_code=400, detail="Permissões insuficientes") + + session.delete(ticket) + session.commit() + return Message(message="Ticket removido com sucesso") + + +@router.post("/{id}/comments", response_model=CommentPublic) +def create_comment( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + comment_in: CommentCreate, +) -> Any: + """ + Adicionar comentário a um ticket. + """ + ticket = session.get(Ticket, id) + if not ticket: + raise HTTPException(status_code=404, detail="Ticket não encontrado") + + comment = Comment( + **comment_in.model_dump(), + ticket_id=id, + user_id=current_user.id + ) + + session.add(comment) + session.commit() + session.refresh(comment) + return comment diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index fc093419b3..06d6b27a38 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -4,6 +4,7 @@ from app.api.deps import get_current_active_superuser from app.models import Message from app.utils import generate_test_email, send_email +from app.core.config import settings router = APIRouter(prefix="/utils", tags=["utils"]) @@ -29,3 +30,10 @@ def test_email(email_to: EmailStr) -> Message: @router.get("/health-check/") async def health_check() -> bool: return True + +@router.get("/cors-origins/") +async def get_cors_origins() -> list[str]: + """ + Get the CORS origins. + """ + return settings.all_cors_origins \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py index 2389b4a532..c31460dd32 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,4 +1,7 @@ import uuid +from datetime import datetime +from enum import Enum +from typing import Optional from pydantic import EmailStr from sqlmodel import Field, Relationship, SQLModel @@ -44,6 +47,7 @@ class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) hashed_password: str items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + tickets: list["Ticket"] = Relationship(back_populates="user", cascade_delete=True) # Properties to return via API, id is always required @@ -111,3 +115,102 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=40) + + +# Ticket Enums +class TicketCategory(str, Enum): + SUPPORT = "Suporte" + MAINTENANCE = "Manutenção" + QUESTION = "Dúvida" + + +class TicketPriority(str, Enum): + LOW = "Baixa" + MEDIUM = "Média" + HIGH = "Alta" + + +class TicketStatus(str, Enum): + OPEN = "Aberto" + IN_PROGRESS = "Em andamento" + CLOSED = "Fechado" + + +# Ticket models +class TicketBase(SQLModel): + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None) + category: TicketCategory + priority: TicketPriority + status: TicketStatus = Field(default=TicketStatus.OPEN) + + +class TicketCreate(TicketBase): + pass + + +class TicketUpdate(SQLModel): + title: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None) + category: TicketCategory | None = None + priority: TicketPriority | None = None + status: TicketStatus | None = None + + +class Ticket(TicketBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user.id", nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow, sa_column_kwargs={"onupdate": datetime.utcnow}) + + # Relationships + user: User = Relationship(back_populates="tickets") + comments: list["Comment"] = Relationship(back_populates="ticket", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) + + +# Comment models +class CommentBase(SQLModel): + comment: str = Field(min_length=1) + + +class CommentCreate(CommentBase): + pass + + +class Comment(CommentBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + ticket_id: uuid.UUID = Field(foreign_key="ticket.id", nullable=False) + user_id: uuid.UUID = Field(foreign_key="user.id", nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + ticket: Ticket = Relationship(back_populates="comments") + user: User = Relationship() + + +# API response models +class CommentPublic(CommentBase): + id: uuid.UUID + created_at: datetime + + +class TicketPublic(TicketBase): + id: uuid.UUID + user_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class TicketDetailPublic(TicketPublic): + comments: list[CommentPublic] = [] + + +class TicketsPublic(SQLModel): + data: list[TicketPublic] + count: int + page: int + + +# Update User model to include tickets relationship +User.model_rebuild() +setattr(User, "tickets", Relationship(back_populates="user", sa_relationship_kwargs={"cascade": "all, delete-orphan"})) diff --git a/docker-compose.yml b/docker-compose.yml index c92d5d4451..e292b55d52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: environment: - DOMAIN=${DOMAIN} - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} - - ENVIRONMENT=${ENVIRONMENT} + - ENVIRONMENT=local - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} - SECRET_KEY=${SECRET_KEY?Variable not set} - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} @@ -92,7 +92,7 @@ services: environment: - DOMAIN=${DOMAIN} - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} - - ENVIRONMENT=${ENVIRONMENT} + - ENVIRONMENT=local - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} - SECRET_KEY=${SECRET_KEY?Variable not set} - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 275b7a6d71..edf47f8180 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "frontend", "version": "0.0.0", + "license": "ISC", "dependencies": { "@chakra-ui/react": "^3.8.0", "@emotion/react": "^11.14.0", diff --git a/frontend/package.json b/frontend/package.json index 4452d0edae..6a619701cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,5 +38,12 @@ "dotenv": "^16.4.5", "typescript": "^5.2.2", "vite": "^5.4.14" - } + }, + "description": "The frontend is built with [Vite](https://vitejs.dev/), [React](https://reactjs.org/), [TypeScript](https://www.typescriptlang.org/), [TanStack Query](https://tanstack.com/query), [TanStack Router](https://tanstack.com/router) and [Chakra UI](https://chakra-ui.com/).", + "main": "index.js", + "directories": { + "test": "tests" + }, + "author": "", + "license": "ISC" } diff --git a/frontend/src/components/Common/SidebarItems.tsx b/frontend/src/components/Common/SidebarItems.tsx index 13f71495f5..9afb4c2cf0 100644 --- a/frontend/src/components/Common/SidebarItems.tsx +++ b/frontend/src/components/Common/SidebarItems.tsx @@ -5,11 +5,14 @@ import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi" import type { IconType } from "react-icons/lib" import type { UserPublic } from "@/client" +import { BsTicketDetailedFill } from "react-icons/bs" const items = [ + //Adicionando mais menus ( ticket), e renomeando seções { icon: FiHome, title: "Dashboard", path: "/" }, { icon: FiBriefcase, title: "Items", path: "/items" }, - { icon: FiSettings, title: "User Settings", path: "/settings" }, + { icon: FiSettings, title: "Configurações do Usuário", path: "/settings" }, + { icon: BsTicketDetailedFill, title: "Tickets", path: "/tickets" }, ] interface SidebarItemsProps { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 0e78c9ba20..4692f575d9 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as RecoverPasswordImport } from './routes/recover-password' import { Route as LoginImport } from './routes/login' import { Route as LayoutImport } from './routes/_layout' import { Route as LayoutIndexImport } from './routes/_layout/index' +import { Route as LayoutTicketsImport } from './routes/_layout/tickets' import { Route as LayoutSettingsImport } from './routes/_layout/settings' import { Route as LayoutItemsImport } from './routes/_layout/items' import { Route as LayoutAdminImport } from './routes/_layout/admin' @@ -53,6 +54,11 @@ const LayoutIndexRoute = LayoutIndexImport.update({ getParentRoute: () => LayoutRoute, } as any) +const LayoutTicketsRoute = LayoutTicketsImport.update({ + path: '/tickets', + getParentRoute: () => LayoutRoute, +} as any) + const LayoutSettingsRoute = LayoutSettingsImport.update({ path: '/settings', getParentRoute: () => LayoutRoute, @@ -104,6 +110,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutSettingsImport parentRoute: typeof LayoutImport } + '/_layout/tickets': { + preLoaderRoute: typeof LayoutTicketsImport + parentRoute: typeof LayoutImport + } '/_layout/': { preLoaderRoute: typeof LayoutIndexImport parentRoute: typeof LayoutImport @@ -118,6 +128,7 @@ export const routeTree = rootRoute.addChildren([ LayoutAdminRoute, LayoutItemsRoute, LayoutSettingsRoute, + LayoutTicketsRoute, LayoutIndexRoute, ]), LoginRoute, diff --git a/frontend/src/routes/_layout/tickets.tsx b/frontend/src/routes/_layout/tickets.tsx new file mode 100644 index 0000000000..b4cf70adfb --- /dev/null +++ b/frontend/src/routes/_layout/tickets.tsx @@ -0,0 +1,35 @@ +import { Box, Container, Text } from "@chakra-ui/react" +import { createFileRoute } from "@tanstack/react-router" + +//import useAuth from "@/hooks/useAuth" + +export const Route = createFileRoute("/_layout/tickets")({ + component: Tickets, +}) + +function Tickets() { + //const { user: currentUser } = useAuth() + + return ( + <> + + + +
    +
      Página de Listagem de Tickets
    +
  • Requisitos:
  • +
  • - Exibição dos tickets cadastrados em formato de tabela ou lista com informações resumidas (título, status, prioridade, data de criação)
  • +
  • - Campo de busca para filtrar por palavra-chave
  • +
  • - Filtros para status, data, prioridade e categoria
  • +
  • - Opções de ordenação e paginação para facilitar a navegação
  • +
+
+ +
+
+ + ) +} + + +