4 Commits

Author SHA1 Message Date
874b6e0cff Merge pull request 'backend-refactoring' (#1) from backend-refactoring into main
Reviewed-on: #1
2026-09-14 14:00:02 +00:00
Jose Selesan
6246bf2341 refactor: migrate monorepo from pnpm to Bun runtime and workspaces
- root: bun workspaces via package.json, packageManager bun@1.4.0, scripts use bun --filter
- backend: Bun.serve, bun test (vitest removed), bun --watch dev script, tsconfig types bun
- tests: migrate 6 test files from vitest to bun:test (mock.module)
- validate: replace @hono/zod-validator with hono validator + zod safeParse (fixes TS2589)
- lockfile: bunfig.toml saveTextLockfile, regenerated bun.lock (text) for turbo
- docs: AGENTS.md, README.md, stack.md updated to Bun stack
2026-09-14 10:58:56 -03:00
Jose Selesan
4b1f356fab 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
2026-09-14 09:18:40 -03:00
Jose Selesan
b41fffa40a feat: basic initial UI layout for mobile and desktop 2026-09-07 09:33:07 -03:00
125 changed files with 4418 additions and 556 deletions

13
.gitignore vendored
View File

@@ -6,7 +6,6 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
@@ -136,19 +135,15 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
# Bun
.bun
bun.lockb
# Turborepo # Turborepo
.turbo .turbo
# Prisma # Prisma
apps/api/prisma/dev.db apps/backend/prisma/dev.db
apps/api/prisma/migrations/dev/ apps/backend/prisma/migrations/dev/
apps/api/prisma/generated/ apps/backend/generated/
*.db *.db
# env # 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 ## 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**. - **Package manager: Bun 1.4** (`packageManager` en la raíz). Usa `bun` para instalar (`bun install`), scripts y filtros. **No uses pnpm/npm/yarn.**
- **Monorepo**: Bun Workspaces orquestado con **Turborepo** (`turbo.json`). Dependencias entre paquetes usan `workspace:*`. - **Monorepo**: bun workspaces (`"workspaces"` en el `package.json` raíz) orquestado con **Turborepo** (`turbo.json`). Dependencias entre paquetes usan `workspace:*`.
- Los paquetes son Node `"type": "module"`. - **Runtime: Bun** (`"type": "module"`). El backend usa `Bun.serve` (`bun --watch src/server.ts`); no es Node/tsx.
- Lockfile: `bun.lock` (texto; `bunfig.toml` fija `[install] saveTextLockfile = true` porque el `~/.bunfig.toml` global lo tiene en `false`). Se versiona. No existe `pnpm-lock.yaml`/`bun.lockb`.
## Comandos ## Comandos
- `bun run typecheck` — corre `tsc --noEmit` en todos los paquetes vía turborepo. **Es la verificación principal; correlo tras tocar código.** ```bash
- `bun run --filter @gruperly/api typecheck` / `... @gruperly/web typecheck` — verificación de un solo paquete. bun install # instala dependencias
- `bun run dev` — levanta API (4000) y web (6173) con turborepo. bun dev # levanta API (4000) y web (6173) vía turborepo
- **No hay script `lint` en ningún paquete**; `bun run lint` no hace nada útil. No dependas de él. bun run typecheck # tsc --noEmit en todos los paquetes. Principal verificación; corre tras tocar código
- Base de datos (API): `bun run db:generate` / `db:migrate` / `db:push` / `db:studio`, o bien `bun --filter @gruperly/api db:*`. bun run build # build de backend (prisma generate + tsc) y web (vite build)
bun run lint # biome check en backend y shared
bun run lint:fix # biome check --write
bun --filter @gruperly/backend typecheck # verificación de un solo paquete
bun --filter @gruperly/backend test # bun test (backend)
bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push / db:studio
```
## Estructura y fronteras ## 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/backend` — Backend Bun + Hono. Entry point `src/server.ts` (`Bun.serve`, puerto **4000** vía `PORT`). `src/app.ts` monta `basePath('/api/v1')` + rutas de módulos + notFound/onError en RFC 7807. Capas:
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). - `src/http/` — infraestructura HTTP: `env.ts`, `problem-details.ts` (+ `problem-builders.ts`, `problem-domain.ts`), `validate.ts` (wrapper del `validator` de hono —`hono/validator`— + `schema.safeParse`), `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`).
- `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. - `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`).
- `packages/config` — `tsconfig.base.json`; todos los tsconfig lo extienden. - `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 bun 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 ## 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`. - **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 `bun --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`.
- **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`. - **Tests (bun test)**: viven en `apps/backend/test/`. Mockean el módulo `@/lib/prisma` con `mock.module` (si el mock se comparte como `tx`, declara la const fuera del mock; `vi` de `bun:test` NO tiene `vi.mocked`). 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.
- `.env`, `dist/`, `.turbo/` están gitignoreados (que no te extrañe su ausencia). - **zod en v3.24 (pin exacto)**, no v4: `better-call` (transitiva de better-auth) pide `zod ^4` — ese peer warning es conocido y aceptado. `validator` de hono (`hono/validator`) sustituyó a `@hono/zod-validator` (sus tipos duales zod v3/v4 causaban TS2589/exhaustion no resolubles con `ZodTypeAny`); las rutas tipan `c.req.valid` vía el `ValidationInput` derivado del schema.
- `.env`, `dist/`, `.turbo/`, `apps/backend/generated/`, `node_modules/` están gitignoreados (que no te extrañe su ausencia).
## Convenciones repo-específicas ## Convenciones repo-específicas
- **Frontend en español**: copy de UI, comentarios y textos en español. - **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`). - **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í.** - **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. - 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.

View File

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

View File

@@ -1,6 +0,0 @@
# PostgreSQL connection string
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gruperly?schema=public"
# Better Auth
BETTER_AUTH_SECRET="replace-with-a-strong-secret"
BETTER_AUTH_URL="http://localhost:4000"

View File

@@ -1,29 +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": {
"@gruperly/shared": "workspace:*",
"@hono/zod-validator": "^0.8.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"better-auth": "^1.1.0",
"hono": "^4.6.0"
},
"devDependencies": {
"@types/bun": "^1.0.0",
"prisma": "^7.10.0",
"typescript": "^5.7.0"
}
}

