From 39aedac972efe2fea5945b91414f4e5a5abeb4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Tue, 11 Nov 2025 17:06:25 -0300 Subject: [PATCH] add user --- api.saladeaula.digital/app/app.py | 5 + api.saladeaula.digital/app/keep_warm.py | 45 ++++ api.saladeaula.digital/template.yaml | 44 +++- .../tests/test_keep_warm.py | 5 + .../app/components/app-sidebar.tsx | 9 +- .../app/components/data-table/data-table.tsx | 19 +- ...rg-switcher.tsx => workspace-switcher.tsx} | 81 ++++++-- .../app/entry.server.tsx | 2 +- .../app/lib/request.ts | 12 +- .../_.$orgid.enrollments._index/route.tsx | 3 - .../routes/_.$orgid.orders._index/columns.tsx | 19 +- .../_.$orgid.users.$id._index/route.tsx | 16 +- .../_.$orgid.users.$id.emails/route.tsx | 73 ++++++- .../routes/_.$orgid.users.$id.orgs/route.tsx | 40 ---- .../app/routes/_.$orgid.users.$id/route.tsx | 3 +- .../routes/_.$orgid.users._index/columns.tsx | 83 +++++--- .../routes/_.$orgid.users._index/route.tsx | 1 + .../app/routes/_.$orgid.users.add/route.tsx | 194 +++++++++++++++++- .../app/routes/_.$orgid/route.tsx | 49 +++-- apps/studio.saladeaula.digital/app/context.ts | 4 - .../app/lib/request.ts | 26 ++- .../app/routes/edit.tsx | 27 +-- ...se_metadata.py => copy_course_metadata.py} | 4 +- courses-events/template.yaml | 10 +- ...tadata.py => test_copy_course_metadata.py} | 2 +- packages/auth/src/middleware/logging.ts | 2 + packages/ui/src/components/dark-mode.tsx | 1 + packages/ui/src/components/ui/item.tsx | 193 +++++++++++++++++ .../ui/src/components/ui/native-select.tsx | 48 +++++ packages/ui/src/components/ui/separator.tsx | 2 + 30 files changed, 802 insertions(+), 220 deletions(-) create mode 100644 api.saladeaula.digital/app/keep_warm.py create mode 100644 api.saladeaula.digital/tests/test_keep_warm.py rename apps/admin.saladeaula.digital/app/components/{org-switcher.tsx => workspace-switcher.tsx} (65%) delete mode 100644 apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id.orgs/route.tsx delete mode 100644 apps/studio.saladeaula.digital/app/context.ts rename courses-events/app/events/{daily_sync_course_metadata.py => copy_course_metadata.py} (89%) rename courses-events/tests/events/{test_daily_sync_course_metadata.py => test_copy_course_metadata.py} (93%) create mode 100644 packages/ui/src/components/ui/item.tsx create mode 100644 packages/ui/src/components/ui/native-select.tsx diff --git a/api.saladeaula.digital/app/app.py b/api.saladeaula.digital/app/app.py index 9d0ca87..0a0c6d0 100644 --- a/api.saladeaula.digital/app/app.py +++ b/api.saladeaula.digital/app/app.py @@ -46,6 +46,11 @@ app.include_router(orders.router, prefix='/orders') app.include_router(orgs.custom_pricing, prefix='/orgs') +@app.get('/health') +def health(): + return {'status': 'available'} + + @app.exception_handler(ServiceError) def exc_error(exc: ServiceError): return JSONResponse( diff --git a/api.saladeaula.digital/app/keep_warm.py b/api.saladeaula.digital/app/keep_warm.py new file mode 100644 index 0000000..8860742 --- /dev/null +++ b/api.saladeaula.digital/app/keep_warm.py @@ -0,0 +1,45 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.utilities.data_classes import ( + EventBridgeEvent, + event_source, +) +from aws_lambda_powertools.utilities.typing import LambdaContext + +logger = Logger(__name__) +tracer = Tracer() +urls = ['https://bcs7fgb9og.execute-api.sa-east-1.amazonaws.com/health'] + + +@tracer.capture_lambda_handler +@event_source(data_class=EventBridgeEvent) +def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + results = [] + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(ping, url) for url in urls] + + for future in as_completed(futures): + results.append(future.result()) + + logger.info(results) + + return True + + +def ping(url: str): + try: + r = requests.get(url, timeout=4) + r.raise_for_status() + except requests.exceptions.RequestException as exc: + return { + 'url': url, + 'error': str(exc), + } + else: + return { + 'url': url, + 'status': r.status_code, + } diff --git a/api.saladeaula.digital/template.yaml b/api.saladeaula.digital/template.yaml index f82cea0..4a0517f 100644 --- a/api.saladeaula.digital/template.yaml +++ b/api.saladeaula.digital/template.yaml @@ -1,5 +1,5 @@ -AWSTemplateFormatVersion: "2010-09-09" -Transform: "AWS::Serverless-2016-10-31" +AWSTemplateFormatVersion: '2010-09-09' +Transform: 'AWS::Serverless-2016-10-31' Parameters: UserTable: @@ -46,11 +46,16 @@ Resources: Properties: RetentionInDays: 90 + ScheduleLog: + Type: AWS::Logs::LogGroup + Properties: + RetentionInDays: 7 + HttpApi: Type: AWS::Serverless::HttpApi Properties: CorsConfiguration: - AllowOrigins: ["*"] + AllowOrigins: ['*'] AllowMethods: [GET, POST, PUT, DELETE, PATCH, OPTIONS] AllowHeaders: [Content-Type, X-Requested-With, Authorization] AllowCredentials: false @@ -59,13 +64,13 @@ Resources: DefaultAuthorizer: OAuth2Authorizer Authorizers: OAuth2Authorizer: - IdentitySource: "$request.header.Authorization" + IdentitySource: '$request.header.Authorization' JwtConfiguration: - issuer: "https://id.saladeaula.digital" + issuer: 'https://id.saladeaula.digital' audience: - - "1a5483ab-4521-4702-9115-5857ac676851" - - "1db63660-063d-4280-b2ea-388aca4a9459" - - "78a0819e-1f9b-4da1-b05f-40ec0eaed0c8" + - '1a5483ab-4521-4702-9115-5857ac676851' + - '1db63660-063d-4280-b2ea-388aca4a9459' + - '78a0819e-1f9b-4da1-b05f-40ec0eaed0c8' HttpApiFunction: Type: AWS::Serverless::Function @@ -97,12 +102,33 @@ Resources: Path: /{proxy+} Method: ANY ApiId: !Ref HttpApi + Health: + Type: HttpApi + Properties: + Path: /health + Method: GET + ApiId: !Ref HttpApi + Auth: + Authorizer: NONE + + EventKeepWarmScheduledFunction: + Type: AWS::Serverless::Function + Properties: + Handler: keep_warm.lambda_handler + LoggingConfig: + LogGroup: !Ref ScheduleLog + Events: + ScheduleEvent: + Type: ScheduleV2 + Properties: + ScheduleExpression: 'cron(*/3 5-23 * * ? *)' + ScheduleExpressionTimezone: America/Sao_Paulo Outputs: HttpApiUrl: Description: URL of your API endpoint Value: - Fn::Sub: "https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" + Fn::Sub: 'https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}' HttpApiId: Description: Api ID of HttpApi Value: diff --git a/api.saladeaula.digital/tests/test_keep_warm.py b/api.saladeaula.digital/tests/test_keep_warm.py new file mode 100644 index 0000000..5d89e59 --- /dev/null +++ b/api.saladeaula.digital/tests/test_keep_warm.py @@ -0,0 +1,5 @@ +import keep_warm + + +def test_keep_warm(): + keep_warm.lambda_handler({}, {}) diff --git a/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx b/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx index 8532500..16b9c7a 100644 --- a/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx +++ b/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx @@ -12,7 +12,7 @@ import { } from 'lucide-react' import { NavMain } from '@/components/nav-main' -import { OrgSwitcher } from '@/components/org-switcher' +import { WorkspaceSwitcher } from '@/components/workspace-switcher' import { Sidebar, SidebarContent, @@ -42,11 +42,6 @@ const data = { url: '/admins', icon: ShieldUserIcon } - // { - // title: 'Webhooks', - // url: '/webhooks', - // icon: WebhookIcon - // } ], navContent: [ { @@ -76,7 +71,7 @@ export function AppSidebar({ orgs = [] }) { return ( - + diff --git a/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx b/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx index 0dfc850..79dbca3 100644 --- a/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx +++ b/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx @@ -29,6 +29,7 @@ import { TableHeader, TableRow } from '@repo/ui/components/ui/table' +import { cn } from '@repo/ui/lib/utils' import { DataTablePagination } from './pagination' interface DataTableProps { @@ -145,7 +146,7 @@ export function DataTable({ {children} - + {table.getHeaderGroups().map((headerGroup) => ( ({ > {headerGroup.headers.map((header) => { return ( - + {header.isPlaceholder ? null : flexRender( @@ -176,7 +183,13 @@ export function DataTable({ data-state={row.getIsSelected() && 'selected'} > {row.getVisibleCells().map((cell) => ( - + {flexRender( cell.column.columnDef.cell, cell.getContext() diff --git a/apps/admin.saladeaula.digital/app/components/org-switcher.tsx b/apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx similarity index 65% rename from apps/admin.saladeaula.digital/app/components/org-switcher.tsx rename to apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx index 5cbc35a..e198147 100644 --- a/apps/admin.saladeaula.digital/app/components/org-switcher.tsx +++ b/apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx @@ -2,7 +2,7 @@ import { formatCNPJ } from '@brazilian-utils/brazilian-utils' import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react' -import { useState } from 'react' +import { createContext, useContext, useState } from 'react' import { useLocation, useParams } from 'react-router' import { @@ -23,23 +23,60 @@ import { } from '@repo/ui/components/ui/sidebar' import { initials } from '@repo/ui/lib/utils' -type Org = { +type Workspace = { id: string name: string cnpj: string } -export function OrgSwitcher({ orgs }: { orgs: Org[] }) { +type WorkspaceContextProps = { + workspaces: Workspace[] + activeWorkspace: Workspace | null + setActiveWorkspace: React.Dispatch> +} + +const WorkspaceContext = createContext(null) + +export function useWorksapce() { + const ctx = useContext(WorkspaceContext) + + if (!ctx) { + throw new Error('WorkspaceContext is null') + } + + return ctx +} + +export function WorkspaceProvider({ + workspaces, + children +}: { + workspaces: Workspace[] + children: React.ReactNode +}) { + const { orgid } = useParams() + const [activeWorkspace, setActiveWorkspace] = useState( + () => workspaces.find((ws) => ws.id === orgid) ?? null + ) + + return ( + + {children} + + ) +} + +export function WorkspaceSwitcher() { const location = useLocation() const { isMobile, state } = useSidebar() - const { orgid } = useParams() - const org = orgs.find((org) => org.id === orgid) as Org - const [activeOrg, setActiveOrg] = useState(org) + const { activeWorkspace, setActiveWorkspace, workspaces } = useWorksapce() const [, fragment, _] = location.pathname.slice(1).split('/') - const onSelect = (org: Org) => { - setActiveOrg(org) - window.location.assign(`/${org.id}/${fragment}`) + const onSelect = (ws: Workspace) => { + setActiveWorkspace(ws) + window.location.assign(`/${ws.id}/${fragment}`) } return ( @@ -55,17 +92,20 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) { className="aria-expanded:border flex aspect-square size-8 items-center justify-center rounded-lg" aria-expanded={state === 'expanded'} > - {initials(activeOrg?.name)} + {initials(activeWorkspace?.name)}
- {activeOrg?.name} + + {activeWorkspace?.name} + - {formatCNPJ(activeOrg?.cnpj)} + {formatCNPJ(activeWorkspace?.cnpj)}
+ Empresas - {orgs.map((org, index) => ( + + {workspaces.map((workspace, index) => ( onSelect(org)} + onClick={() => onSelect(workspace)} className="group gap-2 p-2 cursor-pointer aria-selected:pointer-events-none" - aria-selected={org.id === activeOrg.id} + aria-selected={workspace.id === activeWorkspace?.id} >
- {initials(org?.name)} + {initials(workspace?.name)}
- {org?.name} + + {workspace?.name} + - {formatCNPJ(org?.cnpj)} + {formatCNPJ(workspace?.cnpj)}
@@ -96,7 +139,9 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
))} + +
diff --git a/apps/admin.saladeaula.digital/app/entry.server.tsx b/apps/admin.saladeaula.digital/app/entry.server.tsx index b7ce22e..45e0396 100644 --- a/apps/admin.saladeaula.digital/app/entry.server.tsx +++ b/apps/admin.saladeaula.digital/app/entry.server.tsx @@ -43,4 +43,4 @@ export default async function handleRequest( } // https://reactrouter.com/how-to/suspense#timeouts -export const streamTimeout = 6_000 +export const streamTimeout = 7_000 diff --git a/apps/admin.saladeaula.digital/app/lib/request.ts b/apps/admin.saladeaula.digital/app/lib/request.ts index 81de475..a7b3924 100644 --- a/apps/admin.saladeaula.digital/app/lib/request.ts +++ b/apps/admin.saladeaula.digital/app/lib/request.ts @@ -31,9 +31,15 @@ export function request({ const requestId = context.get(requestIdContext) as string const user = context.get(userContext) as User const url_ = new URL(url, context.cloudflare.env.API_URL) - const headers = new Headers( - Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _headers) - ) + const headers = new Headers({ + Authorization: `Bearer ${user.accessToken}` + }) + + if (_headers instanceof Headers) { + _headers.forEach((value, key) => headers.set(key, value)) + } else { + Object.entries(_headers).forEach(([key, value]) => headers.set(key, value)) + } console.log( `[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}` diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx index 5cef01a..a6d3d61 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx @@ -119,9 +119,6 @@ export default function Route({ loaderData: { data } }) { {selectedRows.length ? ( <>
- [] = [ } }, { - header: 'Data de venc.', + header: 'Comprado em', + cell: ({ row }) => { + const createdAt = new Date(row.original.create_date) + return formatted.format(createdAt) + } + }, + { + header: 'Vencimento em', cell: ({ row }) => { try { const dueDate = new Date(row.original.due_date) @@ -54,10 +61,14 @@ export const columns: ColumnDef[] = [ } }, { - header: 'Comprado em', + header: 'Pago em', cell: ({ row }) => { - const createdAt = new Date(row.original.create_date) - return formatted.format(createdAt) + if (row.original.payment_date) { + const createdAt = new Date(row.original.payment_date) + return formatted.format(createdAt) + } + + return <> } } ] diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id._index/route.tsx index e45d953..2fe2dda 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id._index/route.tsx @@ -1,12 +1,9 @@ import type { Route } from './+types' -import { isValidCPF } from '@brazilian-utils/brazilian-utils' import { zodResolver } from '@hookform/resolvers/zod' import { PatternFormat } from 'react-number-format' import { Link, useOutletContext } from 'react-router' -import { z } from 'zod' -import type { User } from '@/routes/_.$orgid.users.$id/route' import { Button } from '@repo/ui/components/ui/button' import { Card, @@ -27,13 +24,8 @@ import { Input } from '@repo/ui/components/ui/input' import { Spinner } from '@repo/ui/components/ui/spinner' import { useForm } from 'react-hook-form' -const formSchema = z.object({ - name: z.string().trim().nonempty('Digite seu nome'), - email: z.email(), - cpf: z - .string('CPF obrigatório') - .refine(isValidCPF, { message: 'CPF inválido' }) -}) +import type { User } from '../_.$orgid.users.$id/route' +import { formSchema, type Schema } from '../_.$orgid.users.add/route' export default function Route() { const { user } = useOutletContext() as { user: User } @@ -43,7 +35,7 @@ export default function Route() { }) const { handleSubmit, control, formState } = form - const onSubmit = async (data: z.infer) => { + const onSubmit = async (data: Schema) => { console.log(data) } @@ -133,7 +125,7 @@ export default function Route() { disabled={formState.isSubmitting} > {formState.isSubmitting && } - Editar colaborador + Editar
diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id.emails/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id.emails/route.tsx index bb506b2..021f16c 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id.emails/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id.emails/route.tsx @@ -1,10 +1,27 @@ import type { Route } from './+types' import { Suspense } from 'react' -import { Await } from 'react-router' +import { Await, useOutletContext } from 'react-router' import { request as req } from '@/lib/request' + import { Skeleton } from '@repo/ui/components/skeleton' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@repo/ui/components/ui/card' +import { Item, ItemContent, ItemGroup } from '@repo/ui/components/ui/item' +import { Kbd } from '@repo/ui/components/ui/kbd' +import { + NativeSelect, + NativeSelectOption +} from '@repo/ui/components/ui/native-select' + +import { Button } from '@repo/ui/components/ui/button' +import type { User } from '../_.$orgid.users.$id/route' export async function loader({ params, request, context }: Route.LoaderArgs) { const { id } = params @@ -18,16 +35,58 @@ export async function loader({ params, request, context }: Route.LoaderArgs) { } export default function Route({ loaderData: { data } }) { + const { user } = useOutletContext() as { user: User } + return ( }> {({ items = [] }) => ( -
    - {items.map(({ sk }: { sk: string }, idx: number) => { - const [, email] = sk.split('#') - return
  • {email}
  • - })} -
+
+ + + Emails + + Podem ser associados vários emails a uma conta. É possível + usar qualquer email para recuperar a senha, mas apenas o email + principal receberá as mensagens. + + + +
    + {items.map(({ sk }: { sk: string }, idx: number) => { + const [, email] = sk.split('#') + return
  • {email}
  • + })} +
+
+
+ + + + Email principal + + {user.email} será + usado para mensagens e pode ser usado para redefinições de + senha. + + + +
+ + {items.map(({ sk }: { sk: string }, idx: number) => { + const [, email] = sk.split('#') + return ( + + {email} + + ) + })} + + +
+
+
+
)}
diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id.orgs/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id.orgs/route.tsx deleted file mode 100644 index 76a14fd..0000000 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id.orgs/route.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Await } from 'react-router' - -import type { Route } from './+types' - -import { request as req } from '@/lib/request' -import { Skeleton } from '@repo/ui/components/skeleton' -import { Suspense } from 'react' - -export async function loader({ params, request, context }: Route.LoaderArgs) { - const { id } = params - const r = req({ - url: `/users/${id}/orgs`, - request, - context - }).then((r) => r.json()) - - return { data: r } -} - -export default function Route({ loaderData: { data } }) { - return ( - }> - - {({ items = [] }) => ( -
    - {items.map( - ({ name, cnpj }: { name: string; cnpj: string }, idx: number) => { - return ( -
  • - {name} {cnpj} -
  • - ) - } - )} -
- )} -
-
- ) -} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id/route.tsx index a60364c..51a8350 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id/route.tsx @@ -8,6 +8,7 @@ import { } from 'react-router' import { request as req } from '@/lib/request' + import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' import { Breadcrumb, @@ -90,7 +91,7 @@ export default function Route({
    -
  • {user.name}
  • +
  • {user.name}
  • {user.email}
diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/columns.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/columns.tsx index d2593dd..5e536f1 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/columns.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/columns.tsx @@ -2,12 +2,23 @@ import { formatCPF } from '@brazilian-utils/brazilian-utils' import { type ColumnDef } from '@tanstack/react-table' -import { ArrowRight } from 'lucide-react' +import { + ArrowRight, + EllipsisVerticalIcon, + PencilIcon, + UserRoundMinusIcon +} from 'lucide-react' import { NavLink } from 'react-router' import { Abbr } from '@/components/abbr' import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' import { Button } from '@repo/ui/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@repo/ui/components/ui/dropdown-menu' import { Spinner } from '@repo/ui/components/ui/spinner' import { initials } from '@repo/ui/lib/utils' @@ -65,13 +76,6 @@ export const columns: ColumnDef[] = [ return <> } }, - { - header: 'Cadastrado em', - cell: ({ row }) => { - const created_at = new Date(row.original.createDate) - return formatted.format(created_at) - } - }, { header: 'Último accesso', cell: ({ row }) => { @@ -85,30 +89,47 @@ export const columns: ColumnDef[] = [ } }, { - header: ' ', + header: 'Cadastrado em', + meta: { + className: 'w-1/12' + }, cell: ({ row }) => { - return ( -
- -
- ) + const created_at = new Date(row.original.createDate) + return formatted.format(created_at) } + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ + + + + + e.preventDefault()}> + + {({ isPending }) => ( + <> + {isPending ? : } + Editar + + )} + + + + Desvincular + + + +
+ ) } ] diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/route.tsx index 9b4a954..223c973 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/route.tsx @@ -61,6 +61,7 @@ export default function Route({ loaderData: { data } }) { {({ hits, page, hitsPerPage, totalHits }) => { return ( name && name.includes(' ') + +export const formSchema = z.object({ + name: z + .string() + .trim() + .nonempty('Digite um nome') + .refine(isName, { message: 'Nome inválido' }), + email: z.email('Email inválido').trim().toLowerCase(), + cpf: z + .string('CPF obrigatório') + .refine(isValidCPF, { message: 'CPF inválido' }) +}) + +export type Schema = z.infer + +export function meta({}: Route.MetaArgs) { + return [{ title: 'Adicionar colaborador' }] +} + +export async function action({ request, context }: Route.ActionArgs) { + const body = await request.json() + const r = await req({ + url: `users`, + headers: new Headers({ 'Content-Type': 'application/json' }), + method: HttpMethod.POST, + body: JSON.stringify(body), + request, + context + }) + + console.log(r) + + if (!r.ok) { + const error = await r.json().catch(() => ({})) + return { ok: false, error } + } + + return { ok: true } +} export default function Route() { + const fetcher = useFetcher() + const { activeWorkspace } = useWorksapce() + const form = useForm({ + resolver: zodResolver(formSchema) + }) + const { handleSubmit, control, formState, reset } = form + + const onSubmit = async (user: Schema) => { + await fetcher.submit(JSON.stringify({ user, org: activeWorkspace }), { + method: 'post', + encType: 'application/json' + }) + } + + useEffect(() => { + if (fetcher.data?.ok) { + toast.success('O colaborador foi adicionado') + return reset() + } + + switch (fetcher.data?.error?.type) { + case 'UserConflictError': + toast.error('O colaborador já foi vinculado anteriormente') + } + }, [fetcher.data]) + return (
@@ -33,17 +125,97 @@ export default function Route() { -
- - - Adicionar colaborador - - Siga os passos abaixo para cadastrar um novo colaborador - - +
+
+ + + + + Adicionar colaborador + + + Siga os passos abaixo para cadastrar um novo colaborador + + - - + + ( + + Nome + + + + + + )} + /> + +
+ ( + + Email + + + + + + )} + /> +
+ + +
+
+ + ( + + CPF + + { + onChange(value) + }} + {...props} + /> + + + + )} + /> +
+ + +
+ +
+
+
) diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx index e97232e..fc0054f 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx @@ -6,6 +6,7 @@ import { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router' import { AppSidebar } from '@/components/app-sidebar' import { request as req } from '@/lib/request' +import { WorkspaceProvider } from '@/components/workspace-switcher' import { userContext } from '@repo/auth/context' import { authMiddleware } from '@repo/auth/middleware/auth' import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode' @@ -15,6 +16,7 @@ import { SidebarProvider, SidebarTrigger } from '@repo/ui/components/ui/sidebar' +import { Toaster } from '@repo/ui/components/ui/sonner' export const middleware: Route.MiddlewareFunction[] = [authMiddleware] @@ -59,31 +61,34 @@ export default function Route({ loaderData }: Route.ComponentProps) { const { user, orgs, sidebar_state } = loaderData return ( - - + + + - -
-
- - + +
+
+ + -
- - +
+ + +
-
-
+
-
-
- -
-
-
-
+
+
+ + +
+
+ +
+ ) } diff --git a/apps/studio.saladeaula.digital/app/context.ts b/apps/studio.saladeaula.digital/app/context.ts deleted file mode 100644 index 83a3437..0000000 --- a/apps/studio.saladeaula.digital/app/context.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { User } from '@/middleware/auth' -import { createContext } from 'react-router' - -export const userContext = createContext(null) diff --git a/apps/studio.saladeaula.digital/app/lib/request.ts b/apps/studio.saladeaula.digital/app/lib/request.ts index 86b9ebc..81de475 100644 --- a/apps/studio.saladeaula.digital/app/lib/request.ts +++ b/apps/studio.saladeaula.digital/app/lib/request.ts @@ -1,7 +1,7 @@ -import type { LoaderFunctionArgs } from 'react-router' - import type { User } from '@repo/auth/auth' -import { userContext } from '@repo/auth/context' +import { requestIdContext, userContext } from '@repo/auth/context' + +import type { LoaderFunctionArgs } from 'react-router' export enum HttpMethod { GET = 'GET', @@ -23,23 +23,21 @@ type RequestArgs = { export function request({ url, method = HttpMethod.GET, - headers: _headers = {}, body = null, + headers: _headers = {}, request: { signal }, context }: RequestArgs): Promise { + const requestId = context.get(requestIdContext) as string const user = context.get(userContext) as User - // @ts-ignore + const url_ = new URL(url, context.cloudflare.env.API_URL) const headers = new Headers( - Object.assign(_headers, { Authorization: `Bearer ${user.accessToken}` }) + Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _headers) ) - // @ts-ignore - const endpoint = new URL(url, context.cloudflare.env.API_URL) - return fetch(endpoint.toString(), { - method, - headers, - body, - signal - }) + console.log( + `[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}` + ) + + return fetch(url_.toString(), { method, headers, body, signal }) } diff --git a/apps/studio.saladeaula.digital/app/routes/edit.tsx b/apps/studio.saladeaula.digital/app/routes/edit.tsx index e6b646e..1d6c387 100644 --- a/apps/studio.saladeaula.digital/app/routes/edit.tsx +++ b/apps/studio.saladeaula.digital/app/routes/edit.tsx @@ -1,12 +1,7 @@ import type { Route } from './+types/edit' import { zodResolver } from '@hookform/resolvers/zod' -import { - CircleCheckIcon, - FileBadgeIcon, - FileCode2Icon, - MoreHorizontalIcon -} from 'lucide-react' +import { FileBadgeIcon, FileCode2Icon, MoreHorizontalIcon } from 'lucide-react' import { Suspense, useState, type ReactNode } from 'react' import { useForm } from 'react-hook-form' import { Await, useAsyncValue, useFetcher } from 'react-router' @@ -15,15 +10,7 @@ import { z } from 'zod' import { HttpMethod, request as req } from '@/lib/request' -import type { User } from '@repo/auth/auth' -import { userContext } from '@repo/auth/context' - import { Skeleton } from '@repo/ui/components/skeleton' -import { - Alert, - AlertDescription, - AlertTitle -} from '@repo/ui/components/ui/alert' import { Breadcrumb, BreadcrumbItem, @@ -66,7 +53,7 @@ import { import { Spinner } from '@repo/ui/components/ui/spinner' import { Switch } from '@repo/ui/components/ui/switch' -const schema = z +const formSchema = z .object({ given_cert: z.coerce.boolean(), never_expires: z.coerce.boolean(), @@ -94,7 +81,7 @@ const schema = z } ) -type Schema = z.infer +type Schema = z.infer type Cert = { exp_interval: number @@ -132,16 +119,12 @@ export const loader = async ({ } export async function action({ params, request, context }: Route.ActionArgs) { - const user = context.get(userContext) as User const formData = await request.formData() const r = await req({ url: `courses/${params.id}`, method: HttpMethod.PUT, body: formData, - headers: new Headers({ - Authorization: `Bearer ${user.accessToken}` - }), request, context }) @@ -180,7 +163,7 @@ function Editing() { const fetcher = useFetcher() const form = useForm({ - resolver: zodResolver(schema), + resolver: zodResolver(formSchema), defaultValues: { draft: false, given_cert: !!course?.cert, @@ -415,7 +398,7 @@ function Editing() { disabled={formState.isSubmitting} > {formState.isSubmitting && } - Editar curso + Editar
diff --git a/courses-events/app/events/daily_sync_course_metadata.py b/courses-events/app/events/copy_course_metadata.py similarity index 89% rename from courses-events/app/events/daily_sync_course_metadata.py rename to courses-events/app/events/copy_course_metadata.py index 725484f..d088730 100644 --- a/courses-events/app/events/daily_sync_course_metadata.py +++ b/courses-events/app/events/copy_course_metadata.py @@ -10,7 +10,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from boto3clients import dynamodb_client from config import API_URL, COURSE_TABLE -course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client) +dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client) @event_source(data_class=DynamoDBStreamEvent) @@ -21,7 +21,7 @@ def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext) -> bool: data = r.json() now_ = now() - with course_layer.transact_writer() as transact: + with dyn.transact_writer() as transact: for course in data: transact.update( key=KeyPair(course['id'], '0'), diff --git a/courses-events/template.yaml b/courses-events/template.yaml index cec3fd2..63ec9ee 100644 --- a/courses-events/template.yaml +++ b/courses-events/template.yaml @@ -14,7 +14,7 @@ Globals: Architectures: - x86_64 Layers: - - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:83 + - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:100 Environment: Variables: TZ: America/Sao_Paulo @@ -28,19 +28,19 @@ Resources: EventLog: Type: AWS::Logs::LogGroup Properties: - RetentionInDays: 90 + RetentionInDays: 7 - EventDailySyncCourseMetadataFunction: + EventCopyCourseMetadataScheduledFunction: Type: AWS::Serverless::Function Properties: - Handler: events.daily_sync_course_metadata.lambda_handler + Handler: events.copy_course_metadata.lambda_handler LoggingConfig: LogGroup: !Ref EventLog Policies: - DynamoDBWritePolicy: TableName: !Ref CourseTable Events: - Rule: + ScheduleEvent: Type: ScheduleV2 Properties: ScheduleExpression: cron(0 0 * * ? *) diff --git a/courses-events/tests/events/test_daily_sync_course_metadata.py b/courses-events/tests/events/test_copy_course_metadata.py similarity index 93% rename from courses-events/tests/events/test_daily_sync_course_metadata.py rename to courses-events/tests/events/test_copy_course_metadata.py index 8d17d7d..05bafaa 100644 --- a/courses-events/tests/events/test_daily_sync_course_metadata.py +++ b/courses-events/tests/events/test_copy_course_metadata.py @@ -3,7 +3,7 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair -def test_daily_sync_course_metadata( +def test_copy_course_metadata( dynamodb_client, dynamodb_seeds, dynamodb_persistence_layer: DynamoDBPersistenceLayer, diff --git a/packages/auth/src/middleware/logging.ts b/packages/auth/src/middleware/logging.ts index a5b1c80..eddc97b 100644 --- a/packages/auth/src/middleware/logging.ts +++ b/packages/auth/src/middleware/logging.ts @@ -20,5 +20,7 @@ export const loggingMiddleware = async ( `[${new Date().toISOString()}] [${requestId}] Response ${response.status} (${duration}ms)` ) + response.headers.set('Server-Timing', `page;req=${requestId};dur=${duration}`) + return response } diff --git a/packages/ui/src/components/dark-mode.tsx b/packages/ui/src/components/dark-mode.tsx index 7efb298..42634b3 100644 --- a/packages/ui/src/components/dark-mode.tsx +++ b/packages/ui/src/components/dark-mode.tsx @@ -46,6 +46,7 @@ export function ModeToggle() { type ThemedImageProps = { children?: string + className?: string } export function ThemedImage({ children, ...props }: ThemedImageProps) { diff --git a/packages/ui/src/components/ui/item.tsx b/packages/ui/src/components/ui/item.tsx new file mode 100644 index 0000000..d97de21 --- /dev/null +++ b/packages/ui/src/components/ui/item.tsx @@ -0,0 +1,193 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Separator } from "@/components/ui/separator" + +function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const itemVariants = cva( + "group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border-border", + muted: "bg-muted/50", + }, + size: { + default: "p-4 gap-4 ", + sm: "py-3 px-4 gap-2.5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Item({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"div"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "div" + return ( + + ) +} + +const itemMediaVariants = cva( + "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5", + { + variants: { + variant: { + default: "bg-transparent", + icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4", + image: + "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function ItemMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ItemContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} + /> + ) +} + +function ItemActions({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ) +} + +function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Item, + ItemMedia, + ItemContent, + ItemActions, + ItemGroup, + ItemSeparator, + ItemTitle, + ItemDescription, + ItemHeader, + ItemFooter, +} diff --git a/packages/ui/src/components/ui/native-select.tsx b/packages/ui/src/components/ui/native-select.tsx new file mode 100644 index 0000000..a770e3c --- /dev/null +++ b/packages/ui/src/components/ui/native-select.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function NativeSelect({ className, ...props }: React.ComponentProps<"select">) { + return ( +
+