add download cert

This commit is contained in:
2025-11-18 16:58:15 -03:00
parent a96dcb3e96
commit cd5f96210f
13 changed files with 417 additions and 23 deletions

View File

@@ -12,6 +12,7 @@ logger = Logger(__name__)
tracer = Tracer()
urls = [
'https://bcs7fgb9og.execute-api.sa-east-1.amazonaws.com/health',
# 'https://api.saladeaula.digital/health',
'https://id.saladeaula.digital/health',
]

View File

@@ -64,9 +64,7 @@ def put_course(course_id: str):
event = router.current_event
if not event.decoded_body:
raise BadRequestError('Invalid request body')
now_ = now()
raise BadRequestError('Invalid request body') now_ = now()
body = parse(
event.headers,
BytesIO(event.decoded_body.encode()),

View File

@@ -1,6 +1,13 @@
from typing import Annotated
from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import DynamoDBPersistenceLayer
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError,
)
from aws_lambda_powertools.event_handler.openapi.params import Body
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE
@@ -10,5 +17,78 @@ router = Router()
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@router.patch('/')
def cancel(): ...
class CancelPolicyConflictError(BadRequestError):
def __init__(self, *_):
super().__init__('Cancellation policy not found')
class SlotConflictError(BadRequestError):
def __init__(self, *_):
super().__init__('Slot not found')
@router.post('/<enrollment_id>/cancel')
def cancel(
enrollment_id: str,
lock_hash: Annotated[str | None, Body(embed=True)] = None,
):
now_ = now()
with dyn.transact_writer() as transact:
transact.update(
key=KeyPair(enrollment_id, '0'),
cond_expr='#status = pending',
update_expr='SET #status = :canceled, \
canceled_at = :now, \
updated_at = :now',
expr_attr_names={
':status': 'status',
},
expr_attr_values={
':pending': 'PENDING',
':canceled': 'CANCELED',
':true': True,
':now': now_,
},
)
transact.put(
item={
'id': enrollment_id,
'sk': 'CANCELED_BY',
'canceled_by': {},
'created_at': now_,
}
)
transact.delete(
key=KeyPair(
pk=enrollment_id,
sk='CANCEL_POLICY',
),
cond_expr='attribute_exists(sk)',
exc_cls=CancelPolicyConflictError,
)
# Remove reminders and policies that no longer apply
transact.delete(
key=KeyPair(
pk=enrollment_id,
sk='SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS',
)
)
transact.delete(
key=KeyPair(
pk=enrollment_id,
sk='SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS',
)
)
transact.delete(
key=KeyPair(
pk=enrollment_id,
sk='METADATA#PARENT_SLOT',
),
cond_expr='attribute_exists(sk)',
exc_cls=SlotConflictError,
)
if lock_hash:
transact.delete(key=KeyPair(enrollment_id, 'LOCK'))
transact.delete(key=KeyPair('LOCK', lock_hash))

View File

