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

@@ -6,17 +6,19 @@ model User {
email String @unique
emailVerified Boolean @default(false)
image String?
onboardingCompleted Boolean @default(false)
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[]
accounts Account[]
sessions Session[]
passkeys Passkey[]
groupsMember GroupMember[]
groupsOwner Group[] @relation("OwnerGroups")
orgMemberships Member[]
orgInvitations Invitation[] @relation("InvitedBy")
waitlist WaitlistEntry[]
merchantAccount MerchantAccount?
@@map("users")
}

View File

@@ -1,12 +1,18 @@
// Domain models
model Group {
id String @id @default(cuid())
id String @id @default(cuid())
name String
description String?
days WeekDay[]
time String?
capacity Int?
price Decimal? @db.Decimal(10, 2)
billingType BillingType?
dueDay Int?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
members GroupMember[]
@@ -16,6 +22,21 @@ model Group {
@@map("groups")
}
enum WeekDay {
MONDAY
TUESDAY
WEDNESDAY
THURSDAY
FRIDAY
SATURDAY
SUNDAY
}
enum BillingType {
MONTHLY
PER_CLASS
}
model GroupMember {
id String @id @default(cuid())
groupId String
@@ -103,4 +124,24 @@ enum WaitlistStatus {
INVITED
JOINED
DECLINED
}
enum PaymentProvider {
MERCADO_PAGO
STRIPE
}
model MerchantAccount {
id String @id @default(cuid())
userId String @unique
provider PaymentProvider
accessToken String?
sandbox Boolean @default(true)
connectedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("merchant_accounts")
}

View File

@@ -13,6 +13,7 @@ import { logger } from '@/logger';
import { authRoutes } from './modules/auth';
import { groupsRoutes } from './modules/groups';
import { healthCheckRoutes } from './modules/health-check';
import { onboardingRoutes } from './modules/onboarding';
import { paymentsRoutes } from './modules/payments';
import { studentsRoutes } from './modules/students';
import { waitlistRoutes } from './modules/waitlist';
@@ -33,6 +34,7 @@ api.use('*', sessionAuthMiddleware);
api.route('/auth', authRoutes);
api.route('/health', healthCheckRoutes);
api.route('/groups', groupsRoutes);
api.route('/onboarding', onboardingRoutes);
api.route('/students', studentsRoutes);
api.route('/payments', paymentsRoutes);
api.route('/waitlist', waitlistRoutes);

View File

@@ -86,4 +86,18 @@ export function noGroupAccessProblem(): ProblemDetails {
export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails {
return notFoundProblem(`${resourceName} ${id}`);
}
export function paymentNotSetupProblem(): ProblemDetails {
return conflictProblem({
detail: 'Vinculá una cuenta de cobro antes de crear tu primer grupo.',
code: 'payment_not_setup',
});
}
export function onboardingAlreadyCompletedProblem(): ProblemDetails {
return conflictProblem({
detail: 'Ya completaste el onboarding de Gruperly.',
code: 'onboarding_already_completed',
});
}

View File

@@ -12,22 +12,13 @@ import {
organizationNotFoundProblem,
} from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { type GroupDb, toGroupDto } from '../../lib';
import { type GroupDb, type GroupRecord, 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 = {}) {}

View File

@@ -8,21 +8,17 @@ import type {
import { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma';
import { buildGroupWhereForUser, type GroupDb, toGroupDto } from '../../lib';
import {
buildGroupWhereForUser,
type GroupDb,
type GroupRecord,
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 = {}) {}

View File

@@ -1,15 +1,23 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { GroupDto } from '@gruperly/shared';
import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared';
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember' | 'organization'>;
type GroupRecord = {
type PriceLike = { toString(): string };
export type GroupRecord = {
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 toGroupDto(record: GroupRecord): GroupDto {
@@ -20,6 +28,12 @@ export function toGroupDto(record: GroupRecord): GroupDto {
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,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;

View File

@@ -36,6 +36,12 @@ const group = {
createdById: userId,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
days: null,
time: null,
capacity: null,
price: null,
billingType: null,
dueDay: null,
};
function makeApp(userValue: unknown) {

View File

@@ -0,0 +1,281 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
user: {
findUnique: mock(),
update: mock(),
},
merchantAccount: {
findUnique: mock(),
upsert: mock(),
},
group: {
create: mock(),
},
groupMember: {
create: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
},
}));
import prisma from '@/lib/prisma';
import { onboardingRoutes } from '@/modules/onboarding';
const userId = 'user-1';
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/onboarding', onboardingRoutes);
return app;
}
const group = {
id: 'group-1',
name: 'Clase de Yoga',
description: null,
createdById: userId,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
time: '09:00',
capacity: 20,
price: 150.5,
billingType: 'MONTHLY',
dueDay: 5,
};
const firstGroupPayload = {
name: group.name,
days: group.days,
time: group.time,
capacity: group.capacity,
price: group.price,
billingType: group.billingType,
dueDay: group.dueDay,
};
describe('onboarding routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /onboarding/status', () => {
it('returns NOT_STARTED for a user that has not started', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/onboarding/status');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
step: 'NOT_STARTED',
paymentConnected: false,
completed: false,
});
});
it('returns PAYMENT_CONNECTED when the merchant account exists', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId });
const res = await makeApp({ id: userId }).request('/onboarding/status');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
step: 'PAYMENT_CONNECTED',
paymentConnected: true,
completed: false,
});
});
it('returns COMPLETED when onboarding is already done', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true });
const res = await makeApp({ id: userId }).request('/onboarding/status');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
step: 'COMPLETED',
paymentConnected: true,
completed: true,
});
});
it('rejects without a session', async () => {
const res = await makeApp(null).request('/onboarding/status');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
});
describe('POST /onboarding/payment-setup', () => {
it('connects a merchant account in sandbox mode', async () => {
prisma.merchantAccount.upsert.mockResolvedValue({ id: 'ma-1', userId });
const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', {
method: 'POST',
body: JSON.stringify({ provider: 'MERCADO_PAGO', sandbox: true }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
provider: 'MERCADO_PAGO',
sandbox: true,
connected: true,
});
expect(prisma.merchantAccount.upsert).toHaveBeenCalledWith({
where: { userId },
create: { userId, provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true },
update: { provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true },
});
});
it('rejects an unknown provider', async () => {
const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', {
method: 'POST',
body: JSON.stringify({ provider: 'PAYPAL' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(400);
});
it('rejects without a session', async () => {
const res = await makeApp(null).request('/onboarding/payment-setup', {
method: 'POST',
body: JSON.stringify({ provider: 'STRIPE', sandbox: true }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(401);
});
});
describe('POST /onboarding/first-group', () => {
it('creates the first group and completes onboarding', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId });
prisma.group.create.mockResolvedValue(group);
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify(firstGroupPayload),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(201);
expect(await res.json()).toEqual({
group: {
id: 'group-1',
name: 'Clase de Yoga',
description: null,
createdById: userId,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
time: '09:00',
capacity: 20,
price: 150.5,
billingType: 'MONTHLY',
dueDay: 5,
},
onboardingCompleted: true,
});
expect(prisma.group.create).toHaveBeenCalledWith({
data: {
name: 'Clase de Yoga',
createdById: userId,
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
time: '09:00',
capacity: 20,
price: 150.5,
billingType: 'MONTHLY',
dueDay: 5,
},
});
expect(prisma.groupMember.create).toHaveBeenCalledWith({
data: { groupId: 'group-1', userId, role: 'OWNER' },
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: userId },
data: { onboardingCompleted: true },
});
});
it('rejects when the payment has not been set up', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify(firstGroupPayload),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({ code: 'payment_not_setup' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects when onboarding is already completed', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true });
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify(firstGroupPayload),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({ code: 'onboarding_already_completed' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects invalid payloads', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId });
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify({
name: 'Ab',
days: [],
time: '25:99',
capacity: 0,
price: -1,
billingType: 'WEEKLY',
dueDay: 31,
}),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(400);
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects without a session', async () => {
const res = await makeApp(null).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify(firstGroupPayload),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(401);
});
});
});