Compare commits
6 Commits
dfa7f73ebb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 874b6e0cff | |||
|
|
6246bf2341 | ||
|
|
4b1f356fab | ||
|
|
b41fffa40a | ||
|
|
0aa5d70101 | ||
|
|
1ec6200ffa |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -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,18 +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/backend/generated/
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
# env
|
# env
|
||||||
apps/api/.env
|
apps/backend/.env
|
||||||
|
|
||||||
|
|||||||
47
AGENTS.md
47
AGENTS.md
@@ -4,34 +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 requiere `apps/api/.env`** (con `DATABASE_URL`) para `generate`/`validate`/`migrate`; sin él falla con `P1012`. Está gitignoreado; 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/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.
|
||||||
@@ -41,4 +56,4 @@ Monorepo web + API para gestión de cobros grupales. Reglas optimizadas para age
|
|||||||
## Fuentes de contexto
|
## Fuentes de contexto
|
||||||
|
|
||||||
- `stack.md` — arquitectura y spec técnica de referencia (UI tokens exactos, stack por app).
|
- `stack.md` — arquitectura y spec técnica de referencia (UI tokens exactos, stack por app).
|
||||||
- `README.md` — setup de la base de datos y comandos generales.
|
- `README.md` — setup de la base de datos y comandos generales.
|
||||||
25
README.md
25
README.md
@@ -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).
|
||||||
@@ -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"
|
|
||||||
@@ -1,28 +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",
|
|
||||||
"better-auth": "^1.1.0",
|
|
||||||
"hono": "^4.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "^1.0.0",
|
|
||||||
"prisma": "^6.0.0",
|
|
||||||
"@prisma/client": "^6.0.0",
|
|
||||||
"typescript": "^5.7.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
// Gruperly - Prisma Schema (PostgreSQL)
|
|
||||||
|
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
model Verification {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
identifier String
|
|
||||||
value String
|
|
||||||
expiresAt DateTime
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([identifier, value])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
|
|
||||||
enum WaitlistStatus {
|
|
||||||
PENDING
|
|
||||||
INVITED
|
|
||||||
JOINED
|
|
||||||
DECLINED
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
|
||||||
|
|
||||||
export const prisma = new PrismaClient()
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { prisma } from './client'
|
|
||||||
|
|
||||||
export { prisma }
|
|
||||||
@@ -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}`)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Hono } from 'hono'
|
|
||||||
|
|
||||||
export const authRoutes = new Hono()
|
|
||||||
|
|
||||||
authRoutes.get('/session', (c) => c.json({ message: 'placeholder' }))
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Hono } from 'hono'
|
|
||||||
|
|
||||||
export const groupsRoutes = new Hono()
|
|
||||||
|
|
||||||
groupsRoutes.get('/', (c) => c.json([]))
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Hono } from 'hono'
|
|
||||||
|
|
||||||
export const paymentsRoutes = new Hono()
|
|
||||||
|
|
||||||
paymentsRoutes.get('/', (c) => c.json([]))
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Hono } from 'hono'
|
|
||||||
|
|
||||||
export const studentsRoutes = new Hono()
|
|
||||||
|
|
||||||
studentsRoutes.get('/', (c) => c.json([]))
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Hono } from 'hono'
|
|
||||||
|
|
||||||
export const waitlistRoutes = new Hono()
|
|
||||||
|
|
||||||
waitlistRoutes.get('/', (c) => c.json([]))
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
21
apps/backend/.env.example
Normal file
21
apps/backend/.env.example
Normal 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
42
apps/backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/backend/prisma.config.ts
Normal file
15
apps/backend/prisma.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { join } from 'node:path'
|
||||||
|
import { config } from 'dotenv'
|
||||||
|
import { defineConfig, env } from 'prisma/config'
|
||||||
|
|
||||||
|
config({ path: join(process.cwd(), '.env') })
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/',
|
||||||
|
migrations: {
|
||||||
|
path: 'prisma/migrations',
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: env('DATABASE_URL'),
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
3
apps/backend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
140
apps/backend/prisma/models/auth.prisma
Normal file
140
apps/backend/prisma/models/auth.prisma
Normal 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")
|
||||||
|
}
|
||||||
106
apps/backend/prisma/models/domain.prisma
Normal file
106
apps/backend/prisma/models/domain.prisma
Normal 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
|
||||||
|
}
|
||||||
10
apps/backend/prisma/schema.prisma
Normal file
10
apps/backend/prisma/schema.prisma
Normal 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
49
apps/backend/src/app.ts
Normal 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;
|
||||||
19
apps/backend/src/http/env.ts
Normal file
19
apps/backend/src/http/env.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
apps/backend/src/http/problem-builders.ts
Normal file
89
apps/backend/src/http/problem-builders.ts
Normal 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}`);
|
||||||
|
}
|
||||||
82
apps/backend/src/http/problem-details.ts
Normal file
82
apps/backend/src/http/problem-details.ts
Normal 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',
|
||||||
|
});
|
||||||
1
apps/backend/src/http/problem-domain.ts
Normal file
1
apps/backend/src/http/problem-domain.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const PROBLEM_DOMAIN = process.env.PROBLEM_DOMAIN_URL ?? 'https://gruperly.com';
|
||||||
12
apps/backend/src/http/request-id.ts
Normal file
12
apps/backend/src/http/request-id.ts
Normal 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);
|
||||||
|
};
|
||||||
22
apps/backend/src/http/request-logger.ts
Normal file
22
apps/backend/src/http/request-logger.ts
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
34
apps/backend/src/http/security-headers.ts
Normal file
34
apps/backend/src/http/security-headers.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
43
apps/backend/src/http/session-auth.ts
Normal file
43
apps/backend/src/http/session-auth.ts
Normal 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;
|
||||||
|
}
|
||||||
62
apps/backend/src/http/validate.ts
Normal file
62
apps/backend/src/http/validate.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
16
apps/backend/src/http/zod-issues.ts
Normal file
16
apps/backend/src/http/zod-issues.ts
Normal 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;
|
||||||
|
}
|
||||||
94
apps/backend/src/lib/email.ts
Normal file
94
apps/backend/src/lib/email.ts
Normal 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()
|
||||||
3
apps/backend/src/lib/error-message.ts
Normal file
3
apps/backend/src/lib/error-message.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function toErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
25
apps/backend/src/lib/pagination.ts
Normal file
25
apps/backend/src/lib/pagination.ts
Normal 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;
|
||||||
|
}
|
||||||
71
apps/backend/src/lib/prisma.ts
Normal file
71
apps/backend/src/lib/prisma.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/backend/src/logger.ts
Normal file
72
apps/backend/src/logger.ts
Normal 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();
|
||||||
64
apps/backend/src/modules/auth/auth.ts
Normal file
64
apps/backend/src/modules/auth/auth.ts
Normal 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',
|
||||||
|
],
|
||||||
|
})
|
||||||
1
apps/backend/src/modules/auth/index.ts
Normal file
1
apps/backend/src/modules/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as authRoutes } from './routes';
|
||||||
8
apps/backend/src/modules/auth/routes.ts
Normal file
8
apps/backend/src/modules/auth/routes.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/backend/src/modules/groups/features/get-all/route.ts
Normal file
23
apps/backend/src/modules/groups/features/get-all/route.ts
Normal 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;
|
||||||
51
apps/backend/src/modules/groups/features/get-all/use-case.ts
Normal file
51
apps/backend/src/modules/groups/features/get-all/use-case.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/backend/src/modules/groups/index.ts
Normal file
1
apps/backend/src/modules/groups/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as groupsRoutes } from './routes';
|
||||||
33
apps/backend/src/modules/groups/lib/helpers.ts
Normal file
33
apps/backend/src/modules/groups/lib/helpers.ts
Normal 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 } } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
1
apps/backend/src/modules/groups/lib/index.ts
Normal file
1
apps/backend/src/modules/groups/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './helpers';
|
||||||
10
apps/backend/src/modules/groups/routes.ts
Normal file
10
apps/backend/src/modules/groups/routes.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/backend/src/modules/health-check/index.ts
Normal file
1
apps/backend/src/modules/health-check/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as healthCheckRoutes } from './routes';
|
||||||
8
apps/backend/src/modules/health-check/routes.ts
Normal file
8
apps/backend/src/modules/health-check/routes.ts
Normal 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;
|
||||||
18
apps/backend/src/modules/payments/features/get-all/route.ts
Normal file
18
apps/backend/src/modules/payments/features/get-all/route.ts
Normal 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;
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/backend/src/modules/payments/index.ts
Normal file
1
apps/backend/src/modules/payments/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as paymentsRoutes } from './routes';
|
||||||
8
apps/backend/src/modules/payments/routes.ts
Normal file
8
apps/backend/src/modules/payments/routes.ts
Normal 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;
|
||||||
18
apps/backend/src/modules/students/features/get-all/route.ts
Normal file
18
apps/backend/src/modules/students/features/get-all/route.ts
Normal 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;
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/backend/src/modules/students/index.ts
Normal file
1
apps/backend/src/modules/students/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as studentsRoutes } from './routes';
|
||||||
8
apps/backend/src/modules/students/routes.ts
Normal file
8
apps/backend/src/modules/students/routes.ts
Normal 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;
|
||||||
18
apps/backend/src/modules/waitlist/features/get-all/route.ts
Normal file
18
apps/backend/src/modules/waitlist/features/get-all/route.ts
Normal 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;
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/backend/src/modules/waitlist/index.ts
Normal file
1
apps/backend/src/modules/waitlist/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as waitlistRoutes } from './routes';
|
||||||
8
apps/backend/src/modules/waitlist/routes.ts
Normal file
8
apps/backend/src/modules/waitlist/routes.ts
Normal 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;
|
||||||
25
apps/backend/src/server.ts
Normal file
25
apps/backend/src/server.ts
Normal 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();
|
||||||
|
});
|
||||||
191
apps/backend/test/groups.test.ts
Normal file
191
apps/backend/test/groups.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
apps/backend/test/health-check.test.ts
Normal file
45
apps/backend/test/health-check.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
75
apps/backend/test/payments.test.ts
Normal file
75
apps/backend/test/payments.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
apps/backend/test/session-auth.test.ts
Normal file
82
apps/backend/test/session-auth.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
83
apps/backend/test/students.test.ts
Normal file
83
apps/backend/test/students.test.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
60
apps/backend/test/waitlist.test.ts
Normal file
60
apps/backend/test/waitlist.test.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/backend/tsconfig.json
Normal file
17
apps/backend/tsconfig.json
Normal 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
1
apps/web/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:4000
|
||||||
@@ -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",
|
||||||
|
|||||||
26
apps/web/src/components/auth/AuthShell.tsx
Normal file
26
apps/web/src/components/auth/AuthShell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
apps/web/src/components/auth/index.ts
Normal file
1
apps/web/src/components/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AuthShell } from './AuthShell'
|
||||||
62
apps/web/src/components/layout/Breadcrumb.tsx
Normal file
62
apps/web/src/components/layout/Breadcrumb.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
apps/web/src/components/layout/Header.tsx
Normal file
20
apps/web/src/components/layout/Header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
174
apps/web/src/components/layout/UserMenu.tsx
Normal file
174
apps/web/src/components/layout/UserMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
24
apps/web/src/components/ui/input.tsx
Normal file
24
apps/web/src/components/ui/input.tsx
Normal 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'
|
||||||
13
apps/web/src/components/ui/label.tsx
Normal file
13
apps/web/src/components/ui/label.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
apps/web/src/context/AuthProvider.tsx
Normal file
36
apps/web/src/context/AuthProvider.tsx
Normal 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
|
||||||
|
}
|
||||||
19
apps/web/src/hooks/useIsMobile.ts
Normal file
19
apps/web/src/hooks/useIsMobile.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
19
apps/web/src/lib/auth-client.ts
Normal file
19
apps/web/src/lib/auth-client.ts
Normal 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
|
||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
@@ -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 })
|
||||||
@@ -46,4 +103,4 @@ declare module '@tanstack/react-router' {
|
|||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router
|
router: typeof router
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
113
apps/web/src/routes/auth/login.tsx
Normal file
113
apps/web/src/routes/auth/login.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
apps/web/src/routes/auth/signup.tsx
Normal file
142
apps/web/src/routes/auth/signup.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
apps/web/src/routes/auth/verify-email.tsx
Normal file
91
apps/web/src/routes/auth/verify-email.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
apps/web/src/routes/organizations.tsx
Normal file
191
apps/web/src/routes/organizations.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
apps/web/src/routes/profile.tsx
Normal file
27
apps/web/src/routes/profile.tsx
Normal 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
Reference in New Issue
Block a user