innovationhub-cli 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -86
  3. package/index.js +228 -29
  4. package/package.json +12 -3
  5. package/templates/nest/.env.example +41 -0
  6. package/templates/nest/.prettierrc +4 -0
  7. package/templates/nest/Dockerfile +17 -0
  8. package/templates/nest/README.md +224 -0
  9. package/templates/nest/addons/.github/dependabot.yml +11 -0
  10. package/templates/nest/addons/.github/labeler.yml +34 -0
  11. package/templates/nest/addons/.github/workflows/check-branch.yml +24 -0
  12. package/templates/nest/addons/.github/workflows/ci.yml +29 -0
  13. package/templates/nest/addons/.github/workflows/draft-release.yml +22 -0
  14. package/templates/nest/addons/.github/workflows/pr-labeler.yml +16 -0
  15. package/templates/nest/addons/.github/workflows/release.yml +33 -0
  16. package/templates/nest/addons/.github/workflows/security-audit.yml +17 -0
  17. package/templates/nest/addons/.github/workflows/semantic-pr.yml +31 -0
  18. package/templates/nest/addons/.github/workflows/stale.yml +24 -0
  19. package/templates/nest/addons/.husky/commit-msg +17 -0
  20. package/templates/nest/addons/.husky/pre-commit +1 -0
  21. package/templates/nest/addons/addons.json +17 -0
  22. package/templates/nest/addons/cloudinary/cloudinary.module.ts +11 -0
  23. package/templates/nest/addons/cloudinary/cloudinary.provider.ts +16 -0
  24. package/templates/nest/addons/cloudinary/cloudinary.service.ts +54 -0
  25. package/templates/nest/docker-compose.yml +58 -0
  26. package/templates/nest/eslint.config.mjs +34 -0
  27. package/templates/nest/jest.config.js +17 -0
  28. package/templates/nest/nest-cli.json +12 -0
  29. package/templates/nest/package.json +99 -0
  30. package/templates/nest/src/app.controller.ts +7 -0
  31. package/templates/nest/src/app.module.ts +69 -0
  32. package/templates/nest/src/app.service.ts +4 -0
  33. package/templates/nest/src/auth/auth.controller.ts +97 -0
  34. package/templates/nest/src/auth/auth.module.ts +46 -0
  35. package/templates/nest/src/auth/auth.service.ts +231 -0
  36. package/templates/nest/src/auth/decorators/roles.decorator.ts +5 -0
  37. package/templates/nest/src/auth/dto/change-password.dto.ts +21 -0
  38. package/templates/nest/src/auth/dto/login-response.dto.ts +25 -0
  39. package/templates/nest/src/auth/dto/login.dto.ts +15 -0
  40. package/templates/nest/src/auth/dto/refresh-token.dto.ts +12 -0
  41. package/templates/nest/src/auth/entities/refresh-token.entity.ts +18 -0
  42. package/templates/nest/src/auth/enums/role.enum.ts +4 -0
  43. package/templates/nest/src/auth/guards/jwt-auth.guard.ts +5 -0
  44. package/templates/nest/src/auth/guards/refresh-token.guard.ts +5 -0
  45. package/templates/nest/src/auth/guards/roles.guard.ts +23 -0
  46. package/templates/nest/src/auth/interfaces/jwt-payload.interface.ts +10 -0
  47. package/templates/nest/src/auth/strategies/jwt.strategy.ts +28 -0
  48. package/templates/nest/src/auth/strategies/local.strategy.ts +23 -0
  49. package/templates/nest/src/auth/strategies/refresh-token.strategy.ts +32 -0
  50. package/templates/nest/src/common/base.entity.ts +19 -0
  51. package/templates/nest/src/common/base.repository.ts +79 -0
  52. package/templates/nest/src/common/base.service.ts +28 -0
  53. package/templates/nest/src/common/constants/errors.constants.ts +33 -0
  54. package/templates/nest/src/common/decorators/user.decorator.ts +9 -0
  55. package/templates/nest/src/common/dto/base-query.dto.ts +56 -0
  56. package/templates/nest/src/common/irepository.ts +18 -0
  57. package/templates/nest/src/common/utils/duration.utils.ts +33 -0
  58. package/templates/nest/src/common/utils/pagination.utils.ts +35 -0
  59. package/templates/nest/src/common/utils/slug.utils.ts +14 -0
  60. package/templates/nest/src/common/utils/transform.utils.ts +62 -0
  61. package/templates/nest/src/common/validators/is-date-after.validator.ts +40 -0
  62. package/templates/nest/src/data-source.ts +23 -0
  63. package/templates/nest/src/main.ts +44 -0
  64. package/templates/nest/src/user/dto/create-user.dto.ts +50 -0
  65. package/templates/nest/src/user/dto/query-users.dto.ts +23 -0
  66. package/templates/nest/src/user/dto/update-user.dto.ts +15 -0
  67. package/templates/nest/src/user/entities/user.entity.ts +66 -0
  68. package/templates/nest/src/user/user.controller.ts +172 -0
  69. package/templates/nest/src/user/user.module.ts +15 -0
  70. package/templates/nest/src/user/user.repository.ts +61 -0
  71. package/templates/nest/src/user/user.service.ts +138 -0
  72. package/templates/nest/template.json +5 -0
  73. package/templates/nest/test/jest-e2e.json +12 -0
  74. package/templates/nest/tsconfig.build.json +4 -0
  75. package/templates/nest/tsconfig.json +25 -0
  76. package/templates/python/.env.example +37 -0
  77. package/templates/python/Dockerfile +18 -0
  78. package/templates/python/README.md +219 -0
  79. package/templates/python/addons/.github/dependabot.yml +11 -0
  80. package/templates/python/addons/.github/labeler.yml +29 -0
  81. package/templates/python/addons/.github/workflows/check-branch.yml +24 -0
  82. package/templates/python/addons/.github/workflows/ci.yml +30 -0
  83. package/templates/python/addons/.github/workflows/draft-release.yml +22 -0
  84. package/templates/python/addons/.github/workflows/pr-labeler.yml +16 -0
  85. package/templates/python/addons/.github/workflows/release.yml +30 -0
  86. package/templates/python/addons/.github/workflows/security-audit.yml +21 -0
  87. package/templates/python/addons/.github/workflows/semantic-pr.yml +31 -0
  88. package/templates/python/addons/.github/workflows/stale.yml +24 -0
  89. package/templates/python/addons/addons.json +17 -0
  90. package/templates/python/addons/cloudinary/service.py +67 -0
  91. package/templates/python/addons/pre-commit/.pre-commit-config.yaml +31 -0
  92. package/templates/python/alembic/env.py +56 -0
  93. package/templates/python/alembic/script.py.mako +26 -0
  94. package/templates/python/alembic/versions/.gitkeep +0 -0
  95. package/templates/python/alembic.ini +39 -0
  96. package/templates/python/app/__init__.py +0 -0
  97. package/templates/python/app/auth/__init__.py +5 -0
  98. package/templates/python/app/auth/dependencies.py +118 -0
  99. package/templates/python/app/auth/enums.py +6 -0
  100. package/templates/python/app/auth/models.py +18 -0
  101. package/templates/python/app/auth/router.py +68 -0
  102. package/templates/python/app/auth/schemas.py +58 -0
  103. package/templates/python/app/auth/service.py +180 -0
  104. package/templates/python/app/common/__init__.py +18 -0
  105. package/templates/python/app/common/base_model.py +26 -0
  106. package/templates/python/app/common/base_repository.py +83 -0
  107. package/templates/python/app/common/errors.py +35 -0
  108. package/templates/python/app/common/pagination.py +22 -0
  109. package/templates/python/app/common/schemas.py +20 -0
  110. package/templates/python/app/common/utils.py +15 -0
  111. package/templates/python/app/core/__init__.py +4 -0
  112. package/templates/python/app/core/config.py +55 -0
  113. package/templates/python/app/core/database.py +20 -0
  114. package/templates/python/app/main.py +33 -0
  115. package/templates/python/app/user/__init__.py +4 -0
  116. package/templates/python/app/user/models.py +26 -0
  117. package/templates/python/app/user/repository.py +84 -0
  118. package/templates/python/app/user/router.py +170 -0
  119. package/templates/python/app/user/schemas.py +60 -0
  120. package/templates/python/app/user/service.py +114 -0
  121. package/templates/python/docker-compose.yml +55 -0
  122. package/templates/python/pyproject.toml +46 -0
  123. package/templates/python/requirements-dev.txt +7 -0
  124. package/templates/python/requirements.txt +20 -0
  125. package/templates/python/template.json +5 -0
  126. package/utils/template.js +165 -0
  127. package/utils/git.js +0 -71
@@ -0,0 +1,39 @@
1
+ # Alembic configuration file.
2
+ [alembic]
3
+ script_location = alembic
4
+ prepend_sys_path = .
5
+ sqlalchemy.url = driver://user:pass@localhost/dbname
6
+
7
+ [loggers]
8
+ keys = root,sqlalchemy,alembic
9
+
10
+ [handlers]
11
+ keys = console
12
+
13
+ [formatters]
14
+ keys = generic
15
+
16
+ [logger_root]
17
+ level = WARN
18
+ handlers = console
19
+ qualname =
20
+
21
+ [logger_sqlalchemy]
22
+ level = WARN
23
+ handlers =
24
+ qualname = sqlalchemy.engine
25
+
26
+ [logger_alembic]
27
+ level = INFO
28
+ handlers =
29
+ qualname = alembic
30
+
31
+ [handler_console]
32
+ class = StreamHandler
33
+ args = (sys.stderr,)
34
+ level = NOTSET
35
+ formatter = generic
36
+
37
+ [formatter_generic]
38
+ format = %(levelname)-5.5s [%(name)s] %(message)s
39
+ datefmt = %H:%M:%S
File without changes
@@ -0,0 +1,5 @@
1
+ from app.auth.dependencies import get_current_user, require_role
2
+ from app.auth.enums import Role
3
+ from app.auth.router import router
4
+
5
+ __all__ = ["get_current_user", "require_role", "Role", "router"]
@@ -0,0 +1,118 @@
1
+ import uuid
2
+ from datetime import UTC, datetime, timedelta
3
+
4
+ from fastapi import Depends, HTTPException, status
5
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6
+ from jose import JWTError, jwt
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from app.auth.enums import Role
10
+ from app.common.errors import ERRORS
11
+ from app.core.config import settings
12
+ from app.core.database import get_db
13
+
14
+ security = HTTPBearer()
15
+
16
+
17
+ def create_access_token(user_id: uuid.UUID, email: str, role: str) -> str:
18
+ """Cria um JWT access token."""
19
+ expire = datetime.now(UTC) + timedelta(minutes=settings.JWT_EXPIRATION_MINUTES)
20
+ payload = {
21
+ "sub": str(user_id),
22
+ "email": email,
23
+ "role": role,
24
+ "exp": expire,
25
+ }
26
+ return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
27
+
28
+
29
+ def create_refresh_token(user_id: uuid.UUID, email: str, role: str, jti: str) -> str:
30
+ """Cria um JWT refresh token."""
31
+ expire = datetime.now(UTC) + timedelta(days=settings.JWT_REFRESH_EXPIRATION_DAYS)
32
+ payload = {
33
+ "sub": str(user_id),
34
+ "email": email,
35
+ "role": role,
36
+ "jti": jti,
37
+ "exp": expire,
38
+ }
39
+ return jwt.encode(payload, settings.JWT_REFRESH_SECRET, algorithm="HS256")
40
+
41
+
42
+ def decode_access_token(token: str) -> dict:
43
+ """Decodifica e valida um access token."""
44
+ try:
45
+ return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
46
+ except JWTError as exc:
47
+ raise HTTPException(
48
+ status_code=status.HTTP_401_UNAUTHORIZED,
49
+ detail=ERRORS["AUTH"]["INVALID_TOKEN"],
50
+ ) from exc
51
+
52
+
53
+ def decode_refresh_token(token: str) -> dict:
54
+ """Decodifica e valida um refresh token."""
55
+ try:
56
+ return jwt.decode(token, settings.JWT_REFRESH_SECRET, algorithms=["HS256"])
57
+ except JWTError as exc:
58
+ raise HTTPException(
59
+ status_code=status.HTTP_403_FORBIDDEN,
60
+ detail=ERRORS["AUTH"]["ACCESS_DENIED"],
61
+ ) from exc
62
+
63
+
64
+ async def get_current_user(
65
+ credentials: HTTPAuthorizationCredentials = Depends(security),
66
+ db: AsyncSession = Depends(get_db),
67
+ ):
68
+ """Dependency que extrai e valida o usuário atual do token JWT."""
69
+ from app.user.models import User
70
+
71
+ payload = decode_access_token(credentials.credentials)
72
+ user_id = payload.get("sub")
73
+
74
+ if not user_id:
75
+ raise HTTPException(
76
+ status_code=status.HTTP_401_UNAUTHORIZED,
77
+ detail=ERRORS["AUTH"]["INVALID_TOKEN"],
78
+ )
79
+
80
+ user = await db.get(User, uuid.UUID(user_id))
81
+
82
+ if not user:
83
+ raise HTTPException(
84
+ status_code=status.HTTP_401_UNAUTHORIZED,
85
+ detail=ERRORS["AUTH"]["INVALID_TOKEN"],
86
+ )
87
+
88
+ if user.deleted_at is not None:
89
+ raise HTTPException(
90
+ status_code=status.HTTP_401_UNAUTHORIZED,
91
+ detail=ERRORS["AUTH"]["ACCOUNT_DELETED"],
92
+ )
93
+
94
+ if not user.is_active:
95
+ raise HTTPException(
96
+ status_code=status.HTTP_401_UNAUTHORIZED,
97
+ detail=ERRORS["AUTH"]["ACCOUNT_DISABLED"],
98
+ )
99
+
100
+ return user
101
+
102
+
103
+ def require_role(*roles: Role):
104
+ """
105
+ Dependency factory que verifica se o usuário tem um dos papéis necessários.
106
+
107
+ Uso: @router.get("/", dependencies=[Depends(require_role(Role.ADMIN))])
108
+ """
109
+
110
+ async def role_checker(current_user=Depends(get_current_user)):
111
+ if current_user.role not in [r.value for r in roles]:
112
+ raise HTTPException(
113
+ status_code=status.HTTP_403_FORBIDDEN,
114
+ detail=ERRORS["AUTH"]["ACCESS_DENIED"],
115
+ )
116
+ return current_user
117
+
118
+ return role_checker
@@ -0,0 +1,6 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class Role(StrEnum):
5
+ USER = "user"
6
+ ADMIN = "admin"
@@ -0,0 +1,18 @@
1
+ from sqlalchemy import Boolean, Column, ForeignKey, String
2
+ from sqlalchemy.dialects.postgresql import UUID
3
+ from sqlalchemy.orm import relationship
4
+
5
+ from app.common.base_model import BaseModel
6
+
7
+
8
+ class RefreshToken(BaseModel):
9
+ """Entidade de refresh token."""
10
+
11
+ __tablename__ = "refresh_tokens"
12
+
13
+ jti: str = Column(String, unique=True, nullable=False)
14
+ hashed_token: str = Column(String, nullable=False)
15
+ is_revoked: bool = Column(Boolean, default=False, nullable=False)
16
+
17
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
18
+ user = relationship("User", back_populates="refresh_tokens")
@@ -0,0 +1,68 @@
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+
4
+ from app.auth import service as auth_service
5
+ from app.auth.dependencies import get_current_user
6
+ from app.auth.schemas import (
7
+ ChangePasswordRequest,
8
+ LoginRequest,
9
+ LoginResponse,
10
+ MessageResponse,
11
+ RefreshTokenRequest,
12
+ )
13
+ from app.core.database import get_db
14
+ from app.user.models import User
15
+
16
+ router = APIRouter(prefix="/auth", tags=["auth"])
17
+
18
+
19
+ @router.post(
20
+ "/login",
21
+ response_model=LoginResponse,
22
+ summary="Autentica um usuário",
23
+ responses={200: {"description": "Usuário autenticado com sucesso."}},
24
+ )
25
+ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
26
+ return await auth_service.login(db, data.email, data.password)
27
+
28
+
29
+ @router.post(
30
+ "/logout",
31
+ status_code=200,
32
+ summary="Faz logout do usuário",
33
+ responses={200: {"description": "Logout realizado com sucesso."}},
34
+ )
35
+ async def logout(data: RefreshTokenRequest, db: AsyncSession = Depends(get_db)):
36
+ await auth_service.logout(db, data.refresh_token)
37
+ return {"message": "Logout realizado com sucesso."}
38
+
39
+
40
+ @router.post(
41
+ "/refresh",
42
+ response_model=LoginResponse,
43
+ summary="Atualiza os tokens de acesso",
44
+ responses={
45
+ 200: {"description": "Tokens atualizados com sucesso."},
46
+ 401: {"description": "O refresh token é inválido ou expirou."},
47
+ },
48
+ )
49
+ async def refresh_tokens(data: RefreshTokenRequest, db: AsyncSession = Depends(get_db)):
50
+ return await auth_service.refresh_tokens(db, data.refresh_token)
51
+
52
+
53
+ @router.patch(
54
+ "/change-password",
55
+ response_model=MessageResponse,
56
+ summary="Altera a senha do usuário logado",
57
+ responses={
58
+ 200: {"description": "Senha alterada com sucesso."},
59
+ 401: {"description": "Não autorizado."},
60
+ 400: {"description": "Requisição inválida."},
61
+ },
62
+ )
63
+ async def change_password(
64
+ data: ChangePasswordRequest,
65
+ current_user: User = Depends(get_current_user),
66
+ db: AsyncSession = Depends(get_db),
67
+ ):
68
+ return await auth_service.change_password(db, current_user.id, data)
@@ -0,0 +1,58 @@
1
+ import uuid
2
+
3
+ from pydantic import BaseModel, EmailStr, Field
4
+
5
+ from app.auth.enums import Role
6
+
7
+
8
+ class LoginRequest(BaseModel):
9
+
10
+ email: EmailStr
11
+ password: str
12
+
13
+
14
+ class LoginResponse(BaseModel):
15
+
16
+ access_token: str
17
+ refresh_token: str
18
+ expires_in: int
19
+ refresh_expires_in: int
20
+ user: "UserOut"
21
+
22
+
23
+
24
+ class RefreshTokenRequest(BaseModel):
25
+
26
+ refresh_token: str
27
+
28
+
29
+
30
+ class ChangePasswordRequest(BaseModel):
31
+
32
+ old_password: str
33
+ new_password: str = Field(..., min_length=8)
34
+
35
+
36
+ class MessageResponse(BaseModel):
37
+ message: str
38
+
39
+
40
+ # --- User schemas (para evitar import circular, definimos aqui) ---
41
+
42
+
43
+ class UserOut(BaseModel):
44
+ """Schema de saída do usuário (sem password)."""
45
+
46
+ model_config = {"from_attributes": True}
47
+
48
+ id: uuid.UUID
49
+ email: str
50
+ name: str
51
+ phone: str | None = None
52
+ is_active: bool
53
+ role: Role
54
+ must_change_password: bool
55
+
56
+
57
+ # Resolve forward reference
58
+ LoginResponse.model_rebuild()
@@ -0,0 +1,180 @@
1
+ import uuid
2
+
3
+ from fastapi import HTTPException, status
4
+ from passlib.context import CryptContext
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.auth.dependencies import (
9
+ create_access_token,
10
+ create_refresh_token,
11
+ decode_refresh_token,
12
+ )
13
+ from app.auth.models import RefreshToken
14
+ from app.auth.schemas import ChangePasswordRequest, LoginResponse, MessageResponse, UserOut
15
+ from app.common.errors import ERRORS
16
+ from app.core.config import settings
17
+ from app.user.models import User
18
+
19
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
20
+
21
+
22
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
23
+ return pwd_context.verify(plain_password, hashed_password)
24
+
25
+
26
+ def hash_password(password: str) -> str:
27
+ return pwd_context.hash(password)
28
+
29
+
30
+ async def validate_user(db: AsyncSession, email: str, password: str) -> User:
31
+ """Valida credenciais e retorna o usuário."""
32
+ stmt = select(User).where(User.email == email)
33
+ result = await db.execute(stmt)
34
+ user = result.scalar_one_or_none()
35
+
36
+ if not user or not verify_password(password, user.password):
37
+ raise HTTPException(
38
+ status_code=status.HTTP_401_UNAUTHORIZED,
39
+ detail=ERRORS["AUTH"]["INVALID_CREDENTIALS"],
40
+ )
41
+
42
+ if user.deleted_at is not None:
43
+ raise HTTPException(
44
+ status_code=status.HTTP_401_UNAUTHORIZED,
45
+ detail=ERRORS["AUTH"]["ACCOUNT_DELETED"],
46
+ )
47
+
48
+ if not user.is_active:
49
+ raise HTTPException(
50
+ status_code=status.HTTP_401_UNAUTHORIZED,
51
+ detail=ERRORS["AUTH"]["ACCOUNT_DISABLED"],
52
+ )
53
+
54
+ return user
55
+
56
+
57
+ async def generate_auth_response(db: AsyncSession, user: User) -> LoginResponse:
58
+ jti = str(uuid.uuid4())
59
+
60
+ access_token = create_access_token(user.id, user.email, user.role)
61
+ refresh_token = create_refresh_token(user.id, user.email, user.role, jti)
62
+
63
+ # Salvar refresh token com hash
64
+ hashed = hash_password(refresh_token)
65
+ token_entity = RefreshToken(
66
+ jti=jti,
67
+ hashed_token=hashed,
68
+ is_revoked=False,
69
+ user_id=user.id,
70
+ )
71
+ db.add(token_entity)
72
+ await db.flush()
73
+
74
+ return LoginResponse(
75
+ access_token=access_token,
76
+ refresh_token=refresh_token,
77
+ expires_in=settings.JWT_EXPIRATION_MINUTES * 60,
78
+ refresh_expires_in=settings.JWT_REFRESH_EXPIRATION_DAYS * 86400,
79
+ user=UserOut.model_validate(user),
80
+ )
81
+
82
+
83
+ async def login(db: AsyncSession, email: str, password: str) -> LoginResponse:
84
+ """Autentica o usuário e retorna tokens."""
85
+ user = await validate_user(db, email, password)
86
+ return await generate_auth_response(db, user)
87
+
88
+
89
+ async def logout(db: AsyncSession, refresh_token: str) -> None:
90
+ """Revoga o refresh token (logout)."""
91
+ payload = decode_refresh_token(refresh_token)
92
+ jti = payload.get("jti")
93
+ if not jti:
94
+ return
95
+
96
+ stmt = select(RefreshToken).where(RefreshToken.jti == jti)
97
+ result = await db.execute(stmt)
98
+ token = result.scalar_one_or_none()
99
+
100
+ if token:
101
+ token.is_revoked = True
102
+ await db.flush()
103
+
104
+
105
+ async def refresh_tokens(db: AsyncSession, refresh_token_str: str) -> LoginResponse:
106
+ """Rotaciona os tokens (revoga o antigo, gera novos)."""
107
+ payload = decode_refresh_token(refresh_token_str)
108
+ jti = payload.get("jti")
109
+ user_id = payload.get("sub")
110
+
111
+ if not jti or not user_id:
112
+ raise HTTPException(
113
+ status_code=status.HTTP_403_FORBIDDEN,
114
+ detail=ERRORS["AUTH"]["ACCESS_DENIED"],
115
+ )
116
+
117
+ # Verificar se o token existe e não foi revogado
118
+ stmt = select(RefreshToken).where(RefreshToken.jti == jti)
119
+ result = await db.execute(stmt)
120
+ token_record = result.scalar_one_or_none()
121
+
122
+ if not token_record or token_record.is_revoked:
123
+ raise HTTPException(
124
+ status_code=status.HTTP_403_FORBIDDEN,
125
+ detail=ERRORS["AUTH"]["ACCESS_DENIED"],
126
+ )
127
+
128
+ # Verificar hash do token
129
+ if not verify_password(refresh_token_str, token_record.hashed_token):
130
+ raise HTTPException(
131
+ status_code=status.HTTP_403_FORBIDDEN,
132
+ detail=ERRORS["AUTH"]["ACCESS_DENIED"],
133
+ )
134
+
135
+ # Revogar o token antigo
136
+ token_record.is_revoked = True
137
+ await db.flush()
138
+
139
+ # Buscar usuário e gerar novos tokens
140
+ user = await db.get(User, uuid.UUID(user_id))
141
+ if not user:
142
+ raise HTTPException(
143
+ status_code=status.HTTP_401_UNAUTHORIZED,
144
+ detail=ERRORS["AUTH"]["NOT_FOUND"],
145
+ )
146
+
147
+ return await generate_auth_response(db, user)
148
+
149
+
150
+ async def change_password(
151
+ db: AsyncSession, user_id: uuid.UUID, data: ChangePasswordRequest
152
+ ) -> MessageResponse:
153
+ """Altera a senha do usuário."""
154
+ user = await db.get(User, user_id)
155
+ if not user:
156
+ raise HTTPException(
157
+ status_code=status.HTTP_401_UNAUTHORIZED,
158
+ detail=ERRORS["AUTH"]["NOT_FOUND"],
159
+ )
160
+
161
+ if not verify_password(data.old_password, user.password):
162
+ raise HTTPException(
163
+ status_code=status.HTTP_401_UNAUTHORIZED,
164
+ detail=ERRORS["AUTH"]["OLD_PASSWORD_INCORRECT"],
165
+ )
166
+
167
+ if data.old_password == data.new_password:
168
+ raise HTTPException(
169
+ status_code=status.HTTP_400_BAD_REQUEST,
170
+ detail=ERRORS["AUTH"]["PASSWORD_SAME_AS_OLD"],
171
+ )
172
+
173
+ user.password = hash_password(data.new_password)
174
+
175
+ if user.must_change_password:
176
+ user.must_change_password = False
177
+
178
+ await db.flush()
179
+
180
+ return MessageResponse(message=ERRORS["AUTH"]["PASSWORD_CHANGED"])
@@ -0,0 +1,18 @@
1
+ from app.common.base_model import Base, BaseModel
2
+ from app.common.base_repository import BaseRepository
3
+ from app.common.errors import ERRORS
4
+ from app.common.pagination import PaginatedResult, PaginationMeta
5
+ from app.common.schemas import BaseQueryParams, SortOrder
6
+ from app.common.utils import create_slug
7
+
8
+ __all__ = [
9
+ "Base",
10
+ "BaseModel",
11
+ "BaseRepository",
12
+ "ERRORS",
13
+ "PaginatedResult",
14
+ "PaginationMeta",
15
+ "BaseQueryParams",
16
+ "SortOrder",
17
+ "create_slug",
18
+ ]
@@ -0,0 +1,26 @@
1
+ import uuid
2
+ from datetime import datetime
3
+
4
+ from sqlalchemy import DateTime, func
5
+ from sqlalchemy.dialects.postgresql import UUID
6
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
7
+
8
+
9
+ class Base(DeclarativeBase):
10
+
11
+ pass
12
+
13
+
14
+ class BaseModel(Base):
15
+
16
+ __abstract__ = True
17
+
18
+ id: Mapped[uuid.UUID] = mapped_column(
19
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
20
+ )
21
+ created_at: Mapped[datetime] = mapped_column(
22
+ DateTime(timezone=True), server_default=func.now(), nullable=False
23
+ )
24
+ updated_at: Mapped[datetime] = mapped_column(
25
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
26
+ )
@@ -0,0 +1,83 @@
1
+ import uuid
2
+ from typing import Generic, TypeVar
3
+
4
+ from sqlalchemy import desc, func, select
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from app.common.base_model import BaseModel
8
+ from app.common.pagination import PaginatedResult, PaginationMeta
9
+
10
+ T = TypeVar("T", bound=BaseModel)
11
+
12
+
13
+ class BaseRepository(Generic[T]):
14
+ """Repositório base genérico."""
15
+
16
+ def __init__(self, model: type[T], session: AsyncSession):
17
+ self.model = model
18
+ self.session = session
19
+
20
+ async def find_all(self) -> list[T]:
21
+ stmt = select(self.model).order_by(desc(self.model.created_at))
22
+ result = await self.session.execute(stmt)
23
+ return list(result.scalars().all())
24
+
25
+ async def find_by_id(self, id: uuid.UUID) -> T | None:
26
+ return await self.session.get(self.model, id)
27
+
28
+ async def create(self, data: dict) -> T:
29
+ entity = self.model(**data)
30
+ self.session.add(entity)
31
+ await self.session.flush()
32
+ await self.session.refresh(entity)
33
+ return entity
34
+
35
+ async def update(self, id: uuid.UUID, data: dict) -> T | None:
36
+ entity = await self.find_by_id(id)
37
+ if not entity:
38
+ return None
39
+ for key, value in data.items():
40
+ if value is not None:
41
+ setattr(entity, key, value)
42
+ await self.session.flush()
43
+ await self.session.refresh(entity)
44
+ return entity
45
+
46
+ async def delete(self, id: uuid.UUID) -> None:
47
+ entity = await self.find_by_id(id)
48
+ if entity:
49
+ await self.session.delete(entity)
50
+ await self.session.flush()
51
+
52
+ async def find_all_paginated(
53
+ self,
54
+ page: int = 1,
55
+ limit: int = 10,
56
+ order_by: str = "created_at",
57
+ order_dir: str = "DESC",
58
+ ) -> PaginatedResult[T]:
59
+ # Contagem total
60
+ count_stmt = select(func.count()).select_from(self.model)
61
+ total_result = await self.session.execute(count_stmt)
62
+ total_items = total_result.scalar() or 0
63
+
64
+ # Query paginada
65
+ column = getattr(self.model, order_by, self.model.created_at)
66
+ order = desc(column) if order_dir.upper() == "DESC" else column.asc()
67
+
68
+ stmt = select(self.model).order_by(order).offset((page - 1) * limit).limit(limit)
69
+ result = await self.session.execute(stmt)
70
+ data = list(result.scalars().all())
71
+
72
+ total_pages = (total_items + limit - 1) // limit if limit > 0 else 0
73
+
74
+ return PaginatedResult(
75
+ data=data,
76
+ meta=PaginationMeta(
77
+ total_items=total_items,
78
+ item_count=len(data),
79
+ items_per_page=limit,
80
+ total_pages=total_pages,
81
+ current_page=page,
82
+ ),
83
+ )
@@ -0,0 +1,35 @@
1
+ ERRORS = {
2
+ "AUTH": {
3
+ "NOT_FOUND": "Usuário não encontrado.",
4
+ "INVALID_TOKEN": "Token inválido.",
5
+ "ACCESS_DENIED": "Acesso negado",
6
+ "EMAIL_IN_USE": "O e-mail já está em uso.",
7
+ "INVALID_CREDENTIALS": (
8
+ "E-mail ou senha inválidos. Por favor, verifique seus dados e tente novamente."
9
+ ),
10
+ "ACCOUNT_DELETED": "Sua conta foi excluída. Por favor, entre em contato com o suporte.",
11
+ "ACCOUNT_DISABLED": (
12
+ "Sua conta está desativada. Por favor, entre em contato com o suporte."
13
+ ),
14
+ "OLD_PASSWORD_INCORRECT": "A senha antiga está incorreta. Tente novamente.",
15
+ "PASSWORD_SAME_AS_OLD": (
16
+ "A nova senha deve ser diferente da senha antiga. "
17
+ "Por favor, escolha uma nova senha."
18
+ ),
19
+ "PASSWORD_MIN_LENGTH": "A nova senha deve ter no mínimo 8 caracteres.",
20
+ "PASSWORD_CHANGED": "Senha alterada com sucesso.",
21
+ },
22
+ "USER": {
23
+ "NOT_FOUND": "Usuário não encontrado.",
24
+ "EMAIL_IN_USE": "O e-mail já está em uso.",
25
+ "DEFAULT_PASSWORD_NOT_SET": "Variável de ambiente DEFAULT_PASSWORD não configurada.",
26
+ },
27
+ "IMAGE": {
28
+ "REQUIRED": "O arquivo de imagem é obrigatório.",
29
+ },
30
+ "COMMON": {
31
+ "NOT_FOUND": "Recurso não encontrado.",
32
+ "BAD_REQUEST": "Requisição inválida.",
33
+ "INVALID_ARRAY_FORMAT": "Formato de array inválido.",
34
+ },
35
+ }
@@ -0,0 +1,22 @@
1
+ from typing import Generic, TypeVar
2
+
3
+ from pydantic import BaseModel
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class PaginationMeta(BaseModel):
9
+ """Metadados de paginação."""
10
+
11
+ total_items: int
12
+ item_count: int
13
+ items_per_page: int
14
+ total_pages: int
15
+ current_page: int
16
+
17
+
18
+ class PaginatedResult(BaseModel, Generic[T]):
19
+ """Resultado paginado genérico."""
20
+
21
+ data: list[T]
22
+ meta: PaginationMeta