refactor: migrate backend to pnpm monorepo with Hono module architecture

- Replace Bun with pnpm 9 + Turborepo + tsx; apps/api renamed to apps/backend
- Split backend into http/, modules/, lib/ layers mirroring tai-specguard
- Add use-case + Result pattern, Problem Details RFC 7807, basePath /api/v1
- Mount Better Auth at /api/v1/auth, keep session-auth whitelist
- Split Prisma schema into prisma/models/*, generate into generated/
- Rework packages/shared into lib/ + schemas/ with pagination DTOs
- Implement health, groups, students, payments, waitlist modules
- Add vitest suite with prisma mocks (21 tests), biome lint
- Point web client to /api/v1/auth and /api/v1/groups/from-organization
This commit is contained in:
Jose Selesan
2026-09-14 09:18:40 -03:00
parent b41fffa40a
commit 4b1f356fab
101 changed files with 7152 additions and 1456 deletions

12
.gitignore vendored
View File

@@ -136,19 +136,15 @@ dist
.yarn/install-state.gz
.pnp.*
# Bun
.bun
bun.lockb
# Turborepo
.turbo
# Prisma
apps/api/prisma/dev.db
apps/api/prisma/migrations/dev/
apps/api/prisma/generated/
apps/backend/prisma/dev.db
apps/backend/prisma/migrations/dev/
apps/backend/generated/
*.db
# env
apps/api/.env
apps/backend/.env

View File

@@ -4,35 +4,49 @@ Monorepo web + API para gestión de cobros grupales. Reglas optimizadas para age
## Stack & ejecutables de verdad
- **Package manager / runtime: Bun** (v1.4+, `package.json` viene con `workspaces` y `packageManager: bun`). Usa `bun` para instalar (`bun install`), scripts y filtros. **No uses npm/yarn/pnpm**.
- **Monorepo**: Bun Workspaces orquestado con **Turborepo** (`turbo.json`). Dependencias entre paquetes usan `workspace:*`.
- Los paquetes son Node `"type": "module"`.
- **Package manager: pnpm 9** (`packageManager` en la raíz). Usa `pnpm` para instalar (`pnpm install`), scripts y filtros. **No uses bun/npm/yarn.**
- **Monorepo**: pnpm workspaces (`pnpm-workspace.yaml`) orquestado con **Turborepo** (`turbo.json`). Dependencias entre paquetes usan `workspace:*`.
- **Runtime: Node** (`"type": "module"`). El backend se ejecuta con **tsx** (`tsx watch src/server.ts`); no es Bun.
- Lockfile: `pnpm-lock.yaml` (se versiona). No existe `bun.lock`/`bun.lockb`.
## Comandos
- `bun run typecheck` — corre `tsc --noEmit` en todos los paquetes vía turborepo. **Es la verificación principal; correlo tras tocar código.**
- `bun run --filter @gruperly/api typecheck` / `... @gruperly/web typecheck` — verificación de un solo paquete.
- `bun run dev` — levanta API (4000) y web (6173) con turborepo.
- **No hay script `lint` en ningún paquete**; `bun run lint` no hace nada útil. No dependas de él.
- Base de datos (API): `bun run db:generate` / `db:migrate` / `db:push` / `db:studio`, o bien `bun --filter @gruperly/api db:*`.
```bash
pnpm install # instala dependencias
pnpm dev # levanta API (4000) y web (6173) vía turborepo
pnpm typecheck # tsc --noEmit en todos los paquetes. Principal verificación; corre tras tocar código
pnpm build # build de backend (prisma generate + tsc) y web (vite build)
pnpm lint # biome check en backend y shared
pnpm lint:fix # biome check --write
pnpm --filter @gruperly/backend typecheck # verificación de un solo paquete
pnpm --filter @gruperly/backend test # vitest (backend)
pnpm --filter @gruperly/backend db:* # db:generate / db:migrate / db:push / db:studio
```
## Estructura y fronteras
- `apps/api` — Backend Bun + Hono. Entry point `src/index.ts` (monta `Bun.serve`, puerto por defecto **4000** vía `PORT`). Rutas modulares en `src/routes/`. Prisma en `prisma/schema.prisma`.
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`).
- `packages/shared` — Esquemas Zod + tipos. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo). `@gruperly/shared` se resuelve vía `paths` en cada tsconfig de app.
- `packages/config` — `tsconfig.base.json`; todos los tsconfig lo extienden.
- `apps/backend` — Backend Node + Hono. Entry point `src/server.ts` (serve de `@hono/node-server`, puerto **4000** vía `PORT`). `src/app.ts` monta `basePath('/api/v1')` + rutas de módulos + notFound/onError en RFC 7807. Capas:
- `src/http/` — infraestructura HTTP: `env.ts`, `problem-details.ts` (+ `problem-builders.ts`, `problem-domain.ts`), `validate.ts` (wrapper de `@hono/zod-validator`), `request-id.ts`, `request-logger.ts`, `security-headers.ts`, `session-auth.ts` (middleware con whitelist pública `/api/v1/health`, `/api/v1/auth`, `/api/auth`).
- `src/modules/<modulo>/` — por módulo: `index.ts`, `routes.ts` y `features/<accion>/{route,use-case}.ts`. **Patrón**: el route valida (`validate.query/json`) y delega en un use-case que devuelve `Result<T, ProblemDetails>` (`ok`/`err` desde `@gruperly/shared`); el route responde con `resultJson` (o `problemJson`).
- `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
- `src/logger.ts` — pino + pino-pretty.
- Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `students`, `payments`, `waitlist` (listados paginados `{ data, pagination }`).
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`); el backend llama a `/api/v1/groups/from-organization` (`src/routes/organizations.tsx`).
- `packages/shared` — Esquemas Zod (v3.24) + tipos + `Result` + Problem Details. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo); se resuelve vía el symlink de pnpm en `node_modules` (`@gruperly/shared` no está en `paths` de los tsconfig). El `paths` de los tsconfig solo mapea `@/*` → `src/*` y `@generated/*` → `generated/*`.
- `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete).
## Gotchas operativos
- **Prisma 7**: requiere `apps/api/.env` (con `DATABASE_URL`). El CLI lee `apps/api/prisma.config.ts` (datasource `url` vía `env('DATABASE_URL')`); el schema usa `provider = "prisma-client"` con output en `prisma/generated/prisma` (gitignoreado; regenera con `bun run db:generate`). El `PrismaClient` se importa desde `../../prisma/generated/prisma/client` y se instancia con `@prisma/adapter-pg`. Sin `.env` el CLI falla; copia desde `apps/api/.env.example`.
- **Turborepo solo lee lockfile textual `bun.lock`** (no el binario `bun.lockb`). Si se regenera, usa `bun install --save-text-lockfile`. No versiones `bun.lockb`.
- `.env`, `dist/`, `.turbo/` están gitignoreados (que no te extrañe su ausencia).
- **Prisma 7**: requiere `apps/backend/.env` (con `DATABASE_URL`). El CLI lee `apps/backend/prisma.config.ts` (datasource `url` vía `env('DATABASE_URL')`); el schema es **multi-archivo**: `prisma/schema.prisma` (generator con output `../generated/prisma` + datasource simple) que incluye `prisma/models/*.prisma` (`auth.prisma`, `domain.prisma`). El cliente se regenra con `pnpm --filter @gruperly/backend db:generate` y se importa desde `@generated/prisma/client` (alias → `generated/prisma`, gitignoreado); se instancia con `@prisma/adapter-pg`. Sin `.env` el CLI falla; copia desde `apps/backend/.env.example`.
- **Tests (vitest)**: viven en `apps/backend/test/`. Mockean el módulo `@/lib/prisma` con `vi.mock` (usando `vi.hoisted` si el mock se comparte como `tx`); los use-cases ponen el db mockable en `constructor(deps)`. Los tests NO pasan por `sessionAuthMiddleware`; si el route lee `c.get('user')`, inyecta un usuario con un middleware en el harness.
- **zod en v3.24 (pin exacto)**, no v4: `better-call` (transitiva de better-auth) pide `zod ^4` — ese peer warning es conocido y aceptado.
- `.env`, `dist/`, `.turbo/`, `apps/backend/generated/`, `node_modules/` están gitignoreados (que no te extrañe su ausencia).
## Convenciones repo-específicas
- **Frontend en español**: copy de UI, comentarios y textos en español.
- **Base de datos**: todas las tablas usan **snake_case** vía `@@map` en `prisma/schema.prisma` (p. ej. `User` → `users`, `WaitlistEntry` → `waitlist_entries`). Al agregar un modelo nuevo, incluir siempre `@@map("nombre_tabla")`.
- **Base de datos**: todas las tablas usan **snake_case** vía `@@map` en `prisma/models/*.prisma` (p. ej. `User` → `users`, `WaitlistEntry` → `waitlist_entries`). Al agregar un modelo nuevo, incluir siempre `@@map("nombre_tabla")`.
- **API** responde errores en **Problem Details RFC 7807** (`application/problem+json`) y resultados como `{ data, pagination }`; los "use cases" devuelven `Result` y nunca lanzan excepciones de dominio.
- **Tailwind v4** con tokens en `@theme` dentro de `apps/web/src/index.css` (p. ej. `--color-accent: #1e90ff`). Se usan como clases auto-generadas: `text-accent`, `bg-success-soft`, etc. Radius por defecto `0.75rem` (`rounded-xl`).
- **Router NO es file-based** (aunque `stack.md` lo diga): las rutas se declaran manualmente en `apps/web/src/router.tsx` con `createRoute` + `addChildren` y se registran vía module augmentation. **Cada vista nueva debe añadirse ahí.**
- Nav (Inicio/Grupos/Cobros/Ajustes) vive en `apps/web/src/components/layout/nav-items.ts`; es la fuente única para `BottomNav` (móvil) y `Sidebar` (desktop) — no dupliques la lista.
@@ -42,4 +56,4 @@ Monorepo web + API para gestión de cobros grupales. Reglas optimizadas para age
## Fuentes de contexto
- `stack.md` — arquitectura y spec técnica de referencia (UI tokens exactos, stack por app).
- `README.md` — setup de la base de datos y comandos generales.
- `README.md` — setup de la base de datos y comandos generales.

View File

@@ -4,17 +4,17 @@ Monorepo de Gruperly. Consulta [stack.md](./stack.md) para arquitectura y especi
## Stack
- Runtime & package manager: [Bun](https://bun.sh/)
- Monorepo: Bun Workspaces + Turborepo
- Backend (`apps/api`): Bun + Hono + Prisma + PostgreSQL + Better Auth
- Package manager: [pnpm](https://pnpm.io/) 9
- Monorepo: pnpm workspaces + Turborepo
- Backend (`apps/backend`): Node + Hono (`@hono/node-server`) + Prisma + PostgreSQL + Better Auth
- Frontend (`apps/web`): React 19 + TypeScript + TanStack Router + TanStack Query + Tailwind v4
- Shared (`packages/shared`): esquemas Zod y tipos compartidos
- Shared (`packages/shared`): esquemas Zod, tipos, `Result` y Problem Details compartidos
## Estructura
```text
apps/
api/ # Backend (Bun + Hono + Prisma)
backend/ # Backend (Node + Hono + Prisma, tsx)
web/ # Frontend (React PWA)
packages/
shared/ # Esquemas Zod y tipos compartidos
@@ -24,13 +24,16 @@ packages/
## Comandos
```bash
bun install # Instala dependencias de todos los workspaces
bun run dev # Levanta la API y la web (vía Turborepo)
bun run db:push # Aplica el esquema de Prisma a la base de datos
bun run db:studio # Abre Prisma Studio
pnpm install # Instala dependencias de todos los workspaces
pnpm dev # Levanta la API (4000) y la web (6173) vía Turborepo
pnpm typecheck # tsc --noEmit en todos los paquetes
pnpm lint # biome check (backend y shared)
pnpm --filter @gruperly/backend test # vitest (backend)
pnpm --filter @gruperly/backend db:push # Aplica el esquema de Prisma a la base de datos
pnpm --filter @gruperly/backend db:studio # Abre Prisma Studio
```
## Setup de base de datos
1. Crea una base PostgreSQL y copia `apps/api/.env.example` → `apps/api/.env`, ajustando `DATABASE_URL`.
2. Ejecuta `bun run db:generate` y `bun run db:push` (o `bun run db:migrate` en desarrollo).
1. Crea una base PostgreSQL y copia `apps/backend/.env.example` → `apps/backend/.env`, ajustando `DATABASE_URL`.
2. Ejecuta `pnpm --filter @gruperly/backend db:generate` y `pnpm --filter @gruperly/backend db:push` (o `pnpm --filter @gruperly/backend db:migrate` en desarrollo).

View File

@@ -1,32 +0,0 @@
{
"name": "@gruperly/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"build": "bun build ./src/index.ts --target bun --outdir dist",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@better-auth/passkey": "^1.7.2",
"@gruperly/shared": "workspace:*",
"@hono/zod-validator": "^0.8.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"better-auth": "1.7.2",
"hono": "^4.6.0",
"nodemailer": "^9.0.6"
},
"devDependencies": {
"@types/bun": "^1.0.0",
"@types/nodemailer": "^8.0.1",
"prisma": "^7.10.0",
"typescript": "^5.7.0"
}
}

View File

@@ -1,256 +0,0 @@
// Gruperly - Prisma Schema (PostgreSQL)
generator client {
provider = "prisma-client"
output = "./generated/prisma"
}
datasource db {
provider = "postgresql"
}
// Auth (Better Auth) - required tables
model User {
id String @id @default(cuid())
name String
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
passkeys Passkey[]
groupsMember GroupMember[]
groupsOwner Group[] @relation("OwnerGroups")
orgMemberships Member[]
orgInvitations Invitation[] @relation("InvitedBy")
waitlist WaitlistEntry[]
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
providerId String
accountId String
issuer String
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([issuer, accountId])
@@index([userId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
activeOrganizationId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([identifier, value])
@@map("verifications")
}
// Better Auth - Organization plugin
model Organization {
id String @id @default(cuid())
name String
slug String @unique
logo String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members Member[]
invitations Invitation[]
@@map("organizations")
}
model Member {
id String @id @default(cuid())
organizationId String
userId String
role String @default("member")
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId])
@@map("members")
}
model Invitation {
id String @id @default(cuid())
organizationId String
email String
role String
status String @default("pending")
inviterId String
expiresAt DateTime
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
inviter User @relation("InvitedBy", fields: [inviterId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@map("invitations")
}
// Better Auth - Passkey plugin
model Passkey {
id String @id @default(cuid())
name String?
publicKey String
credentialID String @unique
userId String
counter Int
deviceType String
backedUp Boolean
transports String?
createdAt DateTime @default(now())
aaguid String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("passkeys")
}
// Domain models
model Group {
id String @id @default(cuid())
name String
description String?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
members GroupMember[]
students Student[]
payments Payment[]
@@map("groups")
}
model GroupMember {
id String @id @default(cuid())
groupId String
userId String
role Role @default(MEMBER) // OWNER, ADMIN, MEMBER
joinedAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([groupId, userId])
@@map("group_members")
}
enum Role {
OWNER
ADMIN
MEMBER
}
model Student {
id String @id @default(cuid())
groupId String
fullName String
email String?
phone String?
guardianName String?
guardianPhone String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
payments Payment[]
@@index([groupId])
@@map("students")
}
model Payment {
id String @id @default(cuid())
groupId String
studentId String
amount Decimal @db.Decimal(10, 2)
currency String @default("MXN")
status PaymentStatus @default(PENDING) // PENDING, PAID, OVERDUE, CANCELLED
dueDate DateTime
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([groupId])
@@index([studentId])
@@map("payments")
}
enum PaymentStatus {
PENDING
PAID
OVERDUE
CANCELLED
}
model WaitlistEntry {
id String @id @default(cuid())
email String
name String?
status WaitlistStatus @default(PENDING) // PENDING, INVITED, JOINED, DECLINED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String? @unique
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@unique([email])
@@map("waitlist_entries")
}
enum WaitlistStatus {
PENDING
INVITED
JOINED
DECLINED
}

View File

@@ -1,8 +0,0 @@
import { PrismaClient } from '../../prisma/generated/prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
})
export const prisma = new PrismaClient({ adapter })

View File

@@ -1,3 +0,0 @@
import { prisma } from './client'
export { prisma }

View File

@@ -1,10 +0,0 @@
import type { auth } from './auth'
type Session = typeof auth.$Infer.Session
export type AppEnv = {
Variables: {
user: Session['user'] | null
session: Session['session'] | null
}
}

View File

@@ -1,58 +0,0 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { auth } from './auth'
import type { AppEnv } from './env'
import { listGroups, createGroupFromOrganization } from './routes/groups'
import { listPlaceholder as students } from './routes/students'
import { listPlaceholder as payments } from './routes/payments'
import { listPlaceholder as waitlist } from './routes/waitlist'
const app = new Hono<AppEnv>()
const webOrigin = process.env.WEB_URL ?? 'http://localhost:6173'
app.use(
'*',
cors({
origin: [webOrigin],
credentials: true,
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
}),
)
// BetterAuth endpoints (sign-up, sign-in, session, org, passkeys, ...)
app.all('/api/auth', (c) => auth.handler(c.req.raw))
app.all('/api/auth/*', (c) => auth.handler(c.req.raw))
// Sesión disponible en todos los handlers vía c.get('user') / c.get('session')
app.use('*', async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (session) {
c.set('user', session.user)
c.set('session', session.session)
} else {
c.set('user', null)
c.set('session', null)
}
await next()
})
app.get('/', (c) => c.json({ name: 'Gruperly API', status: 'ok' }))
app.get('/api/groups', listGroups)
app.post('/api/groups/from-organization', createGroupFromOrganization)
app.get('/api/students', students)
app.get('/api/payments', payments)
app.get('/api/waitlist', waitlist)
export default app
const port = Number(Bun.env.PORT ?? 4000)
Bun.serve({
port,
fetch: app.fetch,
})
console.log(`Gruperly API running on http://localhost:${port}`)

View File

@@ -1,65 +0,0 @@
import type { Context } from 'hono'
import { z } from 'zod'
import { prisma } from '../db/client'
import type { AppEnv } from '../env'
import { Role } from '../../prisma/generated/prisma/client'
const fromOrganizationSchema = z.object({
organizationId: z.string().min(1),
})
export async function listGroups(c: Context<AppEnv>) {
const user = c.get('user')
if (!user) return c.json({ message: 'No autorizado' }, 401)
void user
return c.json([])
}
// Crea un Group a partir de una Organization de Better Auth (mapeo 1:1).
// Solo el owner de la organización puede crear su grupo.
export async function createGroupFromOrganization(c: Context<AppEnv>) {
const user = c.get('user')
if (!user) return c.json({ message: 'No autorizado' }, 401)
const body = await c.req.json().catch(() => null)
const parsed = fromOrganizationSchema.safeParse(body)
if (!parsed.success) {
return c.json({ message: 'Cuerpo inválido', issues: parsed.error.issues }, 400)
}
const organization = await prisma.organization.findUnique({
where: { id: parsed.data.organizationId },
include: { members: true },
})
if (!organization) return c.json({ message: 'Organización no encontrada' }, 404)
const membership = organization.members.find((m) => m.userId === user.id)
if (!membership || membership.role !== 'owner') {
return c.json({ message: 'Solo el owner puede crear el grupo' }, 403)
}
const existing = await prisma.group.findFirst({
where: { createdById: user.id, name: organization.name },
})
if (existing) return c.json({ group: existing, alreadyExists: true })
const group = await prisma.$transaction(async (tx) => {
const created = await tx.group.create({
data: {
name: organization.name,
createdById: user.id,
},
})
await tx.groupMember.create({
data: {
groupId: created.id,
userId: user.id,
role: Role.OWNER,
},
})
return created
})
return c.json({ group }, 201)
}

View File

@@ -1,5 +0,0 @@
import type { Context } from 'hono'
export function listPlaceholder(c: Context) {
return c.json([])
}

View File

@@ -1,5 +0,0 @@
import type { Context } from 'hono'
export function listPlaceholder(c: Context) {
return c.json([])
}

View File

@@ -1,5 +0,0 @@
import type { Context } from 'hono'
export function listPlaceholder(c: Context) {
return c.json([])
}

View File

@@ -1,12 +0,0 @@
{
"extends": "@gruperly/config/tsconfig.base.json",
"compilerOptions": {
"moduleResolution": "bundler",
"types": ["bun"],
"declaration": false,
"paths": {
"@gruperly/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src/**/*.ts", "prisma/generated/**/*.ts"]
}

45
apps/backend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "@gruperly/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts",
"build": "prisma generate && tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"lint": "biome check .",
"lint:fix": "biome check . --write",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@better-auth/passkey": "^1.7.2",
"@gruperly/shared": "workspace:*",
"@hono/node-server": "^2.1.1",
"@hono/zod-validator": "^0.8.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"better-auth": "1.7.2",
"dotenv": "^16.4.5",
"hono": "^4.13.3",
"nodemailer": "^9.0.6",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"tsx": "^4.19.2",
"zod": "3.24.2"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@gruperly/config": "workspace:*",
"@types/node": "^20.17.0",
"@types/nodemailer": "^8.0.1",
"prisma": "^7.10.0",
"typescript": "^5.7.0",
"vitest": "^2.1.8"
}
}

View File

@@ -1,11 +1,11 @@
import { config } from 'dotenv'
import { join } from 'node:path'
import { config } from 'dotenv'
import { defineConfig, env } from 'prisma/config'
config({ path: join(process.cwd(), '.env') })
export default defineConfig({
schema: 'prisma/schema.prisma',
schema: 'prisma/',
migrations: {
path: 'prisma/migrations',
},

View File

@@ -0,0 +1,140 @@
// Auth (Better Auth) - required tables
model User {
id String @id @default(cuid())
name String
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
passkeys Passkey[]
groupsMember GroupMember[]
groupsOwner Group[] @relation("OwnerGroups")
orgMemberships Member[]
orgInvitations Invitation[] @relation("InvitedBy")
waitlist WaitlistEntry[]
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
providerId String
accountId String
issuer String
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([issuer, accountId])
@@index([userId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
activeOrganizationId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([identifier, value])
@@map("verifications")
}
// Better Auth - Organization plugin
model Organization {
id String @id @default(cuid())
name String
slug String @unique
logo String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members Member[]
invitations Invitation[]
@@map("organizations")
}
model Member {
id String @id @default(cuid())
organizationId String
userId String
role String @default("member")
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId])
@@map("members")
}
model Invitation {
id String @id @default(cuid())
organizationId String
email String
role String
status String @default("pending")
inviterId String
expiresAt DateTime
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
inviter User @relation("InvitedBy", fields: [inviterId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@map("invitations")
}
// Better Auth - Passkey plugin
model Passkey {
id String @id @default(cuid())
name String?
publicKey String
credentialID String @unique
userId String
counter Int
deviceType String
backedUp Boolean
transports String?
createdAt DateTime @default(now())
aaguid String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("passkeys")
}

View File

@@ -0,0 +1,106 @@
// Domain models
model Group {
id String @id @default(cuid())
name String
description String?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
members GroupMember[]
students Student[]
payments Payment[]
@@map("groups")
}
model GroupMember {
id String @id @default(cuid())
groupId String
userId String
role Role @default(MEMBER) // OWNER, ADMIN, MEMBER
joinedAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([groupId, userId])
@@map("group_members")
}
enum Role {
OWNER
ADMIN
MEMBER
}
model Student {
id String @id @default(cuid())
groupId String
fullName String
email String?
phone String?
guardianName String?
guardianPhone String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
payments Payment[]
@@index([groupId])
@@map("students")
}
model Payment {
id String @id @default(cuid())
groupId String
studentId String
amount Decimal @db.Decimal(10, 2)
currency String @default("MXN")
status PaymentStatus @default(PENDING) // PENDING, PAID, OVERDUE, CANCELLED
dueDate DateTime
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([groupId])
@@index([studentId])
@@map("payments")
}
enum PaymentStatus {
PENDING
PAID
OVERDUE
CANCELLED
}
model WaitlistEntry {
id String @id @default(cuid())
email String
name String?
status WaitlistStatus @default(PENDING) // PENDING, INVITED, JOINED, DECLINED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String? @unique
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@unique([email])
@@map("waitlist_entries")
}
enum WaitlistStatus {
PENDING
INVITED
JOINED
DECLINED
}

View File

@@ -0,0 +1,10 @@
// Gruperly - Prisma Schema (PostgreSQL)
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}

49
apps/backend/src/app.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Hono } from 'hono';
import type { BackendEnv } from '@/http/env';
import {
internalServerErrorProblem,
notFoundProblem,
problemJson,
} from '@/http/problem-details';
import { requestIdMiddleware } from '@/http/request-id';
import { requestLoggerMiddleware } from '@/http/request-logger';
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
import { sessionAuthMiddleware } from '@/http/session-auth';
import { logger } from '@/logger';
import { authRoutes } from './modules/auth';
import { groupsRoutes } from './modules/groups';
import { healthCheckRoutes } from './modules/health-check';
import { paymentsRoutes } from './modules/payments';
import { studentsRoutes } from './modules/students';
import { waitlistRoutes } from './modules/waitlist';
const app = new Hono<BackendEnv>();
app.use('*', corsMiddleware);
app.use('*', securityHeadersMiddleware);
app.use('*', requestIdMiddleware);
app.use('*', requestLoggerMiddleware);
app.get('/', (c) => c.json({ name: 'Gruperly API', status: 'ok' }));
const api = app.basePath('/api/v1');
api.use('*', sessionAuthMiddleware);
api.route('/auth', authRoutes);
api.route('/health', healthCheckRoutes);
api.route('/groups', groupsRoutes);
api.route('/students', studentsRoutes);
api.route('/payments', paymentsRoutes);
api.route('/waitlist', waitlistRoutes);
app.notFound((c) => {
return problemJson(c, notFoundProblem(c.req.path));
});
app.onError((error, c) => {
logger.error({ error, path: c.req.path }, 'Unhandled request error');
return problemJson(c, internalServerErrorProblem(c.req.path));
});
export default app;

View File

@@ -0,0 +1,19 @@
import type { auth } from '@/modules/auth/auth';
type AuthSession = typeof auth.$Infer.Session;
export type BackendEnv = {
Variables: {
user: AuthSession['user'] | null;
session: AuthSession['session'] | null;
requestId: string;
};
};
declare module 'hono' {
interface ContextVariableMap {
user: AuthSession['user'] | null;
session: AuthSession['session'] | null;
requestId: string;
}
}

View File

@@ -0,0 +1,89 @@
import type { ProblemDetails } from '@gruperly/shared';
import { notFoundProblem } from './problem-details';
import { PROBLEM_DOMAIN } from './problem-domain';
export function validationProblem(params: {
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
}): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/validation`,
title: 'Bad Request',
status: 400,
detail: params.detail ?? 'Invalid request data',
instance: params.instance,
errors: params.errors,
};
}
export function conflictProblem(params: {
detail: string;
code?: string;
instance?: string;
}): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/conflict`,
title: 'Conflict',
status: 409,
detail: params.detail,
instance: params.instance,
code: params.code,
};
}
export function forbiddenProblem(params: {
detail: string;
code?: string;
instance?: string;
}): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/forbidden`,
title: 'Forbidden',
status: 403,
detail: params.detail,
instance: params.instance,
code: params.code,
};
}
export function organizationNotFoundProblem(id: string): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/organization-not-found`,
title: 'Not Found',
status: 404,
detail: `Organization ${id} was not found.`,
code: 'organization_not_found',
};
}
export function groupOwnerRequiredProblem(): ProblemDetails {
return forbiddenProblem({
detail: 'Only the organization owner can create the group.',
code: 'group_owner_required',
});
}
export function databaseUnavailableProblem(params?: {
instance?: string;
}): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/database-unavailable`,
title: 'Service Unavailable',
status: 503,
detail: 'Database health check failed',
instance: params?.instance,
code: 'database_unavailable',
};
}
export function noGroupAccessProblem(): ProblemDetails {
return forbiddenProblem({
detail: 'You do not have access to this group.',
code: 'group_access_denied',
});
}
export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails {
return notFoundProblem(`${resourceName} ${id}`);
}

View File

@@ -0,0 +1,82 @@
import type { ProblemDetails, Result } from '@gruperly/shared';
import type { Context } from 'hono';
import type {
ClientErrorStatusCode,
ServerErrorStatusCode,
SuccessStatusCode,
} from 'hono/utils/http-status';
type ProblemStatusCode = ClientErrorStatusCode | ServerErrorStatusCode;
type JsonSuccessStatusCode = Exclude<SuccessStatusCode, 204 | 205>;
type ProblemDetailsInput = Omit<ProblemDetails, 'status' | 'title'> & {
status: ProblemStatusCode;
title: string;
};
type ResultJsonOptions = {
status?: JsonSuccessStatusCode;
};
const DEFAULT_TYPE = 'about:blank';
export const problemDetails = ({
type = DEFAULT_TYPE,
title,
status,
detail,
instance,
code,
}: ProblemDetailsInput): ProblemDetails => ({
type,
title,
status,
...(detail ? { detail } : {}),
...(instance ? { instance } : {}),
...(code ? { code } : {}),
});
export const problemJson = (c: Context, problem: ProblemDetails) => {
return c.json(problem, problem.status as ProblemStatusCode, {
'Content-Type': 'application/problem+json',
});
};
export const resultJson = <TValue>(
c: Context,
result: Result<TValue, ProblemDetails>,
{ status = 200 }: ResultJsonOptions = {},
) => {
if (!result.ok) {
return problemJson(c, result.error);
}
return c.json(result.value, status);
};
export const notFoundProblem = (instance: string): ProblemDetails =>
problemDetails({
title: 'Not Found',
status: 404,
detail: 'The requested resource was not found.',
instance,
code: 'not_found',
});
export const internalServerErrorProblem = (instance: string): ProblemDetails =>
problemDetails({
title: 'Internal Server Error',
status: 500,
detail: 'An unexpected error occurred.',
instance,
code: 'internal_server_error',
});
export const unauthorizedProblem = (instance: string): ProblemDetails =>
problemDetails({
title: 'Unauthorized',
status: 401,
detail: 'Authentication is required to access this resource.',
instance,
code: 'unauthorized',
});

View File

@@ -0,0 +1 @@
export const PROBLEM_DOMAIN = process.env.PROBLEM_DOMAIN_URL ?? 'https://gruperly.com';

View File

@@ -0,0 +1,12 @@
import type { MiddlewareHandler } from 'hono';
import { runWithRequestLogContext } from '@/logger';
import type { BackendEnv } from './env';
export const requestIdMiddleware: MiddlewareHandler<BackendEnv> = async (c, next) => {
const requestId = crypto.randomUUID();
c.set('requestId', requestId);
c.header('X-Request-Id', requestId);
await runWithRequestLogContext(requestId, next);
};

View File

@@ -0,0 +1,22 @@
import type { MiddlewareHandler } from 'hono';
import { logger } from '@/logger';
import type { BackendEnv } from './env';
export const requestLoggerMiddleware: MiddlewareHandler<BackendEnv> = async (c, next) => {
const startedAt = Date.now();
try {
await next();
} finally {
logger.info(
{
method: c.req.method,
path: c.req.path,
requestId: c.get('requestId'),
status: c.res.status,
durationMs: Date.now() - startedAt,
},
'Request completed',
);
}
};

View File

@@ -0,0 +1,34 @@
import type { MiddlewareHandler } from 'hono';
import { cors as honoCors } from 'hono/cors';
function getWebOrigin(): string {
return process.env.WEB_URL ?? 'http://localhost:6173';
}
export const corsMiddleware: MiddlewareHandler = honoCors({
origin: (requestOrigin) => {
const allowed = getWebOrigin();
if (requestOrigin === allowed) {
return requestOrigin;
}
return '';
},
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
credentials: true,
});
export const securityHeadersMiddleware: MiddlewareHandler = async (c, next) => {
await next();
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('X-XSS-Protection', '0');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if (process.env.NODE_ENV === 'production') {
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
};

View File

@@ -0,0 +1,43 @@
import type { MiddlewareHandler } from 'hono';
import { auth } from '@/modules/auth/auth';
import type { BackendEnv } from './env';
import { problemJson, unauthorizedProblem } from './problem-details';
export const sessionAuthMiddleware: MiddlewareHandler<BackendEnv> = async (c, next) => {
if (isPublicApiRequest(c.req.method, c.req.path)) {
await next();
return;
}
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
c.set('user', session.user);
c.set('session', session.session);
await next();
};
export function isPublicApiRequest(method: string, path: string): boolean {
if (method === 'OPTIONS') {
return true;
}
const normalizedPath = normalizePath(path);
return (
normalizedPath === '/api/v1/health'
|| matchesPublicPrefix(normalizedPath, '/api/v1/auth')
|| matchesPublicPrefix(normalizedPath, '/api/auth')
);
}
function matchesPublicPrefix(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`);
}
function normalizePath(path: string): string {
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
}

View File

@@ -0,0 +1,57 @@
import { zValidator } from '@hono/zod-validator';
import type { Context } from 'hono';
import type { ZodIssue, ZodSchema } from 'zod';
import { validationProblem } from './problem-builders';
import { zodIssuesToRecord } from './zod-issues';
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
function makeValidator(target: ValidationTarget, schema: ZodSchema) {
return zValidator(
target,
schema,
(result: { success: boolean; error?: { issues: ZodIssue[] } }, c: Context) => {
if (result.success) {
return;
}
const requestId = c.get('requestId');
const instance = requestId ? `/requests/${requestId}` : undefined;
const issues = result.error?.issues ?? [];
return c.json(
validationProblem({
detail: `Invalid ${target} data`,
instance,
errors: zodIssuesToRecord(issues),
}),
400,
{
'Content-Type': 'application/problem+json',
},
);
},
);
}
export const validate = {
json(schema: ZodSchema) {
return makeValidator('json', schema);
},
query(schema: ZodSchema) {
return makeValidator('query', schema);
},
param(schema: ZodSchema) {
return makeValidator('param', schema);
},
header(schema: ZodSchema) {
return makeValidator('header', schema);
},
form(schema: ZodSchema) {
return makeValidator('form', schema);
},
};

View File

@@ -0,0 +1,16 @@
type IssueLike = {
path: PropertyKey[];
message: string;
};
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const issue of issues) {
const key = issue.path.length ? issue.path.join('.') : 'root';
out[key] ??= [];
out[key].push(issue.message);
}
return out;
}

View File

@@ -0,0 +1,3 @@
export function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -0,0 +1,25 @@
type PaginationInput = {
page: number;
pageSize: number;
};
type PaginationMetadata = PaginationInput & {
total: number;
totalPages: number;
};
export function getPaginationMetadata(
{ page, pageSize }: PaginationInput,
total: number,
): PaginationMetadata {
return {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
};
}
export function getPaginationOffset({ page, pageSize }: PaginationInput): number {
return (page - 1) * pageSize;
}

View File

@@ -0,0 +1,72 @@
import 'dotenv/config';
import { type Prisma, PrismaClient } from '@generated/prisma/client';
import type { Result } from '@gruperly/shared';
import { PrismaPg } from '@prisma/adapter-pg';
let prismaClient: PrismaClient | undefined;
function createPrismaClient(): PrismaClient {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is required');
}
return new PrismaClient({
adapter: new PrismaPg({ connectionString: databaseUrl }),
});
}
export function getPrismaClient(): PrismaClient {
prismaClient ??= createPrismaClient();
return prismaClient;
}
const prisma = new Proxy({} as PrismaClient, {
get(_target, property) {
const client = getPrismaClient();
const value = Reflect.get(client, property);
return typeof value === 'function' ? value.bind(client) : value;
},
});
export default prisma;
export type PrismaTransaction = Prisma.TransactionClient;
export type PrismaDb = PrismaClient | PrismaTransaction;
class ResultRollbackError<TError> extends Error {
constructor(readonly result: Result<never, TError>) {
super('Transaction rolled back because callback returned Result.err');
}
}
export class UnitOfWork {
constructor(private readonly prisma: PrismaClient) {}
async execute<T>(callback: (tx: PrismaTransaction) => Promise<T>): Promise<T> {
return this.prisma.$transaction(callback);
}
async executeResult<TValue, TError>(
callback: (tx: PrismaTransaction) => Promise<Result<TValue, TError>>,
options?: { isolationLevel?: Prisma.TransactionIsolationLevel },
): Promise<Result<TValue, TError>> {
try {
return await this.prisma.$transaction(async (tx) => {
const result = await callback(tx);
if (!result.ok) {
throw new ResultRollbackError(result);
}
return result;
}, options);
} catch (error) {
if (error instanceof ResultRollbackError) {
return error.result;
}
throw error;
}
}
}

View File

@@ -0,0 +1,72 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import pino from 'pino';
import pinoPretty from 'pino-pretty';
type LoggerEnvironment = {
nodeEnv?: string;
};
type RequestLogContext = {
requestId: string;
};
const requestLogContext = new AsyncLocalStorage<RequestLogContext>();
const prettyOptions = {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
};
const getLoggerEnvironment = (): LoggerEnvironment => ({
nodeEnv: process.env.NODE_ENV,
});
const isDevelopmentEnvironment = (environment: LoggerEnvironment) =>
environment.nodeEnv !== 'production';
const isTestEnvironment = (environment: LoggerEnvironment) => environment.nodeEnv === 'test';
export const runWithRequestLogContext = <T>(requestId: string, callback: () => T): T =>
requestLogContext.run({ requestId }, callback);
const getRequestLogMetadata = () => {
const context = requestLogContext.getStore();
if (!context) {
return {};
}
return { requestId: context.requestId };
};
export const createLoggerOptions = (environment: LoggerEnvironment): pino.LoggerOptions => ({
enabled: !isTestEnvironment(environment),
level: isDevelopmentEnvironment(environment) ? 'debug' : 'info',
mixin: () => getRequestLogMetadata(),
formatters: {
level(label, number) {
return {
level: number,
severity: label.toUpperCase(),
};
},
},
});
export const createLogger = (
environment: LoggerEnvironment = getLoggerEnvironment(),
) => {
const options = createLoggerOptions(environment);
if (isTestEnvironment(environment)) {
return pino({ ...options, level: 'silent' });
}
if (isDevelopmentEnvironment(environment)) {
return pino(options, pinoPretty(prettyOptions));
}
return pino(options);
};
export const logger = createLogger();

View File

