feat: add complex user invitation and management features

- Implemented complex member and invitation schemas using Zod for validation.
- Created new API endpoints for inviting, accepting, revoking, and canceling complex user invitations.
- Developed handlers for managing complex users, including listing, inviting, revoking, and canceling invitations.
- Added frontend components for displaying and managing complex users and invitations.
- Introduced session storage management for pending invitations.
- Integrated Gravatar for user avatars based on email.
- Created database migration for complex invitations table.
This commit is contained in:
Jose Selesan
2026-04-22 15:30:04 -03:00
parent 5f2a7fd494
commit 6ad4fc9ee9
37 changed files with 1935 additions and 78 deletions

View File

@@ -47,6 +47,11 @@ export type Complex = Prisma.ComplexModel
*
*/
export type ComplexUser = Prisma.ComplexUserModel
/**
* Model ComplexInvitation
*
*/
export type ComplexInvitation = Prisma.ComplexInvitationModel
/**
* Model Sport
*

View File

@@ -71,6 +71,11 @@ export type Complex = Prisma.ComplexModel
*
*/
export type ComplexUser = Prisma.ComplexUserModel
/**
* Model ComplexInvitation
*
*/
export type ComplexInvitation = Prisma.ComplexInvitationModel
/**
* Model Sport
*

File diff suppressed because one or more lines are too long

View File

@@ -390,6 +390,7 @@ export const ModelName = {
Verification: 'Verification',
Complex: 'Complex',
ComplexUser: 'ComplexUser',
ComplexInvitation: 'ComplexInvitation',
Sport: 'Sport',
Court: 'Court',
CourtAvailability: 'CourtAvailability',
@@ -413,7 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "onboardingRequest" | "passwordResetRequest" | "plan"
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "onboardingRequest" | "passwordResetRequest" | "plan"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -861,6 +862,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
ComplexInvitation: {
payload: Prisma.$ComplexInvitationPayload<ExtArgs>
fields: Prisma.ComplexInvitationFieldRefs
operations: {
findUnique: {
args: Prisma.ComplexInvitationFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ComplexInvitationFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload>
}
findFirst: {
args: Prisma.ComplexInvitationFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload> | null
}
findFirstOrThrow: {
args: Prisma.ComplexInvitationFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload>
}
findMany: {
args: Prisma.ComplexInvitationFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload>[]
}
create: {
args: Prisma.ComplexInvitationCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload>
}
createMany: {
args: Prisma.ComplexInvitationCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ComplexInvitationCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload>[]
}
delete: {
args: Prisma.ComplexInvitationDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload>
}
update: {
args: Prisma.ComplexInvitationUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload>
}
deleteMany: {
args: Prisma.ComplexInvitationDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ComplexInvitationUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ComplexInvitationUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload>[]
}
upsert: {
args: Prisma.ComplexInvitationUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ComplexInvitationPayload>
}
aggregate: {
args: Prisma.ComplexInvitationAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateComplexInvitation>
}
groupBy: {
args: Prisma.ComplexInvitationGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ComplexInvitationGroupByOutputType>[]
}
count: {
args: Prisma.ComplexInvitationCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ComplexInvitationCountAggregateOutputType> | number
}
}
}
Sport: {
payload: Prisma.$SportPayload<ExtArgs>
fields: Prisma.SportFieldRefs
@@ -1578,6 +1653,21 @@ export const ComplexUserScalarFieldEnum = {
export type ComplexUserScalarFieldEnum = (typeof ComplexUserScalarFieldEnum)[keyof typeof ComplexUserScalarFieldEnum]
export const ComplexInvitationScalarFieldEnum = {
id: 'id',
complexId: 'complexId',
email: 'email',
tokenHash: 'tokenHash',
expiresAt: 'expiresAt',
acceptedAt: 'acceptedAt',
revokedAt: 'revokedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ComplexInvitationScalarFieldEnum = (typeof ComplexInvitationScalarFieldEnum)[keyof typeof ComplexInvitationScalarFieldEnum]
export const SportScalarFieldEnum = {
id: 'id',
name: 'name',
@@ -1972,6 +2062,7 @@ export type GlobalOmitConfig = {
verification?: Prisma.VerificationOmit
complex?: Prisma.ComplexOmit
complexUser?: Prisma.ComplexUserOmit
complexInvitation?: Prisma.ComplexInvitationOmit
sport?: Prisma.SportOmit
court?: Prisma.CourtOmit
courtAvailability?: Prisma.CourtAvailabilityOmit

View File

@@ -57,6 +57,7 @@ export const ModelName = {
Verification: 'Verification',
Complex: 'Complex',
ComplexUser: 'ComplexUser',
ComplexInvitation: 'ComplexInvitation',
Sport: 'Sport',
Court: 'Court',
CourtAvailability: 'CourtAvailability',
@@ -169,6 +170,21 @@ export const ComplexUserScalarFieldEnum = {
export type ComplexUserScalarFieldEnum = (typeof ComplexUserScalarFieldEnum)[keyof typeof ComplexUserScalarFieldEnum]
export const ComplexInvitationScalarFieldEnum = {
id: 'id',
complexId: 'complexId',
email: 'email',
tokenHash: 'tokenHash',
expiresAt: 'expiresAt',
acceptedAt: 'acceptedAt',
revokedAt: 'revokedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ComplexInvitationScalarFieldEnum = (typeof ComplexInvitationScalarFieldEnum)[keyof typeof ComplexInvitationScalarFieldEnum]
export const SportScalarFieldEnum = {
id: 'id',
name: 'name',

View File

@@ -14,6 +14,7 @@ export type * from './models/Account'
export type * from './models/Verification'
export type * from './models/Complex'
export type * from './models/ComplexUser'
export type * from './models/ComplexInvitation'
export type * from './models/Sport'
export type * from './models/Court'
export type * from './models/CourtAvailability'

View File

@@ -232,6 +232,7 @@ export type ComplexWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"Complex"> | Date | string
plan?: Prisma.XOR<Prisma.PlanNullableScalarRelationFilter, Prisma.PlanWhereInput> | null
users?: Prisma.ComplexUserListRelationFilter
invitations?: Prisma.ComplexInvitationListRelationFilter
courts?: Prisma.CourtListRelationFilter
}
@@ -249,6 +250,7 @@ export type ComplexOrderByWithRelationInput = {
updatedAt?: Prisma.SortOrder
plan?: Prisma.PlanOrderByWithRelationInput
users?: Prisma.ComplexUserOrderByRelationAggregateInput
invitations?: Prisma.ComplexInvitationOrderByRelationAggregateInput
courts?: Prisma.CourtOrderByRelationAggregateInput
}
@@ -269,6 +271,7 @@ export type ComplexWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"Complex"> | Date | string
plan?: Prisma.XOR<Prisma.PlanNullableScalarRelationFilter, Prisma.PlanWhereInput> | null
users?: Prisma.ComplexUserListRelationFilter
invitations?: Prisma.ComplexInvitationListRelationFilter
courts?: Prisma.CourtListRelationFilter
}, "id" | "complexSlug">
@@ -319,6 +322,7 @@ export type ComplexCreateInput = {
updatedAt?: Date | string
plan?: Prisma.PlanCreateNestedOneWithoutComplexesInput
users?: Prisma.ComplexUserCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
}
@@ -335,6 +339,7 @@ export type ComplexUncheckedCreateInput = {
createdAt?: Date | string
updatedAt?: Date | string
users?: Prisma.ComplexUserUncheckedCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
}
@@ -351,6 +356,7 @@ export type ComplexUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
plan?: Prisma.PlanUpdateOneWithoutComplexesNestedInput
users?: Prisma.ComplexUserUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
}
@@ -367,6 +373,7 @@ export type ComplexUncheckedUpdateInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
users?: Prisma.ComplexUserUncheckedUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
}
@@ -482,6 +489,20 @@ export type ComplexUpdateOneRequiredWithoutUsersNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.ComplexUpdateToOneWithWhereWithoutUsersInput, Prisma.ComplexUpdateWithoutUsersInput>, Prisma.ComplexUncheckedUpdateWithoutUsersInput>
}
export type ComplexCreateNestedOneWithoutInvitationsInput = {
create?: Prisma.XOR<Prisma.ComplexCreateWithoutInvitationsInput, Prisma.ComplexUncheckedCreateWithoutInvitationsInput>
connectOrCreate?: Prisma.ComplexCreateOrConnectWithoutInvitationsInput
connect?: Prisma.ComplexWhereUniqueInput
}
export type ComplexUpdateOneRequiredWithoutInvitationsNestedInput = {
create?: Prisma.XOR<Prisma.ComplexCreateWithoutInvitationsInput, Prisma.ComplexUncheckedCreateWithoutInvitationsInput>
connectOrCreate?: Prisma.ComplexCreateOrConnectWithoutInvitationsInput
upsert?: Prisma.ComplexUpsertWithoutInvitationsInput
connect?: Prisma.ComplexWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ComplexUpdateToOneWithWhereWithoutInvitationsInput, Prisma.ComplexUpdateWithoutInvitationsInput>, Prisma.ComplexUncheckedUpdateWithoutInvitationsInput>
}
export type ComplexCreateNestedOneWithoutCourtsInput = {
create?: Prisma.XOR<Prisma.ComplexCreateWithoutCourtsInput, Prisma.ComplexUncheckedCreateWithoutCourtsInput>
connectOrCreate?: Prisma.ComplexCreateOrConnectWithoutCourtsInput
@@ -550,6 +571,7 @@ export type ComplexCreateWithoutUsersInput = {
createdAt?: Date | string
updatedAt?: Date | string
plan?: Prisma.PlanCreateNestedOneWithoutComplexesInput
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
}
@@ -565,6 +587,7 @@ export type ComplexUncheckedCreateWithoutUsersInput = {
planCode?: string | null
createdAt?: Date | string
updatedAt?: Date | string
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
}
@@ -596,6 +619,7 @@ export type ComplexUpdateWithoutUsersInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
plan?: Prisma.PlanUpdateOneWithoutComplexesNestedInput
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
}
@@ -611,6 +635,87 @@ export type ComplexUncheckedUpdateWithoutUsersInput = {
planCode?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
}
export type ComplexCreateWithoutInvitationsInput = {
id: string
complexName: string
physicalAddress?: string | null
city?: string | null
state?: string | null
country?: string | null
complexSlug: string
adminEmail: string
createdAt?: Date | string
updatedAt?: Date | string
plan?: Prisma.PlanCreateNestedOneWithoutComplexesInput
users?: Prisma.ComplexUserCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
}
export type ComplexUncheckedCreateWithoutInvitationsInput = {
id: string
complexName: string
physicalAddress?: string | null
city?: string | null
state?: string | null
country?: string | null
complexSlug: string
adminEmail: string
planCode?: string | null
createdAt?: Date | string
updatedAt?: Date | string
users?: Prisma.ComplexUserUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
}
export type ComplexCreateOrConnectWithoutInvitationsInput = {
where: Prisma.ComplexWhereUniqueInput
create: Prisma.XOR<Prisma.ComplexCreateWithoutInvitationsInput, Prisma.ComplexUncheckedCreateWithoutInvitationsInput>
}
export type ComplexUpsertWithoutInvitationsInput = {
update: Prisma.XOR<Prisma.ComplexUpdateWithoutInvitationsInput, Prisma.ComplexUncheckedUpdateWithoutInvitationsInput>
create: Prisma.XOR<Prisma.ComplexCreateWithoutInvitationsInput, Prisma.ComplexUncheckedCreateWithoutInvitationsInput>
where?: Prisma.ComplexWhereInput
}
export type ComplexUpdateToOneWithWhereWithoutInvitationsInput = {
where?: Prisma.ComplexWhereInput
data: Prisma.XOR<Prisma.ComplexUpdateWithoutInvitationsInput, Prisma.ComplexUncheckedUpdateWithoutInvitationsInput>
}
export type ComplexUpdateWithoutInvitationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
complexName?: Prisma.StringFieldUpdateOperationsInput | string
physicalAddress?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
state?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
country?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
complexSlug?: Prisma.StringFieldUpdateOperationsInput | string
adminEmail?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
plan?: Prisma.PlanUpdateOneWithoutComplexesNestedInput
users?: Prisma.ComplexUserUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateWithoutInvitationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
complexName?: Prisma.StringFieldUpdateOperationsInput | string
physicalAddress?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
state?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
country?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
complexSlug?: Prisma.StringFieldUpdateOperationsInput | string
adminEmail?: Prisma.StringFieldUpdateOperationsInput | string
planCode?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
users?: Prisma.ComplexUserUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
}
@@ -627,6 +732,7 @@ export type ComplexCreateWithoutCourtsInput = {
updatedAt?: Date | string
plan?: Prisma.PlanCreateNestedOneWithoutComplexesInput
users?: Prisma.ComplexUserCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
}
export type ComplexUncheckedCreateWithoutCourtsInput = {
@@ -642,6 +748,7 @@ export type ComplexUncheckedCreateWithoutCourtsInput = {
createdAt?: Date | string
updatedAt?: Date | string
users?: Prisma.ComplexUserUncheckedCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
}
export type ComplexCreateOrConnectWithoutCourtsInput = {
@@ -673,6 +780,7 @@ export type ComplexUpdateWithoutCourtsInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
plan?: Prisma.PlanUpdateOneWithoutComplexesNestedInput
users?: Prisma.ComplexUserUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
}
export type ComplexUncheckedUpdateWithoutCourtsInput = {
@@ -688,6 +796,7 @@ export type ComplexUncheckedUpdateWithoutCourtsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
users?: Prisma.ComplexUserUncheckedUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
}
export type ComplexCreateWithoutPlanInput = {
@@ -702,6 +811,7 @@ export type ComplexCreateWithoutPlanInput = {
createdAt?: Date | string
updatedAt?: Date | string
users?: Prisma.ComplexUserCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtCreateNestedManyWithoutComplexInput
}
@@ -717,6 +827,7 @@ export type ComplexUncheckedCreateWithoutPlanInput = {
createdAt?: Date | string
updatedAt?: Date | string
users?: Prisma.ComplexUserUncheckedCreateNestedManyWithoutComplexInput
invitations?: Prisma.ComplexInvitationUncheckedCreateNestedManyWithoutComplexInput
courts?: Prisma.CourtUncheckedCreateNestedManyWithoutComplexInput
}
@@ -788,6 +899,7 @@ export type ComplexUpdateWithoutPlanInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
users?: Prisma.ComplexUserUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUpdateManyWithoutComplexNestedInput
}
@@ -803,6 +915,7 @@ export type ComplexUncheckedUpdateWithoutPlanInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
users?: Prisma.ComplexUserUncheckedUpdateManyWithoutComplexNestedInput
invitations?: Prisma.ComplexInvitationUncheckedUpdateManyWithoutComplexNestedInput
courts?: Prisma.CourtUncheckedUpdateManyWithoutComplexNestedInput
}
@@ -826,11 +939,13 @@ export type ComplexUncheckedUpdateManyWithoutPlanInput = {
export type ComplexCountOutputType = {
users: number
invitations: number
courts: number
}
export type ComplexCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
users?: boolean | ComplexCountOutputTypeCountUsersArgs
invitations?: boolean | ComplexCountOutputTypeCountInvitationsArgs
courts?: boolean | ComplexCountOutputTypeCountCourtsArgs
}
@@ -851,6 +966,13 @@ export type ComplexCountOutputTypeCountUsersArgs<ExtArgs extends runtime.Types.E
where?: Prisma.ComplexUserWhereInput
}
/**
* ComplexCountOutputType without action
*/
export type ComplexCountOutputTypeCountInvitationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.ComplexInvitationWhereInput
}
/**
* ComplexCountOutputType without action
*/
@@ -873,6 +995,7 @@ export type ComplexSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
updatedAt?: boolean
plan?: boolean | Prisma.Complex$planArgs<ExtArgs>
users?: boolean | Prisma.Complex$usersArgs<ExtArgs>
invitations?: boolean | Prisma.Complex$invitationsArgs<ExtArgs>
courts?: boolean | Prisma.Complex$courtsArgs<ExtArgs>
_count?: boolean | Prisma.ComplexCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["complex"]>
@@ -925,6 +1048,7 @@ export type ComplexOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs =
export type ComplexInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
plan?: boolean | Prisma.Complex$planArgs<ExtArgs>
users?: boolean | Prisma.Complex$usersArgs<ExtArgs>
invitations?: boolean | Prisma.Complex$invitationsArgs<ExtArgs>
courts?: boolean | Prisma.Complex$courtsArgs<ExtArgs>
_count?: boolean | Prisma.ComplexCountOutputTypeDefaultArgs<ExtArgs>
}
@@ -940,6 +1064,7 @@ export type $ComplexPayload<ExtArgs extends runtime.Types.Extensions.InternalArg
objects: {
plan: Prisma.$PlanPayload<ExtArgs> | null
users: Prisma.$ComplexUserPayload<ExtArgs>[]
invitations: Prisma.$ComplexInvitationPayload<ExtArgs>[]
courts: Prisma.$CourtPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
@@ -1350,6 +1475,7 @@ export interface Prisma__ComplexClient<T, Null = never, ExtArgs extends runtime.
readonly [Symbol.toStringTag]: "PrismaPromise"
plan<T extends Prisma.Complex$planArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Complex$planArgs<ExtArgs>>): Prisma.Prisma__PlanClient<runtime.Types.Result.GetResult<Prisma.$PlanPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
users<T extends Prisma.Complex$usersArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Complex$usersArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ComplexUserPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
invitations<T extends Prisma.Complex$invitationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Complex$invitationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ComplexInvitationPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
courts<T extends Prisma.Complex$courtsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Complex$courtsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
@@ -1834,6 +1960,30 @@ export type Complex$usersArgs<ExtArgs extends runtime.Types.Extensions.InternalA
distinct?: Prisma.ComplexUserScalarFieldEnum | Prisma.ComplexUserScalarFieldEnum[]
}
/**
* Complex.invitations
*/
export type Complex$invitationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the ComplexInvitation
*/
select?: Prisma.ComplexInvitationSelect<ExtArgs> | null
/**
* Omit specific fields from the ComplexInvitation
*/
omit?: Prisma.ComplexInvitationOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ComplexInvitationInclude<ExtArgs> | null
where?: Prisma.ComplexInvitationWhereInput
orderBy?: Prisma.ComplexInvitationOrderByWithRelationInput | Prisma.ComplexInvitationOrderByWithRelationInput[]
cursor?: Prisma.ComplexInvitationWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.ComplexInvitationScalarFieldEnum | Prisma.ComplexInvitationScalarFieldEnum[]
}
/**
* Complex.courts
*/

