feat: basic onboarding flow\
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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 = {}) {}
|
||||
|
||||
|
||||
@@ -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 = {}) {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
16
apps/backend/src/modules/onboarding/features/status/route.ts
Normal file
16
apps/backend/src/modules/onboarding/features/status/route.ts
Normal 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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
1
apps/backend/src/modules/onboarding/index.ts
Normal file
1
apps/backend/src/modules/onboarding/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as onboardingRoutes } from './routes';
|
||||
35
apps/backend/src/modules/onboarding/lib/helpers.ts
Normal file
35
apps/backend/src/modules/onboarding/lib/helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
1
apps/backend/src/modules/onboarding/lib/index.ts
Normal file
1
apps/backend/src/modules/onboarding/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type OnboardingGroupRecord, toOnboardingGroupDto } from './helpers';
|
||||
12
apps/backend/src/modules/onboarding/routes.ts
Normal file
12
apps/backend/src/modules/onboarding/routes.ts
Normal 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;
|
||||
Reference in New Issue
Block a user