diff --git a/api.saladeaula.digital/app/routes/users/add.py b/api.saladeaula.digital/app/routes/users/add.py index 20c6cf1..7b472c2 100644 --- a/api.saladeaula.digital/app/routes/users/add.py +++ b/api.saladeaula.digital/app/routes/users/add.py @@ -4,14 +4,14 @@ from uuid import uuid4 from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.exceptions import ( - BadRequestError, NotFoundError, + ServiceError, ) from aws_lambda_powertools.event_handler.openapi.params import Body from layercake.dateutils import now from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey from layercake.extra_types import CnpjStr, CpfStr, NameStr -from pydantic import UUID4, BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr from api_gateway import JSONResponse from boto3clients import dynamodb_client @@ -28,7 +28,6 @@ class Org(BaseModel): class User(BaseModel): - id: UUID4 = Field(default_factory=uuid4) name: NameStr cpf: CpfStr email: EmailStr @@ -40,7 +39,9 @@ class CPFConflictError(Exception): ... class EmailConflictError(Exception): ... -class UserConflictError(BadRequestError): ... +class UserConflictError(ServiceError): + def __init__(self, msg: str | dict): + super().__init__(HTTPStatus.CONFLICT, msg) class UserNotFoundError(NotFoundError): ... @@ -94,27 +95,41 @@ def add_user( exc_cls=OrgMissingError, ) - return JSONResponse(HTTPStatus.CREATED) + return JSONResponse(HTTPStatus.NO_CONTENT) def _create_user(user: User, org: Org) -> bool: now_ = now() + user_id = uuid4() try: with dyn.transact_writer() as transact: transact.put( item={ **user.model_dump(), + 'id': user_id, 'sk': '0', + 'email_verified': False, 'org_id': {org.id}, 'created_at': now_, }, ) + transact.put( + item={ + 'id': user_id, + # Post-migration: rename `emails` to `EMAIL` + 'sk': f'emails#{user.email}', + 'email_verified': False, + 'email_primary': True, + 'created_at': now_, + } + ) transact.put( item={ # Post-migration: rename `cpf` to `CPF` 'id': 'cpf', 'sk': user.cpf, + 'created_at': now_, }, cond_expr='attribute_not_exists(sk)', exc_cls=CPFConflictError, @@ -124,13 +139,14 @@ def _create_user(user: User, org: Org) -> bool: # Post-migration: rename `email` to `EMAIL` 'id': 'email', 'sk': user.email, + 'created_at': now_, }, cond_expr='attribute_not_exists(sk)', exc_cls=EmailConflictError, ) transact.put( item={ - 'id': user.id, + 'id': user_id, # Post-migration: rename `orgs` to `ORG` 'sk': f'orgs#{org.id}', 'name': org.name, @@ -142,7 +158,7 @@ def _create_user(user: User, org: Org) -> bool: item={ # Post-migration: rename `orgmembers` to `ORGMEMBER` 'id': f'orgmembers#{org.id}', - 'sk': user.id, + 'sk': user_id, 'created_at': now_, } ) diff --git a/api.saladeaula.digital/tests/routes/test_users.py b/api.saladeaula.digital/tests/routes/test_users.py index 473d396..70c2553 100644 --- a/api.saladeaula.digital/tests/routes/test_users.py +++ b/api.saladeaula.digital/tests/routes/test_users.py @@ -1,6 +1,13 @@ import json from http import HTTPMethod, HTTPStatus +from layercake.dynamodb import ( + DynamoDBPersistenceLayer, + PartitionKey, + SortKey, + TransactKey, +) + from ..conftest import HttpApiProxy, LambdaContext @@ -40,6 +47,93 @@ def test_get_orgs( def test_add_user( + app, + seeds, + http_api_proxy: HttpApiProxy, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/users', + method=HTTPMethod.POST, + body={ + 'user': { + 'name': 'Scott Weiland', + 'email': 'scott@stonetemplopilots.com', + 'cpf': '40245650016', + }, + 'org': { + 'id': 'f6000f79-6e5c-49a0-952f-3bda330ef278', + 'name': 'Branco do Brasil', + 'cnpj': '00000000000191', + }, + }, + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.CREATED + r = dynamodb_persistence_layer.collection.query( + PartitionKey('orgmembers#f6000f79-6e5c-49a0-952f-3bda330ef278') + ) + user_id = r['items'][0]['sk'] + user = dynamodb_persistence_layer.collection.get_items( + TransactKey(user_id) + + SortKey('0') + + SortKey('emails#scott@stonetemplopilots.com') + ) + assert user['name'] == 'Scott Weiland' + assert 'email' in user + assert 'email_verified' in user + assert 'created_at' in user + assert 'org_id' in user + assert 'emails#scott@stonetemplopilots.com' in user + + +def test_user_exists( + app, + seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/users', + method=HTTPMethod.POST, + body={ + 'user': { + 'name': 'Sérgio R Siqueira', + 'email': 'sergio@somosbeta.com.br', + 'cpf': '07879819908', + }, + 'org': { + 'id': '2a8963fc-4694-4fe2-953a-316d1b10f1f5', + 'name': 'pytest', + 'cnpj': '04978826000180', + }, + }, + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.NO_CONTENT + + r = dynamodb_persistence_layer.collection.query( + PartitionKey('orgmembers#2a8963fc-4694-4fe2-953a-316d1b10f1f5') + ) + user_id = r['items'][0]['sk'] + + user = dynamodb_persistence_layer.collection.get_items( + TransactKey(user_id) + + SortKey('0') + + SortKey('emails#sergio@somosbeta.com.br', rename_key='email') + + SortKey('orgs#2a8963fc-4694-4fe2-953a-316d1b10f1f5', rename_key='org') + ) + assert 'mx_record_exists' in user['email'] + assert 'cnpj' in user['org'] + + +def test_user_conflict( app, seeds, http_api_proxy: HttpApiProxy, @@ -57,14 +151,14 @@ def test_add_user( }, 'org': { 'id': 'f6000f79-6e5c-49a0-952f-3bda330ef278', - 'name': 'Branco do Brasil', + 'name': 'Banco do Brasil', 'cnpj': '00000000000191', }, }, ), lambda_context, ) - assert r['statusCode'] == HTTPStatus.CREATED + assert r['statusCode'] == HTTPStatus.CONFLICT def test_org_not_found( diff --git a/api.saladeaula.digital/tests/seeds.jsonl b/api.saladeaula.digital/tests/seeds.jsonl index 0e6c431..f1d8910 100644 --- a/api.saladeaula.digital/tests/seeds.jsonl +++ b/api.saladeaula.digital/tests/seeds.jsonl @@ -1,11 +1,10 @@ // Users {"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br", "cpf": "07879819908"} - -// User emails -{"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "emails#sergio@somosbeta.com.br"} +{"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "emails#sergio@somosbeta.com.br", "email_primary": true, "mx_record_exists": true} +{"id": "213a6682-2c59-4404-9189-12eec0a846d4", "sk": "orgs#f6000f79-6e5c-49a0-952f-3bda330ef278", "name": "Banco do Brasil", "cnpj": "00000000000191"} // User orgs -{"id": "213a6682-2c59-4404-9189-12eec0a846d4", "sk": "orgs#286f7729-7765-482a-880a-0b153ea799be", "name": "Banco do Brasil", "cnpj": "00000000000191"} +{"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "orgs#286f7729-7765-482a-880a-0b153ea799be", "name": "Banco do Brasil", "cnpj": "00000000000191"} // Enrollments {"id": "578ec87f-94c7-4840-8780-bb4839cc7e64", "sk": "0", "course": {"id": "af3258f0-bccf-4781-aec6-d4c618d234a7", "name": "pytest", "access_period": 180}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Benjamin Burnley", "email": "burnley@breakingbenjamin.com"}} @@ -14,9 +13,15 @@ {"id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5", "sk": "0", "name": "pytest", "cnpj": "04978826000180"} {"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "0", "name": "Banco do Brasil", "cnpj": "00000000000191"} +{"id": "orgmembers#f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "15bacf02-1535-4bee-9022-19d106fd7518"} + +// Indicies // CNPJs {"id": "cnpj", "sk": "04978826000180", "org_id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5"} {"id": "cnpj", "sk": "00000000000191", "org_id": "6000f79-6e5c-49a0-952f-3bda330ef278"} // CPFs -{"id": "cpf", "sk": "07879819908", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518""} \ No newline at end of file +{"id": "cpf", "sk": "07879819908", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"} + +// Emails +{"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"} \ No newline at end of file diff --git a/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx b/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx index 24207e6..8532500 100644 --- a/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx +++ b/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx @@ -4,6 +4,7 @@ import { BookCopyIcon, CalendarClockIcon, DollarSign, + FileBadgeIcon, GraduationCap, LayoutDashboard, ShieldUserIcon, @@ -53,6 +54,11 @@ const data = { url: '/enrollments', icon: GraduationCap }, + { + title: 'Certificações', + url: '/certs', + icon: FileBadgeIcon + }, { title: 'Agendamentos', url: '/scheduled', diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id/route.tsx index cb8d407..a60364c 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id/route.tsx @@ -59,8 +59,7 @@ export type User = { const links = [ { to: '', title: 'Perfil', end: true }, - { to: 'emails', title: 'Emails' }, - { to: 'orgs', title: 'Empresas' } + { to: 'emails', title: 'Emails' } ] export default function Route({ diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/route.tsx index a17ccfd..9b4a954 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/route.tsx @@ -79,16 +79,16 @@ export default function Route({ loaderData: { data } }) { defaultValue={searchParams.get('q') || ''} onChange={(value) => setSearchParams((searchParams) => { - searchParams.set('q', value) + searchParams.set('q', String(value)) searchParams.delete('p') return searchParams }) } /> -