Compare commits

...

10 Commits

47 changed files with 853 additions and 745 deletions

View File

@@ -54,6 +54,7 @@ app.include_router(orgs.add, prefix='/orgs')
app.include_router(orgs.address, prefix='/orgs') app.include_router(orgs.address, prefix='/orgs')
app.include_router(orgs.admins, prefix='/orgs') app.include_router(orgs.admins, prefix='/orgs')
app.include_router(orgs.billing, prefix='/orgs') app.include_router(orgs.billing, prefix='/orgs')
app.include_router(orgs.certs, prefix='/orgs')
app.include_router(orgs.custom_pricing, prefix='/orgs') app.include_router(orgs.custom_pricing, prefix='/orgs')
app.include_router(orgs.scheduled, prefix='/orgs') app.include_router(orgs.scheduled, prefix='/orgs')
app.include_router(orgs.submissions, prefix='/orgs') app.include_router(orgs.submissions, prefix='/orgs')

View File

@@ -28,6 +28,7 @@ from config import (
) )
from exceptions import ( from exceptions import (
ConflictError, ConflictError,
NotAcceptableError,
OrderNotFoundError, OrderNotFoundError,
SubscriptionConflictError, SubscriptionConflictError,
SubscriptionFrozenError, SubscriptionFrozenError,
@@ -47,6 +48,9 @@ class DeduplicationConflictError(ConflictError): ...
class SeatNotFoundError(NotFoundError): ... class SeatNotFoundError(NotFoundError): ...
class TestModeRequiredError(NotAcceptableError): ...
class User(BaseModel): class User(BaseModel):
id: str | UUID4 id: str | UUID4
name: NameStr name: NameStr
@@ -91,6 +95,7 @@ def enroll(
org_id: Annotated[str | UUID4, Body(embed=True)], org_id: Annotated[str | UUID4, Body(embed=True)],
enrollments: Annotated[tuple[Enrollment, ...], Body(embed=True)], enrollments: Annotated[tuple[Enrollment, ...], Body(embed=True)],
subscription: Annotated[Subscription | None, Body(embed=True)] = None, subscription: Annotated[Subscription | None, Body(embed=True)] = None,
test_mode: Annotated[bool, Body(embed=True)] = False,
): ):
now_ = now() now_ = now()
created_by: Authenticated = router.context['user'] created_by: Authenticated = router.context['user']
@@ -106,6 +111,7 @@ def enroll(
'org': Org.model_validate(org), 'org': Org.model_validate(org),
'created_by': created_by, 'created_by': created_by,
'subscription': subscription, 'subscription': subscription,
'test_mode': test_mode,
} }
immediate = [e for e in enrollments if not e.scheduled_for] immediate = [e for e in enrollments if not e.scheduled_for]
@@ -125,12 +131,13 @@ def enroll(
'cause': r.cause, 'cause': r.cause,
} }
expires_after_days = 7 if test_mode else 30 * 3
item = { item = {
'id': f'SUBMISSION#ORG#{org_id}', 'id': f'SUBMISSION#ORG#{org_id}',
'sk': now_, 'sk': now_,
'enrolled': list(map(fmt, now_out)) if now_out else None, 'enrolled': list(map(fmt, now_out)) if now_out else None,
'scheduled': list(map(fmt, later_out)) if later_out else None, 'scheduled': list(map(fmt, later_out)) if later_out else None,
'ttl': ttl(start_dt=now_, days=30 * 3), 'ttl': ttl(start_dt=now_, days=expires_after_days),
'created_by': { 'created_by': {
'id': created_by.id, 'id': created_by.id,
'name': created_by.name, 'name': created_by.name,
@@ -151,6 +158,7 @@ Context = TypedDict(
'org': Org, 'org': Org,
'created_by': Authenticated, 'created_by': Authenticated,
'subscription': NotRequired[Subscription], 'subscription': NotRequired[Subscription],
'test_mode': NotRequired[bool],
}, },
) )
@@ -161,6 +169,7 @@ def enroll_now(enrollment: Enrollment, context: Context):
course = enrollment.course course = enrollment.course
seat = enrollment.seat seat = enrollment.seat
org = context['org'] org = context['org']
test_mode = context.get('test_mode')
subscription = context.get('subscription') subscription = context.get('subscription')
created_by = context['created_by'] created_by = context['created_by']
lock_hash = md5_hash(f'{user.id}{course.id}') lock_hash = md5_hash(f'{user.id}{course.id}')
@@ -194,7 +203,16 @@ def enroll_now(enrollment: Enrollment, context: Context):
'created_at': now_, 'created_at': now_,
} }
| ({'subscription_covered': True} if subscription else {}) | ({'subscription_covered': True} if subscription else {})
| ({'is_test': True} if test_mode else {})
) )
if test_mode:
transact.condition(
key=KeyPair(str(org.id), 'METADATA#TEST_MODE'),
cond_expr='attribute_exists(sk)',
exc_cls=TestModeRequiredError,
)
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
@@ -220,15 +238,25 @@ def enroll_now(enrollment: Enrollment, context: Context):
exc_cls=OrderNotFoundError, exc_cls=OrderNotFoundError,
table_name=ORDER_TABLE, table_name=ORDER_TABLE,
) )
transact.put( transact.update(
item={ key=KeyPair(
'id': seat.order_id, pk=str(seat.order_id),
'sk': f'ENROLLMENT#{enrollment.id}', sk=f'ENROLLMENT#{enrollment.id}',
'course': course.model_dump(), ),
'user': user.model_dump(), update_expr='SET course = :course, \
'status': 'EXECUTED', #user = :user, \
'executed_at': now_, #status = :executed, \
'created_at': now_, executed_at = :now, \
created_at = if_not_exists(created_at, :now)',
expr_attr_names={
'#user': 'user',
'#status': 'status',
},
expr_attr_values={
':course': course.model_dump(),
':user': user.model_dump(),
':executed': 'EXECUTED',
':now': now_,
}, },
table_name=ORDER_TABLE, table_name=ORDER_TABLE,
) )
@@ -240,6 +268,15 @@ def enroll_now(enrollment: Enrollment, context: Context):
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
exc_cls=SeatNotFoundError, exc_cls=SeatNotFoundError,
) )
# Enrollment should know where it comes from
transact.put(
item={
'id': enrollment.id,
'sk': f'LINKED_ENTITY#PARENT#ORDER#{seat.order_id}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
transact.put( transact.put(
item={ item={
@@ -325,6 +362,7 @@ def _enroll_later(enrollment: Enrollment, context: Context):
scheduled_for = _date_to_midnight(enrollment.scheduled_for) # type: ignore scheduled_for = _date_to_midnight(enrollment.scheduled_for) # type: ignore
dedup_window = enrollment.deduplication_window dedup_window = enrollment.deduplication_window
org = context['org'] org = context['org']
test_mode = context.get('test_mode')
subscription = context.get('subscription') subscription = context.get('subscription')
created_by = context['created_by'] created_by = context['created_by']
lock_hash = md5_hash(f'{user.id}{course.id}') lock_hash = md5_hash(f'{user.id}{course.id}')
@@ -352,6 +390,7 @@ def _enroll_later(enrollment: Enrollment, context: Context):
'scheduled_at': now_, 'scheduled_at': now_,
} }
| ({'seat': seat.model_dump()} if seat else {}) | ({'seat': seat.model_dump()} if seat else {})
| ({'is_test': True} if test_mode else {})
| ( | (
{'dedup_window_offset_days': dedup_window.offset_days} {'dedup_window_offset_days': dedup_window.offset_days}
if dedup_window if dedup_window
@@ -366,6 +405,13 @@ def _enroll_later(enrollment: Enrollment, context: Context):
), ),
) )
if test_mode:
transact.condition(
key=KeyPair(str(org.id), 'METADATA#TEST_MODE'),
cond_expr='attribute_exists(sk)',
exc_cls=TestModeRequiredError,
)
if seat: if seat:
transact.condition( transact.condition(
key=KeyPair(str(seat.order_id), '0'), key=KeyPair(str(seat.order_id), '0'),

View File

@@ -277,6 +277,14 @@ def checkout(payload: Checkout):
'created_at': now_, 'created_at': now_,
} }
) )
transact.put(
item={
'id': order_id,
'sk': 'SCHEDULED#AUTO_CLEANUP',
'ttl': ttl(start_dt=now_, days=7),
'created_at': now_,
}
)
return JSONResponse( return JSONResponse(
body={'id': order_id}, body={'id': order_id},

View File

@@ -10,6 +10,7 @@ from .address import router as address
from .admins import router as admins from .admins import router as admins
from .billing import router as billing from .billing import router as billing
from .custom_pricing import router as custom_pricing from .custom_pricing import router as custom_pricing
from .enrollments.certs import router as certs
from .enrollments.scheduled import router as scheduled from .enrollments.scheduled import router as scheduled
from .enrollments.submissions import router as submissions from .enrollments.submissions import router as submissions
from .seats import router as seats from .seats import router as seats
@@ -23,6 +24,7 @@ __all__ = [
'admins', 'admins',
'billing', 'billing',
'custom_pricing', 'custom_pricing',
'certs',
'scheduled', 'scheduled',
'submissions', 'submissions',
'seats', 'seats',

View File

@@ -0,0 +1,29 @@
from datetime import date
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, KeyPair
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE
logger = Logger(__name__)
router = Router()
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@router.get('/<org_id>/enrollments/certs')
def certs(
org_id: str,
month: Annotated[date, Query()],
):
year_month = month.strftime('%Y-%m')
return dyn.collection.query(
KeyPair(
f'CERT_REPORTING#ORG#{org_id}',
f'MONTH#{year_month}#ENROLLMENT',
),
)

View File

@@ -1,19 +1,27 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated, cast 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.exceptions import NotFoundError from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from aws_lambda_powertools.event_handler.openapi.params import Body, Query from aws_lambda_powertools.event_handler.openapi.params import Body, Query
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
from pydantic import FutureDatetime from pydantic import FutureDatetime
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE from config import ENROLLMENT_TABLE
from routes.orgs import billing from middlewares.authentication_middleware import User as Authenticated
from ...enrollments.enroll import Context, Enrollment, Org, Subscription, enroll_now from ...enrollments.enroll import (
Context,
Enrollment,
Org,
Seat,
Subscription,
enroll_now,
)
logger = Logger(__name__) logger = Logger(__name__)
router = Router() router = Router()
@@ -66,6 +74,7 @@ def proceed(
scheduled_for: Annotated[FutureDatetime, Body(embed=True)], scheduled_for: Annotated[FutureDatetime, Body(embed=True)],
lock_hash: Annotated[str, Body(embed=True)], lock_hash: Annotated[str, Body(embed=True)],
): ):
now_ = now()
pk = f'SCHEDULED#ORG#{org_id}' pk = f'SCHEDULED#ORG#{org_id}'
sk = f'{scheduled_for.isoformat()}#{lock_hash}' sk = f'{scheduled_for.isoformat()}#{lock_hash}'
@@ -73,10 +82,13 @@ def proceed(
KeyPair(pk, sk), KeyPair(pk, sk),
exc_cls=ScheduledNotFoundError, exc_cls=ScheduledNotFoundError,
) )
billing_day = scheduled.get('subscription_billing_day') org = Org(id=org_id, name=scheduled['org_name'])
created_by: Authenticated = router.context['user']
seat: Seat | None = scheduled.get('seat')
billing_day: int | None = scheduled.get('subscription_billing_day')
ctx: Context = { ctx: Context = {
'created_by': router.context['user'], 'created_by': created_by,
'org': Org(id=org_id, name=scheduled['org_name']), 'org': org,
} }
if billing_day: if billing_day:
@@ -87,12 +99,26 @@ def proceed(
Enrollment( Enrollment(
user=scheduled['user'], user=scheduled['user'],
course=scheduled['course'], course=scheduled['course'],
seat=scheduled.get('seat'), seat=seat,
), ),
ctx, ctx,
) )
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.put(
item={
'id': pk,
'sk': f'{sk}#EXECUTED',
'enrollment_id': enrollment.id,
'user': scheduled['user'],
'course': scheduled['course'],
'created_by': {
'id': created_by.id,
'name': created_by.name,
},
'created_at': now_,
}
)
transact.delete(key=KeyPair(pk, sk)) transact.delete(key=KeyPair(pk, sk))
transact.delete(key=KeyPair('LOCK#SCHEDULED', lock_hash)) transact.delete(key=KeyPair('LOCK#SCHEDULED', lock_hash))
except Exception: except Exception:

