add notification
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from aws_lambda_powertools import Logger
|
from aws_lambda_powertools import Logger
|
||||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
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 layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
|
||||||
|
|
||||||
from boto3clients import dynamodb_client
|
from boto3clients import dynamodb_client
|
||||||
@@ -11,9 +14,7 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
|||||||
|
|
||||||
|
|
||||||
@router.get('/<org_id>/enrollments/scheduled')
|
@router.get('/<org_id>/enrollments/scheduled')
|
||||||
def scheduled(org_id: str):
|
def scheduled(org_id: str, start_key: Annotated[str | None, Query] = None):
|
||||||
start_key = router.current_event.get_query_string_value('start_key', None)
|
|
||||||
|
|
||||||
return dyn.collection.query(
|
return dyn.collection.query(
|
||||||
# Post-migration: rename `scheduled_items` to `SCHEDULED#ORG#{org_id}`
|
# Post-migration: rename `scheduled_items` to `SCHEDULED#ORG#{org_id}`
|
||||||
key=PartitionKey(f'scheduled_items#{org_id}'),
|
key=PartitionKey(f'scheduled_items#{org_id}'),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from aws_lambda_powertools.event_handler.exceptions import (
|
|||||||
ServiceError,
|
ServiceError,
|
||||||
)
|
)
|
||||||
from aws_lambda_powertools.event_handler.openapi.params import Body
|
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.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
|
||||||
from layercake.extra_types import CnpjStr, CpfStr, NameStr
|
from layercake.extra_types import CnpjStr, CpfStr, NameStr
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
@@ -126,9 +126,26 @@ def _create_user(user: User, org: Org) -> bool:
|
|||||||
'sk': f'emails#{user.email}',
|
'sk': f'emails#{user.email}',
|
||||||
'email_verified': email_verified,
|
'email_verified': email_verified,
|
||||||
'email_primary': True,
|
'email_primary': True,
|
||||||
|
'mx_record_exists': email_verified,
|
||||||
'created_at': now_,
|
'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(
|
transact.put(
|
||||||
item={
|
item={
|
||||||
# Post-migration: rename `cpf` to `CPF`
|
# Post-migration: rename `cpf` to `CPF`
|
||||||
|
|||||||
@@ -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 aws_lambda_powertools.event_handler.openapi.params import Body, Path, Query
|
||||||
from layercake.dateutils import now, ttl
|
from layercake.dateutils import now, ttl
|
||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
|
||||||
|
from layercake.funcs import pick
|
||||||
from pydantic import EmailStr
|
from pydantic import EmailStr
|
||||||
from typing_extensions import Annotated
|
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):
|
class EmailConflictError(ServiceError):
|
||||||
def __init__(self, msg: str | dict):
|
def __init__(self, msg: str | dict):
|
||||||
super().__init__(HTTPStatus.CONFLICT, msg)
|
super().__init__(HTTPStatus.CONFLICT, msg)
|
||||||
@@ -46,13 +50,19 @@ def add(
|
|||||||
)
|
)
|
||||||
|
|
||||||
with dyn.transact_writer() as transact:
|
with dyn.transact_writer() as transact:
|
||||||
|
transact.condition(
|
||||||
|
key=KeyPair(user_id, '0'),
|
||||||
|
cond_expr='attribute_exists(sk)',
|
||||||
|
exc_cls=UserNotFoundError,
|
||||||
|
)
|
||||||
transact.put(
|
transact.put(
|
||||||
item={
|
item={
|
||||||
'id': user_id,
|
'id': user_id,
|
||||||
# Post-migration (users): rename `emails` to `EMAIL`
|
# Post-migration (users): rename `emails` to `EMAIL`
|
||||||
'sk': f'emails#{email}',
|
'sk': f'emails#{email}',
|
||||||
'email_verified': False,
|
'email_verified': False,
|
||||||
'email_primary': True,
|
'mx_record_exists': False,
|
||||||
|
'email_primary': False,
|
||||||
'created_at': now_,
|
'created_at': now_,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -82,10 +92,16 @@ def add(
|
|||||||
|
|
||||||
|
|
||||||
@router.post('/<user_id>/emails/<email>/request-verification')
|
@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()
|
now_ = now()
|
||||||
name = dyn.collection.get_item(
|
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,
|
raise_on_error=False,
|
||||||
)
|
)
|
||||||
dyn.put_item(
|
dyn.put_item(
|
||||||
@@ -107,13 +123,14 @@ class EmailVerificationNotFoundError(NotFoundError): ...
|
|||||||
|
|
||||||
@router.post('/<user_id>/emails/<hash>/verify')
|
@router.post('/<user_id>/emails/<hash>/verify')
|
||||||
def verify(user_id: str, hash: str):
|
def verify(user_id: str, hash: str):
|
||||||
email = dyn.collection.get_item(
|
verification = dyn.collection.get_item(
|
||||||
KeyPair(
|
KeyPair(
|
||||||
pk=user_id,
|
pk=user_id,
|
||||||
sk=SortKey(f'EMAIL_VERIFICATION#{hash}', path_spec='email'),
|
sk=f'EMAIL_VERIFICATION#{hash}',
|
||||||
),
|
),
|
||||||
exc_cls=EmailVerificationNotFoundError,
|
exc_cls=EmailVerificationNotFoundError,
|
||||||
)
|
)
|
||||||
|
email, primary = pick(('email', 'email_primary'), verification, default=False)
|
||||||
|
|
||||||
with dyn.transact_writer() as transact:
|
with dyn.transact_writer() as transact:
|
||||||
transact.delete(
|
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)
|
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +171,7 @@ def primary(
|
|||||||
email_verified: Annotated[bool, Body(embed=True)],
|
email_verified: Annotated[bool, Body(embed=True)],
|
||||||
):
|
):
|
||||||
now_ = now()
|
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:
|
with dyn.transact_writer() as transact:
|
||||||
# Set the old email as non-primary
|
# Set the old email as non-primary
|
||||||
@@ -150,7 +181,7 @@ def primary(
|
|||||||
update_expr=expr,
|
update_expr=expr,
|
||||||
expr_attr_values={
|
expr_attr_values={
|
||||||
':email_primary': False,
|
':email_primary': False,
|
||||||
':updated_at': now_,
|
':now': now_,
|
||||||
},
|
},
|
||||||
cond_expr='attribute_exists(sk)',
|
cond_expr='attribute_exists(sk)',
|
||||||
)
|
)
|
||||||
@@ -161,7 +192,7 @@ def primary(
|
|||||||
update_expr=expr,
|
update_expr=expr,
|
||||||
expr_attr_values={
|
expr_attr_values={
|
||||||
':email_primary': True,
|
':email_primary': True,
|
||||||
':updated_at': now_,
|
':now': now_,
|
||||||
},
|
},
|
||||||
cond_expr='attribute_exists(sk)',
|
cond_expr='attribute_exists(sk)',
|
||||||
)
|
)
|
||||||
@@ -170,13 +201,15 @@ def primary(
|
|||||||
update_expr='DELETE emails :email_set \
|
update_expr='DELETE emails :email_set \
|
||||||
SET email = :email, \
|
SET email = :email, \
|
||||||
email_verified = :email_verified, \
|
email_verified = :email_verified, \
|
||||||
updated_at = :updated_at',
|
updated_at = :now',
|
||||||
expr_attr_values={
|
expr_attr_values={
|
||||||
':email': new_email,
|
':email': new_email,
|
||||||
':email_set': {new_email},
|
':email_set': {new_email},
|
||||||
':email_verified': email_verified,
|
':email_verified': email_verified,
|
||||||
':updated_at': now_,
|
':now': now_,
|
||||||
},
|
},
|
||||||
|
cond_expr='attribute_exists(sk)',
|
||||||
|
exc_cls=UserNotFoundError,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
|
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
|
||||||
@@ -204,10 +237,14 @@ def remove(
|
|||||||
)
|
)
|
||||||
transact.update(
|
transact.update(
|
||||||
key=KeyPair(user_id, '0'),
|
key=KeyPair(user_id, '0'),
|
||||||
update_expr='DELETE emails :email',
|
update_expr='DELETE emails :email \
|
||||||
|
SET updated_at = :now',
|
||||||
expr_attr_values={
|
expr_attr_values={
|
||||||
':email': {email},
|
':email': {email},
|
||||||
|
':now': now(),
|
||||||
},
|
},
|
||||||
|
cond_expr='attribute_exists(sk)',
|
||||||
|
exc_cls=UserNotFoundError,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
|
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import {
|
import {
|
||||||
BookCopyIcon,
|
BookCopyIcon,
|
||||||
CalendarClockIcon,
|
CalendarClockIcon,
|
||||||
DollarSign,
|
DollarSignIcon,
|
||||||
FileBadgeIcon,
|
FileBadgeIcon,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
LayoutDashboard,
|
LayoutDashboardIcon,
|
||||||
ShieldUserIcon,
|
ShieldUserIcon,
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
UsersIcon
|
UsersIcon
|
||||||
@@ -26,12 +26,12 @@ const data = {
|
|||||||
{
|
{
|
||||||
title: 'Visão geral',
|
title: 'Visão geral',
|
||||||
url: '/main',
|
url: '/main',
|
||||||
icon: LayoutDashboard
|
icon: LayoutDashboardIcon
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Histórico de compras',
|
title: 'Histórico de compras',
|
||||||
url: '/orders',
|
url: '/orders',
|
||||||
icon: DollarSign
|
icon: DollarSignIcon
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
navUser: [
|
navUser: [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger
|
AlertDialogTrigger
|
||||||
} from '@repo/ui/components/ui/alert-dialog'
|
} from '@repo/ui/components/ui/alert-dialog'
|
||||||
|
|
||||||
import { Button } from '@repo/ui/components/ui/button'
|
import { Button } from '@repo/ui/components/ui/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -58,24 +57,24 @@ export const columns: ColumnDef<Enrollment>[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
async function getEnrollment(id: string) {
|
function ActionMenu({ row }: { row: any }) {
|
||||||
const r = await fetch(`/~/api/enrollments/${id}`, {
|
const [open, { set: setOpen }] = useToggle(false)
|
||||||
|
const cert = row.original?.cert
|
||||||
|
const { data, loading, runAsync, refresh } = useRequest(
|
||||||
|
async () => {
|
||||||
|
const r = await fetch(`/~/api/enrollments/${row.id}`, {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
})
|
})
|
||||||
await new Promise((r) => setTimeout(r, 150))
|
|
||||||
|
|
||||||
return (await r.json()) as {
|
return (await r.json()) as {
|
||||||
cancel_policy?: any
|
cancel_policy?: any
|
||||||
lock?: { hash: string }
|
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
|
manual: true
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const onSuccess = () => {
|
const onSuccess = () => {
|
||||||
refresh()
|
refresh()
|
||||||
@@ -90,7 +89,7 @@ function ActionMenu({ row }: { row: any }) {
|
|||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setOpen(open)
|
setOpen(open)
|
||||||
if (data) return
|
if (data) return
|
||||||
run(row.id)
|
runAsync()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -140,22 +139,27 @@ type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DownloadItem({ id, onSuccess, ...props }: ItemProps) {
|
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) => {
|
const download = async (e: Event) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
set(true)
|
|
||||||
const r = await fetch(`/~/api/enrollments/${id}/download`, {
|
|
||||||
method: 'GET'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (r.ok) {
|
try {
|
||||||
const { presigned_url } = (await r.json()) as { presigned_url: string }
|
const r = await runAsync()
|
||||||
window.open(presigned_url, '_blank')
|
const { presigned_url } = (await r.json()) as {
|
||||||
|
presigned_url: string
|
||||||
set(false)
|
|
||||||
onSuccess?.()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.open(presigned_url, '_blank')
|
||||||
|
onSuccess?.()
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -299,7 +303,7 @@ function CancelItem({
|
|||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Esta ação não pode ser desfeita. Isso{' '}
|
Esta ação não pode ser desfeita. Isso{' '}
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
cancelar permanentemente a matrícula
|
cancela permanentemente a matrícula
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
deste colaborador.
|
deste colaborador.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Toaster } from '@repo/ui/components/ui/sonner'
|
|||||||
import { request as req } from '@repo/util/request'
|
import { request as req } from '@repo/util/request'
|
||||||
|
|
||||||
import { AppSidebar } from '@/components/app-sidebar'
|
import { AppSidebar } from '@/components/app-sidebar'
|
||||||
|
import { Notification } from '@/components/notification'
|
||||||
|
|
||||||
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export default function Route({ loaderData }: Route.ComponentProps) {
|
|||||||
<ThemedImage className="max-md:hidden" />
|
<ThemedImage className="max-md:hidden" />
|
||||||
|
|
||||||
<div className="ml-auto flex gap-2.5 items-center">
|
<div className="ml-auto flex gap-2.5 items-center">
|
||||||
|
<Notification />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<NavUser user={user} />
|
<NavUser user={user} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export default [
|
|||||||
]),
|
]),
|
||||||
route('konviva', 'routes/konviva.ts'),
|
route('konviva', 'routes/konviva.ts'),
|
||||||
route('player/:id', 'routes/player.tsx'),
|
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('logout', 'routes/auth/logout.ts'),
|
||||||
route('login', 'routes/auth/login.ts')
|
route('login', 'routes/auth/login.ts')
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import type { Route } from './+types'
|
import type { Route } from './+types'
|
||||||
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle
|
|
||||||
} from '@repo/ui/components/ui/empty'
|
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import {
|
import {
|
||||||
BanIcon,
|
BanIcon,
|
||||||
@@ -15,6 +8,8 @@ import {
|
|||||||
CircleOffIcon,
|
CircleOffIcon,
|
||||||
CirclePlusIcon,
|
CirclePlusIcon,
|
||||||
CircleXIcon,
|
CircleXIcon,
|
||||||
|
EllipsisIcon,
|
||||||
|
FileBadgeIcon,
|
||||||
TimerIcon,
|
TimerIcon,
|
||||||
type LucideIcon
|
type LucideIcon
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -27,12 +22,28 @@ import { userContext } from '@repo/auth/context'
|
|||||||
import { FacetedFilter } from '@repo/ui/components/faceted-filter'
|
import { FacetedFilter } from '@repo/ui/components/faceted-filter'
|
||||||
import { SearchForm } from '@repo/ui/components/search-form'
|
import { SearchForm } from '@repo/ui/components/search-form'
|
||||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle,
|
||||||
|
CardAction
|
||||||
} from '@repo/ui/components/ui/card'
|
} from '@repo/ui/components/ui/card'
|
||||||
import { Kbd } from '@repo/ui/components/ui/kbd'
|
import { Kbd } from '@repo/ui/components/ui/kbd'
|
||||||
import { Progress } from '@repo/ui/components/ui/progress'
|
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 placeholder from '@/assets/placeholder.webp'
|
||||||
import { Container } from '@/components/container'
|
import { Container } from '@/components/container'
|
||||||
|
import { Button } from '@repo/ui/components/ui/button'
|
||||||
|
|
||||||
type Course = {
|
type Course = {
|
||||||
name: string
|
name: string
|
||||||
@@ -200,8 +212,10 @@ function Enrollment({ id, course, progress }: Enrollment) {
|
|||||||
<Card className="overflow-hidden relative h-96">
|
<Card className="overflow-hidden relative h-96">
|
||||||
<CardHeader className="z-1 relative">
|
<CardHeader className="z-1 relative">
|
||||||
<CardTitle className="text-xl/6">{course.name}</CardTitle>
|
<CardTitle className="text-xl/6">{course.name}</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<ActionMenu />
|
||||||
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="z-1"></CardContent>
|
|
||||||
<CardFooter className="absolute z-1 bottom-6 w-full flex gap-1.5">
|
<CardFooter className="absolute z-1 bottom-6 w-full flex gap-1.5">
|
||||||
<Progress value={progress} />
|
<Progress value={progress} />
|
||||||
<span className="text-xs">{progress}%</span>
|
<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<
|
const statuses: Record<
|
||||||
string,
|
string,
|
||||||
{ icon: LucideIcon; color?: string; label: string }
|
{ icon: LucideIcon; color?: string; label: string }
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { Route } from './+types'
|
import type { Route } from './+types/layout'
|
||||||
|
|
||||||
import { useToggle } from 'ahooks'
|
import { useToggle } from 'ahooks'
|
||||||
import { MenuIcon } from 'lucide-react'
|
import { MenuIcon } from 'lucide-react'
|
||||||
import { Link, NavLink, Outlet } from 'react-router'
|
import { Link, NavLink, Outlet } from 'react-router'
|
||||||
|
import { Toaster } from '@repo/ui/components/ui/sonner'
|
||||||
|
|
||||||
import { userContext } from '@repo/auth/context'
|
import { userContext } from '@repo/auth/context'
|
||||||
import { authMiddleware } from '@repo/auth/middleware/auth'
|
import { authMiddleware } from '@repo/auth/middleware/auth'
|
||||||
|
|
||||||
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
|
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
|
||||||
import { NavUser } from '@repo/ui/components/nav-user'
|
import { NavUser } from '@repo/ui/components/nav-user'
|
||||||
import { Button } from '@repo/ui/components/ui/button'
|
import { Button } from '@repo/ui/components/ui/button'
|
||||||
@@ -22,11 +22,12 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger
|
SheetTrigger
|
||||||
} from '@repo/ui/components/ui/sheet'
|
} from '@repo/ui/components/ui/sheet'
|
||||||
|
import type { User } from '@repo/auth/auth'
|
||||||
|
|
||||||
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
||||||
|
|
||||||
export async function loader({ context }: Route.ActionArgs) {
|
export async function loader({ context }: Route.ActionArgs) {
|
||||||
const user = context.get(userContext)
|
const user = context.get(userContext) as User
|
||||||
return Response.json({ user })
|
return Response.json({ user })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +46,9 @@ const navMain = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Component({ loaderData }: Route.ComponentProps) {
|
export default function Component({
|
||||||
const { user } = loaderData
|
loaderData: { user }
|
||||||
|
}: Route.ComponentProps) {
|
||||||
const [isOpen, { toggle }] = useToggle()
|
const [isOpen, { toggle }] = useToggle()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,6 +121,12 @@ export default function Component({ loaderData }: Route.ComponentProps) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<Toaster
|
||||||
|
position="top-center"
|
||||||
|
richColors={true}
|
||||||
|
duration={Infinity}
|
||||||
|
closeButton={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import type { Route } from './+types/emails'
|
import type { Route } from './+types/emails'
|
||||||
|
|
||||||
import { Suspense } from 'react'
|
import {
|
||||||
import { Await } from 'react-router'
|
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 { userContext } from '@repo/auth/context'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -11,17 +22,53 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '@repo/ui/components/ui/card'
|
} 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 { request as req } from '@repo/util/request'
|
||||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
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) {
|
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({
|
const data = req({
|
||||||
url: `/users/${user.sub}/emails`,
|
url: `/users/${user.sub}/emails`,
|
||||||
request,
|
request,
|
||||||
context
|
context
|
||||||
}).then((r) => r.json())
|
}).then((r) => r.json() as Promise<{ items: Email[] }>)
|
||||||
|
|
||||||
return { data }
|
return { data }
|
||||||
}
|
}
|
||||||
@@ -40,13 +87,24 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|||||||
principal receberá as mensagens.
|
principal receberá as mensagens.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col gap-2.5">
|
||||||
<ul>
|
{items.map(({ sk, ...props }) => {
|
||||||
{items.map(({ sk }: { sk: string }, idx: number) => {
|
const [, email] = sk.split('#') as [string, string]
|
||||||
const [, email] = sk.split('#')
|
|
||||||
return <li key={idx}>{email}</li>
|
return (
|
||||||
|
<Item key={email} variant="outline">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{email}</ItemTitle>
|
||||||
|
<ItemDescription>...</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<ActionMenuContext value={{ ...props, sk, email }}>
|
||||||
|
<ActionMenu />
|
||||||
|
</ActionMenuContext>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -54,3 +112,94 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|||||||
</Suspense>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export function ModeToggle() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="cursor-pointer text-muted-foreground"
|
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" />
|
<Sun className="size-4 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" />
|
<Moon className="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||||
<span className="sr-only">Alternar tema</span>
|
<span className="sr-only">Alternar tema</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ChevronDown,
|
||||||
CirclePlayIcon,
|
CirclePlayIcon,
|
||||||
DollarSignIcon,
|
DollarSignIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
@@ -71,9 +72,12 @@ export function NavUser({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger className="cursor-pointer" asChild>
|
<DropdownMenuTrigger className="cursor-pointer" asChild>
|
||||||
|
<div className="relative">
|
||||||
<Avatar className="size-10">
|
<Avatar className="size-10">
|
||||||
<AvatarFallback>{initials(user.name)}</AvatarFallback>
|
<AvatarFallback>{initials(user.name)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<ChevronDown className="size-3.5 absolute -bottom-px -right-px bg-neutral-700 border border-background rounded-full px-px" />
|
||||||
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ def get_dynamodb_client():
|
|||||||
|
|
||||||
dynamodb_client = get_dynamodb_client()
|
dynamodb_client = get_dynamodb_client()
|
||||||
s3_client = boto3.client('s3')
|
s3_client = boto3.client('s3')
|
||||||
|
sesv2_client = boto3.client('sesv2')
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ import os
|
|||||||
|
|
||||||
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
||||||
CHUNK_SIZE = 50
|
CHUNK_SIZE = 50
|
||||||
|
|
||||||
|
EMAIL_SENDER = ('EDUSEG®', 'noreply@eduseg.com.br')
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from aws_lambda_powertools.utilities.data_classes import (
|
|||||||
)
|
)
|
||||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||||
from layercake.batch import BatchProcessor
|
from layercake.batch import BatchProcessor
|
||||||
from layercake.dateutils import now
|
from layercake.dateutils import now, ttl
|
||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||||
from layercake.extra_types import CnpjStr, CpfStr, NameStr
|
from layercake.extra_types import CnpjStr, CpfStr, NameStr
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
@@ -144,6 +144,20 @@ def _create_user(rawuser: dict, context: dict) -> None:
|
|||||||
'sk': f'emails#{user.email}',
|
'sk': f'emails#{user.email}',
|
||||||
'email_verified': False,
|
'email_verified': False,
|
||||||
'email_primary': True,
|
'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_,
|
'created_at': now_,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
61
users-events/app/events/send_verification_email.py
Normal file
61
users-events/app/events/send_verification_email.py
Normal 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
|
||||||
61
users-events/app/events/send_welcome_email.py
Normal file
61
users-events/app/events/send_welcome_email.py
Normal 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
|
||||||
@@ -161,3 +161,63 @@ Resources:
|
|||||||
new_image:
|
new_image:
|
||||||
id:
|
id:
|
||||||
- prefix: orgmembers#
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user