@@ -16,7 +16,7 @@ router = Router()
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@router.patch('/<enrollment_id>/dedupwindow', compress=True)
@router.delete('/<enrollment_id>/dedupwindow', compress=True)
def dedup_window(
enrollment_id: str,
lock_hash: Annotated[str, Body(embed=True)],

View File

@@ -8,6 +8,7 @@ import {
GraduationCap,
LayoutDashboard,
ShieldUserIcon,
UploadIcon,
UsersIcon
} from 'lucide-react'
@@ -31,7 +32,9 @@ const data = {
title: 'Histórico de compras',
url: '/orders',
icon: DollarSign
},
}
],
navUser: [
{
title: 'Colaboradores',
url: '/users',
@@ -41,9 +44,14 @@ const data = {
title: 'Gestores',
url: '/admins',
icon: ShieldUserIcon
},
{
title: 'Importações',
url: '/batch',
icon: UploadIcon
}
],
navContent: [
navEnrollment: [
{
title: 'Matrículas',
url: '/enrollments',

View File

@@ -25,7 +25,8 @@ export function NavMain({
}: {
data: {
navMain: NavItem[]
navContent: NavItem[]
navUser: NavItem[]
navEnrollment: NavItem[]
}
}) {
return (
@@ -43,8 +44,23 @@ export function NavMain({
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarGroupLabel>Gestão de matrículas</SidebarGroupLabel>
{data.navContent.map((props, idx) => (
<SidebarGroupLabel className="uppercase">
Colaboradores
</SidebarGroupLabel>
{data.navUser.map((props, idx) => (
<SidebarMenuItemLink key={idx} {...props} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarGroupLabel className="uppercase">
Gestão de matrículas
</SidebarGroupLabel>
{data.navEnrollment.map((props, idx) => (
<SidebarMenuItemLink key={idx} {...props} />
))}
</SidebarMenu>

View File

@@ -0,0 +1,45 @@
import type { Route } from './+types'
import { Suspense } from 'react'
import { Await } from 'react-router'
import { Skeleton } from '@repo/ui/components/skeleton'
import { request as req } from '@repo/util/request'
export function meta({}: Route.MetaArgs) {
return [{ title: ' Importações de colaboradores' }]
}
export async function loader({ context, request, params }: Route.LoaderArgs) {
const data = req({
url: `/orgs/${params.orgid}/enrollments/scheduled`,
context,
request
}).then((r) => r.json())
return {
data
}
}
export default function Route({ loaderData: { data } }) {
return (
<>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Importações de colaboradores
</h1>
<p className="text-muted-foreground">
Acompanhe suas importações de colaboradores, faça novas importações
sempre que precisar e finalize o processo com rapidez.
</p>
</div>
<Suspense fallback={<Skeleton />}>
<Await resolve={data}>
{(resolved) => <>...{console.log(resolved)}</>}
</Await>
</Suspense>
</>
)
}

View File

@@ -0,0 +1,54 @@
import type { Route } from './+types'
import { useNavigate } from 'react-router'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@repo/ui/components/ui/dialog'
import { request as req } from '@repo/util/request'
export async function loader({ params, request, context }: Route.LoaderArgs) {
const { id } = params
const r = await req({
url: `/enrollments/${id}`,
request,
context
})
if (!r.ok) {
throw new Response(null, { status: r.status })
}
const enrollment = await r.json()
return { data: enrollment }
}
export default function UserModal({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate()
const { enrollment } = loaderData
return (
<Dialog
open={true}
onOpenChange={(open) => {
if (!open) navigate('/enrollments') // Volta pra listagem ao fechar
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>...</DialogTitle>
<DialogDescription>Detalhes do usuário</DialogDescription>
</DialogHeader>
<div className="space-y-4">
...
{/* Mais informações... */}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,16 +1,46 @@
'use client'
import type { CellContext, ColumnDef } from '@tanstack/react-table'
import { HelpCircleIcon } from 'lucide-react'
import { useToggle } from 'ahooks'
import {
CircleXIcon,
EllipsisVerticalIcon,
FileBadgeIcon,
HelpCircleIcon,
LockOpenIcon
} from 'lucide-react'
import { NavLink, useParams } from 'react-router'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@repo/ui/components/ui/alert-dialog'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Badge } from '@repo/ui/components/ui/badge'
import { Button } from '@repo/ui/components/ui/button'
import { Checkbox } from '@repo/ui/components/ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import { Progress } from '@repo/ui/components/ui/progress'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { cn, initials } from '@repo/ui/lib/utils'
import { Abbr } from '@/components/abbr'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { useDataTable } from '@/components/data-table/data-table'
import { labels, statuses } from './data'
// This type is used to define the shape of our data.
@@ -167,6 +197,10 @@ export const columns: ColumnDef<Enrollment>[] = [
enableSorting: true,
enableHiding: true,
cell: cellDate
},
{
id: 'actions',
cell: ({ row }) => <ActionMenu row={row} />
}
]
@@ -183,3 +217,159 @@ function cellDate<TData>({
return <></>
}
function ActionMenu({ row }: { row: any }) {
const cert = row.original?.cert
const progress = row.getValue('progress')
return (
<div className="flex justify-end items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
size="icon-sm"
>
<EllipsisVerticalIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-46 *:cursor-pointer">
<DownloadItem id={row.id} disabled={!cert} />
<DropdownMenuSeparator />
<RemoveDedupItem id={row.id} disabled={progress > 0} />
<CancelItem id={row.id} disabled={progress > 0} />
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
function DownloadItem({ id, ...props }: { id: string }) {
const [loading, { set }] = useToggle(false)
const download = async (e) => {
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 }
window.open(presigned_url, '_blank')
}
set(false)
}
return (
<DropdownMenuItem onSelect={download} {...props}>
{loading ? <Spinner /> : <FileBadgeIcon />} Baixar certificado
</DropdownMenuItem>
)
}
function RemoveDedupItem({ id, ...props }: { id: string }) {
const [loading, { set }] = useToggle(false)
const { orgid } = useParams()
const { table } = useDataTable<Enrollment>()
const cancel = async (e) => {
e.preventDefault()
set(true)
const r = await fetch(`/~/api/enrollments/${orgid}/dedupwindow`, {
method: 'DELETE'
})
if (r.ok) {
toast.info('A proteção contra duplicação foi removida')
// @ts-ignore
table.options.meta?.removeRow?.(id)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
variant="destructive"
onSelect={(e) => e.preventDefault()}
{...props}
>
<LockOpenIcon /> Remover proteção
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. Isso remove a proteção contra
duplicação permanentemente desta matrícula.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={cancel} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function CancelItem({ id, ...props }: { id: string }) {
const [loading, { set }] = useToggle(false)
const { orgid } = useParams()
const { table } = useDataTable<Enrollment>()
const cancel = async (e) => {
e.preventDefault()
set(true)
const r = await fetch(`/~/api/enrollments/${orgid}/cancel`, {
method: 'PATCH'
})
if (r.ok) {
toast.info('A matrícula foi cancelada')
// @ts-ignore
table.options.meta?.removeRow?.(id)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
variant="destructive"
onSelect={(e) => e.preventDefault()}
{...props}
>
<CircleXIcon /> Cancelar
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. Isso cancelar permanentemente a
matrícula deste colaborador.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={cancel} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -12,7 +12,7 @@ import {
} from 'lucide-react'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { Suspense, useState } from 'react'
import { Await, Link, useParams, useSearchParams } from 'react-router'
import { Await, Link, Outlet, useParams, useSearchParams } from 'react-router'
import type { BookType } from 'xlsx'
import * as XLSX from 'xlsx'
@@ -221,6 +221,8 @@ export default function Route({ loaderData: { data } }) {
</DataTable>
)}
</Await>
<Outlet />
</Suspense>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { formatCPF } from '@brazilian-utils/brazilian-utils'
import { type ColumnDef, type RowData } from '@tanstack/react-table'
import { type ColumnDef } from '@tanstack/react-table'
import { useToggle } from 'ahooks'
import {
EllipsisVerticalIcon,

View File

@@ -52,14 +52,14 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
)
# Schedule the event to add `access_expired` after the access period ends
transact.put(
item={
'id': enrollment_id,
'sk': 'SCHEDULE#SET_ACCESS_EXPIRED',
'created_at': now_,
'ttl': ttl(start_dt=now_, days=access_period),
},
)
# transact.put(
# item={
# 'id': enrollment_id,
# 'sk': 'SCHEDULE#SET_ACCESS_EXPIRED',
# 'created_at': now_,
# 'ttl': ttl(start_dt=now_, days=access_period),
# },
# )
transact.put(
item={