View File

@@ -1,183 +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[]
groupsMember Member[]
groupsOwner Group[] @relation("OwnerGroups")
waitlist WaitlistEntry[]
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
providerId String
providerAccountId String
refreshToken String?
accessToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
idToken String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([providerId, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent 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")
}
// 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 Member[]
students Student[]
payments Payment[]
@@map("groups")
}
model Member {
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("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,30 +0,0 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { authRoutes } from './routes/auth'
import { groupsRoutes } from './routes/groups'
import { studentsRoutes } from './routes/students'
import { paymentsRoutes } from './routes/payments'
import { waitlistRoutes } from './routes/waitlist'
const app = new Hono()
app.use('*', cors())
app.get('/', (c) => c.json({ name: 'Gruperly API', status: 'ok' }))
app.route('/auth', authRoutes)
app.route('/groups', groupsRoutes)
app.route('/students', studentsRoutes)
app.route('/payments', paymentsRoutes)
app.route('/waitlist', waitlistRoutes)
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,5 +0,0 @@
import { Hono } from 'hono'
export const authRoutes = new Hono()
authRoutes.get('/session', (c) => c.json({ message: 'placeholder' }))

View File

@@ -1,5 +0,0 @@
import { Hono } from 'hono'
export const groupsRoutes = new Hono()
groupsRoutes.get('/', (c) => c.json([]))

View File

@@ -1,5 +0,0 @@
import { Hono } from 'hono'
export const paymentsRoutes = new Hono()
paymentsRoutes.get('/', (c) => c.json([]))

View File

@@ -1,5 +0,0 @@
import { Hono } from 'hono'
export const studentsRoutes = new Hono()
studentsRoutes.get('/', (c) => c.json([]))

View File

@@ -1,5 +0,0 @@
import { Hono } from 'hono'
export const waitlistRoutes = new Hono()
waitlistRoutes.get('/', (c) => c.json([]))

View File

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

21
apps/backend/.env.example Normal file
View File

@@ -0,0 +1,21 @@
# PostgreSQL connection string
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gruperly?schema=public"
# Frontend origin (CORS + CSRF)
WEB_URL="http://localhost:6173"
# Better Auth
BETTER_AUTH_SECRET="replace-with-a-strong-secret"
BETTER_AUTH_URL="http://localhost:4000"
# Social providers
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Email (SMTP por ahora; migrar a un proveedor transaccional en el futuro)
EMAIL_PROVIDER="smtp"
EMAIL_SERVER_HOST=""
EMAIL_SERVER_PORT="587"
EMAIL_SERVER_USER=""
EMAIL_SERVER_PASSWORD=""
EMAIL_FROM="Gruperly <no-reply@gruperly.com>"

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

@@ -0,0 +1,42 @@
{
"name": "@gruperly/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/server.ts",
"start": "bun src/server.ts",
"build": "prisma generate && tsc",
"typecheck": "tsc --noEmit",
"test": "bun test",
"test:watch": "bun test --watch",
"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:*",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"better-auth": "1.7.2",
"hono": "^4.13.3",
"nodemailer": "^9.0.6",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"zod": "3.24.2"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@gruperly/config": "workspace:*",
"@types/bun": "^1.4.0",
"@types/node": "^20.17.0",
"@types/nodemailer": "^8.0.1",
"dotenv": "^16.4.5",
"prisma": "^7.10.0",
"typescript": "^5.7.0"
}
}

View File

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

View File

@@ -0,0 +1,191 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'PAID', 'OVERDUE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "WaitlistStatus" AS ENUM ('PENDING', 'INVITED', 'JOINED', 'DECLINED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refreshToken" TEXT,
"accessToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"idToken" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verifications" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "groups" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "groups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "members" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'MEMBER',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "students" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"guardianName" TEXT,
"guardianPhone" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "students_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MXN',
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"dueDate" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "waitlist_entries" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"status" "WaitlistStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT,
CONSTRAINT "waitlist_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_providerId_providerAccountId_key" ON "accounts"("providerId", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE UNIQUE INDEX "verifications_identifier_value_key" ON "verifications"("identifier", "value");
-- CreateIndex
CREATE UNIQUE INDEX "members_groupId_userId_key" ON "members"("groupId", "userId");
-- CreateIndex
CREATE INDEX "students_groupId_idx" ON "students"("groupId");
-- CreateIndex
CREATE INDEX "payments_groupId_idx" ON "payments"("groupId");
-- CreateIndex
CREATE INDEX "payments_studentId_idx" ON "payments"("studentId");
-- CreateIndex
CREATE UNIQUE INDEX "waitlist_entries_userId_key" ON "waitlist_entries"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "waitlist_entries_email_key" ON "waitlist_entries"("email");
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "groups" ADD CONSTRAINT "groups_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "students" ADD CONSTRAINT "students_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "students"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,117 @@
/*
Warnings:
- You are about to drop the column `groupId` on the `members` table. All the data in the column will be lost.
- You are about to drop the column `joinedAt` on the `members` table. All the data in the column will be lost.
- The `role` column on the `members` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- A unique constraint covering the columns `[organizationId,userId]` on the table `members` will be added. If there are existing duplicate values, this will fail.
- Added the required column `organizationId` to the `members` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "members" DROP CONSTRAINT "members_groupId_fkey";
-- DropIndex
DROP INDEX "members_groupId_userId_key";
-- AlterTable
ALTER TABLE "members" DROP COLUMN "groupId",
DROP COLUMN "joinedAt",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "organizationId" TEXT NOT NULL,
DROP COLUMN "role",
ADD COLUMN "role" TEXT NOT NULL DEFAULT 'member';
-- AlterTable
ALTER TABLE "sessions" ADD COLUMN "activeOrganizationId" TEXT;
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"logo" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invitations" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"inviterId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "passkeys" (
"id" TEXT NOT NULL,
"name" TEXT,
"publicKey" TEXT NOT NULL,
"credentialID" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"counter" INTEGER NOT NULL,
"deviceType" TEXT NOT NULL,
"backedUp" BOOLEAN NOT NULL,
"transports" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aaguid" TEXT,
CONSTRAINT "passkeys_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "group_members" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'MEMBER',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "group_members_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug");
-- CreateIndex
CREATE INDEX "invitations_organizationId_idx" ON "invitations"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "passkeys_credentialID_key" ON "passkeys"("credentialID");
-- CreateIndex
CREATE INDEX "passkeys_userId_idx" ON "passkeys"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "group_members_groupId_userId_key" ON "group_members"("groupId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "members_organizationId_userId_key" ON "members"("organizationId", "userId");
-- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "group_members" ADD CONSTRAINT "group_members_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "group_members" ADD CONSTRAINT "group_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,22 @@
/*
Warnings:
- You are about to drop the column `providerAccountId` on the `accounts` table. All the data in the column will be lost.
- A unique constraint covering the columns `[issuer,accountId]` on the table `accounts` will be added. If there are existing duplicate values, this will fail.
- Added the required column `accountId` to the `accounts` table without a default value. This is not possible if the table is not empty.
- Added the required column `issuer` to the `accounts` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "accounts_providerId_providerAccountId_key";
-- AlterTable
ALTER TABLE "accounts" DROP COLUMN "providerAccountId",
ADD COLUMN "accountId" TEXT NOT NULL,
ADD COLUMN "issuer" TEXT NOT NULL;
-- CreateIndex
CREATE INDEX "accounts_userId_idx" ON "accounts"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_issuer_accountId_key" ON "accounts"("issuer", "accountId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

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,62 @@
import type { Context, MiddlewareHandler } from 'hono';
import { validator } from 'hono/validator';
import type { ZodTypeAny, z } from 'zod';
import { validationProblem } from './problem-builders';
import { zodIssuesToRecord } from './zod-issues';
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
type ValidationInput<S extends ZodTypeAny, T extends ValidationTarget> = {
out: { [K in T]: z.output<S> };
};
function makeValidator<S extends ZodTypeAny, T extends ValidationTarget>(
target: T,
schema: S,
): MiddlewareHandler<{}, string, ValidationInput<S, T>> {
const middleware = validator(target, (value: unknown, c: Context) => {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
}
const requestId = c.get('requestId');
const instance = requestId ? `/requests/${requestId}` : undefined;
return c.json(
validationProblem({
detail: `Invalid ${target} data`,
instance,
errors: zodIssuesToRecord(result.error.issues),
}),
400,
{
'Content-Type': 'application/problem+json',
},
);
});
return middleware as unknown as MiddlewareHandler<{}, string, ValidationInput<S, T>>;
}
export const validate = {
json<S extends ZodTypeAny>(schema: S) {
return makeValidator('json', schema);
},
query<S extends ZodTypeAny>(schema: S) {
return makeValidator('query', schema);
},
param<S extends ZodTypeAny>(schema: S) {
return makeValidator('param', schema);
},
header<S extends ZodTypeAny>(schema: S) {
return makeValidator('header', schema);
},
form<S extends ZodTypeAny>(schema: S) {
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,94 @@
import nodemailer, { type Transporter } from 'nodemailer'
type VerificationEmailData = { email: string; url: string; name?: string }
type PasswordResetEmailData = { email: string; url: string; name?: string }
type InvitationEmailData = { email: string; url: string; organizationName: string }
interface EmailProvider {
sendVerificationEmail(data: VerificationEmailData): Promise<void>
sendPasswordResetEmail(data: PasswordResetEmailData): Promise<void>
sendOrganizationInvitation(data: InvitationEmailData): Promise<void>
}
class SMTPEmailProvider implements EmailProvider {
private transporter: Transporter
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.EMAIL_SERVER_HOST,
port: Number(process.env.EMAIL_SERVER_PORT ?? 587),
secure: (process.env.EMAIL_SERVER_PORT ?? '587') === '465',
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
})
}
private from() {
return process.env.EMAIL_FROM ?? 'Gruperly <no-reply@gruperly.com>'
}
private layout(title: string, body: string) {
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: #0a2540;">
<h2 style="margin: 0 0 16px;">Gruperly</h2>
<h3 style="margin: 0 0 8px;">${title}</h3>
<p style="line-height: 1.6; color: #334155;">${body}</p>
</div>
`
}
private async send(to: string, subject: string, html: string) {
// Sin SMTP configurado (dev) no rompe el flujo: solo registra un aviso
if (!process.env.EMAIL_SERVER_HOST) {
console.warn(`[email] SMTP no configurado. Email no enviado a ${to}: "${subject}"`)
return
}
await this.transporter.sendMail({ from: this.from(), to, subject, html })
}
async sendVerificationEmail({ email, url, name }: VerificationEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Verificar email</a>`
await this.send(
email,
'Verifica tu email en Gruperly',
this.layout(
'Confirma tu dirección de email',
`${name ? `Hola ${name}, ` : 'Hola, '}gracias por crear tu cuenta en Gruperly. Para empezar, verifica tu email con el botón de abajo (el enlace vence en 1 hora).<br/><br/>${link}`,
),
)
}
async sendPasswordResetEmail({ email, url, name }: PasswordResetEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Restablecer contraseña</a>`
await this.send(
email,
'Restablece tu contraseña en Gruperly',
this.layout(
'Solicitaste restablecer tu contraseña',
`${name ? `Hola ${name}, ` : 'Hola, '}haz clic en el botón para elegir una nueva contraseña (el enlace vence en 1 hora). Si no fuiste tú, ignora este email.<br/><br/>${link}`,
),
)
}
async sendOrganizationInvitation({ email, url, organizationName }: InvitationEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Unirme a ${organizationName}</a>`
await this.send(
email,
`Te invitaron a ${organizationName} en Gruperly`,
this.layout(
`Te invitaron a ${organizationName}`,
`Acepta la invitación con el botón de abajo para empezar a colaborar en Gruperly.<br/><br/>${link}`,
),
)
}
}
// Futuro: proveedores transaccionales (Resend, SendGrid, Postmark).
// Añadir implementaciones y alternar con EMAIL_PROVIDER.
export function createEmailProvider(): EmailProvider {
return new SMTPEmailProvider()
}
export const emailProvider = createEmailProvider()

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,71 @@
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

@@ -0,0 +1,64 @@
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',
basePath: '/api/v1/auth',
database: prismaAdapter(getPrismaClient(), {
provider: 'postgresql',
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
sendResetPassword: async ({ user, url }) => {
await emailProvider.sendPasswordResetEmail({
email: user.email,
url,
name: user.name,
})
},
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, token }) => {
const webUrl = process.env.WEB_URL ?? 'http://localhost:6173'
const verificationUrl = new URL('/verify-email', webUrl)
verificationUrl.searchParams.set('token', token)
await emailProvider.sendVerificationEmail({
email: user.email,
url: verificationUrl.toString(),
name: user.name,
})
},
},
socialProviders: {
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
},
plugins: [
organization({
acronym: 'GRP',
allowUserToCreateOrganization: true,
organizationLimit: 10,
}),
passkey({
rpName: 'Gruperly',
}),
],
trustedOrigins: [
process.env.BETTER_AUTH_URL ?? 'http://localhost:4000',
process.env.WEB_URL ?? 'http://localhost:6173',
],
})

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,25 @@
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 = Bun.serve({
port,
fetch: app.fetch,
});
async function shutdown(): Promise<void> {
server.stop(true);
await getPrismaClient().$disconnect();
}
process.once('SIGINT', () => {
void shutdown();
});
process.once('SIGTERM', () => {
void shutdown();
});

View File

@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findMany: mock(),
findFirst: mock(),
count: mock(),
create: mock(),
},
groupMember: {
create: mock(),
},
organization: {
findUnique: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(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 () => {
prisma.group.findMany.mockResolvedValue([group]);
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' }],
};
prisma.organization.findUnique.mockResolvedValue(organization as never);
prisma.group.findFirst.mockResolvedValue(null);
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' }],
};
prisma.organization.findUnique.mockResolvedValue(organization as never);
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 () => {
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' }],
};
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 { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
mock.module('@/lib/prisma', () => ({
default: {
$queryRaw: mock(),
},
getPrismaClient: mock(),
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 () => {
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 () => {
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 { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
mock.module('@/lib/prisma', () => ({
default: {
payment: {
findMany: mock(),
count: mock(),
},
},
getPrismaClient: mock(),
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 () => {
prisma.payment.findMany.mockResolvedValue([payment]);
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,82 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
$queryRaw: mock(),
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(() => db),
UnitOfWork: class {},
}));
mock.module('@/modules/auth/auth', () => ({
auth: {
api: {
getSession: mock(),
},
},
}));
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 () => {
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' },
};
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 { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
mock.module('@/lib/prisma', () => ({
default: {
student: {
findMany: mock(),
count: mock(),
},
},
getPrismaClient: mock(),
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 () => {
prisma.student.findMany.mockResolvedValue([student]);
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 () => {
prisma.student.findMany.mockResolvedValue([]);
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 { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
mock.module('@/lib/prisma', () => ({
default: {
waitlistEntry: {
findMany: mock(),
count: mock(),
},
},
getPrismaClient: mock(),
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 () => {
prisma.waitlistEntry.findMany.mockResolvedValue([entry]);
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": ["bun"],
"declaration": false,
"noEmit": false,
"outDir": "./dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"],
"@generated/*": ["./generated/*"]
}
},
"include": ["src/**/*.ts", "generated/prisma/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}

1
apps/web/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:4000

View File

@@ -10,19 +10,22 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.7.2",
"@gruperly/shared": "workspace:*", "@gruperly/shared": "workspace:*",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.90.0", "@tanstack/react-router": "^1.90.0",
"better-auth": "1.7.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.34.0", "lucide-react": "^1.34.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.0", "react-hook-form": "^7.54.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"zod": "^3.24.0" "zod": "3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@gruperly/config": "workspace:*",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
import { Logo, LogoIcon } from '../brand'
export type AuthShellProps = {
title: string
subtitle?: string
children: ReactNode
}
export function AuthShell({ title, subtitle, children }: AuthShellProps) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-sm">
<div className="mb-6 flex items-center justify-center gap-2">
<LogoIcon className="size-9" />
<Logo className="text-xl" />
</div>
<div className="rounded-xl border border-border bg-white p-6 shadow-sm">
<h1 className="text-xl font-bold text-primary">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null}
<div className="mt-5">{children}</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { AuthShell } from './AuthShell'

View File

@@ -0,0 +1,62 @@
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
type Crumb = { label: string; to?: string }
const BREADCRUMBS: Record<string, Crumb[]> = {
'/': [{ label: 'Inicio' }],
'/groups': [
{ label: 'Inicio', to: '/' },
{ label: 'Grupos' },
],
'/payments': [
{ label: 'Inicio', to: '/' },
{ label: 'Cobros' },
],
'/settings': [
{ label: 'Inicio', to: '/' },
{ label: 'Ajustes' },
],
'/seguridad': [
{ label: 'Inicio', to: '/' },
{ label: 'Ajustes', to: '/settings' },
{ label: 'Seguridad' },
],
'/settings/organizations': [
{ label: 'Inicio', to: '/' },
{ label: 'Ajustes', to: '/settings' },
{ label: 'Organizaciones' },
],
'/profile': [
{ label: 'Inicio', to: '/' },
{ label: 'Mi perfil' },
],
}
export function Breadcrumb() {
const location = useLocation()
const crumbs = BREADCRUMBS[location.pathname] ?? [{ label: 'Inicio' }]
return (
<nav aria-label="Breadcrumb" className="hidden items-center gap-1.5 text-sm lg:flex">
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1
return (
<span key={crumb.label} className="flex items-center gap-1.5">
{i > 0 ? <ChevronRight className="size-3.5 text-foreground/40" /> : null}
{isLast ? (
<span className="font-medium text-primary">{crumb.label}</span>
) : (
<Link
to={crumb.to!}
className="text-foreground/50 transition-colors hover:text-primary"
>
{crumb.label}
</Link>
)}
</span>
)
})}
</nav>
)
}

View File

@@ -0,0 +1,20 @@
import { Logo, LogoIcon } from '../brand'
import { Breadcrumb } from './Breadcrumb'
import { UserMenu } from './UserMenu'
export function Header() {
return (
<header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur">
<div className="mx-auto flex h-full w-full max-w-7xl items-center justify-between px-4 lg:px-6">
<div className="flex min-w-0 items-center">
<a href="/" className="flex items-center gap-2 lg:hidden" aria-label="Gruperly inicio">
<LogoIcon className="size-7" />
<Logo className="text-lg tracking-tight" />
</a>
<Breadcrumb />
</div>
<UserMenu />
</div>
</header>
)
}

View File

@@ -1,44 +1,22 @@
import { Outlet } from '@tanstack/react-router' import { Outlet } from '@tanstack/react-router'
import { Bell } from 'lucide-react' import { Header } from './Header'
import { Logo, LogoIcon } from '../brand'
import { Avatar, Button } from '../ui'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { BottomNav } from './BottomNav' import { BottomNav } from './BottomNav'
export function RootLayout() { export function RootLayout() {
return ( return (
<div className="mx-auto flex min-h-dvh w-full max-w-6xl gap-6 lg:px-6"> <div className="min-h-dvh w-full">
<Sidebar /> <Header />
<div className="flex min-h-dvh w-full flex-col lg:min-w-0"> <div className="mx-auto flex w-full max-w-7xl gap-6 lg:px-6">
<header className="sticky top-0 z-30 border-b border-border bg-background/90 backdrop-blur"> <Sidebar />
<div className="mx-auto flex h-14 w-full max-w-md items-center justify-between px-4 lg:max-w-none lg:px-0">
<a
href="/"
className="flex items-center gap-2 lg:hidden"
aria-label="Gruperly inicio"
>
<LogoIcon className="size-8" />
<Logo className="text-lg tracking-tight" />
</a>
<div className="ml-auto flex items-center gap-1 lg:ml-0">
<Button size="icon" variant="ghost" aria-label="Notificaciones" className="relative">
<Bell className="size-5" />
<span className="absolute right-1.5 top-1.5 size-2 rounded-full bg-accent" />
</Button>
<Button size="icon" variant="ghost" aria-label="Perfil">
<Avatar name="Gruperly User" />
</Button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-md flex-1 px-4 pb-24 pt-4 lg:max-w-none lg:px-0 lg:pb-8 lg:pt-6"> <main className="min-w-0 flex-1 px-4 pb-24 pt-4 lg:px-0 lg:pb-8 lg:pt-8">
<Outlet /> <Outlet />
</main> </main>
<BottomNav />
</div> </div>
<BottomNav />
</div> </div>
) )
} }

View File

@@ -7,7 +7,7 @@ export function Sidebar() {
const location = useLocation() const location = useLocation()
return ( return (
<aside className="sticky top-0 hidden h-dvh w-60 shrink-0 flex-col border-r border-border bg-white px-4 py-6 lg:flex"> <aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] w-64 shrink-0 flex-col border-r border-border bg-white px-4 py-6 lg:flex">
<a href="/" className="mb-8 flex items-center gap-2 px-1" aria-label="Gruperly inicio"> <a href="/" className="mb-8 flex items-center gap-2 px-1" aria-label="Gruperly inicio">
<LogoIcon className="size-8" /> <LogoIcon className="size-8" />
<Logo className="text-lg tracking-tight" /> <Logo className="text-lg tracking-tight" />

View File

@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Link, useNavigate } from '@tanstack/react-router'
import { ChevronDown, LogOut, Settings, UserRound, X } from 'lucide-react'
import { cn } from '../../lib/utils'
import { signOut } from '../../lib/auth-client'
import { useAuth } from '../../context/AuthProvider'
import { useIsMobile } from '../../hooks/useIsMobile'
import { Avatar } from '../ui'
const itemClass =
'flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium text-primary transition-colors hover:bg-primary-soft'
export function UserMenu() {
const { user } = useAuth()
const isMobile = useIsMobile()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
useEffect(() => {
if (!open || isMobile) return
const handlePointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('pointerdown', handlePointerDown)
return () => document.removeEventListener('pointerdown', handlePointerDown)
}, [open, isMobile])
useEffect(() => {
if (isMobile && open) {
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = ''
}
}
}, [isMobile, open])
useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open])
const handleSignOut = async () => {
setOpen(false)
await signOut({ fetchOptions: { headers: { 'Cache-Control': 'no-cache' } } })
navigate({ to: '/login' })
}
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-xl p-1 transition-colors hover:bg-primary-soft"
>
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-8 lg:size-9" />
<span className="hidden max-w-40 flex-col items-start leading-tight lg:flex">
<span className="truncate text-sm font-medium text-primary">{user?.name ?? 'Usuario'}</span>
</span>
<ChevronDown
className={cn(
'hidden size-4 text-foreground/50 transition-transform lg:block',
open && 'rotate-180',
)}
/>
</button>
{isMobile
? open
? createPortal(
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 lg:hidden">
<div
aria-hidden="true"
className="absolute inset-0 animate-fade-in bg-black/20 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<div className="absolute inset-0 flex animate-fade-in flex-col overflow-y-auto bg-white/90 backdrop-blur">
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Cerrar menú"
className="absolute right-4 top-4 z-20 rounded-full bg-white/90 p-2 text-foreground/70 shadow-md backdrop-blur transition-colors hover:bg-white"
>
<X className="size-5" />
</button>
<div className="flex min-h-full flex-col items-center justify-center gap-8 px-6 py-12">
<div className="flex flex-col items-center gap-3 text-center">
<Avatar
name={user?.name}
src={user?.image ?? undefined}
className="size-16 text-xl"
/>
<div>
<p className="text-base font-semibold text-primary">
{user?.name ?? 'Usuario'}
</p>
<p className="text-sm text-foreground/50">{user?.email}</p>
</div>
</div>
<div className="w-full space-y-1.5 rounded-2xl border border-border/60 bg-white/90 p-2 shadow-xl backdrop-blur">
<Link
role="menuitem"
to="/profile"
onClick={() => setOpen(false)}
className={cn(itemClass, 'py-3.5')}
>
<UserRound className="size-5 text-foreground/60" />
Mi perfil
</Link>
<Link
role="menuitem"
to="/settings"
onClick={() => setOpen(false)}
className={cn(itemClass, 'py-3.5')}
>
<Settings className="size-5 text-foreground/60" />
Ajustes
</Link>
</div>
<button
type="button"
role="menuitem"
onClick={handleSignOut}
className={cn(
itemClass,
'w-full justify-center border border-border bg-white/90 py-3.5 shadow-sm backdrop-blur hover:bg-primary-soft',
)}
>
<LogOut className="size-5 text-foreground/60" />
Cerrar sesión
</button>
</div>
</div>
</div>,
document.body,
)
: null
: open ? (
<div
role="menu"
className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-border bg-white p-1.5 shadow-lg"
>
<div className="px-3 py-2">
<p className="truncate text-sm font-semibold text-primary">{user?.name ?? 'Usuario'}</p>
<p className="truncate text-xs text-foreground/50">{user?.email}</p>
</div>
<div className="mx-3 my-1 h-px bg-border" />
<Link role="menuitem" to="/profile" onClick={() => setOpen(false)} className={itemClass}>
<UserRound className="size-4 text-foreground/60" />
Mi perfil
</Link>
<Link role="menuitem" to="/settings" onClick={() => setOpen(false)} className={itemClass}>
<Settings className="size-4 text-foreground/60" />
Ajustes
</Link>
<div className="mx-3 my-1 h-px bg-border" />
<button type="button" role="menuitem" onClick={handleSignOut} className={itemClass}>
<LogOut className="size-4 text-foreground/60" />
Cerrar sesión
</button>
</div>
) : null}
</div>
)
}

