Compare commits
10 Commits
d5db9b6eff
...
2b34caa3be
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b34caa3be | |||
| fc14d425f2 | |||
| 428006cfac | |||
| 6d0d006bb0 | |||
| ecd91dbc5e | |||
| 87f9aa0341 | |||
| 4f0524fd43 | |||
| 82dc878502 | |||
| 10138112fe | |||
| f539d78f14 |
@@ -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')
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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',
|
||||
|
||||
29
api.saladeaula.digital/app/routes/orgs/enrollments/certs.py
Normal file
29
api.saladeaula.digital/app/routes/orgs/enrollments/certs.py
Normal 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',
|
||||
),
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(','))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(','))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -4,6 +4,7 @@ import events.ask_to_sign as app
|
||||
|
||||
|
||||
def test_ask_to_sign(
|
||||
dynamodb_seeds,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
event = {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
"""
|
||||
Stopgap events. Everything here is a quick fix and should be replaced with
|
||||
proper solutions.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
7
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user