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:
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;
|
||||
Reference in New Issue
Block a user