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 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}'),

View File

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

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

View File

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

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, 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) {
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 }) { function ActionMenu({ row }: { row: any }) {
const [open, { set: setOpen }] = useToggle(false) const [open, { set: setOpen }] = useToggle(false)
const cert = row.original?.cert const cert = row.original?.cert
const { data, loading, run, refresh } = useRequest(getEnrollment, { const { data, loading, runAsync, refresh } = useRequest(
manual: true 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 = () => { 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()
const { presigned_url } = (await r.json()) as {
presigned_url: string
}
window.open(presigned_url, '_blank') window.open(presigned_url, '_blank')
set(false)
onSuccess?.() 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">
</ul> <ItemContent>
<ItemTitle>{email}</ItemTitle>
<ItemDescription>...</ItemDescription>
</ItemContent>
<ItemActions>
<ActionMenuContext value={{ ...props, sk, email }}>
<ActionMenu />
</ActionMenuContext>
</ItemActions>
</Item>
)
})}
</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>
)
}

View File

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

View File

@@ -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>
<Avatar className="size-10"> <div className="relative">
<AvatarFallback>{initials(user.name)}</AvatarFallback> <Avatar className="size-10">
</Avatar> <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> </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"

View File

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

View File

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

View File

@@ -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_,
} }
) )

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