View File

@@ -1,37 +1,34 @@
import { dash } from '@better-auth/infra';
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { openAPI } from 'better-auth/plugins';
import { db } from './prisma';
import { dash } from "@better-auth/infra";
export const auth = betterAuth({
database: prismaAdapter(db, {
provider: 'postgresql',
}),
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL,
session: {
cookieCache: {
enabled: false,
},
database: prismaAdapter(db, {
provider: 'postgresql',
}),
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL,
session: {
cookieCache: {
enabled: false,
},
emailAndPassword: {
enabled: true,
},
emailAndPassword: {
enabled: true,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
trustedOrigins: [
process.env.APP_BASE_URL ?? 'http://localhost:5173',
process.env.BETTER_AUTH_URL ?? 'http://localhost:3000',
],
plugins: [
openAPI(),
dash()
],
},
trustedOrigins: [
process.env.APP_BASE_URL ?? 'http://localhost:5173',
process.env.BETTER_AUTH_URL ?? 'http://localhost:3000',
],
plugins: [openAPI(), dash()],
});
export type Session = typeof auth.$Infer.Session.session;

View File

@@ -0,0 +1,17 @@
import { createHash } from 'node:crypto';
function getGravatarUrl(email: string): string {
const hash = createHash('md5').update(email.trim().toLowerCase()).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?d=identicon`;
}
export function resolveAvatarUrl(input: {
email: string;
image?: string | null;
}): string {
if (input.image) {
return input.image;
}
return getGravatarUrl(input.email);
}

View File

@@ -1,14 +1,25 @@
import { requireAuth } from '@/middlewares/require-auth.middleware';
import { acceptComplexInvitationsHandler } from '@/modules/complex/handlers/accept-complex-invitations.handler';
import { cancelComplexInvitationHandler } from '@/modules/complex/handlers/cancel-complex-invitation.handler';
import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler';
import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler';
import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler';
import { getCurrentComplexHandler } from '@/modules/complex/handlers/get-current-complex.handler';
import { inviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
import { listComplexUsersHandler } from '@/modules/complex/handlers/list-complex-users.handler';
import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler';
import { resendComplexInvitationHandler } from '@/modules/complex/handlers/resend-complex-invitation.handler';
import { revokeComplexUserHandler } from '@/modules/complex/handlers/revoke-complex-user.handler';
import { selectComplexHandler } from '@/modules/complex/handlers/select-complex.handler';
import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler';
import type { AppEnv } from '@/types/hono';
import { zValidator } from '@hono/zod-validator';
import { createComplexSchema, updateComplexSchema } from '@repo/api-contract';
import {
acceptComplexInvitationsSchema,
createComplexSchema,
inviteComplexUserSchema,
updateComplexSchema,
} from '@repo/api-contract';
import { Hono } from 'hono';
import { z } from 'zod';
@@ -21,6 +32,11 @@ complexRoutes.use('*', requireAuth);
complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler);
complexRoutes.get('/me', getCurrentComplexHandler);
complexRoutes.post('/select', selectComplexHandler);
complexRoutes.post(
'/accept-pending',
zValidator('json', acceptComplexInvitationsSchema),
acceptComplexInvitationsHandler
);
complexRoutes.get('/mine', listMyComplexesHandler);
complexRoutes.get(
'/slug/:slug',
@@ -28,6 +44,33 @@ complexRoutes.get(
getComplexBySlugHandler
);
complexRoutes.get(
'/:id/users',
zValidator('param', complexIdParamsSchema),
listComplexUsersHandler
);
complexRoutes.post(
'/:id/users',
zValidator('param', complexIdParamsSchema),
zValidator('json', inviteComplexUserSchema),
inviteComplexUserHandler
);
complexRoutes.post(
'/:id/invitations/:invitationId/resend',
zValidator('param', z.object({ id: z.uuid(), invitationId: z.uuid() })),
resendComplexInvitationHandler
);
complexRoutes.delete(
'/:id/invitations/:invitationId',
zValidator('param', z.object({ id: z.uuid(), invitationId: z.uuid() })),
cancelComplexInvitationHandler
);
complexRoutes.delete(
'/:id/users/:userId',
zValidator('param', z.object({ id: z.uuid(), userId: z.string().min(1) })),
revokeComplexUserHandler
);
complexRoutes.get('/:id', zValidator('param', complexIdParamsSchema), getComplexByIdHandler);
complexRoutes.patch(
'/:id',

View File

@@ -0,0 +1,27 @@
import {
ComplexMembersError,
acceptComplexInvitations,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import type { AcceptComplexInvitationsInput } from '@repo/api-contract';
export async function acceptComplexInvitationsHandler(c: AppContext) {
const user = c.get('user');
const payload = c.req.valid('json' as never) as AcceptComplexInvitationsInput;
try {
const result = await acceptComplexInvitations(user.id, user.email, payload);
return c.json(result);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json(
{ message: error.message },
{
status: error.status,
}
);
}
throw error;
}
}

View File

@@ -0,0 +1,27 @@
import {
ComplexMembersError,
cancelComplexInvitation,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
const cancelInvitationParamsSchema = z.object({
id: z.uuid(),
invitationId: z.uuid(),
});
export async function cancelComplexInvitationHandler(c: AppContext) {
const user = c.get('user');
const params = cancelInvitationParamsSchema.parse(c.req.param());
try {
const result = await cancelComplexInvitation(user.id, params.id, params.invitationId);
return c.json(result);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json({ message: error.message }, { status: error.status });
}
throw error;
}
}

View File

@@ -0,0 +1,33 @@
import {
ComplexMembersError,
inviteComplexUser,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import type { InviteComplexUserInput } from '@repo/api-contract';
import { z } from 'zod';
const complexParamsSchema = z.object({
id: z.uuid(),
});
export async function inviteComplexUserHandler(c: AppContext) {
const user = c.get('user');
const params = complexParamsSchema.parse(c.req.param());
const payload = c.req.valid('json' as never) as InviteComplexUserInput;
try {
const result = await inviteComplexUser(user.id, params.id, payload);
return c.json(result, 201);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json(
{ message: error.message },
{
status: error.status,
}
);
}
throw error;
}
}

View File

@@ -0,0 +1,14 @@
import { getComplexUsersOverview } from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
const complexParamsSchema = z.object({
id: z.uuid(),
});
export async function listComplexUsersHandler(c: AppContext) {
const user = c.get('user');
const params = complexParamsSchema.parse(c.req.param());
const result = await getComplexUsersOverview(user.id, params.id);
return c.json(result);
}

View File

@@ -0,0 +1,27 @@
import {
ComplexMembersError,
resendComplexInvitation,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
const resendInvitationParamsSchema = z.object({
id: z.uuid(),
invitationId: z.uuid(),
});
export async function resendComplexInvitationHandler(c: AppContext) {
const user = c.get('user');
const params = resendInvitationParamsSchema.parse(c.req.param());
try {
const result = await resendComplexInvitation(user.id, params.id, params.invitationId);
return c.json(result);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json({ message: error.message }, { status: error.status });
}
throw error;
}
}

View File

@@ -0,0 +1,32 @@
import {
ComplexMembersError,
revokeComplexUser,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
const revokeParamsSchema = z.object({
id: z.uuid(),
userId: z.string().min(1),
});
export async function revokeComplexUserHandler(c: AppContext) {
const user = c.get('user');
const params = revokeParamsSchema.parse(c.req.param());
try {
const result = await revokeComplexUser(user.id, params.id, { userId: params.userId });
return c.json(result);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json(
{ message: error.message },
{
status: error.status,
}
);
}
throw error;
}
}

View File

@@ -0,0 +1,525 @@
import { createHash, randomBytes } from 'node:crypto';
import { resolveAvatarUrl } from '@/lib/avatar';
import { sendMail } from '@/lib/mailer';
import { db } from '@/lib/prisma';
import type {
AcceptComplexInvitationsInput,
AcceptComplexInvitationsResponse,
CancelComplexInvitationResponse,
ComplexInvitation,
ComplexInvitationStatus,
ComplexUsersOverview,
InviteComplexUserInput,
InviteComplexUserResponse,
RevokeComplexUserInput,
} from '@repo/api-contract';
import { v7 as uuidv7 } from 'uuid';
const INVITATION_TTL_DAYS = Number(Bun.env.COMPLEX_INVITATION_TTL_DAYS ?? 7);
const APP_BASE_URL = Bun.env.APP_BASE_URL ?? 'http://localhost:5173';
export class ComplexMembersError extends Error {
status: 400 | 403 | 404 | 409;
constructor(message: string, status: 400 | 403 | 404 | 409 = 400) {
super(message);
this.name = 'ComplexMembersError';
this.status = status;
}
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function createInvitationToken(): string {
return randomBytes(32).toString('hex');
}
function nowPlusDays(days: number): Date {
const date = new Date();
date.setDate(date.getDate() + days);
return date;
}
function getInvitationStatus(invitation: {
expiresAt: Date;
acceptedAt: Date | null;
revokedAt: Date | null;
}): ComplexInvitationStatus {
if (invitation.acceptedAt) return 'ACCEPTED';
if (invitation.revokedAt) return 'REVOKED';
if (invitation.expiresAt < new Date()) return 'EXPIRED';
return 'PENDING';
}
function formatInvitation(invitation: {
id: string;
complexId: string;
email: string;
expiresAt: Date;
acceptedAt: Date | null;
revokedAt: Date | null;
createdAt: Date;
}): ComplexInvitation {
return {
id: invitation.id,
complexId: invitation.complexId,
email: invitation.email,
status: getInvitationStatus(invitation),
createdAt: invitation.createdAt.toISOString(),
expiresAt: invitation.expiresAt.toISOString(),
acceptedAt: invitation.acceptedAt?.toISOString() ?? null,
revokedAt: invitation.revokedAt?.toISOString() ?? null,
};
}
async function ensureComplexAdmin(userId: string, complexId: string) {
const membership = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId,
},
},
select: {
role: true,
},
});
if (!membership) {
throw new ComplexMembersError('No tenés acceso a este complejo.', 403);
}
if (membership.role !== 'ADMIN') {
throw new ComplexMembersError('Solo un ADMIN puede administrar usuarios.', 403);
}
}
async function ensureComplexMember(userId: string, complexId: string) {
const membership = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId,
},
},
});
if (!membership) {
throw new ComplexMembersError('No tenés acceso a este complejo.', 403);
}
return membership;
}
async function sendInvitationEmail(input: {
complexName: string;
inviteeEmail: string;
token: string;
}) {
const inviteUrl = new URL('/invite', APP_BASE_URL);
inviteUrl.searchParams.set('inviteToken', input.token);
inviteUrl.searchParams.set('email', input.inviteeEmail);
await sendMail({
to: input.inviteeEmail,
subject: `Te invitaron a ${input.complexName}`,
text: `Te invitaron a sumarte a ${input.complexName}. Abrí este enlace para crear tu cuenta o iniciar sesión: ${inviteUrl.toString()}`,
html: `
<h2>Te invitaron a <strong>${input.complexName}</strong></h2>
<p>Hacé click en el siguiente enlace para crear tu cuenta o iniciar sesión y aceptar la invitación:</p>
<p><a href="${inviteUrl.toString()}">${inviteUrl.toString()}</a></p>
`,
});
}
async function refreshPendingInvitation(invitationId: string) {
const token = createInvitationToken();
const tokenHash = hashToken(token);
const expiresAt = nowPlusDays(INVITATION_TTL_DAYS);
const invitation = await db.complexInvitation.update({
where: {
id: invitationId,
},
data: {
tokenHash,
expiresAt,
acceptedAt: null,
revokedAt: null,
},
include: {
complex: {
select: {
complexName: true,
},
},
},
});
return {
invitation,
token,
};
}
export async function listComplexUsers(complexId: string) {
const [members, invitations] = await Promise.all([
db.complexUser.findMany({
where: { complexId },
include: {
user: true,
},
orderBy: {
createdAt: 'asc',
},
}),
db.complexInvitation.findMany({
where: {
complexId,
acceptedAt: null,
revokedAt: null,
expiresAt: {
gte: new Date(),
},
},
orderBy: {
createdAt: 'desc',
},
}),
]);
const membersWithAvatars = members.map((member) => ({
userId: member.userId,
fullName: member.user.name,
email: member.user.email,
avatarUrl: resolveAvatarUrl({
email: member.user.email,
image: member.user.image,
}),
role: member.role,
createdAt: member.createdAt.toISOString(),
}));
return {
members: membersWithAvatars,
invitations: invitations.map(formatInvitation),
} satisfies ComplexUsersOverview;
}
export async function inviteComplexUser(
userId: string,
complexId: string,
input: InviteComplexUserInput
): Promise<InviteComplexUserResponse> {
await ensureComplexAdmin(userId, complexId);
const email = normalizeEmail(input.email);
const complex = await db.complex.findUnique({
where: { id: complexId },
select: {
complexName: true,
},
});
if (!complex) {
throw new ComplexMembersError('El complejo no existe.', 404);
}
const existingUser = await db.user.findUnique({
where: { email },
select: { id: true },
});
if (existingUser) {
const existingMembership = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId: existingUser.id,
},
},
select: { userId: true },
});
if (existingMembership) {
throw new ComplexMembersError('Ese usuario ya pertenece al complejo.', 409);
}
}
const token = createInvitationToken();
const invitation = await db.$transaction(async (tx) => {
const pendingInvitation = await tx.complexInvitation.findFirst({
where: {
complexId,
email,
acceptedAt: null,
revokedAt: null,
expiresAt: {
gte: new Date(),
},
},
orderBy: {
createdAt: 'desc',
},
});
if (pendingInvitation) {
const refreshed = await tx.complexInvitation.update({
where: {
id: pendingInvitation.id,
},
data: {
tokenHash: hashToken(token),
expiresAt: nowPlusDays(INVITATION_TTL_DAYS),
acceptedAt: null,
revokedAt: null,
},
});
return refreshed;
}
return tx.complexInvitation.create({
data: {
id: uuidv7(),
complexId,
email,
tokenHash: hashToken(token),
expiresAt: nowPlusDays(INVITATION_TTL_DAYS),
},
});
});
await sendInvitationEmail({
complexName: complex.complexName,
inviteeEmail: email,
token,
});
return {
invitation: formatInvitation(invitation),
};
}
export async function resendComplexInvitation(
userId: string,
complexId: string,
invitationId: string
): Promise<InviteComplexUserResponse> {
await ensureComplexAdmin(userId, complexId);
const pendingInvitation = await db.complexInvitation.findUnique({
where: {
id: invitationId,
},
include: {
complex: {
select: {
complexName: true,
},
},
},
});
if (!pendingInvitation || pendingInvitation.complexId !== complexId) {
throw new ComplexMembersError('La invitación no existe.', 404);
}
if (pendingInvitation.acceptedAt) {
throw new ComplexMembersError('La invitación ya fue aceptada.', 409);
}
if (pendingInvitation.revokedAt) {
throw new ComplexMembersError('La invitación fue cancelada.', 409);
}
const refreshed = await refreshPendingInvitation(invitationId);
await sendInvitationEmail({
complexName: refreshed.invitation.complex.complexName,
inviteeEmail: pendingInvitation.email,
token: refreshed.token,
});
return {
invitation: formatInvitation({
id: refreshed.invitation.id,
complexId: refreshed.invitation.complexId,
email: refreshed.invitation.email,
expiresAt: refreshed.invitation.expiresAt,
acceptedAt: refreshed.invitation.acceptedAt,
revokedAt: refreshed.invitation.revokedAt,
createdAt: refreshed.invitation.createdAt,
}),
};
}
export async function acceptComplexInvitations(
userId: string,
email: string,
input: AcceptComplexInvitationsInput = {}
): Promise<AcceptComplexInvitationsResponse> {
const normalizedEmail = normalizeEmail(email);
const tokenHash = input.inviteToken ? hashToken(input.inviteToken) : null;
const invitations = await db.complexInvitation.findMany({
where: {
email: normalizedEmail,
acceptedAt: null,
revokedAt: null,
expiresAt: {
gte: new Date(),
},
...(tokenHash
? {
tokenHash,
}
: {}),
},
orderBy: {
createdAt: 'asc',
},
});
if (tokenHash && invitations.length === 0) {
throw new ComplexMembersError('La invitación es inválida o ya venció.', 404);
}
let acceptedCount = 0;
for (const invitation of invitations) {
await db.$transaction(async (tx) => {
const existingMembership = await tx.complexUser.findUnique({
where: {
complexId_userId: {
complexId: invitation.complexId,
userId,
},
},
select: {
userId: true,
},
});
if (!existingMembership) {
await tx.complexUser.create({
data: {
complexId: invitation.complexId,
userId,
role: 'EMPLOYEE',
},
});
}
await tx.complexInvitation.update({
where: {
id: invitation.id,
},
data: {
acceptedAt: new Date(),
},
});
});
acceptedCount += 1;
}
return {
acceptedCount,
};
}
export async function revokeComplexUser(
userId: string,
complexId: string,
input: RevokeComplexUserInput
) {
await ensureComplexAdmin(userId, complexId);
const membership = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId: input.userId,
},
},
select: {
role: true,
},
});
if (!membership) {
throw new ComplexMembersError('Ese usuario no pertenece al complejo.', 404);
}
if (membership.role !== 'EMPLOYEE') {
throw new ComplexMembersError('Solo podés revocar usuarios con rol EMPLOYEE.', 409);
}
await db.complexUser.delete({
where: {
complexId_userId: {
complexId,
userId: input.userId,
},
},
});
return {
ok: true,
};
}
export async function cancelComplexInvitation(
userId: string,
complexId: string,
invitationId: string
): Promise<CancelComplexInvitationResponse> {
await ensureComplexAdmin(userId, complexId);
const invitation = await db.complexInvitation.findUnique({
where: {
id: invitationId,
},
select: {
complexId: true,
acceptedAt: true,
revokedAt: true,
},
});
if (!invitation || invitation.complexId !== complexId) {
throw new ComplexMembersError('La invitación no existe.', 404);
}
if (invitation.acceptedAt) {
throw new ComplexMembersError('La invitación ya fue aceptada.', 409);
}
if (invitation.revokedAt) {
return { ok: true };
}
await db.complexInvitation.update({
where: {
id: invitationId,
},
data: {
revokedAt: new Date(),
},
});
return { ok: true };
}
export async function getComplexUsersOverview(userId: string, complexId: string) {
await ensureComplexMember(userId, complexId);
return listComplexUsers(complexId);
}

View File

@@ -65,7 +65,7 @@ async function buildUniqueSlug(source: string, excludeComplexId?: string): Promi
}
export async function createComplex(input: CreateComplexInput) {
const { userId, ...rest } = input;
const { userId } = input;
return db.$transaction(async (tx) => {
const complexSlug = await buildUniqueSlug(input.complexName);

View File

@@ -1,14 +1,24 @@
import { createHash } from 'node:crypto';
import type { User } from '@/lib/auth';
import { resolveAvatarUrl } from '@/lib/avatar';
import { db } from '@/lib/prisma';
import type { UserProfile } from '@repo/api-contract';
function getGravatarUrl(email: string): string {
const hash = createHash('md5').update(email.trim().toLowerCase()).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?d=identicon`;
}
export async function getUserProfile(user: User, complexId?: string): Promise<UserProfile> {
const dbUser = await db.user.findUnique({
where: { id: user.id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
},
});
if (!dbUser) {
throw new Error('User not found.');
}
let role = 'member';
if (complexId) {
@@ -27,11 +37,14 @@ export async function getUserProfile(user: User, complexId?: string): Promise<Us
}
return {
id: user.id,
fullName: user.name,
email: user.email,
id: dbUser.id,
fullName: dbUser.name,
email: dbUser.email,
role,
avatarUrl: user.image || getGravatarUrl(user.email),
createdAt: user.createdAt.toISOString(),
avatarUrl: resolveAvatarUrl({
email: dbUser.email,
image: dbUser.image,
}),
createdAt: dbUser.createdAt.toISOString(),
};
}