Merge pull request #38 from rede5/Front-back-integracao-task16
feat: Adicionado gerenciamento de codigos de acesso
This commit is contained in:
commit
1152d5ada6
14 changed files with 923 additions and 5 deletions
|
|
@ -10,6 +10,7 @@ import (
|
|||
"photum-backend/internal/auth"
|
||||
"photum-backend/internal/availability"
|
||||
"photum-backend/internal/cadastro_fot"
|
||||
"photum-backend/internal/codigos"
|
||||
"photum-backend/internal/config"
|
||||
"photum-backend/internal/cursos"
|
||||
"photum-backend/internal/db"
|
||||
|
|
@ -96,6 +97,7 @@ func main() {
|
|||
availabilityHandler := availability.NewHandler(availabilityService)
|
||||
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
|
||||
logisticaHandler := logistica.NewHandler(logistica.NewService(queries))
|
||||
codigosHandler := codigos.NewHandler(codigos.NewService(queries))
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
|
|
@ -230,6 +232,13 @@ func main() {
|
|||
logisticaGroup.GET("/carros/:id/passageiros", logisticaHandler.ListPassengers)
|
||||
}
|
||||
|
||||
codigosGroup := api.Group("/codigos-acesso")
|
||||
{
|
||||
codigosGroup.POST("", codigosHandler.Create)
|
||||
codigosGroup.GET("", codigosHandler.List)
|
||||
codigosGroup.DELETE("/:id", codigosHandler.Delete)
|
||||
}
|
||||
|
||||
admin := api.Group("/admin")
|
||||
{
|
||||
admin.GET("/users", authHandler.ListUsers)
|
||||
|
|
|
|||
|
|
@ -1056,6 +1056,89 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/codigos-acesso": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"codigos"
|
||||
],
|
||||
"summary": "List Access Codes",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"codigos"
|
||||
],
|
||||
"summary": "Create Access Code",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Req",
|
||||
"name": "req",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codigos.CreateCodigoInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/codigos-acesso/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"codigos"
|
||||
],
|
||||
"summary": "Delete Access Code",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/cursos": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
|
@ -3046,6 +3129,20 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"codigos.CreateCodigoInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"codigo": {
|
||||
"type": "string"
|
||||
},
|
||||
"descricao": {
|
||||
"type": "string"
|
||||
},
|
||||
"validade_dias": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cursos.CreateCursoRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,89 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/codigos-acesso": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"codigos"
|
||||
],
|
||||
"summary": "List Access Codes",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"codigos"
|
||||
],
|
||||
"summary": "Create Access Code",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Req",
|
||||
"name": "req",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codigos.CreateCodigoInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/codigos-acesso/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"codigos"
|
||||
],
|
||||
"summary": "Delete Access Code",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/cursos": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
|
@ -3040,6 +3123,20 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"codigos.CreateCodigoInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"codigo": {
|
||||
"type": "string"
|
||||
},
|
||||
"descricao": {
|
||||
"type": "string"
|
||||
},
|
||||
"validade_dias": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cursos.CreateCursoRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -210,6 +210,15 @@ definitions:
|
|||
pre_venda:
|
||||
type: boolean
|
||||
type: object
|
||||
codigos.CreateCodigoInput:
|
||||
properties:
|
||||
codigo:
|
||||
type: string
|
||||
descricao:
|
||||
type: string
|
||||
validade_dias:
|
||||
type: integer
|
||||
type: object
|
||||
cursos.CreateCursoRequest:
|
||||
properties:
|
||||
nome:
|
||||
|
|
@ -1176,6 +1185,60 @@ paths:
|
|||
summary: Update FOT record
|
||||
tags:
|
||||
- cadastro_fot
|
||||
/api/codigos-acesso:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
summary: List Access Codes
|
||||
tags:
|
||||
- codigos
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Req
|
||||
in: body
|
||||
name: req
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/codigos.CreateCodigoInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: Create Access Code
|
||||
tags:
|
||||
- codigos
|
||||
/api/codigos-acesso/{id}:
|
||||
delete:
|
||||
parameters:
|
||||
- description: ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Delete Access Code
|
||||
tags:
|
||||
- codigos
|
||||
/api/cursos:
|
||||
get:
|
||||
consumes:
|
||||
|
|
|
|||
90
backend/internal/codigos/handler.go
Normal file
90
backend/internal/codigos/handler.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package codigos
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// Create godoc
|
||||
// @Summary Create Access Code
|
||||
// @Tags codigos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param req body CreateCodigoInput true "Req"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Router /api/codigos-acesso [post]
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req CreateCodigoInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
code, err := h.service.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": uuid.UUID(code.ID.Bytes).String(), "codigo": code.Codigo})
|
||||
}
|
||||
|
||||
// List godoc
|
||||
// @Summary List Access Codes
|
||||
// @Tags codigos
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Router /api/codigos-acesso [get]
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
codes, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]map[string]interface{}, len(codes))
|
||||
for i, v := range codes {
|
||||
resp[i] = map[string]interface{}{
|
||||
"id": uuid.UUID(v.ID.Bytes).String(),
|
||||
"codigo": v.Codigo,
|
||||
"descricao": v.Descricao.String,
|
||||
"validade_dias": v.ValidadeDias,
|
||||
"criado_em": v.CriadoEm.Time,
|
||||
"expira_em": v.ExpiraEm.Time,
|
||||
"ativo": v.Ativo,
|
||||
"usos": v.Usos,
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Delete godoc
|
||||
// @Summary Delete Access Code
|
||||
// @Tags codigos
|
||||
// @Param id path string true "ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/codigos-acesso/{id} [delete]
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.Delete(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
79
backend/internal/codigos/service.go
Normal file
79
backend/internal/codigos/service.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package codigos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"photum-backend/internal/db/generated"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
q *generated.Queries
|
||||
}
|
||||
|
||||
func NewService(q *generated.Queries) *Service {
|
||||
return &Service{
|
||||
q: q,
|
||||
}
|
||||
}
|
||||
|
||||
type CreateCodigoInput struct {
|
||||
Codigo string `json:"codigo"`
|
||||
Descricao string `json:"descricao"`
|
||||
ValidadeDias int32 `json:"validade_dias"`
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, input CreateCodigoInput) (generated.CodigosAcesso, error) {
|
||||
days := input.ValidadeDias
|
||||
|
||||
var expiraEm time.Time
|
||||
if days == -1 {
|
||||
// Infinite: 100 years
|
||||
expiraEm = time.Now().AddDate(100, 0, 0)
|
||||
} else {
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
expiraEm = time.Now().Add(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
return s.q.CreateCodigoAcesso(ctx, generated.CreateCodigoAcessoParams{
|
||||
Codigo: input.Codigo,
|
||||
Descricao: pgtype.Text{String: input.Descricao, Valid: input.Descricao != ""},
|
||||
ValidadeDias: days,
|
||||
ExpiraEm: pgtype.Timestamptz{Time: expiraEm, Valid: true},
|
||||
Ativo: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]generated.CodigosAcesso, error) {
|
||||
return s.q.ListCodigosAcesso(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
uid, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert uuid.UUID to pgtype.UUID
|
||||
var pgUUID pgtype.UUID
|
||||
pgUUID.Bytes = uid
|
||||
pgUUID.Valid = true
|
||||
|
||||
return s.q.DeleteCodigoAcesso(ctx, pgUUID)
|
||||
}
|
||||
|
||||
func (s *Service) GetByCode(ctx context.Context, code string) (generated.CodigosAcesso, error) {
|
||||
return s.q.GetCodigoAcesso(ctx, code)
|
||||
}
|
||||
|
||||
func (s *Service) IncrementUse(ctx context.Context, id uuid.UUID) error {
|
||||
var pgUUID pgtype.UUID
|
||||
pgUUID.Bytes = id
|
||||
pgUUID.Valid = true
|
||||
return s.q.IncrementCodigoAcessoUso(ctx, pgUUID)
|
||||
}
|
||||
127
backend/internal/db/generated/codigos_acesso.sql.go
Normal file
127
backend/internal/db/generated/codigos_acesso.sql.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: codigos_acesso.sql
|
||||
|
||||
package generated
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createCodigoAcesso = `-- name: CreateCodigoAcesso :one
|
||||
INSERT INTO codigos_acesso (
|
||||
codigo, descricao, validade_dias, expira_em, ativo
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING id, codigo, descricao, validade_dias, criado_em, expira_em, ativo, usos
|
||||
`
|
||||
|
||||
type CreateCodigoAcessoParams struct {
|
||||
Codigo string `json:"codigo"`
|
||||
Descricao pgtype.Text `json:"descricao"`
|
||||
ValidadeDias int32 `json:"validade_dias"`
|
||||
ExpiraEm pgtype.Timestamptz `json:"expira_em"`
|
||||
Ativo bool `json:"ativo"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateCodigoAcesso(ctx context.Context, arg CreateCodigoAcessoParams) (CodigosAcesso, error) {
|
||||
row := q.db.QueryRow(ctx, createCodigoAcesso,
|
||||
arg.Codigo,
|
||||
arg.Descricao,
|
||||
arg.ValidadeDias,
|
||||
arg.ExpiraEm,
|
||||
arg.Ativo,
|
||||
)
|
||||
var i CodigosAcesso
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Codigo,
|
||||
&i.Descricao,
|
||||
&i.ValidadeDias,
|
||||
&i.CriadoEm,
|
||||
&i.ExpiraEm,
|
||||
&i.Ativo,
|
||||
&i.Usos,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteCodigoAcesso = `-- name: DeleteCodigoAcesso :exec
|
||||
DELETE FROM codigos_acesso
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteCodigoAcesso(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteCodigoAcesso, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getCodigoAcesso = `-- name: GetCodigoAcesso :one
|
||||
SELECT id, codigo, descricao, validade_dias, criado_em, expira_em, ativo, usos FROM codigos_acesso
|
||||
WHERE codigo = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetCodigoAcesso(ctx context.Context, codigo string) (CodigosAcesso, error) {
|
||||
row := q.db.QueryRow(ctx, getCodigoAcesso, codigo)
|
||||
var i CodigosAcesso
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Codigo,
|
||||
&i.Descricao,
|
||||
&i.ValidadeDias,
|
||||
&i.CriadoEm,
|
||||
&i.ExpiraEm,
|
||||
&i.Ativo,
|
||||
&i.Usos,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const incrementCodigoAcessoUso = `-- name: IncrementCodigoAcessoUso :exec
|
||||
UPDATE codigos_acesso
|
||||
SET usos = usos + 1
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) IncrementCodigoAcessoUso(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, incrementCodigoAcessoUso, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const listCodigosAcesso = `-- name: ListCodigosAcesso :many
|
||||
SELECT id, codigo, descricao, validade_dias, criado_em, expira_em, ativo, usos FROM codigos_acesso
|
||||
ORDER BY criado_em DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListCodigosAcesso(ctx context.Context) ([]CodigosAcesso, error) {
|
||||
rows, err := q.db.Query(ctx, listCodigosAcesso)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CodigosAcesso
|
||||
for rows.Next() {
|
||||
var i CodigosAcesso
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Codigo,
|
||||
&i.Descricao,
|
||||
&i.ValidadeDias,
|
||||
&i.CriadoEm,
|
||||
&i.ExpiraEm,
|
||||
&i.Ativo,
|
||||
&i.Usos,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
|
@ -124,6 +124,17 @@ type CadastroProfissionai struct {
|
|||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||
}
|
||||
|
||||
type CodigosAcesso struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Codigo string `json:"codigo"`
|
||||
Descricao pgtype.Text `json:"descricao"`
|
||||
ValidadeDias int32 `json:"validade_dias"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
ExpiraEm pgtype.Timestamptz `json:"expira_em"`
|
||||
Ativo bool `json:"ativo"`
|
||||
Usos int32 `json:"usos"`
|
||||
}
|
||||
|
||||
type Curso struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Nome string `json:"nome"`
|
||||
|
|
|
|||
24
backend/internal/db/queries/codigos_acesso.sql
Normal file
24
backend/internal/db/queries/codigos_acesso.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- name: CreateCodigoAcesso :one
|
||||
INSERT INTO codigos_acesso (
|
||||
codigo, descricao, validade_dias, expira_em, ativo
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListCodigosAcesso :many
|
||||
SELECT * FROM codigos_acesso
|
||||
ORDER BY criado_em DESC;
|
||||
|
||||
-- name: DeleteCodigoAcesso :exec
|
||||
DELETE FROM codigos_acesso
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetCodigoAcesso :one
|
||||
SELECT * FROM codigos_acesso
|
||||
WHERE codigo = $1 LIMIT 1;
|
||||
|
||||
-- name: IncrementCodigoAcessoUso :exec
|
||||
UPDATE codigos_acesso
|
||||
SET usos = usos + 1
|
||||
WHERE id = $1;
|
||||
|
|
@ -421,3 +421,15 @@ CREATE TABLE IF NOT EXISTS logistica_passageiros (
|
|||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(carro_id, profissional_id)
|
||||
);
|
||||
|
||||
-- Codigos de Acesso Table
|
||||
CREATE TABLE IF NOT EXISTS codigos_acesso (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
codigo VARCHAR(50) UNIQUE NOT NULL,
|
||||
descricao VARCHAR(255),
|
||||
validade_dias INT NOT NULL DEFAULT 30,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expira_em TIMESTAMPTZ NOT NULL,
|
||||
ativo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
usos INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
|
|
|||
12
backend/temp_schema_append.sql
Normal file
12
backend/temp_schema_append.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
-- Codigos de Acesso Table
|
||||
CREATE TABLE IF NOT EXISTS codigos_acesso (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
codigo VARCHAR(50) UNIQUE NOT NULL,
|
||||
descricao VARCHAR(255),
|
||||
validade_dias INT NOT NULL DEFAULT 30,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expira_em TIMESTAMPTZ NOT NULL,
|
||||
ativo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
usos INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
|
@ -21,7 +21,7 @@ import { SettingsPage } from "./pages/Settings";
|
|||
import { CourseManagement } from "./pages/CourseManagement";
|
||||
import { InspirationPage } from "./pages/Inspiration";
|
||||
import { UserApproval } from "./pages/UserApproval";
|
||||
import { AccessCodeManagement } from "./pages/AccessCodeManagement";
|
||||
import { AccessCodes } from "./pages/AccessCodes";
|
||||
import { PrivacyPolicy } from "./pages/PrivacyPolicy";
|
||||
import { TermsOfUse } from "./pages/TermsOfUse";
|
||||
import { LGPD } from "./pages/LGPD";
|
||||
|
|
@ -540,7 +540,7 @@ const AppContent: React.FC = () => {
|
|||
allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]}
|
||||
>
|
||||
<PageWrapper>
|
||||
<AccessCodeManagement onNavigate={(page) => { }} />
|
||||
<AccessCodes />
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
|
|
|||
246
frontend/pages/AccessCodes.tsx
Normal file
246
frontend/pages/AccessCodes.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { createAccessCode, listAccessCodes, deleteAccessCode } from '../services/apiService';
|
||||
import { Plus, Trash2, Copy, Check } from 'lucide-react';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
interface AccessCode {
|
||||
id: string;
|
||||
codigo: string;
|
||||
descricao: string;
|
||||
validade_dias: number;
|
||||
criado_em: string;
|
||||
expira_em: string;
|
||||
ativo: boolean;
|
||||
usos: number;
|
||||
}
|
||||
|
||||
export const AccessCodes: React.FC = () => {
|
||||
const { token, user } = useAuth();
|
||||
const [codes, setCodes] = useState<AccessCode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [days, setDays] = useState(30);
|
||||
const [customDays, setCustomDays] = useState('');
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) fetchCodes();
|
||||
}, [token]);
|
||||
|
||||
const fetchCodes = async () => {
|
||||
setLoading(true);
|
||||
const res = await listAccessCodes(token!);
|
||||
if (res.data) {
|
||||
setCodes(res.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const codeToCreate = newCode.trim() || generateRandomCode();
|
||||
|
||||
const res = await createAccessCode(token!, {
|
||||
codigo: codeToCreate,
|
||||
validade_dias: days,
|
||||
descricao: 'Gerado via Painel'
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
alert('Erro ao criar: ' + res.error);
|
||||
} else {
|
||||
setIsCreateModalOpen(false);
|
||||
setNewCode('');
|
||||
fetchCodes();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Excluir este código? Ele não poderá mais ser usado.')) return;
|
||||
|
||||
const res = await deleteAccessCode(token!, id);
|
||||
if (!res.error) {
|
||||
fetchCodes();
|
||||
} else {
|
||||
alert('Erro ao excluir');
|
||||
}
|
||||
};
|
||||
|
||||
const generateRandomCode = () => {
|
||||
const year = new Date().getFullYear();
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `PHOTUM${year}-${random}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
if (!user || (user.role !== UserRole.SUPERADMIN && user.role !== UserRole.BUSINESS_OWNER)) {
|
||||
return <div className="p-8 text-center">Acesso restrito.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-2">Gerenciar Códigos de Acesso</h1>
|
||||
<p className="text-gray-600">Crie e gerencie códigos de acesso temporários para novos cadastros</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="bg-[#492E61] text-white px-6 py-3 rounded-lg font-medium hover:bg-[#5a3a7a] transition-colors flex items-center gap-2 mb-8 shadow-sm"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Gerar Novo Código
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Código</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Validade (Dias)</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Data de Criação</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Data de Expiração</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-8 text-center text-gray-500">Carregando...</td></tr>
|
||||
) : codes.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-8 text-center text-gray-500">Nenhum código gerado.</td></tr>
|
||||
) : (
|
||||
codes.map(code => (
|
||||
<tr key={code.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-block px-3 py-1 text-xs font-medium rounded-full ${code.ativo ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{code.validade_dias === -1 ? 'Infinito' : `${code.validade_dias} dias`}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 group">
|
||||
<span className="font-mono font-bold text-[#492E61]">{code.codigo}</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(code.codigo, code.id)}
|
||||
className="text-gray-400 hover:text-[#492E61] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copiar Código"
|
||||
>
|
||||
{copiedId === code.id ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">{code.validade_dias === -1 ? 'Nunca expira' : `${code.validade_dias} dias`}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{formatDate(code.criado_em)}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{code.validade_dias === -1 ? '-' : formatDate(code.expira_em)}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(code.id)}
|
||||
className="inline-flex items-center px-3 py-1.5 bg-red-50 text-red-600 rounded-md hover:bg-red-100 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Trash2 size={14} className="mr-1.5" />
|
||||
Excluir
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Create */}
|
||||
{isCreateModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 animate-fadeIn">
|
||||
<h2 className="text-xl font-bold mb-4">Novo Código de Acesso</h2>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Código (Opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newCode}
|
||||
onChange={e => setNewCode(e.target.value.toUpperCase())}
|
||||
placeholder={generateRandomCode()}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#492E61] focus:border-transparent outline-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Deixe em branco para gerar automaticamente.</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Validade</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[7, 15, 30, 60, 90].map(d => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => { setDays(d); setCustomDays(''); }}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${days === d ? 'bg-[#492E61] text-white border-[#492E61]' : 'bg-white text-gray-700 border-gray-200 hover:border-[#492E61]'}`}
|
||||
>
|
||||
{d} dias
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDays(-1); setCustomDays(''); }}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${days === -1 ? 'bg-[#492E61] text-white border-[#492E61]' : 'bg-white text-gray-700 border-gray-200 hover:border-[#492E61]'}`}
|
||||
>
|
||||
Nunca Expira
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{days !== -1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Outro:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={customDays}
|
||||
onChange={e => {
|
||||
const val = parseInt(e.target.value);
|
||||
setCustomDays(e.target.value);
|
||||
if (!isNaN(val) && val > 0) setDays(val);
|
||||
}}
|
||||
className="w-24 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-[#492E61] focus:border-transparent outline-none"
|
||||
placeholder="Dias"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateModalOpen(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[#492E61] text-white rounded-lg hover:bg-[#5a3a7a]"
|
||||
>
|
||||
Gerar Código
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -802,8 +802,7 @@ export async function getUploadURL(filename: string, contentType: string): Promi
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
|
@ -813,7 +812,7 @@ export async function getUploadURL(filename: string, contentType: string): Promi
|
|||
isBackendDown: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching upload URL:", error);
|
||||
console.error("Error getting upload URL:", error);
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
|
|
@ -822,6 +821,58 @@ export async function getUploadURL(filename: string, contentType: string): Promi
|
|||
}
|
||||
}
|
||||
|
||||
// Access Codes
|
||||
export async function createAccessCode(token: string, data: { codigo: string; descricao?: string; validade_dias: number }) {
|
||||
return mutationFetch(`${API_BASE_URL}/api/codigos-acesso`, "POST", data, token);
|
||||
}
|
||||
|
||||
export async function listAccessCodes(token: string) {
|
||||
return fetchFromBackendAuthenticated(`${API_BASE_URL}/api/codigos-acesso`, token);
|
||||
}
|
||||
|
||||
export async function deleteAccessCode(token: string, id: string) {
|
||||
return mutationFetch(`${API_BASE_URL}/api/codigos-acesso/${id}`, "DELETE", {}, token);
|
||||
}
|
||||
|
||||
// Helpers for unified fetch
|
||||
async function fetchFromBackendAuthenticated(url: string, token: string) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const data = await res.json();
|
||||
return { data, error: null };
|
||||
} catch (e: any) {
|
||||
return { data: null, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function mutationFetch(url: string, method: string, body: any, token: string) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
const data = await res.json();
|
||||
return { data, error: null };
|
||||
} catch (e: any) {
|
||||
return { data: null, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Realiza o upload do arquivo para a URL pré-assinada
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue