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.admins, 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.scheduled, prefix='/orgs')
app.include_router(orgs.submissions, prefix='/orgs')

View File

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

View File

@@ -277,6 +277,14 @@ def checkout(payload: Checkout):
'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(
body={'id': order_id},

View File

@@ -10,6 +10,7 @@ from .address import router as address
from .admins import router as admins
from .billing import router as billing
from .custom_pricing import router as custom_pricing
from .enrollments.certs import router as certs
from .enrollments.scheduled import router as scheduled
from .enrollments.submissions import router as submissions
from .seats import router as seats
@@ -23,6 +24,7 @@ __all__ = [
'admins',
'billing',
'custom_pricing',
'certs',
'scheduled',
'submissions',
'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 typing import Annotated, cast
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.exceptions import NotFoundError
from aws_lambda_powertools.event_handler.openapi.params import Body, Query
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
from pydantic import FutureDatetime
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
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__)
router = Router()
@@ -66,6 +74,7 @@ def proceed(
scheduled_for: Annotated[FutureDatetime, Body(embed=True)],
lock_hash: Annotated[str, Body(embed=True)],
):
now_ = now()
pk = f'SCHEDULED#ORG#{org_id}'
sk = f'{scheduled_for.isoformat()}#{lock_hash}'
@@ -73,10 +82,13 @@ def proceed(
KeyPair(pk, sk),
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 = {
'created_by': router.context['user'],
'org': Org(id=org_id, name=scheduled['org_name']),
'created_by': created_by,
'org': org,
}
if billing_day:
@@ -87,12 +99,26 @@ def proceed(
Enrollment(
user=scheduled['user'],
course=scheduled['course'],
seat=scheduled.get('seat'),
seat=seat,
),
ctx,
)
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('LOCK#SCHEDULED', lock_hash))
except Exception:

View File

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

View File

@@ -1,3 +1,5 @@
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 RYBBIT_SITE_ID = '83748b35413d'

View File

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

View File