View File

@@ -3,6 +3,7 @@
import { import {
BookCopyIcon, BookCopyIcon,
CalendarClockIcon, CalendarClockIcon,
FileBadgeIcon,
// FileBadgeIcon, // FileBadgeIcon,
GraduationCap, GraduationCap,
LayoutDashboardIcon, LayoutDashboardIcon,
@@ -65,11 +66,11 @@ const data = {
url: '/enrollments', url: '/enrollments',
icon: GraduationCap icon: GraduationCap
}, },
// { {
// title: 'Certificações', title: 'Certificações',
// url: '/certs', url: '/certs',
// icon: FileBadgeIcon icon: FileBadgeIcon
// }, },
{ {
title: 'Agendamentos', title: 'Agendamentos',
url: '/scheduled', url: '/scheduled',

View File

@@ -1,3 +1,5 @@
export const TZ = 'America/Sao_Paulo' export const TZ = 'America/Sao_Paulo'
export const CACHE_NAME = 'saladeaula.digital'
export const CACHE_TTL_SECONDS = 60 * 10
export const INTERNAL_EMAIL_DOMAIN = 'users.noreply.saladeaula.digital' export const INTERNAL_EMAIL_DOMAIN = 'users.noreply.saladeaula.digital'
export const RYBBIT_SITE_ID = '83748b35413d' export const RYBBIT_SITE_ID = '83748b35413d'

View File

@@ -1,8 +1,9 @@
import { createContext, redirect, type LoaderFunctionArgs } from 'react-router' import { createContext, redirect, type LoaderFunctionArgs } from 'react-router'
import { userContext } from '@repo/auth/context' import { requestIdContext, userContext } from '@repo/auth/context'
import { request as req } from '@repo/util/request' import { request as req } from '@repo/util/request'
import { CACHE_NAME, CACHE_TTL_SECONDS } from '@/conf'
import type { Address } from '@/routes/_.$orgid.enrollments.buy/review' import type { Address } from '@/routes/_.$orgid.enrollments.buy/review'
export type Subscription = { export type Subscription = {
@@ -41,18 +42,21 @@ export const workspaceMiddleware = async (
next: () => Promise<Response> next: () => Promise<Response>
): Promise<Response> => { ): Promise<Response> => {
const orgId = params.orgid const orgId = params.orgid
const requestId = context.get(requestIdContext)
const user = context.get(userContext)! const user = context.get(userContext)!
const cacheKey = buildWorkspaceCacheKey(request, user.sub, orgId) const cacheKey = buildWorkspaceCacheKey(request, user.sub, orgId)
try {
const cached = await getWorkspaceFromCache(cacheKey) const cached = await getWorkspaceFromCache(cacheKey)
if (cached) { if (cached) {
console.log('Warm start')
context.set(workspaceContext, cached) context.set(workspaceContext, cached)
return next() return next()
} }
} catch {}
console.log('Cold start') console.log(`[${new Date().toISOString()}] [${requestId}] Cache miss`)
const r = await req({ const r = await req({
url: `/users/${user.sub}/orgs?limit=25`, url: `/users/${user.sub}/orgs?limit=25`,
@@ -122,7 +126,7 @@ function buildWorkspaceCacheKey(
async function getWorkspaceFromCache( async function getWorkspaceFromCache(
key: Request key: Request
): Promise<WorkspaceContextProps | null> { ): Promise<WorkspaceContextProps | null> {
const cache: Cache = await caches.open('saladeaula.digital') const cache: Cache = await caches.open(CACHE_NAME)
const cached: Response | undefined = await cache.match(key) const cached: Response | undefined = await cache.match(key)
if (!cached) { if (!cached) {
@@ -136,12 +140,12 @@ async function saveToCache(
key: Request, key: Request,
workspace: WorkspaceContextProps workspace: WorkspaceContextProps
): Promise<void> { ): Promise<void> {
const cache: Cache = await caches.open('saladeaula.digital') const cache: Cache = await caches.open(CACHE_NAME)
const response: Response = new Response(JSON.stringify(workspace), { const response = new Response(JSON.stringify(workspace), {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${60 * 10}` 'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}`
} }
}) })

View File

@@ -76,7 +76,6 @@ export default function Route({
const search = searchParams.get('s') as string const search = searchParams.get('s') as string
return ( return (
<>
<Suspense fallback={<Skeleton />}> <Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8"> <div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
@@ -105,8 +104,8 @@ export default function Route({
defaultValue={search || ''} defaultValue={search || ''}
placeholder={ placeholder={
<> <>
Digite <Kbd className="border font-mono">/</Kbd>{' '} Digite <Kbd className="border font-mono">/</Kbd> para
para pesquisar pesquisar
</> </>
} }
onChange={(value) => onChange={(value) =>
@@ -142,7 +141,6 @@ export default function Route({
}} }}
</Await> </Await>
</Suspense> </Suspense>
</>
) )
} }
@@ -217,7 +215,7 @@ function List({ items, search }) {
{charges.length ? ( {charges.length ? (
<> <>
<TableHeader> <TableHeader>
<TableRow className="bg-muted-foreground/10 pointer-events-none"> <TableRow className=" pointer-events-none">
<TableHead>Colaborador</TableHead> <TableHead>Colaborador</TableHead>
<TableHead>Curso</TableHead> <TableHead>Curso</TableHead>
<TableHead>Matriculado por</TableHead> <TableHead>Matriculado por</TableHead>

View File

@@ -1,12 +1,51 @@
import type { Route } from './+types/route' import type { Route } from './+types/route'
import { Suspense } from 'react'
import { Await } from 'react-router'
import { DateTime } from '@repo/ui/components/datetime'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Card, CardContent } from '@repo/ui/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@repo/ui/components/ui/table'
import { request as req } from '@repo/util/request'
export function meta({}) { export function meta({}) {
return [{ title: 'Certificações' }] return [{ title: 'Certificações' }]
} }
export default function Route({}: Route.ComponentProps) { const dtOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit'
}
export async function loader({ context, request, params }: Route.LoaderArgs) {
const { searchParams } = new URL(request.url)
const month =
searchParams.get('month') || new Date().toISOString().slice(0, 10)
const reporting = req({
url: `/orgs/${params.orgid}/enrollments/certs?month=${month}`,
context,
request
}).then((r) => r.json())
return {
reporting
}
}
export default function Route({
loaderData: { reporting }
}: Route.ComponentProps) {
return ( return (
<> <Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8"> <div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
Gerenciar certificações Gerenciar certificações
@@ -16,6 +55,74 @@ export default function Route({}: Route.ComponentProps) {
prazos e renovações com facilidade. prazos e renovações com facilidade.
</p> </p>
</div> </div>
</>
<Card>
<CardContent>
<Table>
<TableHeader>
<TableRow className=" pointer-events-none">
<TableHead>Colaborador</TableHead>
<TableHead>Curso</TableHead>
<TableHead>Matriculado em</TableHead>
<TableHead>Concluído em</TableHead>
<TableHead>Cert. válido até</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<Await resolve={reporting}>
{({ items = [] }) => {
return (
<>
{items.map(
(
{
course,
user,
enrolled_at,
completed_at,
expires_at
},
idx
) => {
return (
<TableRow key={idx}>
<TableCell>{user.name}</TableCell>
<TableCell>{course.name}</TableCell>
<TableCell>
<DateTime options={dtOptions}>
{enrolled_at}
</DateTime>
</TableCell>
<TableCell>
<DateTime options={dtOptions}>
{completed_at}
</DateTime>
</TableCell>
<TableCell>
<DateTime options={dtOptions}>
{expires_at}
</DateTime>
</TableCell>
</TableRow>
)
}
)}
{items.length === 0 && (
<TableRow className="pointer-events-none">
<TableCell className="h-24 text-center" colSpan={5}>
Nenhum resultado.
</TableCell>
</TableRow>
)}
</>
)
}}
</Await>
</TableBody>
</Table>
</CardContent>
</Card>
</Suspense>
) )
} }

View File

@@ -18,6 +18,7 @@ import { Kbd } from '@repo/ui/components/ui/kbd'
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data' import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
import { createSearch } from '@repo/util/meili' import { createSearch } from '@repo/util/meili'
import { workspaceContext } from '@/middleware/workspace'
import { columns, type Enrollment } from './columns' import { columns, type Enrollment } from './columns'
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
@@ -26,6 +27,7 @@ export function meta({}: Route.MetaArgs) {
export async function loader({ params, context, request }: Route.LoaderArgs) { export async function loader({ params, context, request }: Route.LoaderArgs) {
const cloudflare = context.get(cloudflareContext) const cloudflare = context.get(cloudflareContext)
const { test_mode } = context.get(workspaceContext)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const { orgid } = params const { orgid } = params
const query = searchParams.get('q') || '' const query = searchParams.get('q') || ''
@@ -36,7 +38,9 @@ export async function loader({ params, context, request }: Route.LoaderArgs) {
const page = Number(searchParams.get('p')) + 1 const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25 const hitsPerPage = Number(searchParams.get('perPage')) || 25
let builder = new MeiliSearchFilterBuilder().where('org_id', '=', orgid) let builder = new MeiliSearchFilterBuilder()
.where('org_id', '=', orgid)
.where('is_test', 'exists', test_mode)
if (status) { if (status) {
builder = builder.where('status', 'in', status.split(',')) builder = builder.where('status', 'in', status.split(','))

View File

@@ -7,13 +7,7 @@ import {
CheckIcon, CheckIcon,
ChevronsUpDownIcon ChevronsUpDownIcon
} from 'lucide-react' } from 'lucide-react'
import { import { forwardRef, useMemo, useState, type InputHTMLAttributes } from 'react'
forwardRef,
use,
useMemo,
useState,
type InputHTMLAttributes
} from 'react'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { import {
@@ -45,7 +39,7 @@ interface CoursePickerProps extends Omit<
'value' | 'onChange' 'value' | 'onChange'
> { > {
value?: Course value?: Course
options: Promise<{ hits: any[] }> options: any[]
onChange?: (value: any) => void onChange?: (value: any) => void
error?: any error?: any
} }
@@ -59,12 +53,11 @@ const normalize = (value: string) =>
export const CoursePicker = forwardRef<HTMLInputElement, CoursePickerProps>( export const CoursePicker = forwardRef<HTMLInputElement, CoursePickerProps>(
({ value, onChange, options, error, ...props }, ref) => { ({ value, onChange, options, error, ...props }, ref) => {
const { hits } = use(options)
const [search, setSearch] = useState<string>('') const [search, setSearch] = useState<string>('')
const [open, { set }] = useToggle() const [open, { set }] = useToggle()
const [sort, { toggle }] = useToggle('a-z', 'z-a') const [sort, { toggle }] = useToggle('a-z', 'z-a')
const fuse = useMemo(() => { const fuse = useMemo(() => {
return new Fuse(hits, { return new Fuse(options, {
keys: ['name'], keys: ['name'],
threshold: 0.3, threshold: 0.3,
includeMatches: true, includeMatches: true,
@@ -73,11 +66,11 @@ export const CoursePicker = forwardRef<HTMLInputElement, CoursePickerProps>(
return typeof value === 'string' ? normalize(value) : value return typeof value === 'string' ? normalize(value) : value
} }
}) })
}, [hits]) }, [options])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) { if (!search) {
return [...hits].sort((a, b) => { return [...options].sort((a, b) => {
const comparison = a.name.localeCompare(b.name) const comparison = a.name.localeCompare(b.name)
return sort === 'a-z' ? comparison : -comparison return sort === 'a-z' ? comparison : -comparison
}) })
@@ -87,7 +80,7 @@ export const CoursePicker = forwardRef<HTMLInputElement, CoursePickerProps>(
...item, ...item,
matches matches
})) }))
}, [search, fuse, hits, sort]) }, [search, fuse, options, sort])
return ( return (
<Popover open={open} onOpenChange={set}> <Popover open={open} onOpenChange={set}>
@@ -149,13 +142,15 @@ export const CoursePicker = forwardRef<HTMLInputElement, CoursePickerProps>(
name, name,
access_period, access_period,
metadata__unit_price: unit_price, metadata__unit_price: unit_price,
quantity = null quantity = null,
disabled = false
}) => { }) => {
return ( return (
<CommandItem <CommandItem
key={id} key={id}
value={id} value={id}
className="cursor-pointer" className="cursor-pointer"
disabled={disabled}
onSelect={() => { onSelect={() => {
onChange?.({ onChange?.({
id, id,

View File

@@ -4,6 +4,7 @@ import { z } from 'zod'
export const MAX_ITEMS = 50 export const MAX_ITEMS = 50
export const enrollment = z.object({ export const enrollment = z.object({
id: z.uuidv4().optional(),
user: z user: z
.object( .object(
{ {
@@ -34,7 +35,8 @@ export const enrollment = z.object({
scheduled_for: z scheduled_for: z
.date() .date()
.optional() .optional()
.transform((date) => (date ? format(date, 'yyyy-MM-dd') : undefined)) .transform((date) => (date ? format(date, 'yyyy-MM-dd') : undefined)),
seat: z.object({ order_id: z.uuidv4() }).optional()
}) })
export const formSchema = z.object({ export const formSchema = z.object({

View File

@@ -131,8 +131,9 @@ export async function action({ params, request, context }: Route.ActionArgs) {
} }
export default function Route({ export default function Route({
loaderData: { courses, submission } loaderData: { courses: courses_, submission }
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { hits: courses } = use(courses_)
const { orgid } = useParams() const { orgid } = useParams()
const { enrolled } = use(submission) const { enrolled } = use(submission)
const fetcher = useFetcher() const fetcher = useFetcher()

View File

@@ -1,48 +1,48 @@
import { Fragment, useEffect } from 'react'
import {
Trash2Icon,
PlusIcon,
CircleQuestionMarkIcon,
ArrowRightIcon
} from 'lucide-react'
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form'
import { useParams } from 'react-router'
import { ErrorMessage } from '@hookform/error-message' import { ErrorMessage } from '@hookform/error-message'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { DateTime } from 'luxon'
import { Form } from '@repo/ui/components/ui/form'
import { import {
InputGroup, ArrowRightIcon,
InputGroupAddon, CircleQuestionMarkIcon,
InputGroupInput PlusIcon,
} from '@repo/ui/components/ui/input-group' Trash2Icon
} from 'lucide-react'
import { DateTime } from 'luxon'
import { Fragment, use, useEffect } from 'react'
import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'
import { useParams } from 'react-router'
import { z } from 'zod'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { Separator } from '@repo/ui/components/ui/separator' import { Form } from '@repo/ui/components/ui/form'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { import {
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
HoverCardTrigger HoverCardTrigger
} from '@repo/ui/components/ui/hover-card' } from '@repo/ui/components/ui/hover-card'
import { TZ } from '@/conf'
import { import {
MAX_ITEMS, InputGroup,
InputGroupAddon,
InputGroupInput
} from '@repo/ui/components/ui/input-group'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { useWizard } from '@/components/wizard'
import { TZ } from '@/conf'
import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker'
import {
formSchema, formSchema,
MAX_ITEMS,
type Course, type Course,
type User type User
} from '../_.$orgid.enrollments.add/data' } from '../_.$orgid.enrollments.add/data'
import { ScheduledForInput } from '../_.$orgid.enrollments.add/scheduled-for'
import { Cell } from '../_.$orgid.enrollments.add/route' import { Cell } from '../_.$orgid.enrollments.add/route'
import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker' import { ScheduledForInput } from '../_.$orgid.enrollments.add/scheduled-for'
import { UserPicker } from '../_.$orgid.enrollments.add/user-picker' import { UserPicker } from '../_.$orgid.enrollments.add/user-picker'
import { Summary } from './bulk' import { Summary } from './bulk'
import { currency } from './utils'
import { useWizard } from '@/components/wizard'
import { useWizardStore } from './store' import { useWizardStore } from './store'
import { currency } from './utils'
const emptyRow = { const emptyRow = {
user: undefined, user: undefined,
@@ -56,8 +56,9 @@ type AssignedProps = {
courses: Promise<{ hits: Course[] }> courses: Promise<{ hits: Course[] }>
} }
export function Assigned({ courses }: AssignedProps) { export function Assigned({ courses: courses_ }: AssignedProps) {
const wizard = useWizard() const wizard = useWizard()
const { hits: courses } = use(courses_)
const { orgid } = useParams() const { orgid } = useParams()
const { update, ...state } = useWizardStore() const { update, ...state } = useWizardStore()
const form = useForm({ const form = useForm({
@@ -76,7 +77,7 @@ export function Assigned({ courses }: AssignedProps) {
} }
}) })
const { formState, control, handleSubmit, setValue } = form const { formState, control, handleSubmit } = form
const { fields, remove, append } = useFieldArray({ const { fields, remove, append } = useFieldArray({
control, control,
name: 'enrollments' name: 'enrollments'

View File

@@ -7,7 +7,7 @@ import {
Trash2Icon, Trash2Icon,
XIcon XIcon
} from 'lucide-react' } from 'lucide-react'
import { Fragment, useEffect } from 'react' import { Fragment, use, useEffect } from 'react'
import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form' import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'
import { z } from 'zod' import { z } from 'zod'
@@ -64,7 +64,8 @@ type BulkProps = {
courses: Promise<{ hits: Course[] }> courses: Promise<{ hits: Course[] }>
} }
export function Bulk({ courses }: BulkProps) { export function Bulk({ courses: courses_ }: BulkProps) {
const { hits: courses } = use(courses_)
const wizard = useWizard() const wizard = useWizard()
const { update, ...state } = useWizardStore() const { update, ...state } = useWizardStore()
const form = useForm({ const form = useForm({

View File

@@ -44,7 +44,6 @@ import { HttpMethod, request as req } from '@repo/util/request'
import { Step, StepItem, StepSeparator } from '@/components/step' import { Step, StepItem, StepSeparator } from '@/components/step'
import { Wizard, WizardStep } from '@/components/wizard' import { Wizard, WizardStep } from '@/components/wizard'
import { useWorksapce } from '@/components/workspace-switcher' import { useWorksapce } from '@/components/workspace-switcher'
import { INTERNAL_EMAIL_DOMAIN } from '@/conf'
import { workspaceContext } from '@/middleware/workspace' import { workspaceContext } from '@/middleware/workspace'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
@@ -190,7 +189,10 @@ export default function Route({
<Button size="sm" variant="outline" asChild> <Button size="sm" variant="outline" asChild>
<NavLink to="../enrollments/seats"> <NavLink to="../enrollments/seats">
{({ isPending }) => ( {({ isPending }) => (
<>{isPending ? <Spinner /> : <PlusIcon />} Matricular</> <>
{isPending ? <Spinner /> : <PlusIcon />}
<span className="max-lg:hidden">Matricular</span>
</>
)} )}
</NavLink> </NavLink>
</Button> </Button>

View File

@@ -9,8 +9,8 @@ import {
Trash2Icon Trash2Icon
} from 'lucide-react' } from 'lucide-react'
import { Fragment, useMemo } from 'react' import { Fragment, useMemo } from 'react'
import { Controller, useFieldArray, useForm } from 'react-hook-form' import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'
import { Link, redirect, useParams } from 'react-router' import { Link, redirect, useFetcher, useParams } from 'react-router'
import { import {
Breadcrumb, Breadcrumb,
@@ -34,7 +34,9 @@ import {
HoverCardTrigger HoverCardTrigger
} from '@repo/ui/components/ui/hover-card' } from '@repo/ui/components/ui/hover-card'
import { Kbd } from '@repo/ui/components/ui/kbd' import { Kbd } from '@repo/ui/components/ui/kbd'
import { request as req } from '@repo/util/request' import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { HttpMethod, request as req } from '@repo/util/request'
import { workspaceContext } from '@/middleware/workspace' import { workspaceContext } from '@/middleware/workspace'
import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker' import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker'
@@ -42,6 +44,7 @@ import {
formSchema, formSchema,
MAX_ITEMS, MAX_ITEMS,
type Course, type Course,
type Schema,
type User type User
} from '../_.$orgid.enrollments.add/data' } from '../_.$orgid.enrollments.add/data'
import { import {
@@ -57,9 +60,8 @@ export function meta({}: Route.MetaArgs) {
} }
type Seat = { type Seat = {
id: string order_id: string
pk: string enrollment_id: string
course: Course
} }
export async function loader({ request, params, context }: Route.LoaderArgs) { export async function loader({ request, params, context }: Route.LoaderArgs) {
@@ -75,13 +77,31 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
context context
}) })
.then((r) => r.json() as any) .then((r) => r.json() as any)
.then(({ items }) => items as Seat[]) .then(({ items }) => items as { sk: string; course: Course }[])
return { seats } return { seats }
} }
export async function action({ params, request, context }: Route.ActionArgs) {
const { orgid: org_id } = params
const body = (await request.json()) as object
const r = await req({
url: `enrollments`,
headers: new Headers({ 'Content-Type': 'application/json' }),
method: HttpMethod.POST,
body: JSON.stringify({ org_id, ...body }),
request,
context
})
const data = (await r.json()) as { sk: string }
return redirect(`/${org_id}/enrollments/${data.sk}/submitted`)
}
export default function Route({ loaderData: { seats } }: Route.ComponentProps) { export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
const { orgid } = useParams() const { orgid } = useParams()
const fetcher = useFetcher()
const form = useForm({ const form = useForm({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { enrollments: [emptyRow] } defaultValues: { enrollments: [emptyRow] }
@@ -92,34 +112,80 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
name: 'enrollments' name: 'enrollments'
}) })
const courses = useMemo( const enrollments = useWatch({
() => control,
Promise.resolve({ name: 'enrollments'
})
const usedSeatIds = useMemo(() => {
return new Set(enrollments?.map((e) => e.id).filter(Boolean))
}, [enrollments])
const seatsByCourse = useMemo(() => {
return seats.reduce<Record<string, Seat[]>>((acc, seat) => {
const courseId = seat.course.id
const [, order_id, , enrollment_id] = seat.sk.split('#')
if (!acc[courseId]) {
acc[courseId] = []
}
acc[courseId].push({ order_id, enrollment_id })
return acc
}, {})
}, [seats])
const usedSeatsByCourse = useMemo(() => {
const acc = new Map<string, number>()
seats.forEach((seat) => {
const [, , , enrollment_id] = seat.sk.split('#')
if (!usedSeatIds.has(enrollment_id)) {
return
}
const courseId = seat.course.id
acc.set(courseId, (acc.get(courseId) ?? 0) + 1)
})
return acc
}, [seats, usedSeatIds])
const courses = useMemo(() => {
return {
hits: Array.from( hits: Array.from(
seats seats
.reduce((map, { course }) => { .reduce((acc, { course }) => {
const existing = map.get(course.id) const existing = acc.get(course.id)
if (existing) { if (existing) {
existing.quantity += 1 existing.quantity += 1
} else { } else {
map.set(course.id, { acc.set(course.id, {
...course, ...course,
metadata__unit_price: 1, metadata__unit_price: 1,
quantity: 1 quantity: 1,
disabled: false
}) })
} }
return map return acc
}, new Map()) }, new Map<string, any>())
.values() .values()
) ).map((course) => {
}), const used = usedSeatsByCourse.get(course.id) ?? 0
[seats] return { ...course, disabled: used >= course.quantity }
) })
}
console.log(seats) }, [seats, usedSeatsByCourse])
const onSubmit = async (data: Schema) => {
await fetcher.submit(JSON.stringify(data), {
method: 'post',
encType: 'application/json'
})
}
const onSearch = async (search: string) => { const onSearch = async (search: string) => {
const params = new URLSearchParams({ q: search }) const params = new URLSearchParams({ q: search })
const r = await fetch(`/${orgid}/users.json?${params.toString()}`) const r = await fetch(`/${orgid}/users.json?${params.toString()}`)
@@ -127,15 +193,58 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
return hits return hits
} }
const pickSeat = (courseId: string): Seat | null => {
const pool = seatsByCourse[courseId]
if (!pool) {
return null
}
return (
pool.find((seat) => {
return !usedSeatIds.has(seat.enrollment_id)
}) ?? null
)
}
const duplicateRow = (index: number, times: number = 1) => { const duplicateRow = (index: number, times: number = 1) => {
if (fields.length + times > MAX_ITEMS) { if (fields.length + times > MAX_ITEMS) {
return null return null
} }
const { user, ...rest } = getValues(`enrollments.${index}`) const { course, scheduled_for } = getValues(`enrollments.${index}`)
if (!course?.id) {
Array.from({ length: times }, (_, i) => { Array.from({ length: times }, (_, i) => {
// @ts-ignore // @ts-ignore
insert(index + 1 + i, rest) insert(index + 1 + i, { course: null })
})
return null
}
const reservedSeatIds = new Set(usedSeatIds)
Array.from({ length: times }, (_, i) => {
const pool = seatsByCourse[course.id]
const seat =
pool?.find((seat) => !reservedSeatIds.has(seat.enrollment_id)) ?? null
if (seat) {
reservedSeatIds.add(seat.enrollment_id)
// @ts-ignore
insert(index + 1 + i, {
id: seat.enrollment_id,
seat: { order_id: seat.order_id },
course,
scheduled_for
})
} else {
// @ts-ignore
insert(index + 1 + i, { course: null })
}
}) })
} }
@@ -154,7 +263,13 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<form
onSubmit={handleSubmit(onSubmit)}
className="lg:max-w-4xl mx-auto space-y-2.5"
autoComplete="off"
data-1p-ignore="true"
data-lpignore="true"
>
<Card className="lg:max-w-4xl mx-auto"> <Card className="lg:max-w-4xl mx-auto">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">Adicionar matrículas</CardTitle> <CardTitle className="text-2xl">Adicionar matrículas</CardTitle>
@@ -242,8 +357,26 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
<div className="grid gap-1"> <div className="grid gap-1">
<CoursePicker <CoursePicker
value={value} value={value}
onChange={onChange} onChange={(course) => {
options={courses} const seat = pickSeat(course.id)
if (!seat) {
return
}
setValue(
`enrollments.${index}.id`,
seat.enrollment_id
)
setValue(
`enrollments.${index}.seat.order_id`,
seat.order_id
)
onChange(course)
}}
options={courses.hits}
error={fieldState.error} error={fieldState.error}
readOnly readOnly
/> />
@@ -314,8 +447,22 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
<PlusIcon /> Adicionar <PlusIcon /> Adicionar
</Button> </Button>
</div> </div>
<Separator />
<div className="flex justify-end">
<Button
type="submit"
className="cursor-pointer"
disabled={formState.isSubmitting}
>
{formState.isSubmitting && <Spinner />}
Matricular
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
</form>
</div> </div>
) )
} }

View File

@@ -3,7 +3,7 @@ import type { Route } from './+types/route'
import { redirect } from 'react-router' import { redirect } from 'react-router'
import { createAuth, type User } from '@repo/auth/auth' import { createAuth, type User } from '@repo/auth/auth'
import { requestIdContext, cloudflareContext } from '@repo/auth/context' import { cloudflareContext, requestIdContext } from '@repo/auth/context'
import { createSessionStorage } from '@repo/auth/session' import { createSessionStorage } from '@repo/auth/session'
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
@@ -13,6 +13,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const sessionStorage = createSessionStorage(cloudflare.env) const sessionStorage = createSessionStorage(cloudflare.env)
const session = await sessionStorage.getSession(request.headers.get('cookie')) const session = await sessionStorage.getSession(request.headers.get('cookie'))
const user = session.get('user') const user = session.get('user')
const now = new Date().toISOString()
const returnTo = (session.get('returnTo') as string | undefined) ?? '/' const returnTo = (session.get('returnTo') as string | undefined) ?? '/'
if (user) { if (user) {
@@ -26,9 +27,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
request request
)) as User )) as User
session.set('user', authenticatedUser) session.set('user', authenticatedUser)
session.unset('returnTo')
console.log(`[${requestId}] Redirecting the user to ${returnTo}`) console.log(`[${now}] [${requestId}] Redirecting the user to ${returnTo}`)
// Redirect to the home page after successful login // Redirect to the home page after successful login
return redirect(returnTo, { return redirect(returnTo, {

View File

@@ -42,6 +42,7 @@
"@types/node": "^25.0.8", "@types/node": "^25.0.8",
"@types/react": "^19.2.8", "@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.18",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"remix-flat-routes": "^0.8.5", "remix-flat-routes": "^0.8.5",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",

View File

@@ -1,36 +1,36 @@
import type { Route } from './+types/route' import type { Route } from './+types/route'
import { pick } from 'ramda' import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { import {
CalendarIcon,
PlusCircleIcon,
BuildingIcon, BuildingIcon,
CheckIcon CalendarIcon,
CheckIcon,
PlusCircleIcon
} from 'lucide-react' } from 'lucide-react'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper' import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { pick } from 'ramda'
import { Suspense, useState } from 'react' import { Suspense, useState } from 'react'
import { Await, Outlet, useSearchParams } from 'react-router' import { Await, Outlet, useSearchParams } from 'react-router'
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { cloudflareContext } from '@repo/auth/context' import { cloudflareContext } from '@repo/auth/context'
import { Abbr } from '@repo/ui/components/abbr'
import { DataTable, DataTableViewOptions } from '@repo/ui/components/data-table' import { DataTable, DataTableViewOptions } from '@repo/ui/components/data-table'
import { ExportMenu } from '@repo/ui/components/export-menu'
import { FacetedFilter } from '@repo/ui/components/faceted-filter' import { FacetedFilter } from '@repo/ui/components/faceted-filter'
import { RangeCalendarFilter } from '@repo/ui/components/range-calendar-filter' import { RangeCalendarFilter } from '@repo/ui/components/range-calendar-filter'
import { SearchForm } from '@repo/ui/components/search-form'
import { SearchFilter } from '@repo/ui/components/search-filter' import { SearchFilter } from '@repo/ui/components/search-filter'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { ExportMenu } from '@repo/ui/components/export-menu'
import { createSearch } from '@repo/util/meili'
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
import { cn, initials } from '@repo/ui/lib/utils'
import { CommandItem } from '@repo/ui/components/ui/command'
import { Button } from '@repo/ui/components/ui/button'
import { Separator } from '@repo/ui/components/ui/separator'
import { Badge } from '@repo/ui/components/ui/badge'
import { Abbr } from '@repo/ui/components/abbr'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' 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 { CommandItem } from '@repo/ui/components/ui/command'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { cn, initials } from '@repo/ui/lib/utils'
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
import { createSearch } from '@repo/util/meili'
import { columns, type Enrollment } from './columns' import { columns, type Enrollment } from './columns'
@@ -50,7 +50,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
const page = Number(searchParams.get('p')) + 1 const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25 const hitsPerPage = Number(searchParams.get('perPage')) || 25
let builder = new MeiliSearchFilterBuilder() let builder = new MeiliSearchFilterBuilder().where('is_test', 'exists', false)
if (status) { if (status) {
builder = builder.where('status', 'in', status.split(',')) builder = builder.where('status', 'in', status.split(','))

View File

@@ -11,8 +11,8 @@ BUCKET_NAME: str = os.getenv('BUCKET_NAME') # type: ignore
EMAIL_SENDER = ('EDUSEG®', 'noreply@eduseg.com.br') EMAIL_SENDER = ('EDUSEG®', 'noreply@eduseg.com.br')
HTTP_CONNECT_TIMEOUT = int(os.environ.get('HTTP_CONNECT_TIMEOUT', 1)) HTTP_CONNECT_TIMEOUT = int(os.environ.get('HTTP_CONNECT_TIMEOUT', 2))
HTTP_READ_TIMEOUT = int(os.environ.get('HTTP_READ_TIMEOUT', 3)) HTTP_READ_TIMEOUT = int(os.environ.get('HTTP_READ_TIMEOUT', 6))
PAPERFORGE_API = 'https://paperforge.saladeaula.digital' PAPERFORGE_API = 'https://paperforge.saladeaula.digital'
CERT_REPORTING_URI = 's3://saladeaula.digital/certs/reporting.html' CERT_REPORTING_URI = 's3://saladeaula.digital/certs/reporting.html'

View File

@@ -1,9 +1,5 @@
from abc import ABC
from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from typing import Literal, TypedDict
from typing import Any, Literal, TypedDict
from uuid import uuid4
from layercake.dateutils import now, ttl from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
@@ -27,7 +23,6 @@ class User(BaseModel):
id: UUID4 | str id: UUID4 | str
name: NameStr name: NameStr
email: EmailStr email: EmailStr
email_verified: bool = False
cpf: CpfStr | None = None cpf: CpfStr | None = None
@@ -44,18 +39,6 @@ class Enrollment(BaseModel):
progress: int = Field(default=0, ge=0, le=100) progress: int = Field(default=0, ge=0, le=100)
status: Literal['PENDING'] = 'PENDING' status: Literal['PENDING'] = 'PENDING'
def model_dump(
self,
exclude=None,
*args,
**kwargs,
) -> dict[str, Any]:
return super().model_dump(
exclude={'user': {'email_verified'}},
*args,
**kwargs,
)
Org = TypedDict('Org', {'org_id': str, 'name': str}) Org = TypedDict('Org', {'org_id': str, 'name': str})
@@ -75,18 +58,6 @@ Subscription = TypedDict(
) )
class Kind(str, Enum):
ORDER = 'ORDER'
ENROLLMENT = 'ENROLLMENT'
@dataclass(frozen=True)
class LinkedEntity(ABC):
id: str
kind: Kind
table_name: str | None = None
class DeduplicationConflictError(Exception): class DeduplicationConflictError(Exception):
def __init__(self, *args): def __init__(self, *args):
super().__init__('Enrollment already exists') super().__init__('Enrollment already exists')
@@ -121,7 +92,7 @@ def enroll(
created_by: CreatedBy | None = None, created_by: CreatedBy | None = None,
scheduled_at: datetime | None = None, scheduled_at: datetime | None = None,
seat: Seat | None = None, seat: Seat | None = None,
linked_entities: frozenset[LinkedEntity] = frozenset(), parent_entity: str | None = None,
deduplication_window: DeduplicationWindow | None = None, deduplication_window: DeduplicationWindow | None = None,
persistence_layer: DynamoDBPersistenceLayer, persistence_layer: DynamoDBPersistenceLayer,
) -> bool: ) -> bool:
@@ -156,43 +127,51 @@ def enroll(
) )
if seat: if seat:
order_id = seat['order_id']
transact.condition( transact.condition(
key=KeyPair(str(seat['order_id']), '0'), key=KeyPair(order_id, '0'),
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
exc_cls=OrderNotFoundError, exc_cls=OrderNotFoundError,
table_name=ORDER_TABLE, table_name=ORDER_TABLE,
) )
transact.put( transact.put(
item={ item={
'id': seat['order_id'], 'id': order_id,
'sk': f'ENROLLMENT#{enrollment.id}', 'sk': f'ENROLLMENT#{enrollment.id}',
'course': course.model_dump(), 'course': course.model_dump(),
'user': user.model_dump(), 'user': user.model_dump(exclude={'cpf'}),
'status': 'EXECUTED', 'status': 'EXECUTED',
'executed_at': now_, 'executed_at': now_,
'created_at': now_, 'created_at': now_,
}, },
table_name=ORDER_TABLE, table_name=ORDER_TABLE,
) )
# Enrollment should know where it comes from
# Relationships between this enrollment and its related entities
for entity in linked_entities:
# Parent knows the child
transact.put( transact.put(
item={ item={
'id': entity.id, 'id': enrollment.id,
'sk': f'LINKED_ENTITIES#CHILD#ENROLLMENT#{enrollment.id}', 'sk': f'LINKED_ENTITY#PARENT#ORDER#{order_id}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
if parent_entity:
# Parent knows the child
transact.put(
item={
'id': parent_entity,
'sk': f'LINKED_ENTITY#CHILD#ENROLLMENT#{enrollment.id}',
'created_at': now_, 'created_at': now_,
}, },
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',
table_name=entity.table_name,
) )
# Child knows the parent # Child knows the parent
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
'sk': f'LINKED_ENTITIES#PARENT#{entity.kind.value}#{entity.id}', 'sk': f'LINKED_ENTITY#PARENT#ENROLLMENT#{parent_entity}',
'created_at': now_, 'created_at': now_,
}, },
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',

View File

@@ -21,8 +21,6 @@ from config import COURSE_TABLE, ENROLLMENT_TABLE, ORDER_TABLE
from enrollment import ( from enrollment import (
Course, Course,
Enrollment, Enrollment,
Kind,
LinkedEntity,
User, User,
enroll, enroll,
) )
@@ -91,19 +89,7 @@ def _handler(course: Course, context: dict) -> Enrollment:
course=course, course=course,
) )
enroll( enroll(enrollment, persistence_layer=enrollment_layer)
enrollment,
persistence_layer=enrollment_layer,
linked_entities=frozenset(
{
LinkedEntity(
id=context['order_id'],
kind=Kind.ORDER,
table_name=ORDER_TABLE,
),
}
),
)
return enrollment return enrollment

View File

@@ -14,8 +14,6 @@ from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE, ORDER_TABLE from config import ENROLLMENT_TABLE, ORDER_TABLE
from enrollment import ( from enrollment import (
Enrollment, Enrollment,
Kind,
LinkedEntity,
Seat, Seat,
Subscription, Subscription,
enroll, enroll,
@@ -54,21 +52,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
) )
try: try:
# The enrollment must know its source
linked_entities = (
frozenset(
{
LinkedEntity(
id=seat['order_id'],
kind=Kind.ORDER,
table_name=ORDER_TABLE,
),
},
)
if seat
else frozenset()
)
enroll( enroll(
enrollment, enrollment,
org={ org={
@@ -82,7 +65,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
scheduled_at=datetime.fromisoformat(old_image['scheduled_at']), scheduled_at=datetime.fromisoformat(old_image['scheduled_at']),
# Transfer the deduplication window if it exists # Transfer the deduplication window if it exists
deduplication_window={'offset_days': offset_days} if offset_days else None, deduplication_window={'offset_days': offset_days} if offset_days else None,
linked_entities=linked_entities,
persistence_layer=dyn, persistence_layer=dyn,
) )
@@ -105,7 +87,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
), ),
) )
except Exception as exc: except Exception as exc:
dyn.put_item( with dyn.transact_writer() as transact:
transact.put(
item={ item={
'id': old_image['id'], 'id': old_image['id'],
'sk': f'{sk}#FAILED', 'sk': f'{sk}#FAILED',
@@ -118,4 +101,35 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
} }
) )
if seat:
order_id = seat['order_id']
transact.update(
key=KeyPair(
pk=order_id,
sk=f'ENROLLMENT#{enrollment.id}',
),
cond_expr='attribute_exists(sk) AND #status = :scheduled',
update_expr='SET #status = :rollback, \
rollback_at = :now, \
reason = :reason',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':rollback': 'ROLLBACK',
':scheduled': 'SCHEDULED',
':reason': 'DEDUPLICATION',
':now': now_,
},
table_name=ORDER_TABLE,
)
transact.put(
item={
'id': f'SEAT#ORG#{org_id}',
'sk': f'ORDER#{order_id}#ENROLLMENT#{uuid4()}',
'course': enrollment.course.model_dump(),
'created_at': now_,
}
)
return True return True

View File

@@ -13,8 +13,6 @@ from config import ENROLLMENT_TABLE
from enrollment import ( from enrollment import (
Course, Course,
Enrollment, Enrollment,
Kind,
LinkedEntity,
SubscriptionFrozenError, SubscriptionFrozenError,
User, User,
enroll, enroll,
@@ -64,14 +62,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
# Reuse the deduplication window if it exists # Reuse the deduplication window if it exists
deduplication_window={'offset_days': offset_days} if offset_days else None, deduplication_window={'offset_days': offset_days} if offset_days else None,
# The enrollment must know its source # The enrollment must know its source
linked_entities=frozenset( parent_entity=new_image['id'],
{
LinkedEntity(
id=new_image['id'],
kind=Kind.ENROLLMENT,
),
},
),
persistence_layer=dyn, persistence_layer=dyn,
) )
except SubscriptionFrozenError: except SubscriptionFrozenError:

View File

@@ -30,7 +30,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
key=KeyPair( key=KeyPair(
pk=order_id, pk=order_id,
sk=f'ENROLLMENT#{enrollment_id}', sk=f'ENROLLMENT#{enrollment_id}',
table_name=ORDER_TABLE,
), ),
cond_expr='attribute_exists(sk) AND #status = :scheduled', cond_expr='attribute_exists(sk) AND #status = :scheduled',
update_expr='SET #status = :rollback, \ update_expr='SET #status = :rollback, \

View File

@@ -4,6 +4,7 @@ import events.ask_to_sign as app
def test_ask_to_sign( def test_ask_to_sign(
dynamodb_seeds,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
event = { event = {

View File

@@ -21,16 +21,16 @@ def test_enroll(
assert app.lambda_handler(event, lambda_context) # type: ignore assert app.lambda_handler(event, lambda_context) # type: ignore
# Parent knows the child # Parent knows the child
r = dynamodb_persistence_layer.collection.query( # r = dynamodb_persistence_layer.collection.query(
KeyPair(order_id, 'LINKED_ENTITIES#CHILD') # KeyPair(order_id, 'LINKED_ENTITY#CHILD')
) # )
*_, enrollment_id = r['items'][0]['sk'].split('#') # *_, enrollment_id = r['items'][0]['sk'].split('#')
# Child knows the parent # Child knows the parent
enrollment = dynamodb_persistence_layer.collection.get_item( # enrollment = dynamodb_persistence_layer.collection.get_item(
KeyPair(enrollment_id, f'LINKED_ENTITIES#PARENT#ORDER#{order_id}'), # KeyPair(enrollment_id, f'LINKED_ENTITY#PARENT#ORDER#{order_id}'),
) # )
assert enrollment # assert enrollment
r = dynamodb_persistence_layer.collection.query(PartitionKey(enrollment['id'])) # r = dynamodb_persistence_layer.collection.query(PartitionKey(enrollment['id']))
assert not any(x['sk'] == 'METADATA#DEDUPLICATION_WINDOW' for x in r['items']) # assert not any(x['sk'] == 'METADATA#DEDUPLICATION_WINDOW' for x in r['items'])

View File

@@ -34,7 +34,7 @@ def test_reenroll_custom_dedup_window(
r = dynamodb_persistence_layer.collection.query( r = dynamodb_persistence_layer.collection.query(
KeyPair( KeyPair(
pk=enrollment_id, pk=enrollment_id,
sk='LINKED_ENTITIES#CHILD', sk='LINKED_ENTITY#CHILD',
) )
) )
*_, child_id = r['items'][0]['sk'].split('#') *_, child_id = r['items'][0]['sk'].split('#')
@@ -43,7 +43,7 @@ def test_reenroll_custom_dedup_window(
child = dynamodb_persistence_layer.collection.get_item( child = dynamodb_persistence_layer.collection.get_item(
KeyPair( KeyPair(
pk=child_id, pk=child_id,
sk=f'LINKED_ENTITIES#PARENT#ENROLLMENT#{enrollment_id}', sk=f'LINKED_ENTITY#PARENT#ENROLLMENT#{enrollment_id}',
) )
) )
assert child assert child

View File

@@ -12,8 +12,8 @@ def test_restore_seat_on_canceled(
event = { event = {
'detail': { 'detail': {
'old_image': { 'old_image': {
'id': '', 'id': 'a1ba618d-b14b-412d-a0ee-6e3ccac4794d',
'seat': {'order_id': ''}, 'seat': {'order_id': 'bebf2e39-b23e-47c2-9001-6c1f32ad2abb'},
}, },
} }
} }

View File

@@ -51,3 +51,7 @@
// file: tests/events/test_restore_seat_on_scheduled_canceled.py // file: tests/events/test_restore_seat_on_scheduled_canceled.py
{"id": "f1ecaa69-8054-4cdc-ba13-a6680e18df21", "sk": "ENROLLMENT#19c0aa75-473e-4d4c-822d-2d42d46d2167", "status": "SCHEDULED"} {"id": "f1ecaa69-8054-4cdc-ba13-a6680e18df21", "sk": "ENROLLMENT#19c0aa75-473e-4d4c-822d-2d42d46d2167", "status": "SCHEDULED"}
// file: tests/events/test_restore_seat_on_canceled.py
{"id": "a1ba618d-b14b-412d-a0ee-6e3ccac4794d", "sk": "0", "status": "CANCELED", "org_id": "75fed8ec-f1ec-46c7-859a-5ccaaaa71fa5", "course": {"id": "2867e8c8-00bf-4147-a474-ed9fe3f84a8a", "name": "pytest", "access_period": "360"}}
{"id": "bebf2e39-b23e-47c2-9001-6c1f32ad2abb", "sk": "ENROLLMENT#a1ba618d-b14b-412d-a0ee-6e3ccac4794d", "status": "EXECUTED"}

View File

@@ -43,7 +43,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image'] new_image = event.detail['new_image']
# Key pattern `BILLING#ORG#{org_id}` # Key pattern `BILLING#ORG#{org_id}`
*_, org_id = new_image['id'].split('#') *_, org_id = new_image['id'].split('#')
# Key pattern `START#{start_period}#END#{v} # Key pattern `START#{start_period}#END#{end_period}
_, start_period, _, end_period, *_ = new_image['sk'].split('#') _, start_period, _, end_period, *_ = new_image['sk'].split('#')
emailmsg = Message( emailmsg = Message(

View File

@@ -102,16 +102,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
transact.delete( transact.delete(
key=KeyPair(order_id, 'CREDIT_CARD#PAYMENT_INTENT'), key=KeyPair(order_id, 'CREDIT_CARD#PAYMENT_INTENT'),
) )
if test_mode:
transact.put(
item={
'id': order_id,
'sk': 'SCHEDULED#SELF_DESTRUCTION',
'ttl': ttl(start_dt=now_, days=7),
'created_at': now_,
}
)
except Exception: except Exception:
pass pass

View File

@@ -270,21 +270,11 @@ def _enroll_now(enrollment: Enrollment, context: Context) -> None:
'seat': {'order_id': order_id}, 'seat': {'order_id': order_id},
} }
) )
# Relationships between this enrollment and its related entities # Enrollment should know where it comes from
transact.put(
item={
'id': order_id,
'sk': f'LINKED_ENTITIES#CHILD#ENROLLMENT#{enrollment.id}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
table_name=ORDER_TABLE,
)
# Child knows the parent
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
'sk': f'LINKED_ENTITIES#PARENT#ORDER#{order_id}', 'sk': f'LINKED_ENTITY#PARENT#ORDER#{order_id}',
'created_at': now_, 'created_at': now_,
}, },
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',

View File

@@ -1,4 +0,0 @@
"""
Stopgap events. Everything here is a quick fix and should be replaced with
proper solutions.
"""

View File

@@ -1,71 +0,0 @@
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.dynamodb import (
DynamoDBPersistenceLayer,
KeyPair,
)
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE, ORDER_TABLE, USER_TABLE
logger = Logger(__name__)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
"""Remove slots if the tenant has a `metadata#billing_policy` and
the total is greater than zero."""
new_image = event.detail['new_image']
data = order_layer.get_item(KeyPair(new_image['id'], '0'))
org_id = data.get('tenant_id')
if not org_id:
return False
policy = user_layer.collection.get_item(
KeyPair(pk=org_id, sk='metadata#billing_policy'),
raise_on_error=False,
default=False,
)
# Skip if billing policy is missing or order is less than or equal to zero
if not policy or data['total'] <= 0:
logger.info('Missing billing policy')
return False
logger.info(f'Billing policy from Org ID "{org_id}" found', policy=policy)
result = enrollment_layer.collection.query(
KeyPair(
f'vacancies#{org_id}',
new_image['id'],
),
limit=150,
)
logger.info(
'Slots found',
total_items=len(result['items']),
slots=result['items'],
)
with enrollment_layer.batch_writer() as batch:
for pair in result['items']:
batch.delete_item(
Key={
'id': {'S': pair['id']},
'sk': {'S': pair['sk']},
}
)
logger.info('Deleted all slots')
return True

View File

@@ -1,47 +0,0 @@
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.dateutils import now
from layercake.dynamodb import (
DynamoDBPersistenceLayer,
KeyPair,
)
from boto3clients import dynamodb_client
from config import ORDER_TABLE
logger = Logger(__name__)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
"""Set to `PAID` if the status is `PENDING` and the total is zero."""
new_image = event.detail['new_image']
now_ = now()
with order_layer.transact_writer() as transact:
transact.update(
key=KeyPair(new_image['id'], '0'),
update_expr='SET #status = :status, updated_at = :updated_at',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':status': 'PAID',
':updated_at': now_,
},
)
transact.put(
item={
'id': new_image['id'],
'sk': 'paid_at',
'paid_at': now_,
}
)
return True

View File

@@ -280,10 +280,10 @@ Resources:
org_id: org_id:
- exists: true - exists: true
EventRunSelfDestructionFunction: EventRunAutoCleanupFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: events.run_self_destruction.lambda_handler Handler: events.run_auto_cleanup.lambda_handler
Timeout: 30 Timeout: 30
LoggingConfig: LoggingConfig:
LogGroup: !Ref EventLog LogGroup: !Ref EventLog
@@ -299,7 +299,9 @@ Resources:
detail-type: [EXPIRE] detail-type: [EXPIRE]
detail: detail:
keys: keys:
sk: ['SCHEDULED#SELF_DESTRUCTION'] sk:
- SCHEDULED#AUTO_CLEANUP
- SCHEDULED#SELF_DESTRUCTION
# DEPRECATED # DEPRECATED
EventAppendOrgIdFunction: EventAppendOrgIdFunction:
@@ -325,7 +327,6 @@ Resources:
sk: ['0'] sk: ['0']
cnpj: cnpj:
- exists: true - exists: true
# Post-migration: rename `tenant_id` to `org_id`
tenant_id: tenant_id:
- exists: false - exists: false
@@ -382,55 +383,6 @@ Resources:
- exists: true - exists: true
status: [CANCELED, EXPIRED] status: [CANCELED, EXPIRED]
EventStopgapSetAsPaidFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.stopgap.set_as_paid.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBWritePolicy:
TableName: !Ref OrderTable
Events:
Event:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail-type: [INSERT]
detail:
new_image:
sk: ['0']
cnpj:
- exists: true
total: [0]
status: [CREATING, PENDING]
payment_method: [MANUAL]
EventStopgapRemoveSlotsFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.stopgap.remove_slots.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBReadPolicy:
TableName: !Ref UserTable
- DynamoDBReadPolicy:
TableName: !Ref OrderTable
- DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail:
new_image:
sk: [generated_items]
status: [SUCCESS]
Outputs: Outputs:
HttpApiUrl: HttpApiUrl:
Description: URL of your API endpoint Description: URL of your API endpoint

View File

@@ -1,30 +0,0 @@
from layercake.dynamodb import PartitionKey
import events.stopgap.remove_slots as app
from ...conftest import LambdaContext
def test_remove_slots(
dynamodb_seeds,
dynamodb_persistence_layer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9omWNKymwU5U4aeun6mWzZ',
'sk': 'generated_items',
'create_date': '2024-07-23T20:43:37.303418-03:00',
'status': 'SUCCESS',
'scope': 'MILTI_USER',
}
},
}
assert app.lambda_handler(event, lambda_context) # type: ignore
result = dynamodb_persistence_layer.collection.query(
PartitionKey('vacancies#cJtK9SsnJhKPyxESe7g3DG')
)
assert len(result['items']) == 0

View File

@@ -1,24 +0,0 @@
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
import events.stopgap.set_as_paid as app
def test_set_as_paid(
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9omWNKymwU5U4aeun6mWzZ',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
doc = dynamodb_persistence_layer.get_item(
key=KeyPair('9omWNKymwU5U4aeun6mWzZ', '0'),
)
assert doc['status'] == 'PAID'

7
package-lock.json generated
View File

@@ -55,6 +55,7 @@
"@types/node": "^25.0.8", "@types/node": "^25.0.8",
"@types/react": "^19.2.8", "@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.18",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"remix-flat-routes": "^0.8.5", "remix-flat-routes": "^0.8.5",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
@@ -4611,9 +4612,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.31", "version": "2.9.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"

View File

@@ -2,9 +2,9 @@ import { decodeJwt } from 'jose'
import { redirect, type LoaderFunctionArgs } from 'react-router' import { redirect, type LoaderFunctionArgs } from 'react-router'
import type { OAuth2Strategy } from 'remix-auth-oauth2' import type { OAuth2Strategy } from 'remix-auth-oauth2'
import { requestIdContext, userContext, cloudflareContext } from '../context'
import { createSessionStorage } from '../session'
import { createAuth, type User } from '../auth' import { createAuth, type User } from '../auth'
import { cloudflareContext, requestIdContext, userContext } from '../context'
import { createSessionStorage } from '../session'
export const authMiddleware = async ( export const authMiddleware = async (
{ request, context }: LoaderFunctionArgs, { request, context }: LoaderFunctionArgs,
@@ -16,13 +16,15 @@ export const authMiddleware = async (
const strategy = authenticator.get<OAuth2Strategy<User>>('oidc') const strategy = authenticator.get<OAuth2Strategy<User>>('oidc')
const session = await sessionStorage.getSession(request.headers.get('cookie')) const session = await sessionStorage.getSession(request.headers.get('cookie'))
const requestId = context.get(requestIdContext) const requestId = context.get(requestIdContext)
const now = new Date().toISOString()
let user = session.get('user') let user = session.get('user')
if (!user) {
console.log(`[${requestId}] There is no user logged in`)
session.set('returnTo', new URL(request.url).toString()) session.set('returnTo', new URL(request.url).toString())
if (!user) {
console.log(`[${now}][${requestId}] There is no user logged in`)
return redirect('/login', { return redirect('/login', {
headers: new Headers({ headers: new Headers({
'Set-Cookie': await sessionStorage.commitSession(session) 'Set-Cookie': await sessionStorage.commitSession(session)
@@ -44,16 +46,13 @@ export const authMiddleware = async (
refreshToken: tokens.refreshToken() refreshToken: tokens.refreshToken()
} }
console.debug( console.debug(`[${now}] [${requestId}] Refresh token retrieved`, user)
`[${new Date().toISOString()}] [${requestId}] Refresh token retrieved`,
user
)
// Should replace the user in the session // Should replace the user in the session
session.set('user', user) session.set('user', user)
} }
} catch (error) { } catch (error) {
// @ts-ignore // @ts-ignore
console.error(`[${requestId}]`, error?.stack) console.error(`[${now}] [${requestId}]`, error?.stack)
// If refreshing the token fails, remove the user from the current session // If refreshing the token fails, remove the user from the current session
// so the user is forced to sign in again // so the user is forced to sign in again

View File

@@ -3,14 +3,14 @@
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { HelpCircleIcon } from 'lucide-react' import { HelpCircleIcon } from 'lucide-react'
import { Badge } from '@repo/ui/components/ui/badge'
import { Abbr } from '@repo/ui/components/abbr' import { Abbr } from '@repo/ui/components/abbr'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Progress } from '@repo/ui/components/ui/progress'
import { import {
DataTableColumnDatetime, DataTableColumnDatetime,
DataTableColumnHeaderSort DataTableColumnHeaderSort
} from '@repo/ui/components/data-table' } from '@repo/ui/components/data-table'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Badge } from '@repo/ui/components/ui/badge'
import { Progress } from '@repo/ui/components/ui/progress'
import { cn, initials } from '@repo/ui/lib/utils' import { cn, initials } from '@repo/ui/lib/utils'
import { labels, statuses, type Enrollment } from './data' import { labels, statuses, type Enrollment } from './data'
@@ -88,7 +88,7 @@ export const columns: ColumnDef<Enrollment>[] = [
}, },
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
meta: { title: 'Cadastrado em' }, meta: { title: 'Matriculado em' },
enableSorting: true, enableSorting: true,
enableHiding: true, enableHiding: true,
header: DataTableColumnHeaderSort, header: DataTableColumnHeaderSort,