add notification

This commit is contained in:
2025-11-27 20:41:29 -03:00
parent ab7e4ea38b
commit 2467798855
19 changed files with 560 additions and 80 deletions

View File

@@ -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('/<org_id>/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}'),

View File

@@ -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`

View File

@@ -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('/<user_id>/emails/<email>/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('/<user_id>/emails/<hash>/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)

View File

@@ -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: [

View File

@@ -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 (
<Popover>
<PopoverTrigger asChild>
<Button
variant="link"
size="icon"
className="cursor-pointer text-muted-foreground"
>
<BellIcon className="size-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<>Notificações</>
</PopoverContent>
</Popover>
)
}

View File

@@ -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<Enrollment>[] = [
}
]
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()
}}
>
<DropdownMenuTrigger asChild>
@@ -140,22 +139,27 @@ type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
}
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({
<AlertDialogDescription>
Esta ação não pode ser desfeita. Isso{' '}
<span className="font-bold">
cancelar permanentemente a matrícula
cancela permanentemente a matrícula
</span>{' '}
deste colaborador.
</AlertDialogDescription>

View File

@@ -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) {
<ThemedImage className="max-md:hidden" />
<div className="ml-auto flex gap-2.5 items-center">
<Notification />
<ModeToggle />
<NavUser user={user} />
</div>

View File

@@ -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')

View File

@@ -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) {
<Card className="overflow-hidden relative h-96">
<CardHeader className="z-1 relative">
<CardTitle className="text-xl/6">{course.name}</CardTitle>
<CardAction>
<ActionMenu />
</CardAction>
</CardHeader>
<CardContent className="z-1"></CardContent>
<CardFooter className="absolute z-1 bottom-6 w-full flex gap-1.5">
<Progress value={progress} />
<span className="text-xs">{progress}%</span>
@@ -216,6 +230,23 @@ function Enrollment({ id, course, progress }: Enrollment) {
)
}
function ActionMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={true}>
<FileBadgeIcon /> Baixar certificado
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
const statuses: Record<
string,
{ icon: LucideIcon; color?: string; label: string }

View File

@@ -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) {
</header>
<Outlet />
<Toaster
position="top-center"
richColors={true}
duration={Infinity}
closeButton={true}
/>
</div>
)
}

View File

@@ -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<Email | null>(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.
</CardDescription>
</CardHeader>
<CardContent>
<ul>
{items.map(({ sk }: { sk: string }, idx: number) => {
const [, email] = sk.split('#')
return <li key={idx}>{email}</li>
})}
</ul>
<CardContent className="flex flex-col gap-2.5">
{items.map(({ sk, ...props }) => {
const [, email] = sk.split('#') as [string, string]
return (
<Item key={email} variant="outline">
<ItemContent>
<ItemTitle>{email}</ItemTitle>
<ItemDescription>...</ItemDescription>
</ItemContent>
<ItemActions>
<ActionMenuContext value={{ ...props, sk, email }}>
<ActionMenu />
</ActionMenuContext>
</ItemActions>
</Item>
)
})}
</CardContent>
</Card>
)}
@@ -54,3 +112,94 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
</Suspense>
)
}
function ActionMenu() {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="*:cursor-pointer">
<DropdownMenuItem>
<SendIcon /> Reenviar email de verificação
</DropdownMenuItem>
<RemoveItem />
</DropdownMenuContent>
</DropdownMenu>
)
}
type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
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<HTMLButtonElement>) => {
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 (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<DropdownMenuItem
variant="destructive"
onSelect={(e) => e.preventDefault()}
{...props}
>
<CircleXIcon /> Remover
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. Isso{' '}
<span className="font-bold">remove permanentemente</span> o seu
endereço de email.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogAction asChild>
<Button onClick={cancel} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -24,8 +24,8 @@ export function ModeToggle() {
size="icon"
className="cursor-pointer text-muted-foreground"
>
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<Sun className="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Alternar tema</span>
</Button>
</DropdownMenuTrigger>

View File

@@ -1,6 +1,7 @@
'use client'
import {
ChevronDown,
CirclePlayIcon,
DollarSignIcon,
GraduationCapIcon,
@@ -71,9 +72,12 @@ export function NavUser({
return (
<DropdownMenu>
<DropdownMenuTrigger className="cursor-pointer" asChild>
<Avatar className="size-10">
<AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar>
<div className="relative">
<Avatar className="size-10">
<AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar>
<ChevronDown className="size-3.5 absolute -bottom-px -right-px bg-neutral-700 border border-background rounded-full px-px" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"

View File

@@ -12,3 +12,4 @@ def get_dynamodb_client():
dynamodb_client = get_dynamodb_client()
s3_client = boto3.client('s3')
sesv2_client = boto3.client('sesv2')

View File

@@ -2,3 +2,5 @@ import os
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
CHUNK_SIZE = 50
EMAIL_SENDER = ('EDUSEG®', 'noreply@eduseg.com.br')

View File

@@ -11,7 +11,7 @@ from aws_lambda_powertools.utilities.data_classes import (
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.batch import BatchProcessor
from layercake.dateutils import now
from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from layercake.extra_types import CnpjStr, CpfStr, NameStr
from pydantic import BaseModel, EmailStr, Field
@@ -144,6 +144,20 @@ def _create_user(rawuser: dict, context: dict) -> 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_,
}
)

View File

@@ -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?<br/><br/>
Para proteger sua conta na EDUSEG, precisamos apenas verificar seu
endereço de email: {email}.<br/><br/>
<a href="https://saladeaula.digital/settings/emails/{code}/verify">
👉 Verificar endereço de email
</a>
"""
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

View File

@@ -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?<br/><br/>
Sua conta foi criada na EDUSEG pela empresa <b>{org_name}</b>.<br/><br/>
<a href="https://id.saladeaula.digital/signup?uid={user_id}&code={code}">
👉 Faça agora seu primeiro acesso
</a>
"""
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

View File

@@ -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