View File

@@ -1,4 +1,5 @@
export { RootLayout } from './RootLayout' export { RootLayout } from './RootLayout'
export { Header } from './Header'
export { Sidebar } from './Sidebar' export { Sidebar } from './Sidebar'
export { BottomNav } from './BottomNav' export { BottomNav } from './BottomNav'
export { NAV_ITEMS, type NavItem } from './nav-items' export { NAV_ITEMS, type NavItem } from './nav-items'

View File

@@ -1,3 +1,5 @@
export { Avatar, type AvatarProps } from './avatar' export { Avatar, type AvatarProps } from './avatar'
export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './button' export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './button'
export { Badge, type BadgeProps, type BadgeVariant } from './badge' export { Badge, type BadgeProps, type BadgeVariant } from './badge'
export { Input, type InputProps } from './input'
export { Label, type LabelProps } from './label'

View File

@@ -0,0 +1,24 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
export type InputProps = InputHTMLAttributes<HTMLInputElement> & {
invalid?: boolean
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, invalid, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(
'h-10 w-full rounded-xl border border-border bg-white px-3 text-sm text-primary placeholder:text-foreground/40 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50',
invalid && 'border-danger focus:border-danger focus:ring-danger/30',
className,
)}
{...props}
/>
)
},
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,13 @@
import type { LabelHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
export type LabelProps = LabelHTMLAttributes<HTMLLabelElement>
export function Label({ className, ...props }: LabelProps) {
return (
<label
className={cn('mb-1.5 block text-sm font-medium text-primary', className)}
{...props}
/>
)
}

View File

@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { authClient } from '../lib/auth-client'
type AuthSession = {
user: (typeof authClient.$Infer.Session)['user']
session: (typeof authClient.$Infer.Session)['session']
}
type AuthContextValue = {
user: AuthSession['user'] | null
session: AuthSession['session'] | null
isPending: boolean
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const { data, isPending } = authClient.useSession()
const refresh = async () => {
await authClient.getSession()
}
return (
<AuthContext.Provider value={{ user: data?.user ?? null, session: data?.session ?? null, isPending, refresh }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth debe usarse dentro de <AuthProvider>')
return ctx
}

View File

@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react'
const MOBILE_QUERY = '(max-width: 1023px)'
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && window.matchMedia(MOBILE_QUERY).matches,
)
useEffect(() => {
const mql = window.matchMedia(MOBILE_QUERY)
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches)
setIsMobile(mql.matches)
mql.addEventListener('change', handleChange)
return () => mql.removeEventListener('change', handleChange)
}, [])
return isMobile
}

View File

@@ -21,6 +21,18 @@
/* Border radius por defecto */ /* Border radius por defecto */
--radius-xl: 0.75rem; --radius-xl: 0.75rem;
/* Animaciones */
--animate-fade-in: fade-in 0.2s ease-out;
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
} }
@layer base { @layer base {

View File

@@ -0,0 +1,19 @@
import { createAuthClient } from 'better-auth/react'
import { organizationClient } from 'better-auth/client/plugins'
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()],
})
export const {
signIn,
signUp,
signOut,
useSession,
getSession,
passkey,
organization,
} = authClient

View File

@@ -2,10 +2,13 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router' import { RouterProvider } from '@tanstack/react-router'
import { router } from './router' import { router } from './router'
import { AuthProvider } from './context/AuthProvider'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@@ -1,43 +1,100 @@
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router' import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
import { RootLayout } from './components/layout' import { RootLayout } from './components/layout'
import { HomeView } from './routes/home' import { HomeView } from './routes/home'
import { GroupsView } from './routes/groups' import { GroupsView } from './routes/groups'
import { PaymentsView } from './routes/payments' import { PaymentsView } from './routes/payments'
import { SettingsView } from './routes/settings' import { SettingsView } from './routes/settings'
import { ProfileView } from './routes/profile'
import { SecurityPage } from './routes/security'
import { OrganizationsPage } from './routes/organizations'
import { LoginPage } from './routes/auth/login'
import { SignupPage } from './routes/auth/signup'
import { VerifyEmailPage } from './routes/auth/verify-email'
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: () => <Outlet />,
})
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: LoginPage,
})
const signupRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/signup',
component: SignupPage,
})
const verifyEmailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/verify-email',
component: VerifyEmailPage,
})
// Capa con la navegación de la app autenticada (Sidebar + BottomNav).
const appLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
component: RootLayout, component: RootLayout,
}) })
const indexRoute = createRoute({ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => appLayoutRoute,
path: '/', path: '/',
component: HomeView, component: HomeView,
}) })
const groupsRoute = createRoute({ const groupsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => appLayoutRoute,
path: '/groups', path: '/groups',
component: GroupsView, component: GroupsView,
}) })
const paymentsRoute = createRoute({ const paymentsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => appLayoutRoute,
path: '/payments', path: '/payments',
component: PaymentsView, component: PaymentsView,
}) })
const settingsRoute = createRoute({ const settingsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => appLayoutRoute,
path: '/settings', path: '/settings',
component: SettingsView, component: SettingsView,
}) })
const securityRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/seguridad',
component: SecurityPage,
})
const profileRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/profile',
component: ProfileView,
})
const organizationsRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/settings/organizations',
component: OrganizationsPage,
})
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, loginRoute,
groupsRoute, signupRoute,
paymentsRoute, verifyEmailRoute,
settingsRoute, appLayoutRoute.addChildren([
indexRoute,
groupsRoute,
paymentsRoute,
settingsRoute,
securityRoute,
organizationsRoute,
profileRoute,
]),
]) ])
export const router = createRouter({ routeTree }) export const router = createRouter({ routeTree })