@@ -1,13 +1,14 @@
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { passkey } from '@better-auth/passkey'
import { organization } from 'better-auth/plugins/organization'
import { prisma } from './db'
import { emailProvider } from './lib/email'
import { passkey } from '@better-auth/passkey';
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { organization } from 'better-auth/plugins/organization';
import { emailProvider } from '@/lib/email';
import { getPrismaClient } from '@/lib/prisma';
export const auth = betterAuth({
appName: 'Gruperly',
database: prismaAdapter(prisma, {
basePath: '/api/v1/auth',
database: prismaAdapter(getPrismaClient(), {
provider: 'postgresql',
}),
emailAndPassword: {

View File

@@ -0,0 +1 @@
export { default as authRoutes } from './routes';

View File

@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import { auth } from './auth';
const routes = new Hono();
routes.all('*', (c) => auth.handler(c.req.raw));
export default routes;

View File

@@ -0,0 +1,27 @@
import type { CreateGroupFromOrganization } from '@gruperly/shared';
import { CreateGroupFromOrganizationSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { CreateGroupFromOrganization as UseCase } from './use-case';
const route = new Hono();
route.post('/', validate.json(CreateGroupFromOrganizationSchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const data = c.req.valid('json') as CreateGroupFromOrganization;
const useCase = new UseCase();
const result = await useCase.execute(data, user.id);
if (!result.ok) {
return problemJson(c, result.error);
}
return c.json(result.value, result.value.alreadyExists ? 200 : 201);
});
export default route;

View File

@@ -0,0 +1,92 @@
import type { Prisma } from '@generated/prisma/client';
import { Role } from '@generated/prisma/client';
import type {
CreateGroupFromOrganization as CreateGroupFromOrganizationInput,
CreateGroupFromOrganizationResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import {
groupOwnerRequiredProblem,
organizationNotFoundProblem,
} from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { type GroupDb, toGroupDto } from '../../lib';
type CreateGroupFromOrganizationDeps = {
db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>;
unitOfWork?: UnitOfWork;
};
type GroupRecord = {
id: string;
name: string;
description: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date;
};
export class CreateGroupFromOrganization {
constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {}
async execute(
data: CreateGroupFromOrganizationInput,
userId: string,
): Promise<Result<CreateGroupFromOrganizationResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const organization = await db.organization.findUnique({
where: { id: data.organizationId },
include: { members: true },
});
if (!organization) {
return err(organizationNotFoundProblem(data.organizationId));
}
const membership = organization.members.find((member: { userId: string; role: string }) => member.userId === userId);
if (membership?.role !== 'owner') {
return err(groupOwnerRequiredProblem());
}
const existing = await db.group.findFirst({
where: { createdById: userId, name: organization.name },
});
if (existing) {
return ok({ group: toGroupDto(existing), alreadyExists: true });
}
const transaction = unitOfWork.executeResult(
async (tx: Prisma.TransactionClient) => {
const created = await tx.group.create({
data: {
name: organization.name,
createdById: userId,
},
});
await tx.groupMember.create({
data: {
groupId: created.id,
userId,
role: Role.OWNER,
},
});
return ok(created);
},
);
const result = await transaction;
if (!result.ok) {
return result;
}
return ok({
group: toGroupDto(result.value as GroupRecord),
alreadyExists: false,
});
}
}

View File

@@ -0,0 +1,23 @@
import type { GroupQuery } from '@gruperly/shared';
import { GroupQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { ListGroups } from './use-case';
const route = new Hono();
route.get('/', validate.query(GroupQuerySchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const query = c.req.valid('query') as GroupQuery;
const useCase = new ListGroups();
const result = await useCase.execute(query, user.id);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,51 @@
import type { Prisma } from '@generated/prisma/client';
import type {
GroupList,
GroupQuery,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma';
import { buildGroupWhereForUser, type GroupDb, toGroupDto } from '../../lib';
type ListGroupsDeps = {
db?: Pick<GroupDb, 'group'>;
};
type GroupRecord = {
id: string;
name: string;
description: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date;
};
export class ListGroups {
constructor(private readonly deps: ListGroupsDeps = {}) {}
async execute(
query: GroupQuery,
userId: string,
): Promise<Result<GroupList, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const where = buildGroupWhereForUser(userId) as Prisma.GroupWhereInput;
const [records, total] = await Promise.all([
db.group.findMany({
where,
skip: getPaginationOffset(query),
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
db.group.count({ where }),
]);
return ok({
data: records.map((record: GroupRecord) => toGroupDto(record)),
pagination: getPaginationMetadata(query, total),
});
}
}

View File

@@ -0,0 +1 @@
export { default as groupsRoutes } from './routes';

View File

@@ -0,0 +1,33 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { GroupDto } from '@gruperly/shared';
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember' | 'organization'>;
type GroupRecord = {
id: string;
name: string;
description: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date;
};
export function toGroupDto(record: GroupRecord): GroupDto {
return {
id: record.id,
name: record.name,
description: record.description,
createdById: record.createdById,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
export function buildGroupWhereForUser(userId: string) {
return {
OR: [
{ createdById: userId },
{ members: { some: { userId } } },
],
};
}

View File

@@ -0,0 +1 @@
export * from './helpers';

View File

@@ -0,0 +1,10 @@
import { Hono } from 'hono';
import createFromOrganizationRoute from './features/create-from-organization/route';
import getAllRoute from './features/get-all/route';
const routes = new Hono();
routes.route('/', getAllRoute);
routes.route('/from-organization', createFromOrganizationRoute);
export default routes;

View File

@@ -0,0 +1,14 @@
import { Hono } from 'hono';
import { resultJson } from '@/http/problem-details';
import { GetHealthCheck } from './use-case';
const route = new Hono();
route.get('/', async (c) => {
const useCase = new GetHealthCheck({ instance: c.req.path });
const result = await useCase.execute();
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,35 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { HealthCheck, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { databaseUnavailableProblem } from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { logger } from '@/logger';
type GetHealthCheckDeps = {
db?: PrismaClient;
instance: string;
};
export class GetHealthCheck {
constructor(private readonly deps: GetHealthCheckDeps) {}
async execute(): Promise<Result<HealthCheck, ProblemDetails>> {
const { instance } = this.deps;
const db = this.deps.db ?? prisma;
try {
await db.$queryRaw`SELECT 1`;
} catch (error) {
logger.warn({ error, instance }, 'Database health check failed');
return err(databaseUnavailableProblem({ instance }));
}
return ok({
status: 'ok',
timestamp: new Date().toISOString(),
checks: {
database: 'ok',
},
});
}
}

View File

@@ -0,0 +1 @@
export { default as healthCheckRoutes } from './routes';

View File

@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import getHealthCheckRoute from './features/get-health-check/route';
const routes = new Hono();
routes.route('/', getHealthCheckRoute);
export default routes;

View File

@@ -0,0 +1,18 @@
import type { PaymentQuery } from '@gruperly/shared';
import { PaymentQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { resultJson } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { ListPayments } from './use-case';
const route = new Hono();
route.get('/', validate.query(PaymentQuerySchema), async (c) => {
const query = c.req.valid('query') as PaymentQuery;
const useCase = new ListPayments();
const result = await useCase.execute(query);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,58 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { PaymentDto, PaymentList, PaymentQuery, ProblemDetails, Result } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma';
type ListPaymentsDeps = {
db?: Pick<PrismaClient, 'payment'>;
};
type PaymentRecord = {
id: string;
groupId: string;
studentId: string;
amount: { toString(): string };
currency: string;
status: PaymentDto['status'];
dueDate: Date;
paidAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
function toPaymentDto(record: PaymentRecord): PaymentDto {
return {
id: record.id,
groupId: record.groupId,
studentId: record.studentId,
amount: Number(record.amount.toString()),
currency: record.currency,
status: record.status,
dueDate: record.dueDate.toISOString(),
paidAt: record.paidAt ? record.paidAt.toISOString() : null,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
export class ListPayments {
constructor(private readonly deps: ListPaymentsDeps = {}) {}
async execute(query: PaymentQuery): Promise<Result<PaymentList, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const [records, total] = await Promise.all([
db.payment.findMany({
skip: getPaginationOffset(query),
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
db.payment.count(),
]);
return ok({
data: records.map(toPaymentDto),
pagination: getPaginationMetadata(query, total),
});
}
}

View File

@@ -0,0 +1 @@
export { default as paymentsRoutes } from './routes';

View File

@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import getAllRoute from './features/get-all/route';
const routes = new Hono();
routes.route('/', getAllRoute);
export default routes;

View File

@@ -0,0 +1,18 @@
import type { StudentQuery } from '@gruperly/shared';
import { StudentQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { resultJson } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { ListStudents } from './use-case';
const route = new Hono();
route.get('/', validate.query(StudentQuerySchema), async (c) => {
const query = c.req.valid('query') as StudentQuery;
const useCase = new ListStudents();
const result: Awaited<ReturnType<typeof useCase.execute>> = await useCase.execute(query);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,58 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { ProblemDetails, Result, StudentDto, StudentList, StudentQuery } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma';
type ListStudentsDeps = {
db?: Pick<PrismaClient, 'student'>;
};
type StudentRecord = {
id: string;
groupId: string;
fullName: string;
email: string | null;
phone: string | null;
guardianName: string | null;
guardianPhone: string | null;
notes: string | null;
createdAt: Date;
updatedAt: Date;
};
function toStudentDto(record: StudentRecord): StudentDto {
return {
id: record.id,
groupId: record.groupId,
fullName: record.fullName,
email: record.email,
phone: record.phone,
guardianName: record.guardianName,
guardianPhone: record.guardianPhone,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
export class ListStudents {
constructor(private readonly deps: ListStudentsDeps = {}) {}
async execute(query: StudentQuery): Promise<Result<StudentList, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const [records, total] = await Promise.all([
db.student.findMany({
skip: getPaginationOffset(query),
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
db.student.count(),
]);
return ok({
data: records.map(toStudentDto),
pagination: getPaginationMetadata(query, total),
});
}
}

View File

@@ -0,0 +1 @@
export { default as studentsRoutes } from './routes';

View File

@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import getAllRoute from './features/get-all/route';
const routes = new Hono();
routes.route('/', getAllRoute);
export default routes;

View File

@@ -0,0 +1,18 @@
import type { WaitlistQuery } from '@gruperly/shared';
import { WaitlistQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { resultJson } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { ListWaitlistEntries } from './use-case';
const route = new Hono();
route.get('/', validate.query(WaitlistQuerySchema), async (c) => {
const query = c.req.valid('query') as WaitlistQuery;
const useCase = new ListWaitlistEntries();
const result = await useCase.execute(query);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,50 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { ProblemDetails, Result, WaitlistEntryDto, WaitlistList, WaitlistQuery } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma';
type ListWaitlistEntriesDeps = {
db?: Pick<PrismaClient, 'waitlistEntry'>;
};
type WaitlistRecord = {
id: string;
email: string;
name: string | null;
status: WaitlistEntryDto['status'];
createdAt: Date;
updatedAt: Date;
};
function toWaitlistEntryDto(record: WaitlistRecord): WaitlistEntryDto {
return {
id: record.id,
email: record.email,
name: record.name,
status: record.status,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
export class ListWaitlistEntries {
constructor(private readonly deps: ListWaitlistEntriesDeps = {}) {}
async execute(query: WaitlistQuery): Promise<Result<WaitlistList, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const [records, total] = await Promise.all([
db.waitlistEntry.findMany({
skip: getPaginationOffset(query),
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
db.waitlistEntry.count(),
]);
return ok({
data: records.map(toWaitlistEntryDto),
pagination: getPaginationMetadata(query, total),
});
}
}

View File

@@ -0,0 +1 @@
export { default as waitlistRoutes } from './routes';

View File

@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import getAllRoute from './features/get-all/route';
const routes = new Hono();
routes.route('/', getAllRoute);
export default routes;

View File

@@ -0,0 +1,36 @@
import 'dotenv/config';
import { serve } from '@hono/node-server';
import app from '@/app';
import { getPrismaClient } from '@/lib/prisma';
import { logger } from '@/logger';
const port = Number(process.env.PORT ?? 4000);
logger.info(`Server running on http://localhost:${port}`);
const server = serve({
fetch: app.fetch,
port,
});
async function shutdown(): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
await getPrismaClient().$disconnect();
}
process.once('SIGINT', () => {
void shutdown();
});
process.once('SIGTERM', () => {
void shutdown();
});

View File

@@ -0,0 +1,208 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { db } = vi.hoisted(() => {
return {
db: {
group: {
findMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
groupMember: {
create: vi.fn(),
},
organization: {
findUnique: vi.fn(),
},
} as {
group: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
groupMember: { create: ReturnType<typeof vi.fn> };
organization: { findUnique: ReturnType<typeof vi.fn> };
},
};
});
vi.mock('@/lib/prisma', () => ({
default: db,
getPrismaClient: vi.fn(),
UnitOfWork: class {
executeResult = vi.fn(
async (cb: (tx: unknown) => Promise<unknown>) => cb(db),
);
execute = vi.fn(
async (cb: (tx: unknown) => Promise<unknown>) => cb(db),
);
},
}));
import prisma from '@/lib/prisma';
import { groupsRoutes } from '@/modules/groups';
const userId = 'user-1';
const group = {
id: 'group-1',
name: 'Cuadrilla Alfa',
description: null,
createdById: userId,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/groups', groupsRoutes);
return app;
}
describe('groups routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('lists groups scoped to the current user', async () => {
vi.mocked(prisma.group.findMany).mockResolvedValue([group]);
vi.mocked(prisma.group.count).mockResolvedValue(1);
const res = await makeApp({ id: userId }).request('/groups');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [
{
...group,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
},
],
pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 },
});
const where = {
OR: [{ createdById: userId }, { members: { some: { userId } } }],
};
expect(prisma.group.findMany).toHaveBeenCalledWith({
where,
skip: 0,
take: 10,
orderBy: { createdAt: 'desc' },
});
expect(prisma.group.count).toHaveBeenCalledWith({ where });
});
it('rejects listing groups without a session', async () => {
const res = await makeApp(null).request('/groups');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
it('rejects invalid pagination query parameters', async () => {
const res = await makeApp({ id: userId }).request('/groups?pageSize=101');
expect(res.status).toBe(400);
});
it('creates a group from an owned organization', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId, role: 'owner' }],
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organization as never);
vi.mocked(prisma.group.findFirst).mockResolvedValue(null);
vi.mocked(prisma.group.create).mockResolvedValue(group);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(201);
expect(await res.json()).toEqual({
group: {
...group,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
},
alreadyExists: false,
});
expect(prisma.group.create).toHaveBeenCalledWith({
data: { name: 'Escuela Alfa', createdById: userId },
});
});
it('reuses an existing group with the same name', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId, role: 'owner' }],
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organization as never);
vi.mocked(prisma.group.findFirst).mockResolvedValue(group);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.alreadyExists).toBe(true);
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group for an unknown organization', async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'missing' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'organization_not_found' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group when the user is not the owner', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId: 'other-user', role: 'owner' }],
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organization as never);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'group_owner_required' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group without a session', async () => {
const res = await makeApp(null).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,45 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
$queryRaw: vi.fn(),
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
import prisma from '@/lib/prisma';
import { healthCheckRoutes } from '@/modules/health-check';
const app = new Hono();
app.route('/health', healthCheckRoutes);
describe('health check routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('reports ok when the database answers', async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ '?column?': 1 }]);
const res = await app.request('/health');
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({ status: 'ok', checks: { database: 'ok' } });
expect(typeof body.timestamp).toBe('string');
});
it('reports degraded when the database is unreachable', async () => {
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error('connection refused'));
const res = await app.request('/health');
expect(res.status).toBe(503);
expect(await res.json()).toMatchObject({
code: 'database_unavailable',
status: 503,
});
});
});

View File

@@ -0,0 +1,75 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
payment: {
findMany: vi.fn(),
count: vi.fn(),
},
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
import prisma from '@/lib/prisma';
import { paymentsRoutes } from '@/modules/payments';
const app = new Hono();
app.route('/payments', paymentsRoutes);
const payment = {
id: 'payment-1',
groupId: 'group-1',
studentId: 'student-1',
amount: { toString: () => '150.50' } as { toString(): string },
currency: 'MXN',
status: 'pending',
dueDate: new Date('2026-09-01T10:00:00.000Z'),
paidAt: null,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};
describe('payments routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('lists payments with pagination and converts Decimal amounts', async () => {
vi.mocked(prisma.payment.findMany).mockResolvedValue([payment]);
vi.mocked(prisma.payment.count).mockResolvedValue(1);
const res = await app.request('/payments');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [
{
id: 'payment-1',
groupId: 'group-1',
studentId: 'student-1',
amount: 150.5,
currency: 'MXN',
status: 'pending',
dueDate: payment.dueDate.toISOString(),
paidAt: null,
createdAt: payment.createdAt.toISOString(),
updatedAt: payment.updatedAt.toISOString(),
},
],
pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 },
});
expect(prisma.payment.findMany).toHaveBeenCalledWith({
skip: 0,
take: 10,
orderBy: { createdAt: 'desc' },
});
});
it('rejects invalid pagination query parameters', async () => {
const res = await app.request('/payments?pageSize=101');
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,80 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
$queryRaw: vi.fn(),
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
vi.mock('@/modules/auth/auth', () => ({
auth: {
api: {
getSession: vi.fn(),
},
},
}));
import {
isPublicApiRequest,
sessionAuthMiddleware,
} from '@/http/session-auth';
import { auth } from '@/modules/auth/auth';
describe('session auth', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('classifies public API requests', () => {
expect(isPublicApiRequest('OPTIONS', '/api/v1/groups')).toBe(true);
expect(isPublicApiRequest('GET', '/api/v1/health')).toBe(true);
expect(isPublicApiRequest('POST', '/api/v1/auth/sign-in/email')).toBe(true);
expect(isPublicApiRequest('GET', '/api/v1/groups')).toBe(false);
expect(isPublicApiRequest('GET', '/api/v1/students')).toBe(false);
});
it('allows public requests without a session', async () => {
const app = new Hono();
app.use('*', sessionAuthMiddleware);
app.get('/api/v1/health', (c) => c.json({ status: 'ok' }));
const res = await app.request('/api/v1/health');
expect(res.status).toBe(200);
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('rejects protected requests without a session', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null);
const app = new Hono();
app.use('*', sessionAuthMiddleware);
app.get('/api/v1/groups', (c) => c.json({}));
const res = await app.request('/api/v1/groups');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
it('sets the user and session for authenticated requests', async () => {
const session = {
user: { id: 'user-1', name: 'Ana' },
session: { id: 'session-1' },
};
vi.mocked(auth.api.getSession).mockResolvedValue(session as never);
const app = new Hono();
app.use('*', sessionAuthMiddleware);
app.get('/api/v1/groups', (c) => c.json({ userId: c.get('user').id }));
const res = await app.request('/api/v1/groups');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ userId: 'user-1' });
expect(auth.api.getSession).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
student: {
findMany: vi.fn(),
count: vi.fn(),
},
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
import prisma from '@/lib/prisma';
import { studentsRoutes } from '@/modules/students';
const app = new Hono();
app.route('/students', studentsRoutes);
const student = {
id: 'student-1',
groupId: 'group-1',
fullName: 'Ana Pérez',
email: 'ana@example.com',
phone: null,
guardianName: 'Luis Pérez',
guardianPhone: null,
notes: null,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};
describe('students routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('lists students with pagination', async () => {
vi.mocked(prisma.student.findMany).mockResolvedValue([student]);
vi.mocked(prisma.student.count).mockResolvedValue(21);
const res = await app.request('/students?page=2&pageSize=10');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [
{
...student,
createdAt: student.createdAt.toISOString(),
updatedAt: student.updatedAt.toISOString(),
},
],
pagination: { page: 2, pageSize: 10, total: 21, totalPages: 3 },
});
expect(prisma.student.findMany).toHaveBeenCalledWith({
skip: 10,
take: 10,
orderBy: { createdAt: 'desc' },
});
expect(prisma.student.count).toHaveBeenCalledWith();
});
it('uses default pagination', async () => {
vi.mocked(prisma.student.findMany).mockResolvedValue([]);
vi.mocked(prisma.student.count).mockResolvedValue(0);
const res = await app.request('/students');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [],
pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 },
});
});
it('rejects invalid pagination query parameters', async () => {
const res = await app.request('/students?page=0&pageSize=25');
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ status: 400, title: 'Bad Request' });
});
});

View File

@@ -0,0 +1,60 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
waitlistEntry: {
findMany: vi.fn(),
count: vi.fn(),
},
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
import prisma from '@/lib/prisma';
import { waitlistRoutes } from '@/modules/waitlist';
const app = new Hono();
app.route('/waitlist', waitlistRoutes);
const entry = {
id: 'waitlist-1',
email: 'caro@example.com',
name: 'Caro',
status: 'pending',
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};
describe('waitlist routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('lists waitlist entries with pagination', async () => {
vi.mocked(prisma.waitlistEntry.findMany).mockResolvedValue([entry]);
vi.mocked(prisma.waitlistEntry.count).mockResolvedValue(1);
const res = await app.request('/waitlist');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [
{
...entry,
createdAt: entry.createdAt.toISOString(),
updatedAt: entry.updatedAt.toISOString(),
},
],
pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 },
});
});
it('rejects invalid pagination query parameters', async () => {
const res = await app.request('/waitlist?page=0');
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ status: 400, title: 'Bad Request' });
});
});

View File

@@ -0,0 +1,17 @@
{
"extends": "@gruperly/config/tsconfig.base.json",
"compilerOptions": {
"moduleResolution": "bundler",
"types": ["node"],
"declaration": false,
"noEmit": false,
"outDir": "./dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"],
"@generated/*": ["./generated/*"]
}
},
"include": ["src/**/*.ts", "generated/prisma/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@@ -0,0 +1,15 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@generated': path.resolve(__dirname, 'generated'),
},
},
});

