diff --git a/api.saladeaula.digital/app/routes/users/__init__.py b/api.saladeaula.digital/app/routes/users/__init__.py index ae87ccf..7d1b628 100644 --- a/api.saladeaula.digital/app/routes/users/__init__.py +++ b/api.saladeaula.digital/app/routes/users/__init__.py @@ -1,13 +1,22 @@ +from http import HTTPStatus from typing import Annotated from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.exceptions import ( NotFoundError, + ServiceError, ) from aws_lambda_powertools.event_handler.openapi.params import Body -from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair +from layercake.dateutils import now, ttl +from layercake.dynamodb import ( + DynamoDBPersistenceLayer, + KeyPair, + SortKey, + TransactKey, +) from layercake.extra_types import CpfStr, NameStr +from api_gateway import JSONResponse from boto3clients import dynamodb_client from config import USER_TABLE @@ -21,17 +30,96 @@ router = Router() dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) +class UserNotFoundError(NotFoundError): ... + + +class CPFConflictError(ServiceError): + def __init__(self, msg: str | dict): + super().__init__(HTTPStatus.CONFLICT, msg) + + +class RateLimitExceededError(ServiceError): + def __init__(self, msg: str | dict): + super().__init__(HTTPStatus.TOO_MANY_REQUESTS, msg) + + @router.get('/') def get_user(user_id: str): - return dyn.collection.get_item( - KeyPair(user_id, '0'), - exc_cls=NotFoundError, + user = dyn.collection.get_items( + TransactKey(user_id) + + SortKey('0') + + SortKey( + sk='RATE_LIMIT_EXCEEDED', + rename_key='rate_limit_exceeded', + ) + + SortKey( + sk='CREATED_BY', + rename_key='created_by', + ), ) + if not user: + return UserNotFoundError('User not found') + + return user + @router.patch('/') def update( user_id: str, name: Annotated[NameStr, Body(embed=True)], - cpf: Annotated[CpfStr, Body(embed=True)], -): ... + new_cpf: Annotated[CpfStr, Body(embed=True, alias='cpf')], +): + now_ = now() + old_cpf = dyn.collection.get_item( + KeyPair( + pk=user_id, + sk=SortKey( + '0', + path_spec='cpf', + ), + ), + ) + + with dyn.transact_writer() as transact: + transact.put( + item={ + 'id': user_id, + 'sk': 'RATE_LIMIT_EXCEEDED', + 'ttl': ttl(start_dt=now_, hours=24), + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=RateLimitExceededError, + ) + + transact.update( + key=KeyPair(user_id, '0'), + update_expr='SET #name = :name, cpf = :cpf, updated_at = :now', + expr_attr_names={ + '#name': 'name', + }, + expr_attr_values={ + ':name': name, + ':cpf': new_cpf, + ':now': now_, + }, + cond_expr='attribute_exists(sk)', + ) + + if old_cpf != new_cpf: + transact.put( + item={ + 'id': 'cpf', + 'cpf': new_cpf, + 'user_id': user_id, + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=CPFConflictError, + ) + + if old_cpf: + transact.delete(key=KeyPair('cpf', old_cpf)) + + return JSONResponse(status_code=HTTPStatus.OK) diff --git a/api.saladeaula.digital/tests/routes/users/test_user.py b/api.saladeaula.digital/tests/routes/users/test_user.py new file mode 100644 index 0000000..978c0d4 --- /dev/null +++ b/api.saladeaula.digital/tests/routes/users/test_user.py @@ -0,0 +1,31 @@ +from http import HTTPMethod, HTTPStatus + +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair + +from ...conftest import HttpApiProxy, LambdaContext + + +def test_update( + app, + seeds, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, +): + user_id = '15bacf02-1535-4bee-9022-19d106fd7518' + r = app.lambda_handler( + http_api_proxy( + raw_path=f'/users/{user_id}', + method=HTTPMethod.PATCH, + body={ + 'name': 'Sérgio Rafael Siqueira', + 'cpf': '07879819908', + }, + ), + lambda_context, + ) + + assert r['statusCode'] == HTTPStatus.OK + + r = dynamodb_persistence_layer.collection.get_item(KeyPair(user_id, '0')) + assert r['name'] == 'Sérgio Rafael Siqueira' diff --git a/apps/saladeaula.digital/app/routes/settings/profile.tsx b/apps/saladeaula.digital/app/routes/settings/profile.tsx index fbe2d34..43e3158 100644 --- a/apps/saladeaula.digital/app/routes/settings/profile.tsx +++ b/apps/saladeaula.digital/app/routes/settings/profile.tsx @@ -1,5 +1,6 @@ import type { Route } from './+types/profile' +import { AlertCircleIcon } from 'lucide-react' import { useForm } from 'react-hook-form' import { Link, useOutletContext } from 'react-router' import { PatternFormat } from 'react-number-format' @@ -24,14 +25,34 @@ import { import { Input } from '@repo/ui/components/ui/input' import { Spinner } from '@repo/ui/components/ui/spinner' import { type User } from '@repo/ui/routes/users/data' +import { request as req, HttpMethod } from '@repo/util/request' import { formSchema, type Schema } from './emails/data' import { useFetcher } from 'react-router' +import { userContext } from '@repo/auth/context' +import { + Alert, + AlertDescription, + AlertTitle +} from '@repo/ui/components/ui/alert' -export async function action({ request }: Route.ActionArgs) { +export async function action({ request, context }: Route.ActionArgs) { const body = await request.json() - console.log(body) - return { ok: true } + const user = context.get(userContext) + const r = await req({ + url: `/users/${user.sub}`, + method: HttpMethod.PATCH, + headers: new Headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(body), + request, + context + }) + + if (r.ok) { + return { ok: true } + } + + return { ok: false, error: await r.json() } } export default function Route({}: Route.ComponentProps) { @@ -52,95 +73,124 @@ export default function Route({}: Route.ComponentProps) { return (
- - - - Meu perfil - - Mantenha os dados do seu perfil atualizados para que apareçam - corretamente em seus cursos e certificados. - - + +
+ {user?.rate_limit_exceeded && ( + + + Limite de atualizações excedido + + Nova tentativa disponível a partir de{' '} + {remainingTime(user.rate_limit_exceeded.ttl)} + + + )} - - ( - - Nome - - - - - - )} - /> + + + Meu perfil + + Mantenha os dados do seu perfil atualizados para que apareçam + corretamente em seus cursos e certificados. + + - ( - - Email - - - - - - Para gerenciar os emails ou trocar o email principal, use - as{' '} - - configurações de emails - - - - - - )} - /> + + ( + + Nome + + + + + + )} + /> - ( - - CPF - - { - onChange(value) - }} - {...props} - /> - - - - )} - /> - - + ( + + Email + + + + + + Para gerenciar os emails ou trocar o email principal, + use as{' '} + + configurações de emails + + + + + + )} + /> -
- -
+ ( + + CPF + + { + onChange(value) + }} + {...props} + /> + + + + )} + /> +
+ + +
+ +
+
) } + +function remainingTime(ttl: number) { + const date = new Date(ttl * 1000) + + const day = date.toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit' + }) + + const time = date.toLocaleTimeString('pt-BR', { + hour: '2-digit', + minute: '2-digit' + }) + + return `${day} às ${time}` +} diff --git a/id.saladeaula.digital/app/oauth2.py b/id.saladeaula.digital/app/oauth2.py index 25e97b8..2e1bf42 100644 --- a/id.saladeaula.digital/app/oauth2.py +++ b/id.saladeaula.digital/app/oauth2.py @@ -41,7 +41,7 @@ private_jwk = JsonWebKey.import_key(private_key) # https://docs.authlib.org/en/v0.12/specs/rfc6750.html#authlib.oauth2.rfc6750.BearerToken.GRANT_TYPES_EXPIRES_IN GRANT_TYPES_EXPIRES_IN = { - 'authorization_code': 60 * 3, # 3 minutes + 'authorization_code': 3600, # 1 hour 'refresh_token': 3600, # 1 hour } diff --git a/packages/ui/src/routes/users/data.tsx b/packages/ui/src/routes/users/data.tsx index 487f0c4..af79fe9 100644 --- a/packages/ui/src/routes/users/data.tsx +++ b/packages/ui/src/routes/users/data.tsx @@ -5,6 +5,7 @@ export type User = { name: string email: string cpf: string + rate_limit_exceeded?: any } export const headers = {