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:
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>"
|
||||
45
apps/backend/package.json
Normal file
45
apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
57
apps/backend/src/http/validate.ts
Normal file
57
apps/backend/src/http/validate.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
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;
|
||||
}
|
||||
72
apps/backend/src/lib/prisma.ts
Normal file
72
apps/backend/src/lib/prisma.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
36
apps/backend/src/server.ts
Normal file
36
apps/backend/src/server.ts
Normal 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();
|
||||
});
|
||||
208
apps/backend/test/groups.test.ts
Normal file
208
apps/backend/test/groups.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
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 { 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
75
apps/backend/test/payments.test.ts
Normal file
75
apps/backend/test/payments.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
apps/backend/test/session-auth.test.ts
Normal file
80
apps/backend/test/session-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
apps/backend/test/students.test.ts
Normal file
83
apps/backend/test/students.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
60
apps/backend/test/waitlist.test.ts
Normal file
60
apps/backend/test/waitlist.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
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": ["node"],
|
||||
"declaration": false,
|
||||
"noEmit": false,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@generated/*": ["./generated/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "generated/prisma/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
15
apps/backend/vitest.config.ts
Normal file
15
apps/backend/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user