refactor: migrate backend to pnpm monorepo with Hono module architecture

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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