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,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;