View File

@@ -0,0 +1,113 @@
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Link, useNavigate } from '@tanstack/react-router'
import { Fingerprint, Loader2 } from 'lucide-react'
import { authClient } from '../../lib/auth-client'
import { Button, Input, Label } from '../../components/ui'
import { AuthShell } from '../../components/auth'
const schema = z.object({
email: z.string().email('Ingresá un email válido'),
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
})
type FormValues = z.infer<typeof schema>
export function LoginPage() {
const navigate = useNavigate()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = handleSubmit(async ({ email, password }) => {
const { error } = await authClient.signIn.email({ email, password })
if (error) {
setError('root', { message: error.message ?? 'No se pudo iniciar sesión' })
return
}
navigate({ to: '/' })
})
const handleGoogle = async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
}
const handlePasskey = async () => {
const { error, data } = await authClient.signIn.passkey()
if (error) {
setError('root', { message: error.message ?? 'No se pudo autenticar con passkey' })
return
}
if (data) navigate({ to: '/' })
}
return (
<AuthShell title="Iniciar sesión" subtitle="Accedé a tus cobros grupales">
<div className="space-y-4">
<form onSubmit={onSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="vos@ejemplo.com"
invalid={!!errors.email}
{...register('email')}
/>
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
</div>
<div>
<Label htmlFor="password">Contraseña</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
invalid={!!errors.password}
{...register('password')}
/>
{errors.password ? (
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
) : null}
</div>
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
{isSubmitting ? 'Ingresando…' : 'Ingresar'}
</Button>
</form>
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="grid gap-2">
<Button variant="outline" className="w-full" onClick={handleGoogle}>
Continuar con Google
</Button>
<Button variant="outline" className="w-full" onClick={handlePasskey}>
<Fingerprint className="size-4" />
Entrar con passkey
</Button>
</div>
<p className="text-center text-sm text-foreground/60">
¿No tenés cuenta?{' '}
<Link to="/signup" className="font-medium text-accent hover:underline">
Registrate
</Link>
</p>
</div>
</AuthShell>
)
}

View File

