feat(groups): implement group creation functionality and refactor related components

- Removed organization dependency from group creation logic.
- Introduced new route and use-case for creating groups.
- Refactored form handling for group creation into a reusable GroupForm component.
- Updated API calls to support new group creation endpoint.
- Adjusted tests to reflect changes in group creation logic and validation.
- Removed obsolete organizations route and related components.
- Updated breadcrumb navigation and routing for group management.
This commit is contained in:
Jose Selesan
2026-09-23 09:19:52 -03:00
parent fc30927b8b
commit 5572ef0869
19 changed files with 456 additions and 644 deletions

View File

@@ -47,23 +47,6 @@ export function forbiddenProblem(params: {
};
}
export function organizationNotFoundProblem(id: string): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/organization-not-found`,
title: 'Not Found',
status: 404,
detail: `Organization ${id} was not found.`,
code: 'organization_not_found',
};
}
export function groupOwnerRequiredProblem(): ProblemDetails {
return forbiddenProblem({
detail: 'Only the organization owner can create the group.',
code: 'group_owner_required',
});
}
export function databaseUnavailableProblem(params?: {
instance?: string;
}): ProblemDetails {

View File

@@ -1,27 +0,0 @@
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

@@ -1,83 +0,0 @@
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, type GroupRecord, toGroupDto } from '../../lib';
type CreateGroupFromOrganizationDeps = {
db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>;
unitOfWork?: UnitOfWork;
};
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,22 @@
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 { CreateGroup as CreateGroupUseCase } 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 CreateGroupUseCase();
const result = await useCase.execute(data, user.id);
return resultJson(c, result, { status: 201 });
});
export default route;

View File

@@ -0,0 +1,68 @@
import type { Prisma, PrismaClient } from '@generated/prisma/client';
import { Role } from '@generated/prisma/client';
import type {
CreateFirstGroup as CreateGroupInput,
CreateGroupResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { paymentNotSetupProblem } from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { type GroupRecord, toGroupDto } from '../../lib';
type CreateGroupDeps = {
db?: Pick<PrismaClient, 'group' | 'groupMember' | 'merchantAccount'>;
unitOfWork?: UnitOfWork;
};
export class CreateGroup {
constructor(private readonly deps: CreateGroupDeps = {}) {}
async execute(
data: CreateGroupInput,
userId: string,
): Promise<Result<CreateGroupResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
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,
},
});
return ok(created);
},
);
const result = await transaction;
if (!result.ok) {
return result;
}
return ok({ group: toGroupDto(result.value as GroupRecord) });
}
}

View File

@@ -1,7 +1,7 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared';
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember' | 'organization'>;
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember'>;
type PriceLike = { toString(): string };

View File

@@ -4,7 +4,7 @@ import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
import createAttendeeRoute from '../attendees/features/create/route';
import removeAttendeeRoute from '../attendees/features/remove/route';
import groupWaitlistRoutes from '../group-waitlist/routes';
import createFromOrganizationRoute from './features/create-from-organization/route';
import createGroupRoute from './features/create/route';
import getAllRoute from './features/get-all/route';
import getByIdRoute from './features/get-by-id/route';
import inviteTokenRoute from './features/invite-token/route';
@@ -13,7 +13,7 @@ import listAttendeesRoute from './features/list-attendees/route';
const routes = new Hono();
routes.route('/', getAllRoute);
routes.route('/from-organization', createFromOrganizationRoute);
routes.route('/', createGroupRoute);
routes.route('/', inviteTokenRoute);
routes.route('/', listAttendeesRoute);
routes.route('/', createAttendeeRoute);