feat: basic onboarding flow\

This commit is contained in:
Jose Selesan
2026-09-15 15:41:45 -03:00
parent 874b6e0cff
commit a92e9e77c3
37 changed files with 1775 additions and 42 deletions

View File

@@ -0,0 +1,20 @@
import type { CreateFirstGroup } from '@gruperly/shared';
import { CreateFirstGroupSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { CreateFirstGroup as CreateFirstGroupUseCase } from './use-case';
const route = new Hono();
route.post('/', validate.json(CreateFirstGroupSchema), async (c) => {
const user = c.get('user');
if (!user) return problemJson(c, unauthorizedProblem(c.req.path));
const data = c.req.valid('json') as CreateFirstGroup;
const useCase = new CreateFirstGroupUseCase();
const result = await useCase.execute(data, user.id);
return resultJson(c, result, { status: 201 });
});
export default route;

View File

@@ -0,0 +1,88 @@
import type { Prisma, PrismaClient } from '@generated/prisma/client';
import { Role } from '@generated/prisma/client';
import type {
CreateFirstGroup as CreateFirstGroupInput,
CreateFirstGroupResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import {
onboardingAlreadyCompletedProblem,
paymentNotSetupProblem,
} from '@/http/problem-builders';
import { problemDetails } from '@/http/problem-details';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { toOnboardingGroupDto } from '../../lib';
type CreateFirstGroupDeps = {
db?: Pick<PrismaClient, 'user' | 'merchantAccount'>;
unitOfWork?: UnitOfWork;
};
export class CreateFirstGroup {
constructor(private readonly deps: CreateFirstGroupDeps = {}) {}
async execute(
data: CreateFirstGroupInput,
userId: string,
): Promise<Result<CreateFirstGroupResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) {
return err(problemDetails({ title: 'Not Found', status: 404, code: 'user_not_found', detail: 'Usuario no encontrado.' }));
}
if (user.onboardingCompleted) {
return err(onboardingAlreadyCompletedProblem());
}
const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } });
if (!merchantAccount) {
return err(paymentNotSetupProblem());
}
const transaction = unitOfWork.executeResult(
async (tx: Prisma.TransactionClient) => {
const created = await tx.group.create({
data: {
name: data.name,
createdById: userId,
days: data.days,
time: data.time,
capacity: data.capacity,
price: data.price,
billingType: data.billingType,
dueDay: data.dueDay,
},
});
await tx.groupMember.create({
data: {
groupId: created.id,
userId,
role: Role.OWNER,
},
});
await tx.user.update({
where: { id: userId },
data: { onboardingCompleted: true },
});
return ok(created);
},
);
const result = await transaction;
if (!result.ok) {
return result;
}
return ok({
group: toOnboardingGroupDto(result.value),
onboardingCompleted: true,
});
}
}

View File

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

View File

@@ -0,0 +1,38 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { ConnectPayment, ConnectPaymentResult, ProblemDetails, Result } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
type PaymentSetupDeps = { db?: PrismaClient };
export class PaymentSetup {
constructor(private readonly deps: PaymentSetupDeps = {}) {}
async execute(
data: ConnectPayment,
userId: string,
): Promise<Result<ConnectPaymentResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
await db.merchantAccount.upsert({
where: { userId },
create: {
userId,
provider: data.provider,
accessToken: data.accessToken,
sandbox: data.sandbox,
},
update: {
provider: data.provider,
accessToken: data.accessToken,
sandbox: data.sandbox,
},
});
return ok({
provider: data.provider,
sandbox: data.sandbox,
connected: true,
});
}
}

View File

@@ -0,0 +1,16 @@
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { GetOnboardingStatus } from './use-case';
const route = new Hono();
route.get('/', async (c) => {
const user = c.get('user');
if (!user) return problemJson(c, unauthorizedProblem(c.req.path));
const useCase = new GetOnboardingStatus();
const result = await useCase.execute(user.id);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,38 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { OnboardingStatusDto, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { problemDetails } from '@/http/problem-details';
import prisma from '@/lib/prisma';
type GetOnboardingStatusDeps = { db?: PrismaClient };
export class GetOnboardingStatus {
constructor(private readonly deps: GetOnboardingStatusDeps = {}) {}
async execute(userId: string): Promise<Result<OnboardingStatusDto, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) {
return err(
problemDetails({
title: 'Not Found',
status: 404,
code: 'user_not_found',
detail: 'Usuario no encontrado.',
}),
);
}
if (user.onboardingCompleted) {
return ok({ step: 'COMPLETED', paymentConnected: true, completed: true });
}
const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } });
if (merchantAccount) {
return ok({ step: 'PAYMENT_CONNECTED', paymentConnected: true, completed: false });
}
return ok({ step: 'NOT_STARTED', paymentConnected: false, completed: false });
}
}

View File

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

View File

@@ -0,0 +1,35 @@
import type { BillingType, OnboardingGroupDto, WeekDay } from '@gruperly/shared';
type PriceLike = { toString(): string };
export type OnboardingGroupRecord = {
id: string;
name: string;
description: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date;
days: WeekDay[] | null;
time: string | null;
capacity: number | null;
price: PriceLike | number | null;
billingType: BillingType | null;
dueDay: number | null;
};
export function toOnboardingGroupDto(record: OnboardingGroupRecord): OnboardingGroupDto {
return {
id: record.id,
name: record.name,
description: record.description,
createdById: record.createdById,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
days: record.days,
time: record.time,
capacity: record.capacity,
price: record.price == null ? null : Number(record.price.toString()),
billingType: record.billingType,
dueDay: record.dueDay,
};
}

View File

@@ -0,0 +1 @@
export { type OnboardingGroupRecord, toOnboardingGroupDto } from './helpers';

View File

@@ -0,0 +1,12 @@
import { Hono } from 'hono';
import firstGroupRoute from './features/first-group/route';
import paymentSetupRoute from './features/payment-setup/route';
import statusRoute from './features/status/route';
const routes = new Hono();
routes.route('/status', statusRoute);
routes.route('/payment-setup', paymentSetupRoute);
routes.route('/first-group', firstGroupRoute);
export default routes;