@@ -0,0 +1,142 @@
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Link, useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { authClient } from '../../lib/auth-client'
import { Button, Input, Label } from '../../components/ui'
import { AuthShell } from '../../components/auth'
const schema = z
.object({
name: z.string().min(2, 'Ingresá tu nombre'),
email: z.string().email('Ingresá un email válido'),
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
confirmPassword: z.string(),
})
.refine((v) => v.password === v.confirmPassword, {
message: 'Las contraseñas no coinciden',
path: ['confirmPassword'],
})
type FormValues = z.infer<typeof schema>
export function SignupPage() {
const navigate = useNavigate()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = handleSubmit(async ({ name, email, password }) => {
const { error, data } = await authClient.signUp.email(
{ name, email, password },
{
onSuccess: async () => {
await authClient.getSession()
},
},
)
if (error) {
setError('root', { message: error.message ?? 'No se pudo registrar' })
return
}
if (data?.token) {
// Con email verification activo, la sesión no se crea hasta verificar.
navigate({ to: '/verify-email', search: { email } })
}
})
const handleGoogle = async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
}
return (
<AuthShell title="Crear cuenta" subtitle="Empezá a cobrar en grupo en minutos">
<div className="space-y-4">
<form onSubmit={onSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Nombre</Label>
<Input
id="name"
autoComplete="name"
placeholder="Nombre completo"
invalid={!!errors.name}
{...register('name')}
/>
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="vos@ejemplo.com"
invalid={!!errors.email}
{...register('email')}
/>
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
</div>
<div>
<Label htmlFor="password">Contraseña</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
placeholder="Mínimo 8 caracteres"
invalid={!!errors.password}
{...register('password')}
/>
{errors.password ? (
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
) : null}
</div>
<div>
<Label htmlFor="confirmPassword">Repetí la contraseña</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder="••••••••"
invalid={!!errors.confirmPassword}
{...register('confirmPassword')}
/>
{errors.confirmPassword ? (
<p className="mt-1 text-sm text-danger">{errors.confirmPassword.message}</p>
) : null}
</div>
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
{isSubmitting ? 'Creando…' : 'Crear cuenta'}
</Button>
</form>
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
<div className="h-px flex-1 bg-border" />
</div>
<Button variant="outline" className="w-full" onClick={handleGoogle}>
Continuar con Google
</Button>
<p className="text-center text-sm text-foreground/60">
¿Ya tenés cuenta?{' '}
<Link to="/login" className="font-medium text-accent hover:underline">
Iniciá sesión
</Link>
</p>
</div>
</AuthShell>
)
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
import { MailCheck, MailWarning, Loader2 } from 'lucide-react'
import { authClient } from '../../lib/auth-client'
import { AuthShell } from '../../components/auth'
type SearchParams = {
token?: string
email?: string
}
type Status = 'idle' | 'verifying' | 'success' | 'error'
export function VerifyEmailPage() {
const { token, email } = useSearch({ strict: false }) as SearchParams
const navigate = useNavigate()
const [status, setStatus] = useState<Status>(token ? 'verifying' : 'idle')
useEffect(() => {
if (!token || status !== 'verifying') return
let cancelled = false
authClient
.verifyEmail({ query: { token } })
.then(async ({ error }) => {
if (cancelled) return
if (error) {
setStatus('error')
return
}
await authClient.getSession()
navigate({ to: '/' })
})
.catch(() => {
if (!cancelled) setStatus('error')
})
return () => {
cancelled = true
}
}, [token, status, navigate])
const title =
status === 'success'
? 'Email verificado'
: status === 'error'
? 'Vínculo inválido'
: 'Verificá tu email'
return (
<AuthShell title={title}>
<div className="flex flex-col items-center gap-4 py-2 text-center">
{status === 'verifying' ? (
<Loader2 className="size-10 animate-spin text-accent" />
) : status === 'success' ? (
<MailCheck className="size-10 text-success" />
) : (
<MailWarning className="size-10 text-warning" />
)}
{status === 'success' ? (
<p className="text-sm text-foreground/60">
Tu cuenta quedó verificada. Te estamos llevando a Gruperly…
</p>
) : status === 'error' ? (
<p className="text-sm text-foreground/60">
El vínculo de verificación no es válido o expiró.
</p>
) : (
<p className="text-sm text-foreground/60">
Te enviamos un correo a <span className="font-medium text-primary">{email}</span> para
confirmar tu cuenta.
</p>
)}
{status === 'success' ? (
<Link
to="/"
className="w-full rounded-xl bg-accent py-2 text-center text-sm font-medium text-white hover:bg-accent-strong"
>
Ir a Gruperly
</Link>
) : null}
{status === 'error' ? (
<Link to="/login" className="text-sm font-medium text-accent hover:underline">
Intentar iniciar sesión
</Link>
) : null}
</div>
</AuthShell>
)
}

View File

@@ -0,0 +1,191 @@
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Building2, Check, Loader2, Plus } from 'lucide-react'
import { authClient } from '../lib/auth-client'
import { Badge, Button, Input, Label } from '../components/ui'
const createOrgSchema = z.object({
name: z.string().min(2, 'Ingresá el nombre del grupo'),
slug: z
.string()
.min(2, 'Mínimo 2 caracteres')
.regex(/^[a-z0-9-]+$/, 'Solo minúsculas, números y guiones'),
})
type CreateOrgValues = z.infer<typeof createOrgSchema>
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
type OrganizationRow = {
id: string
name: string
slug: string
logo: string | null
}
function OrganizationList({ onRefresh }: { onRefresh: () => void }) {
const { data, isPending } = authClient.useListOrganizations()
const [syncing, setSyncing] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const organizations = (data ?? []) as OrganizationRow[]
const handleSyncGroup = async (org: OrganizationRow) => {
setSyncing(org.id)
try {
const res = await fetch(`${API_URL}/api/v1/groups/from-organization`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ organizationId: org.id }),
})
const body = (await res.json()) as { message?: string }
if (!res.ok) {
onRefresh()
setSyncing(null)
return
}
void body
} finally {
setSyncing(null)
}
onRefresh()
}
const handleSetActive = async (organizationId: string) => {
setActiveId(organizationId)
await authClient.organization.setActive({ organizationId })
setActiveId(null)
}
if (isPending) {
return <Loader2 className="size-5 animate-spin text-foreground/40" />
}
if (organizations.length === 0) {
return <p className="text-sm text-foreground/60">Todavía no pertenecés a ningún grupo.</p>
}
return (
<ul className="divide-y divide-border rounded-xl border border-border bg-white">
{organizations.map((org) => (
<li key={org.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="rounded-lg bg-accent-soft p-2">
<Building2 className="size-4 text-accent" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-primary">{org.name}</p>
<p className="text-xs text-foreground/50">/{org.slug}</p>
</div>
<Button
variant="outline"
size="sm"
disabled={syncing === org.id}
onClick={() => handleSyncGroup(org)}
>
{syncing === org.id ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
Sincronizar grupo
</Button>
<Button
variant="ghost"
size="sm"
disabled={activeId === org.id}
onClick={() => handleSetActive(org.id)}
>
{activeId === org.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
<span className="hidden sm:inline">Usar</span>
</Button>
<Badge>Owner</Badge>
</li>
))}
</ul>
)
}
export function OrganizationsPage() {
const [refreshKey, setRefreshKey] = useState(0)
const [feedback, setFeedback] = useState<string | null>(null)
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<CreateOrgValues>({ resolver: zodResolver(createOrgSchema) })
const refreshOrganizations = () => setRefreshKey((k) => k + 1)
const onCreateOrg = handleSubmit(async ({ name, slug }) => {
setFeedback(null)
const { error, data } = await authClient.organization.create({ name, slug })
if (error) {
setError('root', { message: error.message ?? 'No se pudo crear el grupo' })
return
}
reset({ name: '', slug: '' })
setFeedback('Grupo creado. Sincronizalo con el cobro grupal.')
refreshOrganizations()
void data
})
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
<p className="mt-1 text-sm text-foreground/60">
Creá un grupo para organizar tus cobros. Cada organización se vincula a un grupo.
</p>
</div>
{feedback ? (
<p className="rounded-xl bg-success-soft px-4 py-3 text-sm text-success">{feedback}</p>
) : null}
<div className="rounded-xl border border-border bg-white p-5">
<div className="mb-4 flex items-center gap-2">
<Plus className="size-4 text-accent" />
<h2 className="text-base font-semibold text-primary">Crear grupo</h2>
</div>
<form onSubmit={onCreateOrg} className="space-y-4">
<div>
<Label htmlFor="name">Nombre</Label>
<Input
id="name"
placeholder="Gimnasio, club, kermés…"
invalid={!!errors.name}
{...register('name')}
/>
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
</div>
<div>
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
placeholder="gimnasio-don-bosco"
invalid={!!errors.slug}
{...register('slug')}
/>
{errors.slug ? <p className="mt-1 text-sm text-danger">{errors.slug.message}</p> : null}
</div>
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
<Button type="submit" variant="primary" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
Crear grupo
</Button>
</form>
</div>
<div className="rounded-xl border border-border bg-white p-5">
<h2 className="mb-4 text-base font-semibold text-primary">Tus grupos</h2>
<OrganizationList key={refreshKey} onRefresh={refreshOrganizations} />
</div>
</section>
)
}

View File

@@ -0,0 +1,27 @@
import { useAuth } from '../context/AuthProvider'
import { Avatar } from '../components/ui'
export function ProfileView() {
const { user } = useAuth()
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Mi perfil</h1>
<p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p>
</div>
<div className="rounded-xl border border-border bg-white p-5">
<div className="flex items-center gap-4">
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" />
<div className="min-w-0">
<p className="truncate text-base font-semibold text-primary">
{user?.name ?? 'Usuario'}
</p>
<p className="truncate text-sm text-foreground/50">{user?.email}</p>
</div>
</div>
</div>
</section>
)
}

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