From 2467798855ace784853a6f5fc70eeaa46b997620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Thu, 27 Nov 2025 20:41:29 -0300 Subject: [PATCH] add notification --- .../app/routes/orgs/enrollments/scheduled.py | 7 +- .../app/routes/orgs/users/__init__.py | 19 +- .../app/routes/users/emails.py | 59 ++++-- .../app/components/app-sidebar.tsx | 8 +- .../app/components/notification.tsx | 27 +++ .../_.$orgid.enrollments._index/columns.tsx | 60 +++--- .../app/routes/_.$orgid/route.tsx | 2 + apps/saladeaula.digital/app/routes.ts | 3 +- apps/saladeaula.digital/app/routes/index.tsx | 49 ++++- apps/saladeaula.digital/app/routes/layout.tsx | 18 +- .../app/routes/settings/emails.tsx | 173 ++++++++++++++++-- packages/ui/src/components/dark-mode.tsx | 4 +- packages/ui/src/components/nav-user.tsx | 10 +- users-events/app/boto3clients.py | 1 + users-events/app/config.py | 2 + .../app/events/batch/chunks_into_users.py | 16 +- .../app/events/send_verification_email.py | 61 ++++++ users-events/app/events/send_welcome_email.py | 61 ++++++ users-events/template.yaml | 60 ++++++ 19 files changed, 560 insertions(+), 80 deletions(-) create mode 100644 apps/admin.saladeaula.digital/app/components/notification.tsx create mode 100644 users-events/app/events/send_verification_email.py create mode 100644 users-events/app/events/send_welcome_email.py diff --git a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py index 43d8828..7f92185 100644 --- a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py +++ b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py @@ -1,5 +1,8 @@ +from typing import Annotated + from aws_lambda_powertools import Logger from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.openapi.params import Query from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey from boto3clients import dynamodb_client @@ -11,9 +14,7 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) @router.get('//enrollments/scheduled') -def scheduled(org_id: str): - start_key = router.current_event.get_query_string_value('start_key', None) - +def scheduled(org_id: str, start_key: Annotated[str | None, Query] = None): return dyn.collection.query( # Post-migration: rename `scheduled_items` to `SCHEDULED#ORG#{org_id}` key=PartitionKey(f'scheduled_items#{org_id}'), diff --git a/api.saladeaula.digital/app/routes/orgs/users/__init__.py b/api.saladeaula.digital/app/routes/orgs/users/__init__.py index 0c29d32..265c882 100644 --- a/api.saladeaula.digital/app/routes/orgs/users/__init__.py +++ b/api.saladeaula.digital/app/routes/orgs/users/__init__.py @@ -8,7 +8,7 @@ from aws_lambda_powertools.event_handler.exceptions import ( ServiceError, ) from aws_lambda_powertools.event_handler.openapi.params import Body -from layercake.dateutils import now +from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey from layercake.extra_types import CnpjStr, CpfStr, NameStr from pydantic import BaseModel, EmailStr, Field @@ -126,9 +126,26 @@ def _create_user(user: User, org: Org) -> bool: 'sk': f'emails#{user.email}', 'email_verified': email_verified, 'email_primary': True, + 'mx_record_exists': email_verified, 'created_at': now_, } ) + + if not email_verified: + transact.put( + item={ + 'id': user_id, + 'sk': f'EMAIL_VERIFICATION#{uuid4()}', + 'fresh_user': True, + 'name': user.name, + 'email': user.email, + 'email_primary': True, + 'org_name': org.name, + 'ttl': ttl(start_dt=now_, days=30), + 'created_at': now_, + } + ) + transact.put( item={ # Post-migration: rename `cpf` to `CPF` diff --git a/api.saladeaula.digital/app/routes/users/emails.py b/api.saladeaula.digital/app/routes/users/emails.py index 38a3e88..3d77b64 100644 --- a/api.saladeaula.digital/app/routes/users/emails.py +++ b/api.saladeaula.digital/app/routes/users/emails.py @@ -9,6 +9,7 @@ from aws_lambda_powertools.event_handler.exceptions import ( from aws_lambda_powertools.event_handler.openapi.params import Body, Path, Query from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey +from layercake.funcs import pick from pydantic import EmailStr from typing_extensions import Annotated @@ -29,6 +30,9 @@ def get_emails(user_id: str, start_key: Annotated[str | None, Query] = None): ) +class UserNotFoundError(NotFoundError): ... + + class EmailConflictError(ServiceError): def __init__(self, msg: str | dict): super().__init__(HTTPStatus.CONFLICT, msg) @@ -46,13 +50,19 @@ def add( ) with dyn.transact_writer() as transact: + transact.condition( + key=KeyPair(user_id, '0'), + cond_expr='attribute_exists(sk)', + exc_cls=UserNotFoundError, + ) transact.put( item={ 'id': user_id, # Post-migration (users): rename `emails` to `EMAIL` 'sk': f'emails#{email}', 'email_verified': False, - 'email_primary': True, + 'mx_record_exists': False, + 'email_primary': False, 'created_at': now_, } ) @@ -82,10 +92,16 @@ def add( @router.post('//emails//request-verification') -def request_verification(user_id: str, email: Annotated[EmailStr, Path]): +def request_verification( + user_id: str, + email: Annotated[EmailStr, Path], +): now_ = now() name = dyn.collection.get_item( - KeyPair(user_id, SortKey('0', path_spec='name')), + KeyPair( + pk=user_id, + sk=SortKey('0', path_spec='name'), + ), raise_on_error=False, ) dyn.put_item( @@ -107,13 +123,14 @@ class EmailVerificationNotFoundError(NotFoundError): ... @router.post('//emails//verify') def verify(user_id: str, hash: str): - email = dyn.collection.get_item( + verification = dyn.collection.get_item( KeyPair( pk=user_id, - sk=SortKey(f'EMAIL_VERIFICATION#{hash}', path_spec='email'), + sk=f'EMAIL_VERIFICATION#{hash}', ), exc_cls=EmailVerificationNotFoundError, ) + email, primary = pick(('email', 'email_primary'), verification, default=False) with dyn.transact_writer() as transact: transact.delete( @@ -129,6 +146,20 @@ def verify(user_id: str, hash: str): }, ) + if primary: + transact.update( + key=KeyPair(user_id, '0'), + update_expr='SET email_verified = :true, \ + updated_at = :now', + expr_attr_values={ + ':email': email, + ':true': True, + ':now': now(), + }, + cond_expr='attribute_exists(sk)', + exc_cls=UserNotFoundError, + ) + return JSONResponse(status_code=HTTPStatus.NO_CONTENT) @@ -140,7 +171,7 @@ def primary( email_verified: Annotated[bool, Body(embed=True)], ): now_ = now() - expr = 'SET email_primary = :email_primary, updated_at = :updated_at' + expr = 'SET email_primary = :email_primary, updated_at = :now' with dyn.transact_writer() as transact: # Set the old email as non-primary @@ -150,7 +181,7 @@ def primary( update_expr=expr, expr_attr_values={ ':email_primary': False, - ':updated_at': now_, + ':now': now_, }, cond_expr='attribute_exists(sk)', ) @@ -161,7 +192,7 @@ def primary( update_expr=expr, expr_attr_values={ ':email_primary': True, - ':updated_at': now_, + ':now': now_, }, cond_expr='attribute_exists(sk)', ) @@ -170,13 +201,15 @@ def primary( update_expr='DELETE emails :email_set \ SET email = :email, \ email_verified = :email_verified, \ - updated_at = :updated_at', + updated_at = :now', expr_attr_values={ ':email': new_email, ':email_set': {new_email}, ':email_verified': email_verified, - ':updated_at': now_, + ':now': now_, }, + cond_expr='attribute_exists(sk)', + exc_cls=UserNotFoundError, ) return JSONResponse(status_code=HTTPStatus.NO_CONTENT) @@ -204,10 +237,14 @@ def remove( ) transact.update( key=KeyPair(user_id, '0'), - update_expr='DELETE emails :email', + update_expr='DELETE emails :email \ + SET updated_at = :now', expr_attr_values={ ':email': {email}, + ':now': now(), }, + cond_expr='attribute_exists(sk)', + exc_cls=UserNotFoundError, ) return JSONResponse(status_code=HTTPStatus.NO_CONTENT) diff --git a/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx b/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx index 09c8aa0..e91bcbb 100644 --- a/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx +++ b/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx @@ -3,10 +3,10 @@ import { BookCopyIcon, CalendarClockIcon, - DollarSign, + DollarSignIcon, FileBadgeIcon, GraduationCap, - LayoutDashboard, + LayoutDashboardIcon, ShieldUserIcon, UploadIcon, UsersIcon @@ -26,12 +26,12 @@ const data = { { title: 'Visão geral', url: '/main', - icon: LayoutDashboard + icon: LayoutDashboardIcon }, { title: 'Histórico de compras', url: '/orders', - icon: DollarSign + icon: DollarSignIcon } ], navUser: [ diff --git a/apps/admin.saladeaula.digital/app/components/notification.tsx b/apps/admin.saladeaula.digital/app/components/notification.tsx new file mode 100644 index 0000000..92e9dcb --- /dev/null +++ b/apps/admin.saladeaula.digital/app/components/notification.tsx @@ -0,0 +1,27 @@ +import { BellIcon } from 'lucide-react' + +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@repo/ui/components/ui/popover' +import { Button } from '@repo/ui/components/ui/button' + +export function Notification() { + return ( + + + + + + <>Notificações + + + ) +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx index f6686dd..d0de34d 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx @@ -26,7 +26,6 @@ import { AlertDialogTitle, AlertDialogTrigger } from '@repo/ui/components/ui/alert-dialog' - import { Button } from '@repo/ui/components/ui/button' import { DropdownMenu, @@ -58,24 +57,24 @@ export const columns: ColumnDef[] = [ } ] -async function getEnrollment(id: string) { - const r = await fetch(`/~/api/enrollments/${id}`, { - method: 'GET' - }) - await new Promise((r) => setTimeout(r, 150)) - - return (await r.json()) as { - cancel_policy?: any - lock?: { hash: string } - } -} - function ActionMenu({ row }: { row: any }) { const [open, { set: setOpen }] = useToggle(false) const cert = row.original?.cert - const { data, loading, run, refresh } = useRequest(getEnrollment, { - manual: true - }) + const { data, loading, runAsync, refresh } = useRequest( + async () => { + const r = await fetch(`/~/api/enrollments/${row.id}`, { + method: 'GET' + }) + + return (await r.json()) as { + cancel_policy?: any + lock?: { hash: string } + } + }, + { + manual: true + } + ) const onSuccess = () => { refresh() @@ -90,7 +89,7 @@ function ActionMenu({ row }: { row: any }) { onOpenChange={(open) => { setOpen(open) if (data) return - run(row.id) + runAsync() }} > @@ -140,22 +139,27 @@ type ItemProps = ComponentProps & { } function DownloadItem({ id, onSuccess, ...props }: ItemProps) { - const [loading, { set }] = useToggle(false) + const { runAsync, loading } = useRequest( + async () => { + return await fetch(`/~/api/enrollments/${id}/download`) + }, + { + manual: true + } + ) const download = async (e: Event) => { e.preventDefault() - set(true) - const r = await fetch(`/~/api/enrollments/${id}/download`, { - method: 'GET' - }) - if (r.ok) { - const { presigned_url } = (await r.json()) as { presigned_url: string } + try { + const r = await runAsync() + const { presigned_url } = (await r.json()) as { + presigned_url: string + } + window.open(presigned_url, '_blank') - - set(false) onSuccess?.() - } + } catch {} } return ( @@ -299,7 +303,7 @@ function CancelItem({ Esta ação não pode ser desfeita. Isso{' '} - cancelar permanentemente a matrícula + cancela permanentemente a matrícula {' '} deste colaborador. diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx index f657d84..0cca3e7 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx @@ -20,6 +20,7 @@ import { Toaster } from '@repo/ui/components/ui/sonner' import { request as req } from '@repo/util/request' import { AppSidebar } from '@/components/app-sidebar' +import { Notification } from '@/components/notification' export const middleware: Route.MiddlewareFunction[] = [authMiddleware] @@ -78,6 +79,7 @@ export default function Route({ loaderData }: Route.ComponentProps) {
+
diff --git a/apps/saladeaula.digital/app/routes.ts b/apps/saladeaula.digital/app/routes.ts index 86a97b4..5aba7db 100644 --- a/apps/saladeaula.digital/app/routes.ts +++ b/apps/saladeaula.digital/app/routes.ts @@ -17,7 +17,8 @@ export default [ ]), route('konviva', 'routes/konviva.ts'), route('player/:id', 'routes/player.tsx'), - route('proxy/*', 'routes/proxy.tsx') + route('proxy/*', 'routes/proxy.tsx'), + route('api/*', 'routes/api.ts') ]), route('logout', 'routes/auth/logout.ts'), route('login', 'routes/auth/login.ts') diff --git a/apps/saladeaula.digital/app/routes/index.tsx b/apps/saladeaula.digital/app/routes/index.tsx index cd3c80e..2fdcf96 100644 --- a/apps/saladeaula.digital/app/routes/index.tsx +++ b/apps/saladeaula.digital/app/routes/index.tsx @@ -1,12 +1,5 @@ import type { Route } from './+types' -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle -} from '@repo/ui/components/ui/empty' import Fuse from 'fuse.js' import { BanIcon, @@ -15,6 +8,8 @@ import { CircleOffIcon, CirclePlusIcon, CircleXIcon, + EllipsisIcon, + FileBadgeIcon, TimerIcon, type LucideIcon } from 'lucide-react' @@ -27,12 +22,28 @@ import { userContext } from '@repo/auth/context' import { FacetedFilter } from '@repo/ui/components/faceted-filter' import { SearchForm } from '@repo/ui/components/search-form' import { Skeleton } from '@repo/ui/components/skeleton' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@repo/ui/components/ui/dropdown-menu' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from '@repo/ui/components/ui/empty' import { Card, CardContent, CardFooter, CardHeader, - CardTitle + CardTitle, + CardAction } from '@repo/ui/components/ui/card' import { Kbd } from '@repo/ui/components/ui/kbd' import { Progress } from '@repo/ui/components/ui/progress' @@ -40,6 +51,7 @@ import { createSearch } from '@repo/util/meili' import placeholder from '@/assets/placeholder.webp' import { Container } from '@/components/container' +import { Button } from '@repo/ui/components/ui/button' type Course = { name: string @@ -200,8 +212,10 @@ function Enrollment({ id, course, progress }: Enrollment) { {course.name} + + + - {progress}% @@ -216,6 +230,23 @@ function Enrollment({ id, course, progress }: Enrollment) { ) } +function ActionMenu() { + return ( + + + + + + + Baixar certificado + + + + ) +} + const statuses: Record< string, { icon: LucideIcon; color?: string; label: string } diff --git a/apps/saladeaula.digital/app/routes/layout.tsx b/apps/saladeaula.digital/app/routes/layout.tsx index 8684cf3..3d282b8 100644 --- a/apps/saladeaula.digital/app/routes/layout.tsx +++ b/apps/saladeaula.digital/app/routes/layout.tsx @@ -1,12 +1,12 @@ -import type { Route } from './+types' +import type { Route } from './+types/layout' import { useToggle } from 'ahooks' import { MenuIcon } from 'lucide-react' import { Link, NavLink, Outlet } from 'react-router' +import { Toaster } from '@repo/ui/components/ui/sonner' import { userContext } from '@repo/auth/context' import { authMiddleware } from '@repo/auth/middleware/auth' - import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode' import { NavUser } from '@repo/ui/components/nav-user' import { Button } from '@repo/ui/components/ui/button' @@ -22,11 +22,12 @@ import { SheetTitle, SheetTrigger } from '@repo/ui/components/ui/sheet' +import type { User } from '@repo/auth/auth' export const middleware: Route.MiddlewareFunction[] = [authMiddleware] export async function loader({ context }: Route.ActionArgs) { - const user = context.get(userContext) + const user = context.get(userContext) as User return Response.json({ user }) } @@ -45,8 +46,9 @@ const navMain = [ } ] -export default function Component({ loaderData }: Route.ComponentProps) { - const { user } = loaderData +export default function Component({ + loaderData: { user } +}: Route.ComponentProps) { const [isOpen, { toggle }] = useToggle() return ( @@ -119,6 +121,12 @@ export default function Component({ loaderData }: Route.ComponentProps) { + ) } diff --git a/apps/saladeaula.digital/app/routes/settings/emails.tsx b/apps/saladeaula.digital/app/routes/settings/emails.tsx index 27ff16f..8e8748e 100644 --- a/apps/saladeaula.digital/app/routes/settings/emails.tsx +++ b/apps/saladeaula.digital/app/routes/settings/emails.tsx @@ -1,8 +1,19 @@ import type { Route } from './+types/emails' -import { Suspense } from 'react' -import { Await } from 'react-router' +import { + Suspense, + type ComponentProps, + type MouseEvent, + createContext, + use +} from 'react' +import { Await } from 'react-router' +import { EllipsisIcon, CircleXIcon, SendIcon } from 'lucide-react' +import { useRequest, useToggle } from 'ahooks' +import { toast } from 'sonner' + +import { Spinner } from '@repo/ui/components/ui/spinner' import { userContext } from '@repo/auth/context' import { Card, @@ -11,17 +22,53 @@ import { CardHeader, CardTitle } from '@repo/ui/components/ui/card' -import type { User } from '@repo/auth/auth' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@repo/ui/components/ui/alert-dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@repo/ui/components/ui/dropdown-menu' import { request as req } from '@repo/util/request' import { Skeleton } from '@repo/ui/components/skeleton' +import { Button } from '@repo/ui/components/ui/button' +import { useOutletContext } from 'react-router' +import type { User as AuthUser } from '@repo/auth/auth' +import type { User } from '@repo/ui/routes/users/data' +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle +} from '@repo/ui/components/ui/item' + +type Email = { + sk: string + email: string + email_verified: boolean + email_primary: boolean +} + +const ActionMenuContext = createContext(null) export async function loader({ request, context }: Route.LoaderArgs) { - const user = context.get(userContext) as User + const user = context.get(userContext) as AuthUser const data = req({ url: `/users/${user.sub}/emails`, request, context - }).then((r) => r.json()) + }).then((r) => r.json() as Promise<{ items: Email[] }>) return { data } } @@ -40,13 +87,24 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) { principal receberá as mensagens. - -
    - {items.map(({ sk }: { sk: string }, idx: number) => { - const [, email] = sk.split('#') - return
  • {email}
  • - })} -
+ + {items.map(({ sk, ...props }) => { + const [, email] = sk.split('#') as [string, string] + + return ( + + + {email} + ... + + + + + + + + ) + })}
)} @@ -54,3 +112,94 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) { ) } + +function ActionMenu() { + return ( + + + + + + + Reenviar email de verificação + + + + + ) +} + +type ItemProps = ComponentProps & { + onSuccess?: () => void +} + +function RemoveItem({ onSuccess, ...props }: ItemProps) { + const { user } = useOutletContext() as { user: User } + const { email } = use(ActionMenuContext) as Email + const [open, { set: setOpen }] = useToggle(false) + const { runAsync, loading } = useRequest( + async () => { + const r = await fetch(`/api/users/${user.id}/emails/${email}`, { + method: 'DELETE', + headers: new Headers({ 'Content-Type': 'application/json' }) + }) + + if (!r.ok) { + throw await r.json() + } + }, + { + manual: true + } + ) + + const cancel = async (e: MouseEvent) => { + e.preventDefault() + try { + await runAsync() + + toast.success('O email foi removido') + onSuccess?.() + setOpen(false) + } catch (err) { + // @ts-ignore + if (err?.type === 'EmailConflictError') { + toast.error('O email não pode ser removido') + } + } + } + + return ( + + + e.preventDefault()} + {...props} + > + Remover + + + + + Tem certeza absoluta? + + Esta ação não pode ser desfeita. Isso{' '} + remove permanentemente o seu + endereço de email. + + + + + + + Cancelar + + + + ) +} diff --git a/packages/ui/src/components/dark-mode.tsx b/packages/ui/src/components/dark-mode.tsx index 53a1b14..80d4235 100644 --- a/packages/ui/src/components/dark-mode.tsx +++ b/packages/ui/src/components/dark-mode.tsx @@ -24,8 +24,8 @@ export function ModeToggle() { size="icon" className="cursor-pointer text-muted-foreground" > - - + + Alternar tema
diff --git a/packages/ui/src/components/nav-user.tsx b/packages/ui/src/components/nav-user.tsx index 1df9ce3..57b90b6 100644 --- a/packages/ui/src/components/nav-user.tsx +++ b/packages/ui/src/components/nav-user.tsx @@ -1,6 +1,7 @@ 'use client' import { + ChevronDown, CirclePlayIcon, DollarSignIcon, GraduationCapIcon, @@ -71,9 +72,12 @@ export function NavUser({ return ( - - {initials(user.name)} - +
+ + {initials(user.name)} + + +
None: 'sk': f'emails#{user.email}', 'email_verified': False, 'email_primary': True, + 'mx_record_exists': False, + 'created_at': now_, + } + ) + transact.put( + item={ + 'id': user_id, + 'sk': f'EMAIL_VERIFICATION#{uuid4()}', + 'fresh_user': True, + 'name': user.name, + 'email': user.email, + 'email_primary': True, + 'org_name': org.name, + 'ttl': ttl(start_dt=now_, days=30), 'created_at': now_, } ) diff --git a/users-events/app/events/send_verification_email.py b/users-events/app/events/send_verification_email.py new file mode 100644 index 0000000..a5b9252 --- /dev/null +++ b/users-events/app/events/send_verification_email.py @@ -0,0 +1,61 @@ +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.email_ import Message +from layercake.strutils import first_word + +from boto3clients import sesv2_client +from config import EMAIL_SENDER + +SUBJECT = 'Por favor, verifique seu endereço de email na EDUSEG®' +MESSAGE = """ +Oi {first_name}, tudo bem?

+ +Para proteger sua conta na EDUSEG, precisamos apenas verificar seu +endereço de email: {email}.

+ + +👉 Verificar endereço de email + +""" + +logger = Logger(__name__) + + +@event_source(data_class=EventBridgeEvent) +def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + new_image = event.detail['new_image'] + first_name = first_word(new_image['name']) + # Key pattern `EMAIL_VERIFICATION#{hash}` + *_, hash = new_image['sk'].split('#') + + emailmsg = Message( + from_=EMAIL_SENDER, + to=(new_image['name'], new_image['email']), + subject=SUBJECT, + ) + emailmsg.add_alternative( + MESSAGE.format( + first_name=first_name, + email=new_image['email'], + code=hash, + ) + ) + + try: + sesv2_client.send_email( + Content={ + 'Raw': { + 'Data': emailmsg.as_bytes(), + }, + } + ) + logger.info('Email sent') + except Exception as exc: + logger.exception(exc) + return False + else: + return True diff --git a/users-events/app/events/send_welcome_email.py b/users-events/app/events/send_welcome_email.py new file mode 100644 index 0000000..fce2eb4 --- /dev/null +++ b/users-events/app/events/send_welcome_email.py @@ -0,0 +1,61 @@ +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.email_ import Message +from layercake.strutils import first_word + +from boto3clients import sesv2_client +from config import EMAIL_SENDER + +SUBJECT = '{first_name} você foi cadastrado na EDUSEG®' +MESSAGE = """ +Oi {first_name}, tudo bem?

+ +Sua conta foi criada na EDUSEG pela empresa {org_name}.

+ + +👉 Faça agora seu primeiro acesso + +""" + +logger = Logger(__name__) + + +@event_source(data_class=EventBridgeEvent) +def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + new_image = event.detail['new_image'] + # Key pattern `EMAIL_VERIFICATION#{hash}` + *_, hash = new_image['sk'].split('#') + first_name = first_word(new_image['name']) + + emailmsg = Message( + from_=EMAIL_SENDER, + to=(new_image['name'], new_image['email']), + subject=SUBJECT.format(first_name=first_name), + ) + emailmsg.add_alternative( + MESSAGE.format( + user_id=new_image['id'], + first_name=first_name, + org_name=new_image.get('org_name'), + code=hash, + ) + ) + + try: + sesv2_client.send_email( + Content={ + 'Raw': { + 'Data': emailmsg.as_bytes(), + }, + } + ) + logger.info('Email sent') + except Exception as exc: + logger.exception(exc) + return False + else: + return True diff --git a/users-events/template.yaml b/users-events/template.yaml index a71737a..65c2f55 100644 --- a/users-events/template.yaml +++ b/users-events/template.yaml @@ -161,3 +161,63 @@ Resources: new_image: id: - prefix: orgmembers# + + EventSendWelcomeEmailFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.send_welcome_email.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - ses:SendRawEmail + Resource: + - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/eduseg.com.br + - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/tracking + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref UserTable] + detail-type: [INSERT] + detail: + new_image: + sk: + - prefix: EMAIL_VERIFICATION# + fresh_user: + - exists: true + org_name: + - exists: true + + EventSendVerificationEmailFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.send_verification_email.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - ses:SendRawEmail + Resource: + - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/eduseg.com.br + - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/tracking + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref UserTable] + detail-type: [INSERT] + detail: + new_image: + sk: + - prefix: EMAIL_VERIFICATION# + fresh_user: + - exists: false