View File

@@ -22,9 +22,10 @@
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"tailwind-merge": "^3.6.0",
"zod": "^3.24.0"
"zod": "3.24.2"
},
"devDependencies": {
"@gruperly/config": "workspace:*",
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@@ -4,6 +4,7 @@ import { passkeyClient } from '@better-auth/passkey/client'
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:4000',
basePath: '/api/v1/auth',
plugins: [organizationClient(), passkeyClient()],
})

View File

@@ -35,7 +35,7 @@ function OrganizationList({ onRefresh }: { onRefresh: () => void }) {
const handleSyncGroup = async (org: OrganizationRow) => {
setSyncing(org.id)
try {
const res = await fetch(`${API_URL}/api/groups/from-organization`, {
const res = await fetch(`${API_URL}/api/v1/groups/from-organization`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',

26
biome.json Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"useImportType": "off"
}
}
},
"formatter": {
"enabled": false
}
}

860
bun.lock
View File

@@ -1,860 +0,0 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "gruperly",
"devDependencies": {
"turbo": "^2.3.3",
},
},
"apps/api": {
"name": "@gruperly/api",
"version": "0.1.0",
"dependencies": {
"@better-auth/passkey": "^1.7.2",
"@gruperly/shared": "workspace:*",
"@hono/zod-validator": "^0.8.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"better-auth": "1.7.2",
"hono": "^4.6.0",
"nodemailer": "^9.0.6",
},
"devDependencies": {
"@types/bun": "^1.0.0",
"@types/nodemailer": "^8.0.1",
"prisma": "^7.10.0",
"typescript": "^5.7.0",
},
},
"apps/web": {
"name": "@gruperly/web",
"version": "0.1.0",
"dependencies": {
"@better-auth/passkey": "^1.7.2",
"@gruperly/shared": "workspace:*",
"@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.90.0",
"better-auth": "1.7.2",
"clsx": "^2.1.1",
"lucide-react": "^1.34.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"tailwind-merge": "^3.6.0",
"zod": "^3.24.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
},
},
"packages/config": {
"name": "@gruperly/config",
"version": "0.1.0",
},
"packages/shared": {
"name": "@gruperly/shared",
"version": "0.1.0",
"dependencies": {
"zod": "^3.24.0",
},
"devDependencies": {
"@prisma/client": "^7.10.0",
"typescript": "^5.7.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@better-auth/core": ["@better-auth/core@1.7.2", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg=="],
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ=="],
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2" } }, "sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA=="],
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA=="],
"@better-auth/passkey": ["@better-auth/passkey@1.7.2", "", { "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.1", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.2", "better-call": "1.4.0", "nanostores": "^1.0.1" } }, "sha512-KBK852b+HsCdstVPPDHsuRa9Rc+7IEuRMPQboi/OXNOwgKL8GwHpzzWD2WhiT/FXJPrLCPh8vHJQfc1wdl5OZw=="],
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ=="],
"@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
"@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.1.3", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.3" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ=="],
"@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.3.3", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.3" } }, "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@gruperly/api": ["@gruperly/api@workspace:apps/api"],
"@gruperly/config": ["@gruperly/config@workspace:packages/config"],
"@gruperly/shared": ["@gruperly/shared@workspace:packages/shared"],
"@gruperly/web": ["@gruperly/web@workspace:apps/web"],
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
"@hono/zod-validator": ["@hono/zod-validator@0.8.0", "", { "peerDependencies": { "hono": ">=4.10.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w=="],
"@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@noble/ciphers": ["@noble/ciphers@2.3.0", "", {}, "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw=="],
"@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ=="],
"@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-attr": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw=="],
"@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw=="],
"@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ=="],
"@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.9.4", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-pkcs8": "^2.9.4", "@peculiar/asn1-rsa": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw=="],
"@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ=="],
"@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.9.4", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-pfx": "^2.9.4", "@peculiar/asn1-pkcs8": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-attr": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ=="],
"@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.9.4", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg=="],
"@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q=="],
"@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg=="],
"@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
"@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="],
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.10.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.10.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw=="],
"@prisma/client": ["@prisma/client@7.10.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.10.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q=="],
"@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.10.0", "", {}, "sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw=="],
"@prisma/config": ["@prisma/config@7.10.0", "", { "dependencies": { "c12": "3.3.4", "deepmerge-ts": "7.1.5", "effect": "3.20.0", "empathic": "2.0.0" } }, "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA=="],
"@prisma/debug": ["@prisma/debug@7.10.0", "", {}, "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g=="],
"@prisma/dev": ["@prisma/dev@0.24.17", "", { "dependencies": { "@electric-sql/pglite": "0.4.3", "@electric-sql/pglite-socket": "0.1.3", "@electric-sql/pglite-tools": "0.3.3", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "@prisma/streams-local": "0.1.11", "find-my-way": "9.7.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.4.2", "zeptomatch": "2.1.0" } }, "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA=="],
"@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA=="],
"@prisma/engines": ["@prisma/engines@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0", "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", "@prisma/fetch-engine": "7.10.0", "@prisma/get-platform": "7.10.0" } }, "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ=="],
"@prisma/engines-version": ["@prisma/engines-version@7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", "", {}, "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0", "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", "@prisma/get-platform": "7.10.0" } }, "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g=="],
"@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="],
"@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="],
"@prisma/streams-local": ["@prisma/streams-local@0.1.11", "", { "dependencies": { "ajv": "^8.12.0", "better-result": "^2.7.0", "env-paths": "^3.0.0", "proper-lockfile": "^4.1.2" } }, "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg=="],
"@prisma/studio-core": ["@prisma/studio-core@0.33.0", "", { "dependencies": { "@radix-ui/react-toggle": "1.1.10", "@visx/curve": "4.0.1-alpha.0", "@visx/event": "4.0.1-alpha.0", "@visx/grid": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/responsive": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/shape": "4.0.1-alpha.0", "d3-array": "3.2.4", "d3-shape": "3.2.0", "elkjs": "0.11.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.5", "", { "os": "android", "cpu": "arm" }, "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.5", "", { "os": "android", "cpu": "arm64" }, "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.5", "", { "os": "linux", "cpu": "arm" }, "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.5", "", { "os": "linux", "cpu": "arm" }, "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.5", "", { "os": "linux", "cpu": "x64" }, "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.5", "", { "os": "none", "cpu": "arm64" }, "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.5", "", { "os": "win32", "cpu": "x64" }, "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg=="],
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="],
"@simplewebauthn/server": ["@simplewebauthn/server@13.3.3", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@tanstack/history": ["@tanstack/history@1.162.1", "", {}, "sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w=="],
"@tanstack/query-core": ["@tanstack/query-core@5.102.3", "", {}, "sha512-5O2VEceonqC4uaTLUGglb0hgPouWCJ4K1ykVWyeV8aThhdNCzwpwu01bYoaRNJ9mgFUXS7Kf9utJ46ysT8m+bw=="],
"@tanstack/react-query": ["@tanstack/react-query@5.102.3", "", { "dependencies": { "@tanstack/query-core": "5.102.3" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-nHazxUEUQSGJOswGgSL2DI77f2K75WRCowDgaiyEi0ACocZTFKewTv+A/rJfq6QkuE35rVPff1QUT3ClbaePGQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.170.32", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.27", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
"@tanstack/router-core": ["@tanstack/router-core@1.171.27", "", { "dependencies": { "@tanstack/history": "1.162.1", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H4Elb1jqTZVeIC9bbcNwjSzemZ6RegoTOVHeuV5Osirt2Z8UguTyisMEkvZjPVZgMeN9J4ERZBFad40tFnkb7w=="],
"@turbo/linux-64": ["@turbo/linux-64@2.10.12", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-lr7KIotukvjZwEXiFSYAeOH3BWzjFVBbSzTbv0fuGFsNukYyH0+g1hB5ecqnJkgkYU+KHEMG1edOhnjiKON1wQ=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.12", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-f0pZDTtvzB5SuNwuXBaKbZHUCMCukgc8nMlHEuvLmj91Fzec+MEbr3cAvGNor5htEDqZnO6Lxt9N/GPI/77oGA=="],
"@turbo/windows-64": ["@turbo/windows-64@2.10.12", "", { "os": "win32", "cpu": "x64" }, "sha512-SDOueJRjS/QcykWf2KCRtTLmIl5YMKsLbXkXQGhDwcTXvKXZiS5ih5lBl/gkwZIpYFjqA/rAlfMzlAFcVHNe0g=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-0i0mVUa4kKk+/B3RwEwPMf9CB+T7ul56hn5FFHNA4VUNTOoLBEd6aNf3FaKfCatDNZ6cicCEf6if9QUTVyzzcA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/d3-array": ["@types/d3-array@3.0.3", "", {}, "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ=="],
"@types/d3-color": ["@types/d3-color@3.1.0", "", {}, "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="],
"@types/d3-delaunay": ["@types/d3-delaunay@6.0.1", "", {}, "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="],
"@types/d3-format": ["@types/d3-format@3.0.1", "", {}, "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="],
"@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.1", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.2", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.0", "", {}, "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="],
"@types/d3-time-format": ["@types/d3-time-format@2.1.0", "", {}, "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/lodash": ["@types/lodash@4.17.25", "", {}, "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ=="],
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
"@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="],
"@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="],
"@visx/curve": ["@visx/curve@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg=="],
"@visx/event": ["@visx/event@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/point": "4.0.1-alpha.0" } }, "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g=="],
"@visx/grid": ["@visx/grid@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/point": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/shape": "4.0.1-alpha.0", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg=="],
"@visx/group": ["@visx/group@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w=="],
"@visx/point": ["@visx/point@4.0.1-alpha.0", "", {}, "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg=="],
"@visx/responsive": ["@visx/responsive@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA=="],
"@visx/scale": ["@visx/scale@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A=="],
"@visx/shape": ["@visx/shape@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/vendor": "4.0.0-alpha.0", "classnames": "^2.3.1", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA=="],
"@visx/vendor": ["@visx/vendor@4.0.0-alpha.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw=="],
"better-auth": ["better-auth@1.7.2", "", { "dependencies": { "@better-auth/core": "1.7.2", "@better-auth/drizzle-adapter": "1.7.2", "@better-auth/kysely-adapter": "1.7.2", "@better-auth/memory-adapter": "1.7.2", "@better-auth/mongo-adapter": "1.7.2", "@better-auth/prisma-adapter": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A=="],
"better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="],
"better-result": ["better-result@2.10.0", "", {}, "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw=="],
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-delaunay": ["d3-delaunay@6.0.2", "", { "dependencies": { "delaunator": "5" } }, "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-geo": ["d3-geo@3.1.0", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.412", "", {}, "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA=="],
"elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
"fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"find-my-way": ["find-my-way@9.7.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="],
"giget": ["giget@3.3.1", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"grammex": ["grammex@3.1.13", "", {}, "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg=="],
"graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="],
"hono": ["hono@4.13.4", "", {}, "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ=="],
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"isbot": ["isbot@5.2.1", "", {}, "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"jose": ["jose@6.2.10", "", {}, "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lru.min": ["lru.min@1.1.5", "", {}, "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA=="],
"lucide-react": ["lucide-react@1.34.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="],
"named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"nanostores": ["nanostores@1.5.2", "", {}, "sha512-B0UbxzK1s0CN8Xht6r+7iT5+xV8PTaRERR1nATeplRv1Rw5YLWfVAid0hkqY3EceqpG4RjTk8GAwIxQY39Rnwg=="],
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
"nodemailer": ["nodemailer@9.0.6", "", {}, "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg=="],
"ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
"pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="],
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
"pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"prisma": ["prisma@7.10.0", "", { "dependencies": { "@prisma/config": "7.10.0", "@prisma/dev": "0.24.17", "@prisma/engines": "7.10.0", "@prisma/studio-core": "0.33.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="],
"rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-hook-form": ["react-hook-form@7.86.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-4kbWJrh5jPZt1+YqVcXcGKffGcXV/XVbozknLh0Yjh0KhpoAkus21TAQhzRYqNwFkkObmnSvRlZZ3GT+ehoIrA=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"rollup": ["rollup@4.62.5", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.5", "@rollup/rollup-android-arm64": "4.62.5", "@rollup/rollup-darwin-arm64": "4.62.5", "@rollup/rollup-darwin-x64": "4.62.5", "@rollup/rollup-freebsd-arm64": "4.62.5", "@rollup/rollup-freebsd-x64": "4.62.5", "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", "@rollup/rollup-linux-arm-musleabihf": "4.62.5", "@rollup/rollup-linux-arm64-gnu": "4.62.5", "@rollup/rollup-linux-arm64-musl": "4.62.5", "@rollup/rollup-linux-loong64-gnu": "4.62.5", "@rollup/rollup-linux-loong64-musl": "4.62.5", "@rollup/rollup-linux-ppc64-gnu": "4.62.5", "@rollup/rollup-linux-ppc64-musl": "4.62.5", "@rollup/rollup-linux-riscv64-gnu": "4.62.5", "@rollup/rollup-linux-riscv64-musl": "4.62.5", "@rollup/rollup-linux-s390x-gnu": "4.62.5", "@rollup/rollup-linux-x64-gnu": "4.62.5", "@rollup/rollup-linux-x64-musl": "4.62.5", "@rollup/rollup-openbsd-x64": "4.62.5", "@rollup/rollup-openharmony-arm64": "4.62.5", "@rollup/rollup-win32-arm64-msvc": "4.62.5", "@rollup/rollup-win32-ia32-msvc": "4.62.5", "@rollup/rollup-win32-x64-gnu": "4.62.5", "@rollup/rollup-win32-x64-msvc": "4.62.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw=="],
"rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="],
"safe-regex2": ["safe-regex2@5.1.1", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
"seroval": ["seroval@1.6.4", "", {}, "sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw=="],
"seroval-plugins": ["seroval-plugins@1.6.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ=="],
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
"turbo": ["turbo@2.10.12", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.12", "@turbo/darwin-arm64": "2.10.12", "@turbo/linux-64": "2.10.12", "@turbo/linux-arm64": "2.10.12", "@turbo/windows-64": "2.10.12", "@turbo/windows-arm64": "2.10.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@better-auth/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@better-auth/passkey/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w=="],
"@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w=="],
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="],
"better-auth/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
}
}

View File

@@ -3,22 +3,24 @@
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"lint:fix": "turbo lint:fix",
"typecheck": "turbo typecheck",
"db:generate": "bun run --filter @gruperly/api db:generate",
"db:migrate": "bun run --filter @gruperly/api db:migrate",
"db:push": "bun run --filter @gruperly/api db:push",
"db:studio": "bun run --filter @gruperly/api db:studio"
"test": "pnpm --filter @gruperly/backend test",
"db:generate": "pnpm --filter @gruperly/backend db:generate",
"db:migrate": "pnpm --filter @gruperly/backend db:migrate",
"db:push": "pnpm --filter @gruperly/backend db:push",
"db:studio": "pnpm --filter @gruperly/backend db:studio"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"turbo": "^2.3.3"
},
"packageManager": "bun@1.4.0"
}
"packageManager": "pnpm@9.0.0",
"engines": {
"node": ">=20"
}
}

View File

@@ -6,17 +6,19 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./schemas/*": "./src/schemas/*"
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check . --write"
},
"dependencies": {
"zod": "^3.24.0"
"zod": "3.24.2"
},
"devDependencies": {
"@prisma/client": "^7.10.0",
"@biomejs/biome": "^2.4.15",
"@gruperly/config": "workspace:*",
"typescript": "^5.7.0"
}
}
}

View File

@@ -1,3 +1,9 @@
export * from './schemas/group.schema'
export * from './schemas/student.schema'
export * from './schemas/payment.schema'
export * from './lib/problem-details.js';
export * from './lib/result.js';
export * from './schemas/groups.js';
export * from './schemas/health-check.js';
export * from './schemas/pagination.js';
export * from './schemas/payments.js';
export * from './schemas/problem-details.js';
export * from './schemas/students.js';
export * from './schemas/waitlist.js';

View File

@@ -0,0 +1,9 @@
export type ProblemDetails = {
type?: string;
title: string;
status: number;
detail?: string;
instance?: string;
code?: string;
errors?: Record<string, string[]>;
};

View File

@@ -0,0 +1,13 @@
export type Result<TValue, TError> =
| { ok: true; value: TValue }
| { ok: false; error: TError };
export const ok = <TValue>(value: TValue): Result<TValue, never> => ({
ok: true,
value,
});
export const err = <TError>(error: TError): Result<never, TError> => ({
ok: false,
error,
});

View File

@@ -1,15 +0,0 @@
import { z } from 'zod'
export const createGroupSchema = z.object({
name: z.string().min(1, 'El nombre es obligatorio'),
description: z.string().optional(),
})
export const updateGroupSchema = createGroupSchema.partial()
export const groupIdParamsSchema = z.object({
id: z.string().min(1),
})
export type CreateGroupInput = z.infer<typeof createGroupSchema>
export type UpdateGroupInput = z.infer<typeof updateGroupSchema>

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
const isoDateTimeSchema = z.string().datetime();
const pageSchema = createPageSchema();
const pageSizeSchema = createPageSizeSchema(10);
const paginationSchema = createPaginationSchema();
export const GroupDtoSchema = z.object({
id: z.string(),
name: z.string().min(1).max(100),
description: z.string().nullable(),
createdById: z.string(),
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type GroupDto = z.output<typeof GroupDtoSchema>;
export const GroupQuerySchema = z.object({
page: pageSchema,
pageSize: pageSizeSchema,
});
export type GroupQuery = z.output<typeof GroupQuerySchema>;
export const GroupListSchema = z.object({
data: z.array(GroupDtoSchema),
pagination: paginationSchema,
});
export type GroupList = z.output<typeof GroupListSchema>;
export const CreateGroupFromOrganizationSchema = z.strictObject({
organizationId: z.string().min(1),
});
export type CreateGroupFromOrganization = z.output<typeof CreateGroupFromOrganizationSchema>;
export const CreateGroupFromOrganizationResultSchema = z.object({
group: GroupDtoSchema,
alreadyExists: z.boolean(),
});
export type CreateGroupFromOrganizationResult = z.output<
typeof CreateGroupFromOrganizationResultSchema
>;

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const HealthCheckSchema = z.object({
status: z.string(),
timestamp: z.string().datetime(),
checks: z.object({
database: z.string(),
}),
});
export type HealthCheck = z.output<typeof HealthCheckSchema>;

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
export function createPageSchema() {
return z.coerce.number().int().min(1).default(1);
}
export function createPageSizeSchema(defaultValue: number = 10) {
return z.coerce.number().int().min(1).max(100).default(defaultValue);
}
export function createPaginationSchema() {
return z.object({
page: z.number().int().min(1),
pageSize: z.number().int().min(1).max(100),
total: z.number().int().min(0),
totalPages: z.number().int().min(0),
});
}

View File

@@ -1,17 +0,0 @@
import { z } from 'zod'
export const paymentStatusSchema = z.enum(['PENDING', 'PAID', 'OVERDUE', 'CANCELLED'])
export const createPaymentSchema = z.object({
groupId: z.string().min(1),
studentId: z.string().min(1),
amount: z.coerce.number().positive(),
currency: z.string().default('MXN'),
dueDate: z.coerce.date(),
status: paymentStatusSchema.optional(),
})
export const updatePaymentSchema = createPaymentSchema.partial()
export type CreatePaymentInput = z.infer<typeof createPaymentSchema>
export type UpdatePaymentInput = z.infer<typeof updatePaymentSchema>

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
const isoDateTimeSchema = z.string().datetime();
const pageSchema = createPageSchema();
const pageSizeSchema = createPageSizeSchema(10);
const paginationSchema = createPaginationSchema();
export const paymentStatusSchema = z.enum(['PENDING', 'PAID', 'OVERDUE', 'CANCELLED']);
export const PaymentDtoSchema = z.object({
id: z.string(),
groupId: z.string(),
studentId: z.string(),
amount: z.coerce.number().positive(),
currency: z.string(),
status: paymentStatusSchema,
dueDate: isoDateTimeSchema,
paidAt: isoDateTimeSchema.nullable(),
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type PaymentDto = z.output<typeof PaymentDtoSchema>;
export const PaymentQuerySchema = z.object({
page: pageSchema,
pageSize: pageSizeSchema,
});
export type PaymentQuery = z.output<typeof PaymentQuerySchema>;
export const PaymentListSchema = z.object({
data: z.array(PaymentDtoSchema),
pagination: paginationSchema,
});
export type PaymentList = z.output<typeof PaymentListSchema>;

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
import type { ProblemDetails } from '../lib/problem-details.js';
export const ProblemDetailsSchema: z.ZodType<ProblemDetails> = z.object({
type: z.string().optional(),
title: z.string(),
status: z.number().int().min(100).max(599),
detail: z.string().optional(),
instance: z.string().optional(),
code: z.string().optional(),
errors: z.record(z.string(), z.array(z.string())).optional(),
});

View File

@@ -1,16 +0,0 @@
import { z } from 'zod'
export const createStudentSchema = z.object({
groupId: z.string().min(1),
fullName: z.string().min(1, 'El nombre completo es obligatorio'),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
guardianName: z.string().optional().or(z.literal('')),
guardianPhone: z.string().optional().or(z.literal('')),
notes: z.string().optional().or(z.literal('')),
})
export const updateStudentSchema = createStudentSchema.omit({ groupId: true }).partial()
export type CreateStudentInput = z.infer<typeof createStudentSchema>
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
const isoDateTimeSchema = z.string().datetime();
const pageSchema = createPageSchema();
const pageSizeSchema = createPageSizeSchema(10);
const paginationSchema = createPaginationSchema();
export const StudentDtoSchema = z.object({
id: z.string(),
groupId: z.string(),
fullName: z.string(),
email: z.string().nullable(),
phone: z.string().nullable(),
guardianName: z.string().nullable(),
guardianPhone: z.string().nullable(),
notes: z.string().nullable(),
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type StudentDto = z.output<typeof StudentDtoSchema>;
export const StudentQuerySchema = z.object({
page: pageSchema,
pageSize: pageSizeSchema,
});
export type StudentQuery = z.output<typeof StudentQuerySchema>;
export const StudentListSchema = z.object({
data: z.array(StudentDtoSchema),
pagination: paginationSchema,
});
export type StudentList = z.output<typeof StudentListSchema>;

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
const isoDateTimeSchema = z.string().datetime();
const pageSchema = createPageSchema();
const pageSizeSchema = createPageSizeSchema(10);
const paginationSchema = createPaginationSchema();
export const waitlistStatusSchema = z.enum(['PENDING', 'INVITED', 'JOINED', 'DECLINED']);
export const WaitlistEntryDtoSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string().nullable(),
status: waitlistStatusSchema,
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type WaitlistEntryDto = z.output<typeof WaitlistEntryDtoSchema>;
export const WaitlistQuerySchema = z.object({
page: pageSchema,
pageSize: pageSizeSchema,
});
export type WaitlistQuery = z.output<typeof WaitlistQuerySchema>;
export const WaitlistListSchema = z.object({
data: z.array(WaitlistEntryDtoSchema),
pagination: paginationSchema,
});
export type WaitlistList = z.output<typeof WaitlistListSchema>;

View File

@@ -2,9 +2,10 @@
"extends": "@gruperly/config/tsconfig.base.json",
"compilerOptions": {
"moduleResolution": "bundler",
"paths": {
"@gruperly/shared": ["./src/index.ts"]
}
"declaration": false,
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}
}

4728
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

View File

@@ -7,11 +7,12 @@ Este documento define la arquitectura, convenciones y especificaciones del stack
## 1. Stack Tecnológico Principal
### Runtime & Monorepo Manager
* **Runtime / Package Manager:** [Bun](https://bun.sh/) (última versión estable)
* **Monorepo Tooling:** Bun Workspaces (opcionalmente orquestado con Turborepo)
* **Runtime:** Node.js (>=20)
* **Package Manager:** [pnpm](https://pnpm.io/) 9
* **Monorepo Tooling:** pnpm workspaces + Turborepo
### Backend (`apps/api`)
* **Framework:** [Hono](https://hono.dev/) (para Bun)
### Backend (`apps/backend`)
* **Framework:** [Hono](https://hono.dev/) (servido con `@hono/node-server`, ejecutado con `tsx`)
* **ORM:** [Prisma](https://www.prisma.io/)
* **Base de Datos:** PostgreSQL
* **Autenticación:** [Better Auth](https://www.better-auth.com/)
@@ -36,21 +37,24 @@ Este documento define la arquitectura, convenciones y especificaciones del stack
```text
gruperly/
├── apps/
│ ├── api/ # Servicio Backend (Bun + Hono)
│ ├── backend/ # Servicio Backend (Node + Hono + tsx)
│ │ ├── src/
│ │ │ ├── db/ # Cliente e inicialización de Prisma
│ │ │ ├── lib/ # Instancia de Better Auth y helpers
│ │ │ ├── routes/ # Endpoints modularizados de Hono
│ │ │ │ ├── auth.ts
│ │ │ │ ├── groups.ts
│ │ │ │ ├── students.ts
│ │ │ │ ├── payments.ts
│ │ │ │ └── waitlist.ts
│ │ │ └── index.ts # Entry point de la API
│ │ │ ├── http/ # Infraestructura HTTP (validate, problem-details, session-auth, ...)
│ │ │ ├── lib/ # Prisma (cliente + UnitOfWork), pagination, email, helpers
│ │ │ ├── modules/ # Módulos: health-check, auth, groups, students, payments, waitlist
│ │ │ │ └── <módulo>/
│ │ │ │ ├── routes.ts
│ │ │ │ └── features/<accion>/{route,use-case}.ts
│ │ │ ├── app.ts # Monta basePath('/api/v1') + rutas + notFound/onError (RFC 7807)
│ │ │ ├── server.ts # Entry point (serve de @hono/node-server)
│ │ │ └── logger.ts # pino + pino-pretty
│ │ ├── prisma/
│ │ │ ├── schema.prisma # Definición del modelo de datos
│ │ │ └── generated/ # Cliente generado de Prisma (gitignoreado)
│ │ ├── prisma.config.ts # Config de Prisma CLI (datasource URL, migrations)
│ │ │ ├── schema.prisma # Generator + datasource simple
│ │ │ ├── models/ # Modelos multi-archivo (auth.prisma, domain.prisma)
│ │ │ └── migrations/
│ │ ├── generated/ # Cliente generado de Prisma (gitignoreado, alias @generated/*)
│ │ ├── prisma.config.ts # Config de Prisma CLI (datasource URL, migrations)
│ │ ├── test/ # Tests vitest
│ │ ├── tsconfig.json
│ │ └── package.json
│ │

Some files were not shown because too many files have changed in this diff Show More