@@ -76,73 +76,71 @@ export default function Route({
const search = searchParams.get('s') as string
return (
<>
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Resumo de cobranças
</h1>
<p className="text-muted-foreground">
Acompanhe as cobranças em tempo real e garanta mais eficiência no
controle financeiro.
</p>
</div>
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Resumo de cobranças
</h1>
<p className="text-muted-foreground">
Acompanhe as cobranças em tempo real e garanta mais eficiência no
controle financeiro.
</p>
</div>
<Await resolve={billing}>
{({ items = [], ...billing }) => {
const {
icon: Icon,
label: status,
color
} = statuses?.[billing?.status || 'CLOSED']
<Await resolve={billing}>
{({ items = [], ...billing }) => {
const {
icon: Icon,
label: status,
color
} = statuses?.[billing?.status || 'CLOSED']
return (
<Card>
<CardContent className="space-y-4">
<div className="flex max-lg:flex-col gap-2.5">
<div className="w-full xl:w-1/4">
<SearchForm
defaultValue={search || ''}
placeholder={
<>
Digite <Kbd className="border font-mono">/</Kbd>{' '}
para pesquisar
</>
}
onChange={(value) =>
setSearchParams((searchParams) => {
searchParams.set('s', String(value))
return searchParams
})
}
/>
</div>
<RangePeriod
startDate={startDate}
endDate={endDate}
billingDay={billing_day}
return (
<Card>
<CardContent className="space-y-4">
<div className="flex max-lg:flex-col gap-2.5">
<div className="w-full xl:w-1/4">
<SearchForm
defaultValue={search || ''}
placeholder={
<>
Digite <Kbd className="border font-mono">/</Kbd> para
pesquisar
</>
}
onChange={(value) =>
setSearchParams((searchParams) => {
searchParams.set('s', String(value))
return searchParams
})
}
/>
<Button
className={cn('pointer-events-none lg:ml-auto', color)}
variant="outline"
asChild
>
<span>
<Icon className="size-3.5" /> {status}
</span>
</Button>
</div>
<List items={items} search={search} />
</CardContent>
</Card>
)
}}
</Await>
</Suspense>
</>
<RangePeriod
startDate={startDate}
endDate={endDate}
billingDay={billing_day}
/>
<Button
className={cn('pointer-events-none lg:ml-auto', color)}
variant="outline"
asChild
>
<span>
<Icon className="size-3.5" /> {status}
</span>
</Button>
</div>
<List items={items} search={search} />
</CardContent>
</Card>
)
}}
</Await>
</Suspense>
)
}
@@ -217,7 +215,7 @@ function List({ items, search }) {
{charges.length ? (
<>
<TableHeader>
<TableRow className="bg-muted-foreground/10 pointer-events-none">
<TableRow className=" pointer-events-none">
<TableHead>Colaborador</TableHead>
<TableHead>Curso</TableHead>
<TableHead>Matriculado por</TableHead>

View File

@@ -1,12 +1,51 @@
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({}) {
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 (
<>
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Gerenciar certificações
@@ -16,6 +55,74 @@ export default function Route({}: Route.ComponentProps) {
prazos e renovações com facilidade.
</p>
</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 { createSearch } from '@repo/util/meili'
import { workspaceContext } from '@/middleware/workspace'
import { columns, type Enrollment } from './columns'
export function meta({}: Route.MetaArgs) {
@@ -26,6 +27,7 @@ export function meta({}: Route.MetaArgs) {
export async function loader({ params, context, request }: Route.LoaderArgs) {
const cloudflare = context.get(cloudflareContext)
const { test_mode } = context.get(workspaceContext)
const { searchParams } = new URL(request.url)
const { orgid } = params
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 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) {
builder = builder.where('status', 'in', status.split(','))

View File

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

View File

@@ -4,6 +4,7 @@ import { z } from 'zod'
export const MAX_ITEMS = 50
export const enrollment = z.object({
id: z.uuidv4().optional(),
user: z
.object(
{
@@ -34,7 +35,8 @@ export const enrollment = z.object({
scheduled_for: z
.date()
.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({

View File

@@ -131,8 +131,9 @@ export async function action({ params, request, context }: Route.ActionArgs) {
}
export default function Route({
loaderData: { courses, submission }
loaderData: { courses: courses_, submission }
}: Route.ComponentProps) {
const { hits: courses } = use(courses_)
const { orgid } = useParams()
const { enrolled } = use(submission)
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 { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { DateTime } from 'luxon'
import { Form } from '@repo/ui/components/ui/form'
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from '@repo/ui/components/ui/input-group'
ArrowRightIcon,
CircleQuestionMarkIcon,
PlusIcon,
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 { Separator } from '@repo/ui/components/ui/separator'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { Form } from '@repo/ui/components/ui/form'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger
} from '@repo/ui/components/ui/hover-card'
import { TZ } from '@/conf'
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,
MAX_ITEMS,
type Course,
type User
} from '../_.$orgid.enrollments.add/data'
import { ScheduledForInput } from '../_.$orgid.enrollments.add/scheduled-for'
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 { Summary } from './bulk'
import { currency } from './utils'
import { useWizard } from '@/components/wizard'
import { useWizardStore } from './store'
import { currency } from './utils'
const emptyRow = {
user: undefined,
@@ -56,8 +56,9 @@ type AssignedProps = {
courses: Promise<{ hits: Course[] }>
}
export function Assigned({ courses }: AssignedProps) {
export function Assigned({ courses: courses_ }: AssignedProps) {
const wizard = useWizard()
const { hits: courses } = use(courses_)
const { orgid } = useParams()
const { update, ...state } = useWizardStore()
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({
control,
name: 'enrollments'

View File

@@ -7,7 +7,7 @@ import {
Trash2Icon,
XIcon
} from 'lucide-react'
import { Fragment, useEffect } from 'react'
import { Fragment, use, useEffect } from 'react'
import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'
import { z } from 'zod'
@@ -64,7 +64,8 @@ type BulkProps = {
courses: Promise<{ hits: Course[] }>
}
export function Bulk({ courses }: BulkProps) {
export function Bulk({ courses: courses_ }: BulkProps) {
const { hits: courses } = use(courses_)
const wizard = useWizard()
const { update, ...state } = useWizardStore()
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 { Wizard, WizardStep } from '@/components/wizard'
import { useWorksapce } from '@/components/workspace-switcher'
import { INTERNAL_EMAIL_DOMAIN } from '@/conf'
import { workspaceContext } from '@/middleware/workspace'
import { Button } from '@repo/ui/components/ui/button'
import { Spinner } from '@repo/ui/components/ui/spinner'
@@ -190,7 +189,10 @@ export default function Route({
<Button size="sm" variant="outline" asChild>
<NavLink to="../enrollments/seats">
{({ isPending }) => (
<>{isPending ? <Spinner /> : <PlusIcon />} Matricular</>
<>
{isPending ? <Spinner /> : <PlusIcon />}
<span className="max-lg:hidden">Matricular</span>
</>
)}
</NavLink>
</Button>

View File

@@ -9,8 +9,8 @@ import {
Trash2Icon
} from 'lucide-react'
import { Fragment, useMemo } from 'react'
import { Controller, useFieldArray, useForm } from 'react-hook-form'
import { Link, redirect, useParams } from 'react-router'
import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'
import { Link, redirect, useFetcher, useParams } from 'react-router'
import {
Breadcrumb,
@@ -34,7 +34,9 @@ import {
HoverCardTrigger
} from '@repo/ui/components/ui/hover-card'
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 { CoursePicker } from '../_.$orgid.enrollments.add/course-picker'
@@ -42,6 +44,7 @@ import {
formSchema,
MAX_ITEMS,
type Course,
type Schema,
type User
} from '../_.$orgid.enrollments.add/data'
import {
@@ -57,9 +60,8 @@ export function meta({}: Route.MetaArgs) {
}
type Seat = {
id: string
pk: string
course: Course
order_id: string
enrollment_id: string
}
export async function loader({ request, params, context }: Route.LoaderArgs) {
@@ -75,13 +77,31 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
context
})
.then((r) => r.json() as any)
.then(({ items }) => items as Seat[])
.then(({ items }) => items as { sk: string; course: Course }[])
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) {
const { orgid } = useParams()
const fetcher = useFetcher()
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: { enrollments: [emptyRow] }
@@ -92,34 +112,80 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
name: 'enrollments'
})
const courses = useMemo(
() =>
Promise.resolve({
hits: Array.from(
seats
.reduce((map, { course }) => {
const existing = map.get(course.id)
const enrollments = useWatch({
control,
name: 'enrollments'
})
if (existing) {
existing.quantity += 1
} else {
map.set(course.id, {
...course,
metadata__unit_price: 1,
quantity: 1
})
}
const usedSeatIds = useMemo(() => {
return new Set(enrollments?.map((e) => e.id).filter(Boolean))
}, [enrollments])
return map
}, new Map())
.values()
)
}),
[seats]
)
const seatsByCourse = useMemo(() => {
return seats.reduce<Record<string, Seat[]>>((acc, seat) => {
const courseId = seat.course.id
const [, order_id, , enrollment_id] = seat.sk.split('#')
console.log(seats)
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(
seats
.reduce((acc, { course }) => {
const existing = acc.get(course.id)
if (existing) {
existing.quantity += 1
} else {
acc.set(course.id, {
...course,
metadata__unit_price: 1,
quantity: 1,
disabled: false
})
}
return acc
}, new Map<string, any>())
.values()
).map((course) => {
const used = usedSeatsByCourse.get(course.id) ?? 0
return { ...course, disabled: used >= course.quantity }
})
}
}, [seats, usedSeatsByCourse])
const onSubmit = async (data: Schema) => {
await fetcher.submit(JSON.stringify(data), {
method: 'post',
encType: 'application/json'
})
}
const onSearch = async (search: string) => {
const params = new URLSearchParams({ q: search })
const r = await fetch(`/${orgid}/users.json?${params.toString()}`)
@@ -127,15 +193,58 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
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) => {
if (fields.length + times > MAX_ITEMS) {
return null
}
const { user, ...rest } = getValues(`enrollments.${index}`)
const { course, scheduled_for } = getValues(`enrollments.${index}`)
if (!course?.id) {
Array.from({ length: times }, (_, i) => {
// @ts-ignore
insert(index + 1 + i, { course: null })
})
return null
}
const reservedSeatIds = new Set(usedSeatIds)
Array.from({ length: times }, (_, i) => {
// @ts-ignore
insert(index + 1 + i, rest)
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,168 +263,206 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
</BreadcrumbItem>
</BreadcrumbList>
</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">
<CardHeader>
<CardTitle className="text-2xl">Adicionar matrículas</CardTitle>
<CardDescription>
Siga os passos abaixo para adicionar colaboradores às matrículas
abertas.
</CardDescription>
</CardHeader>
<Card className="lg:max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="text-2xl">Adicionar matrículas</CardTitle>
<CardDescription>
Siga os passos abaixo para adicionar colaboradores às matrículas
abertas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid lg:grid-cols-[repeat(3,1fr)_auto] w-full gap-3">
{/* Header */}
<>
<Cell>Colaborador</Cell>
<Cell>Curso</Cell>
<Cell className="flex items-center gap-1.5">
Matricular em
<HoverCard openDelay={0}>
<HoverCardTrigger asChild>
<button type="button">
<CircleQuestionMarkIcon className="size-4 text-muted-foreground" />
</button>
</HoverCardTrigger>
<HoverCardContent
align="end"
className="text-sm space-y-1.5 lg:w-76"
>
<p>
Escolha a data em que o colaborador será matriculado no
curso.
</p>
<p>
Você poderá acompanhar as matrículas em{' '}
<Kbd>Agendamentos</Kbd>
</p>
</HoverCardContent>
</HoverCard>
</Cell>
<Cell>{/**/}</Cell>
</>
{/* Rows */}
<>
{fields.map((field, index) => (
<Fragment key={field.id}>
{/* Separator only for mobile */}
{index >= 1 && <div className="h-2.5 lg:hidden"></div>}
<Controller
control={control}
name={`enrollments.${index}.user`}
render={({
field: { name, value, onChange },
fieldState
}) => (
<div className="grid gap-1">
<UserPicker
value={value}
onChange={onChange}
onSearch={onSearch}
fieldState={fieldState}
/>
<ErrorMessage
errors={formState.errors}
name={name}
render={({ message }) => (
<p className="text-destructive text-sm">
{message}
</p>
)}
/>
</div>
)}
/>
<Controller
control={control}
name={`enrollments.${index}.course`}
render={({
field: { name, value, onChange },
fieldState
}) => (
<div className="grid gap-1">
<CoursePicker
value={value}
onChange={onChange}
options={courses}
error={fieldState.error}
readOnly
/>
<ErrorMessage
errors={formState.errors}
name={name}
render={({ message }) => (
<p className="text-destructive text-sm">
{message}
</p>
)}
/>
</div>
)}
/>
<Controller
control={control}
name={`enrollments.${index}.scheduled_for`}
render={({ field: { value, onChange } }) => (
<ScheduledForInput value={value} onChange={onChange} />
)}
/>
{/* Action */}
<div className="flex gap-1.5">
<Button
type="button"
tabIndex={-1}
variant="outline"
className="cursor-pointer"
onClick={() => duplicateRow(index)}
title="Duplicar linha"
<CardContent className="space-y-4">
<div className="grid lg:grid-cols-[repeat(3,1fr)_auto] w-full gap-3">
{/* Header */}
<>
<Cell>Colaborador</Cell>
<Cell>Curso</Cell>
<Cell className="flex items-center gap-1.5">
Matricular em
<HoverCard openDelay={0}>
<HoverCardTrigger asChild>
<button type="button">
<CircleQuestionMarkIcon className="size-4 text-muted-foreground" />
</button>
</HoverCardTrigger>
<HoverCardContent
align="end"
className="text-sm space-y-1.5 lg:w-76"
>
<CopyIcon />
</Button>
<p>
Escolha a data em que o colaborador será matriculado no
curso.
</p>
<DuplicateRowMultipleTimes
index={index}
duplicateRow={duplicateRow}
<p>
Você poderá acompanhar as matrículas em{' '}
<Kbd>Agendamentos</Kbd>
</p>
</HoverCardContent>
</HoverCard>
</Cell>
<Cell>{/**/}</Cell>
</>
{/* Rows */}
<>
{fields.map((field, index) => (
<Fragment key={field.id}>
{/* Separator only for mobile */}
{index >= 1 && <div className="h-2.5 lg:hidden"></div>}
<Controller
control={control}
name={`enrollments.${index}.user`}
render={({
field: { name, value, onChange },
fieldState
}) => (
<div className="grid gap-1">
<UserPicker
value={value}
onChange={onChange}
onSearch={onSearch}
fieldState={fieldState}
/>
<ErrorMessage
errors={formState.errors}
name={name}
render={({ message }) => (
<p className="text-destructive text-sm">
{message}
</p>
)}
/>
</div>
)}
/>
<Button
tabIndex={-1}
variant="destructive"
className="cursor-pointer"
disabled={fields.length == 1}
onClick={() => remove(index)}
>
<Trash2Icon />
</Button>
</div>
</Fragment>
))}
</>
</div>
<Controller
control={control}
name={`enrollments.${index}.course`}
render={({
field: { name, value, onChange },
fieldState
}) => (
<div className="grid gap-1">
<CoursePicker
value={value}
onChange={(course) => {
const seat = pickSeat(course.id)
<div className="max-lg:mt-2.5">
<Button
type="button"
// @ts-ignore
onClick={() => append(emptyRow)}
className="cursor-pointer"
disabled={fields.length == MAX_ITEMS}
variant="outline"
size="sm"
>
<PlusIcon /> Adicionar
</Button>
</div>
</CardContent>
</Card>
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}
readOnly
/>
<ErrorMessage
errors={formState.errors}
name={name}
render={({ message }) => (
<p className="text-destructive text-sm">
{message}
</p>
)}
/>
</div>
)}
/>
<Controller
control={control}
name={`enrollments.${index}.scheduled_for`}
render={({ field: { value, onChange } }) => (
<ScheduledForInput value={value} onChange={onChange} />
)}
/>
{/* Action */}
<div className="flex gap-1.5">
<Button
type="button"
tabIndex={-1}
variant="outline"
className="cursor-pointer"
onClick={() => duplicateRow(index)}
title="Duplicar linha"
>
<CopyIcon />
</Button>
<DuplicateRowMultipleTimes
index={index}
duplicateRow={duplicateRow}
/>
<Button
tabIndex={-1}
variant="destructive"
className="cursor-pointer"
disabled={fields.length == 1}
onClick={() => remove(index)}
>
<Trash2Icon />
</Button>
</div>
</Fragment>
))}
</>
</div>
<div className="max-lg:mt-2.5">
<Button
type="button"
// @ts-ignore
onClick={() => append(emptyRow)}
className="cursor-pointer"
disabled={fields.length == MAX_ITEMS}
variant="outline"
size="sm"
>
<PlusIcon /> Adicionar
</Button>
</div>
<Separator />
<div className="flex justify-end">
<Button
type="submit"
className="cursor-pointer"
disabled={formState.isSubmitting}
>
{formState.isSubmitting && <Spinner />}
Matricular
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
)
}

View File

@@ -3,7 +3,7 @@ import type { Route } from './+types/route'
import { redirect } from 'react-router'
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'
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 session = await sessionStorage.getSession(request.headers.get('cookie'))
const user = session.get('user')
const now = new Date().toISOString()
const returnTo = (session.get('returnTo') as string | undefined) ?? '/'
if (user) {
@@ -26,9 +27,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
request
)) as User
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
return redirect(returnTo, {

View File

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

View File

@@ -1,36 +1,36 @@
import type { Route } from './+types/route'
import { pick } from 'ramda'
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import {
CalendarIcon,
PlusCircleIcon,
BuildingIcon,
CheckIcon
CalendarIcon,
CheckIcon,
PlusCircleIcon
} from 'lucide-react'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { pick } from 'ramda'
import { Suspense, useState } from 'react'
import { Await, Outlet, useSearchParams } from 'react-router'
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { cloudflareContext } from '@repo/auth/context'
import { Abbr } from '@repo/ui/components/abbr'
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 { 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 { SearchForm } from '@repo/ui/components/search-form'
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 { 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'
@@ -50,7 +50,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25
let builder = new MeiliSearchFilterBuilder()
let builder = new MeiliSearchFilterBuilder().where('is_test', 'exists', false)
if (status) {
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')
HTTP_CONNECT_TIMEOUT = int(os.environ.get('HTTP_CONNECT_TIMEOUT', 1))
HTTP_READ_TIMEOUT = int(os.environ.get('HTTP_READ_TIMEOUT', 3))
HTTP_CONNECT_TIMEOUT = int(os.environ.get('HTTP_CONNECT_TIMEOUT', 2))
HTTP_READ_TIMEOUT = int(os.environ.get('HTTP_READ_TIMEOUT', 6))
PAPERFORGE_API = 'https://paperforge.saladeaula.digital'
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 enum import Enum
from typing import Any, Literal, TypedDict
from uuid import uuid4
from typing import Literal, TypedDict
from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
@@ -27,7 +23,6 @@ class User(BaseModel):
id: UUID4 | str
name: NameStr
email: EmailStr
email_verified: bool = False
cpf: CpfStr | None = None
@@ -44,18 +39,6 @@ class Enrollment(BaseModel):
progress: int = Field(default=0, ge=0, le=100)
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})
@@ -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):
def __init__(self, *args):
super().__init__('Enrollment already exists')
@@ -121,7 +92,7 @@ def enroll(
created_by: CreatedBy | None = None,
scheduled_at: datetime | None = None,
seat: Seat | None = None,
linked_entities: frozenset[LinkedEntity] = frozenset(),
parent_entity: str | None = None,
deduplication_window: DeduplicationWindow | None = None,
persistence_layer: DynamoDBPersistenceLayer,
) -> bool:
@@ -156,43 +127,51 @@ def enroll(
)
if seat:
order_id = seat['order_id']
transact.condition(
key=KeyPair(str(seat['order_id']), '0'),
key=KeyPair(order_id, '0'),
cond_expr='attribute_exists(sk)',
exc_cls=OrderNotFoundError,
table_name=ORDER_TABLE,
)
transact.put(
item={
'id': seat['order_id'],
'id': order_id,
'sk': f'ENROLLMENT#{enrollment.id}',
'course': course.model_dump(),
'user': user.model_dump(),
'user': user.model_dump(exclude={'cpf'}),
'status': 'EXECUTED',
'executed_at': now_,
'created_at': now_,
},
table_name=ORDER_TABLE,
)
# Relationships between this enrollment and its related entities
for entity in linked_entities:
# Parent knows the child
# Enrollment should know where it comes from
transact.put(
item={
'id': entity.id,
'sk': f'LINKED_ENTITIES#CHILD#ENROLLMENT#{enrollment.id}',
'id': 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_,
},
cond_expr='attribute_not_exists(sk)',
table_name=entity.table_name,
)
# Child knows the parent
transact.put(
item={
'id': enrollment.id,
'sk': f'LINKED_ENTITIES#PARENT#{entity.kind.value}#{entity.id}',
'sk': f'LINKED_ENTITY#PARENT#ENROLLMENT#{parent_entity}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',

View File

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

View File

@@ -14,8 +14,6 @@ from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE, ORDER_TABLE
from enrollment import (
Enrollment,
Kind,
LinkedEntity,
Seat,
Subscription,
enroll,
@@ -54,21 +52,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
)
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(
enrollment,
org={
@@ -82,7 +65,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
scheduled_at=datetime.fromisoformat(old_image['scheduled_at']),
# Transfer the deduplication window if it exists
deduplication_window={'offset_days': offset_days} if offset_days else None,
linked_entities=linked_entities,
persistence_layer=dyn,
)
@@ -105,17 +87,49 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
),
)
except Exception as exc:
dyn.put_item(
item={
'id': old_image['id'],
'sk': f'{sk}#FAILED',
'cause': {
'type': type(exc).__name__,
'message': str(exc),
},
'snapshot': old_image,
'created_at': now_,
}
)
with dyn.transact_writer() as transact:
transact.put(
item={
'id': old_image['id'],
'sk': f'{sk}#FAILED',
'cause': {
'type': type(exc).__name__,
'message': str(exc),
},
'snapshot': old_image,
'created_at': now_,
}
)
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,3 +51,7 @@
// 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"}
// 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']
# Key pattern `BILLING#ORG#{org_id}`
*_, 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('#')
emailmsg = Message(

View File

@@ -102,16 +102,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
transact.delete(
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:
pass

View File

@@ -270,21 +270,11 @@ def _enroll_now(enrollment: Enrollment, context: Context) -> None:
'seat': {'order_id': order_id},
}
)
# Relationships between this enrollment and its related entities
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
# Enrollment should know where it comes from
transact.put(
item={
'id': enrollment.id,
'sk': f'LINKED_ENTITIES#PARENT#ORDER#{order_id}',
'sk': f'LINKED_ENTITY#PARENT#ORDER#{order_id}',
'created_at': now_,
},
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:
- exists: true
EventRunSelfDestructionFunction:
EventRunAutoCleanupFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.run_self_destruction.lambda_handler
Handler: events.run_auto_cleanup.lambda_handler
Timeout: 30
LoggingConfig:
LogGroup: !Ref EventLog
@@ -299,7 +299,9 @@ Resources:
detail-type: [EXPIRE]
detail:
keys:
sk: ['SCHEDULED#SELF_DESTRUCTION']
sk:
- SCHEDULED#AUTO_CLEANUP
- SCHEDULED#SELF_DESTRUCTION
# DEPRECATED
EventAppendOrgIdFunction:
@@ -325,7 +327,6 @@ Resources:
sk: ['0']
cnpj:
- exists: true
# Post-migration: rename `tenant_id` to `org_id`
tenant_id:
- exists: false
@@ -382,55 +383,6 @@ Resources:
- exists: true
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:
HttpApiUrl:
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/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.18",
"prettier": "^3.7.4",
"remix-flat-routes": "^0.8.5",
"tailwindcss": "^4.1.18",
@@ -4611,9 +4612,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
"version": "2.9.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
"integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"

View File

@@ -2,9 +2,9 @@ import { decodeJwt } from 'jose'
import { redirect, type LoaderFunctionArgs } from 'react-router'
import type { OAuth2Strategy } from 'remix-auth-oauth2'
import { requestIdContext, userContext, cloudflareContext } from '../context'
import { createSessionStorage } from '../session'
import { createAuth, type User } from '../auth'
import { cloudflareContext, requestIdContext, userContext } from '../context'
import { createSessionStorage } from '../session'
export const authMiddleware = async (
{ request, context }: LoaderFunctionArgs,
@@ -16,12 +16,14 @@ export const authMiddleware = async (
const strategy = authenticator.get<OAuth2Strategy<User>>('oidc')
const session = await sessionStorage.getSession(request.headers.get('cookie'))
const requestId = context.get(requestIdContext)
const now = new Date().toISOString()
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', {
headers: new Headers({
@@ -44,16 +46,13 @@ export const authMiddleware = async (
refreshToken: tokens.refreshToken()
}
console.debug(
`[${new Date().toISOString()}] [${requestId}] Refresh token retrieved`,
user
)
console.debug(`[${now}] [${requestId}] Refresh token retrieved`, user)
// Should replace the user in the session
session.set('user', user)
}
} catch (error) {
// @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
// so the user is forced to sign in again

View File

@@ -3,14 +3,14 @@
import type { ColumnDef } from '@tanstack/react-table'
import { HelpCircleIcon } from 'lucide-react'
import { Badge } from '@repo/ui/components/ui/badge'
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 {
DataTableColumnDatetime,
DataTableColumnHeaderSort
} 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 { labels, statuses, type Enrollment } from './data'
@@ -88,7 +88,7 @@ export const columns: ColumnDef<Enrollment>[] = [
},
{
accessorKey: 'created_at',
meta: { title: 'Cadastrado em' },
meta: { title: 'Matriculado em' },
enableSorting: true,
enableHiding: true,
header: DataTableColumnHeaderSort,