diff --git a/api.saladeaula.digital/app/routes/enrollments/enroll.py b/api.saladeaula.digital/app/routes/enrollments/enroll.py index da60bea..73a962a 100644 --- a/api.saladeaula.digital/app/routes/enrollments/enroll.py +++ b/api.saladeaula.digital/app/routes/enrollments/enroll.py @@ -28,6 +28,7 @@ from config import ( ) from exceptions import ( ConflictError, + NotAcceptableError, OrderNotFoundError, SubscriptionConflictError, SubscriptionFrozenError, @@ -47,6 +48,9 @@ class DeduplicationConflictError(ConflictError): ... class SeatNotFoundError(NotFoundError): ... +class TestModeRequiredError(NotAcceptableError): ... + + class User(BaseModel): id: str | UUID4 name: NameStr @@ -91,6 +95,7 @@ def enroll( org_id: Annotated[str | UUID4, Body(embed=True)], enrollments: Annotated[tuple[Enrollment, ...], Body(embed=True)], subscription: Annotated[Subscription | None, Body(embed=True)] = None, + test_mode: Annotated[bool, Body(embed=True)] = False, ): now_ = now() created_by: Authenticated = router.context['user'] @@ -106,6 +111,7 @@ def enroll( 'org': Org.model_validate(org), 'created_by': created_by, 'subscription': subscription, + 'test_mode': test_mode, } immediate = [e for e in enrollments if not e.scheduled_for] @@ -125,12 +131,13 @@ def enroll( 'cause': r.cause, } + expires_after_days = 7 if test_mode else 30 * 3 item = { 'id': f'SUBMISSION#ORG#{org_id}', 'sk': now_, 'enrolled': list(map(fmt, now_out)) if now_out else None, 'scheduled': list(map(fmt, later_out)) if later_out else None, - 'ttl': ttl(start_dt=now_, days=30 * 3), + 'ttl': ttl(start_dt=now_, days=expires_after_days), 'created_by': { 'id': created_by.id, 'name': created_by.name, @@ -151,6 +158,7 @@ Context = TypedDict( 'org': Org, 'created_by': Authenticated, 'subscription': NotRequired[Subscription], + 'test_mode': NotRequired[bool], }, ) @@ -161,6 +169,7 @@ def enroll_now(enrollment: Enrollment, context: Context): course = enrollment.course seat = enrollment.seat org = context['org'] + test_mode = context.get('test_mode') subscription = context.get('subscription') created_by = context['created_by'] lock_hash = md5_hash(f'{user.id}{course.id}') @@ -194,7 +203,16 @@ def enroll_now(enrollment: Enrollment, context: Context): 'created_at': now_, } | ({'subscription_covered': True} if subscription else {}) + | ({'is_test': True} if test_mode else {}) ) + + if test_mode: + transact.condition( + key=KeyPair(str(org.id), 'METADATA#TEST_MODE'), + cond_expr='attribute_exists(sk)', + exc_cls=TestModeRequiredError, + ) + transact.put( item={ 'id': enrollment.id, @@ -344,6 +362,7 @@ def _enroll_later(enrollment: Enrollment, context: Context): scheduled_for = _date_to_midnight(enrollment.scheduled_for) # type: ignore dedup_window = enrollment.deduplication_window org = context['org'] + test_mode = context.get('test_mode') subscription = context.get('subscription') created_by = context['created_by'] lock_hash = md5_hash(f'{user.id}{course.id}') @@ -371,6 +390,7 @@ def _enroll_later(enrollment: Enrollment, context: Context): 'scheduled_at': now_, } | ({'seat': seat.model_dump()} if seat else {}) + | ({'is_test': True} if test_mode else {}) | ( {'dedup_window_offset_days': dedup_window.offset_days} if dedup_window @@ -385,6 +405,13 @@ def _enroll_later(enrollment: Enrollment, context: Context): ), ) + if test_mode: + transact.condition( + key=KeyPair(str(org.id), 'METADATA#TEST_MODE'), + cond_expr='attribute_exists(sk)', + exc_cls=TestModeRequiredError, + ) + if seat: transact.condition( key=KeyPair(str(seat.order_id), '0'), diff --git a/api.saladeaula.digital/app/routes/orders/checkout.py b/api.saladeaula.digital/app/routes/orders/checkout.py index 91c9f9b..1f071c3 100644 --- a/api.saladeaula.digital/app/routes/orders/checkout.py +++ b/api.saladeaula.digital/app/routes/orders/checkout.py @@ -277,6 +277,14 @@ def checkout(payload: Checkout): 'created_at': now_, } ) + transact.put( + item={ + 'id': order_id, + 'sk': 'SCHEDULED#AUTO_CLEANUP', + 'ttl': ttl(start_dt=now_, days=7), + 'created_at': now_, + } + ) return JSONResponse( body={'id': order_id}, diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx index 1dfee6d..ad8a551 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx @@ -18,6 +18,7 @@ import { Kbd } from '@repo/ui/components/ui/kbd' import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data' import { createSearch } from '@repo/util/meili' +import { workspaceContext } from '@/middleware/workspace' import { columns, type Enrollment } from './columns' export function meta({}: Route.MetaArgs) { @@ -26,6 +27,7 @@ export function meta({}: Route.MetaArgs) { export async function loader({ params, context, request }: Route.LoaderArgs) { const cloudflare = context.get(cloudflareContext) + const { test_mode } = context.get(workspaceContext) const { searchParams } = new URL(request.url) const { orgid } = params const query = searchParams.get('q') || '' @@ -36,7 +38,9 @@ export async function loader({ params, context, request }: Route.LoaderArgs) { const page = Number(searchParams.get('p')) + 1 const hitsPerPage = Number(searchParams.get('perPage')) || 25 - let builder = new MeiliSearchFilterBuilder().where('org_id', '=', orgid) + let builder = new MeiliSearchFilterBuilder() + .where('org_id', '=', orgid) + .where('is_test', 'exists', test_mode) if (status) { builder = builder.where('status', 'in', status.split(',')) diff --git a/apps/insights.saladeaula.digital/app/routes/_app.enrollments._index/route.tsx b/apps/insights.saladeaula.digital/app/routes/_app.enrollments._index/route.tsx index aa2d6e1..40e64f9 100644 --- a/apps/insights.saladeaula.digital/app/routes/_app.enrollments._index/route.tsx +++ b/apps/insights.saladeaula.digital/app/routes/_app.enrollments._index/route.tsx @@ -1,36 +1,36 @@ import type { Route } from './+types/route' -import { pick } from 'ramda' +import { formatCNPJ } from '@brazilian-utils/brazilian-utils' import { - CalendarIcon, - PlusCircleIcon, BuildingIcon, - CheckIcon + CalendarIcon, + CheckIcon, + PlusCircleIcon } from 'lucide-react' import { MeiliSearchFilterBuilder } from 'meilisearch-helper' +import { pick } from 'ramda' import { Suspense, useState } from 'react' import { Await, Outlet, useSearchParams } from 'react-router' -import { formatCNPJ } from '@brazilian-utils/brazilian-utils' import { cloudflareContext } from '@repo/auth/context' +import { Abbr } from '@repo/ui/components/abbr' import { DataTable, DataTableViewOptions } from '@repo/ui/components/data-table' +import { ExportMenu } from '@repo/ui/components/export-menu' import { FacetedFilter } from '@repo/ui/components/faceted-filter' import { RangeCalendarFilter } from '@repo/ui/components/range-calendar-filter' -import { SearchForm } from '@repo/ui/components/search-form' import { SearchFilter } from '@repo/ui/components/search-filter' +import { SearchForm } from '@repo/ui/components/search-form' import { Skeleton } from '@repo/ui/components/skeleton' -import { Kbd } from '@repo/ui/components/ui/kbd' -import { ExportMenu } from '@repo/ui/components/export-menu' -import { createSearch } from '@repo/util/meili' -import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data' -import { cn, initials } from '@repo/ui/lib/utils' -import { CommandItem } from '@repo/ui/components/ui/command' -import { Button } from '@repo/ui/components/ui/button' -import { Separator } from '@repo/ui/components/ui/separator' -import { Badge } from '@repo/ui/components/ui/badge' -import { Abbr } from '@repo/ui/components/abbr' -import { Spinner } from '@repo/ui/components/ui/spinner' import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' +import { Badge } from '@repo/ui/components/ui/badge' +import { Button } from '@repo/ui/components/ui/button' +import { CommandItem } from '@repo/ui/components/ui/command' +import { Kbd } from '@repo/ui/components/ui/kbd' +import { Separator } from '@repo/ui/components/ui/separator' +import { Spinner } from '@repo/ui/components/ui/spinner' +import { cn, initials } from '@repo/ui/lib/utils' +import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data' +import { createSearch } from '@repo/util/meili' import { columns, type Enrollment } from './columns' @@ -50,7 +50,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { const page = Number(searchParams.get('p')) + 1 const hitsPerPage = Number(searchParams.get('perPage')) || 25 - let builder = new MeiliSearchFilterBuilder() + let builder = new MeiliSearchFilterBuilder().where('is_test', 'exists', false) if (status) { builder = builder.where('status', 'in', status.split(',')) diff --git a/orders-events/app/events/billing/send_email_on_closing.py b/orders-events/app/events/billing/send_email_on_closing.py index 782a9c7..a15fa67 100644 --- a/orders-events/app/events/billing/send_email_on_closing.py +++ b/orders-events/app/events/billing/send_email_on_closing.py @@ -43,7 +43,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: new_image = event.detail['new_image'] # Key pattern `BILLING#ORG#{org_id}` *_, org_id = new_image['id'].split('#') - # Key pattern `START#{start_period}#END#{v} + # Key pattern `START#{start_period}#END#{end_period} _, start_period, _, end_period, *_ = new_image['sk'].split('#') emailmsg = Message( diff --git a/orders-events/app/events/payments/create_invoice.py b/orders-events/app/events/payments/create_invoice.py index 9668f85..056d80f 100644 --- a/orders-events/app/events/payments/create_invoice.py +++ b/orders-events/app/events/payments/create_invoice.py @@ -102,16 +102,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: transact.delete( key=KeyPair(order_id, 'CREDIT_CARD#PAYMENT_INTENT'), ) - - if test_mode: - transact.put( - item={ - 'id': order_id, - 'sk': 'SCHEDULED#SELF_DESTRUCTION', - 'ttl': ttl(start_dt=now_, days=7), - 'created_at': now_, - } - ) except Exception: pass diff --git a/orders-events/app/events/run_self_destruction.py b/orders-events/app/events/run_auto_cleanup.py similarity index 100% rename from orders-events/app/events/run_self_destruction.py rename to orders-events/app/events/run_auto_cleanup.py diff --git a/orders-events/app/events/stopgap/__init__.py b/orders-events/app/events/stopgap/__init__.py deleted file mode 100644 index 5a77fb0..0000000 --- a/orders-events/app/events/stopgap/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Stopgap events. Everything here is a quick fix and should be replaced with -proper solutions. -""" diff --git a/orders-events/app/events/stopgap/remove_slots.py b/orders-events/app/events/stopgap/remove_slots.py deleted file mode 100644 index 35d87e7..0000000 --- a/orders-events/app/events/stopgap/remove_slots.py +++ /dev/null @@ -1,71 +0,0 @@ -from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.data_classes import ( - EventBridgeEvent, - event_source, -) -from aws_lambda_powertools.utilities.typing import LambdaContext -from layercake.dynamodb import ( - DynamoDBPersistenceLayer, - KeyPair, -) - -from boto3clients import dynamodb_client -from config import ENROLLMENT_TABLE, ORDER_TABLE, USER_TABLE - -logger = Logger(__name__) -user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) -order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) -enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) - - -@event_source(data_class=EventBridgeEvent) -@logger.inject_lambda_context -def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: - """Remove slots if the tenant has a `metadata#billing_policy` and - the total is greater than zero.""" - new_image = event.detail['new_image'] - data = order_layer.get_item(KeyPair(new_image['id'], '0')) - org_id = data.get('tenant_id') - - if not org_id: - return False - - policy = user_layer.collection.get_item( - KeyPair(pk=org_id, sk='metadata#billing_policy'), - raise_on_error=False, - default=False, - ) - - # Skip if billing policy is missing or order is less than or equal to zero - if not policy or data['total'] <= 0: - logger.info('Missing billing policy') - return False - - logger.info(f'Billing policy from Org ID "{org_id}" found', policy=policy) - - result = enrollment_layer.collection.query( - KeyPair( - f'vacancies#{org_id}', - new_image['id'], - ), - limit=150, - ) - - logger.info( - 'Slots found', - total_items=len(result['items']), - slots=result['items'], - ) - - with enrollment_layer.batch_writer() as batch: - for pair in result['items']: - batch.delete_item( - Key={ - 'id': {'S': pair['id']}, - 'sk': {'S': pair['sk']}, - } - ) - - logger.info('Deleted all slots') - - return True diff --git a/orders-events/app/events/stopgap/set_as_paid.py b/orders-events/app/events/stopgap/set_as_paid.py deleted file mode 100644 index 605ec67..0000000 --- a/orders-events/app/events/stopgap/set_as_paid.py +++ /dev/null @@ -1,47 +0,0 @@ -from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.data_classes import ( - EventBridgeEvent, - event_source, -) -from aws_lambda_powertools.utilities.typing import LambdaContext -from layercake.dateutils import now -from layercake.dynamodb import ( - DynamoDBPersistenceLayer, - KeyPair, -) - -from boto3clients import dynamodb_client -from config import ORDER_TABLE - -logger = Logger(__name__) -order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) - - -@event_source(data_class=EventBridgeEvent) -@logger.inject_lambda_context -def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: - """Set to `PAID` if the status is `PENDING` and the total is zero.""" - new_image = event.detail['new_image'] - now_ = now() - - with order_layer.transact_writer() as transact: - transact.update( - key=KeyPair(new_image['id'], '0'), - update_expr='SET #status = :status, updated_at = :updated_at', - expr_attr_names={ - '#status': 'status', - }, - expr_attr_values={ - ':status': 'PAID', - ':updated_at': now_, - }, - ) - transact.put( - item={ - 'id': new_image['id'], - 'sk': 'paid_at', - 'paid_at': now_, - } - ) - - return True diff --git a/orders-events/template.yaml b/orders-events/template.yaml index 1f86e4a..abed0b4 100644 --- a/orders-events/template.yaml +++ b/orders-events/template.yaml @@ -280,10 +280,10 @@ Resources: org_id: - exists: true - EventRunSelfDestructionFunction: + EventRunAutoCleanupFunction: Type: AWS::Serverless::Function Properties: - Handler: events.run_self_destruction.lambda_handler + Handler: events.run_auto_cleanup.lambda_handler Timeout: 30 LoggingConfig: LogGroup: !Ref EventLog @@ -299,7 +299,9 @@ Resources: detail-type: [EXPIRE] detail: keys: - sk: ['SCHEDULED#SELF_DESTRUCTION'] + sk: + - SCHEDULED#AUTO_CLEANUP + - SCHEDULED#SELF_DESTRUCTION # DEPRECATED EventAppendOrgIdFunction: @@ -325,7 +327,6 @@ Resources: sk: ['0'] cnpj: - exists: true - # Post-migration: rename `tenant_id` to `org_id` tenant_id: - exists: false @@ -382,55 +383,6 @@ Resources: - exists: true status: [CANCELED, EXPIRED] - EventStopgapSetAsPaidFunction: - Type: AWS::Serverless::Function - Properties: - Handler: events.stopgap.set_as_paid.lambda_handler - LoggingConfig: - LogGroup: !Ref EventLog - Policies: - - DynamoDBWritePolicy: - TableName: !Ref OrderTable - Events: - Event: - Type: EventBridgeRule - Properties: - Pattern: - resources: [!Ref OrderTable] - detail-type: [INSERT] - detail: - new_image: - sk: ['0'] - cnpj: - - exists: true - total: [0] - status: [CREATING, PENDING] - payment_method: [MANUAL] - - EventStopgapRemoveSlotsFunction: - Type: AWS::Serverless::Function - Properties: - Handler: events.stopgap.remove_slots.lambda_handler - LoggingConfig: - LogGroup: !Ref EventLog - Policies: - - DynamoDBReadPolicy: - TableName: !Ref UserTable - - DynamoDBReadPolicy: - TableName: !Ref OrderTable - - DynamoDBCrudPolicy: - TableName: !Ref EnrollmentTable - Events: - DynamoDBEvent: - Type: EventBridgeRule - Properties: - Pattern: - resources: [!Ref OrderTable] - detail: - new_image: - sk: [generated_items] - status: [SUCCESS] - Outputs: HttpApiUrl: Description: URL of your API endpoint diff --git a/orders-events/tests/events/stopgap/__init__.py b/orders-events/tests/events/stopgap/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/orders-events/tests/events/stopgap/test_remove_slots.py b/orders-events/tests/events/stopgap/test_remove_slots.py deleted file mode 100644 index 28b18d2..0000000 --- a/orders-events/tests/events/stopgap/test_remove_slots.py +++ /dev/null @@ -1,30 +0,0 @@ -from layercake.dynamodb import PartitionKey - -import events.stopgap.remove_slots as app - -from ...conftest import LambdaContext - - -def test_remove_slots( - dynamodb_seeds, - dynamodb_persistence_layer, - lambda_context: LambdaContext, -): - event = { - 'detail': { - 'new_image': { - 'id': '9omWNKymwU5U4aeun6mWzZ', - 'sk': 'generated_items', - 'create_date': '2024-07-23T20:43:37.303418-03:00', - 'status': 'SUCCESS', - 'scope': 'MILTI_USER', - } - }, - } - assert app.lambda_handler(event, lambda_context) # type: ignore - - result = dynamodb_persistence_layer.collection.query( - PartitionKey('vacancies#cJtK9SsnJhKPyxESe7g3DG') - ) - - assert len(result['items']) == 0 diff --git a/orders-events/tests/events/stopgap/test_set_as_paid.py b/orders-events/tests/events/stopgap/test_set_as_paid.py deleted file mode 100644 index db8eaee..0000000 --- a/orders-events/tests/events/stopgap/test_set_as_paid.py +++ /dev/null @@ -1,24 +0,0 @@ -from aws_lambda_powertools.utilities.typing import LambdaContext -from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair - -import events.stopgap.set_as_paid as app - - -def test_set_as_paid( - dynamodb_seeds, - dynamodb_persistence_layer: DynamoDBPersistenceLayer, - lambda_context: LambdaContext, -): - event = { - 'detail': { - 'new_image': { - 'id': '9omWNKymwU5U4aeun6mWzZ', - } - } - } - assert app.lambda_handler(event, lambda_context) # type: ignore - - doc = dynamodb_persistence_layer.get_item( - key=KeyPair('9omWNKymwU5U4aeun6mWzZ', '0'), - ) - assert doc['status'] == 'PAID'