refactor: migrate backend to pnpm monorepo with Hono module architecture

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

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

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

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

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

View File

@@ -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'),
},
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
import nodemailer, { type Transporter } from 'nodemailer'
type VerificationEmailData = { email: string; url: string; name?: string }
type PasswordResetEmailData = { email: string; url: string; name?: string }
type InvitationEmailData = { email: string; url: string; organizationName: string }
interface EmailProvider {
sendVerificationEmail(data: VerificationEmailData): Promise<void>
sendPasswordResetEmail(data: PasswordResetEmailData): Promise<void>
sendOrganizationInvitation(data: InvitationEmailData): Promise<void>
}
class SMTPEmailProvider implements EmailProvider {
private transporter: Transporter
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.EMAIL_SERVER_HOST,
port: Number(process.env.EMAIL_SERVER_PORT ?? 587),
secure: (process.env.EMAIL_SERVER_PORT ?? '587') === '465',
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
})
}
private from() {
return process.env.EMAIL_FROM ?? 'Gruperly <no-reply@gruperly.com>'
}
private layout(title: string, body: string) {
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: #0a2540;">
<h2 style="margin: 0 0 16px;">Gruperly</h2>
<h3 style="margin: 0 0 8px;">${title}</h3>
<p style="line-height: 1.6; color: #334155;">${body}</p>
</div>
`
}
private async send(to: string, subject: string, html: string) {
// Sin SMTP configurado (dev) no rompe el flujo: solo registra un aviso
if (!process.env.EMAIL_SERVER_HOST) {
console.warn(`[email] SMTP no configurado. Email no enviado a ${to}: "${subject}"`)
return
}
await this.transporter.sendMail({ from: this.from(), to, subject, html })
}
async sendVerificationEmail({ email, url, name }: VerificationEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Verificar email</a>`
await this.send(
email,
'Verifica tu email en Gruperly',
this.layout(
'Confirma tu dirección de email',
`${name ? `Hola ${name}, ` : 'Hola, '}gracias por crear tu cuenta en Gruperly. Para empezar, verifica tu email con el botón de abajo (el enlace vence en 1 hora).<br/><br/>${link}`,
),
)
}
async sendPasswordResetEmail({ email, url, name }: PasswordResetEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Restablecer contraseña</a>`
await this.send(
email,
'Restablece tu contraseña en Gruperly',
this.layout(
'Solicitaste restablecer tu contraseña',
`${name ? `Hola ${name}, ` : 'Hola, '}haz clic en el botón para elegir una nueva contraseña (el enlace vence en 1 hora). Si no fuiste tú, ignora este email.<br/><br/>${link}`,
),
)
}
async sendOrganizationInvitation({ email, url, organizationName }: InvitationEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Unirme a ${organizationName}</a>`
await this.send(
email,
`Te invitaron a ${organizationName} en Gruperly`,
this.layout(
`Te invitaron a ${organizationName}`,
`Acepta la invitación con el botón de abajo para empezar a colaborar en Gruperly.<br/><br/>${link}`,
),
)
}
}
// Futuro: proveedores transaccionales (Resend, SendGrid, Postmark).
// Añadir implementaciones y alternar con EMAIL_PROVIDER.
export function createEmailProvider(): EmailProvider {
return new SMTPEmailProvider()
}
export const emailProvider = createEmailProvider()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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