forked from StellarSplit/StellarSplit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinvitations.service.ts
More file actions
364 lines (315 loc) · 10.9 KB
/
Copy pathinvitations.service.ts
File metadata and controls
364 lines (315 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
import {
Injectable,
NotFoundException,
HttpException,
HttpStatus,
BadRequestException,
ConflictException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan, IsNull } from 'typeorm';
import { randomUUID } from 'crypto';
import { Invitation } from './invitation.entity';
import { Participant } from '../entities/participant.entity';
import { Split } from '../entities/split.entity';
import { User } from '../entities/user.entity';
import { CreateInvitationDto } from './dto/create-invitation.dto';
import { JoinInvitationDto } from './dto/join-invitation.dto';
import { UpgradeGuestDto } from './dto/upgrade-guest.dto';
const DEFAULT_EXPIRY_HOURS = 72;
/**
* Custom error codes for invitation-specific errors
*/
export enum InvitationErrorCode {
DUPLICATE_PARTICIPANT = 'DUPLICATE_PARTICIPANT',
INVITE_EXPIRED = 'INVITE_EXPIRED',
INVITE_ALREADY_USED = 'INVITE_ALREADY_USED',
MAX_USES_REACHED = 'MAX_USES_REACHED',
NOT_UPGRADEABLE = 'NOT_UPGRADEABLE',
GUEST_NOT_FOUND = 'GUEST_NOT_FOUND',
USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS',
}
@Injectable()
export class InvitationsService {
private readonly logger = new Logger(InvitationsService.name);
constructor(
@InjectRepository(Invitation)
private readonly invitationRepository: Repository<Invitation>,
@InjectRepository(Participant)
private readonly participantRepository: Repository<Participant>,
@InjectRepository(Split)
private readonly splitRepository: Repository<Split>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async create(dto: CreateInvitationDto): Promise<{
id: string;
token: string;
splitId: string;
expiresAt: Date;
link: string;
maxUses: number;
usesCount: number;
isUpgradeable: boolean;
}> {
const split = await this.splitRepository.findOne({ where: { id: dto.splitId } });
if (!split) {
throw new NotFoundException(`Split ${dto.splitId} not found`);
}
// Check for duplicate invitation if invitee email is provided
if (dto.inviteeEmail) {
const existingInvitation = await this.invitationRepository.findOne({
where: {
splitId: dto.splitId,
inviteeEmail: dto.inviteeEmail,
expiresAt: MoreThan(new Date()),
usedAt: IsNull(),
},
});
if (existingInvitation) {
throw new ConflictException({
message: 'An active invitation already exists for this email',
code: InvitationErrorCode.DUPLICATE_PARTICIPANT,
existingInvitationId: existingInvitation.id,
});
}
}
const expiresInHours = dto.expiresInHours ?? DEFAULT_EXPIRY_HOURS;
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + expiresInHours);
const token = randomUUID();
const invitation = this.invitationRepository.create({
token,
splitId: dto.splitId,
expiresAt,
maxUses: dto.maxUses ?? 1,
usesCount: 0,
isUpgradeable: dto.isUpgradeable ?? true,
inviteeEmail: dto.inviteeEmail,
tokenVersion: 1,
});
const saved = await this.invitationRepository.save(invitation);
const baseUrl = process.env.FRONTEND_URL || process.env.API_URL || 'http://localhost:3000';
const link = `${baseUrl.replace(/\/$/, '')}/invite/join/${token}`;
this.logger.log(`Created invitation ${saved.id} for split ${saved.splitId}`);
return {
id: saved.id,
token: saved.token,
splitId: saved.splitId,
expiresAt: saved.expiresAt,
link,
maxUses: saved.maxUses,
usesCount: saved.usesCount,
isUpgradeable: saved.isUpgradeable,
};
}
/**
* Returns the invitation if valid (not used, not expired, within max uses). Throws 410 Gone otherwise.
*/
async getByToken(token: string): Promise<Invitation> {
const invitation = await this.invitationRepository.findOne({
where: { token },
relations: ['split'],
});
if (!invitation) {
throw new HttpException('Invitation not found or no longer valid', HttpStatus.GONE);
}
// Check if max uses reached
if (invitation.usesCount >= invitation.maxUses) {
throw new HttpException({
message: 'This invitation has reached its maximum number of uses',
code: InvitationErrorCode.MAX_USES_REACHED,
}, HttpStatus.GONE);
}
if (invitation.usedAt && invitation.maxUses === 1) {
throw new HttpException('This invitation has already been used', HttpStatus.GONE);
}
if (new Date() >= invitation.expiresAt) {
throw new HttpException({
message: 'This invitation has expired',
code: InvitationErrorCode.INVITE_EXPIRED,
expiredAt: invitation.expiresAt,
}, HttpStatus.GONE);
}
return invitation;
}
/**
* Join a split using an invite token. Creates a participant and marks the invite as used.
* Returns 410 Gone if the invite is expired or already used.
*/
async joinByToken(token: string, dto: JoinInvitationDto): Promise<{
participant: Participant;
split: Split;
isNewUser: boolean;
}> {
const invitation = await this.invitationRepository.findOne({
where: { token },
relations: ['split'],
});
if (!invitation) {
throw new HttpException('Invitation not found or no longer valid', HttpStatus.GONE);
}
// Check max uses reached
if (invitation.usesCount >= invitation.maxUses) {
throw new HttpException({
message: 'This invitation has reached its maximum number of uses',
code: InvitationErrorCode.MAX_USES_REACHED,
}, HttpStatus.GONE);
}
if (invitation.usedAt && invitation.maxUses === 1) {
throw new HttpException('This invitation has already been used', HttpStatus.GONE);
}
if (new Date() >= invitation.expiresAt) {
throw new HttpException({
message: 'This invitation has expired',
code: InvitationErrorCode.INVITE_EXPIRED,
}, HttpStatus.GONE);
}
const split = await this.splitRepository.findOne({
where: { id: invitation.splitId },
});
if (!split) {
throw new HttpException('Split no longer exists', HttpStatus.GONE);
}
// Check for duplicate participant by email
if (dto.email) {
const existingParticipant = await this.participantRepository.findOne({
where: {
splitId: invitation.splitId,
userId: dto.email,
},
});
if (existingParticipant) {
throw new ConflictException({
message: 'A participant with this email already exists in the split',
code: InvitationErrorCode.DUPLICATE_PARTICIPANT,
participantId: existingParticipant.id,
});
}
}
// Determine if this is a new guest or returning user
const isNewUser = !dto.email;
// Use email if provided, otherwise create a guest identifier
const userId = dto.email ?? `guest-${randomUUID()}`;
// Store the email in invitation if provided, for duplicate detection
if (dto.email && !invitation.inviteeEmail) {
invitation.inviteeEmail = dto.email;
}
const participant = this.participantRepository.create({
splitId: invitation.splitId,
userId,
amountOwed: 0,
amountPaid: 0,
status: 'pending',
walletAddress: undefined,
});
const savedParticipant = await this.participantRepository.save(participant);
// Update invitation usage count
invitation.usesCount += 1;
if (invitation.maxUses === 1 || invitation.usesCount >= invitation.maxUses) {
invitation.usedAt = new Date();
}
await this.invitationRepository.save(invitation);
this.logger.log(`Participant ${savedParticipant.id} joined split ${split.id} via invitation ${invitation.id}`);
return {
participant: savedParticipant,
split: split!,
isNewUser,
};
}
/**
* Upgrade a guest participant to a registered user.
* This is used when a guest who joined via invitation later creates an account.
*/
async upgradeGuest(dto: UpgradeGuestDto): Promise<{
participant: Participant;
user: User;
wasGuest: boolean;
}> {
// Find the participant
const participant = await this.participantRepository.findOne({
where: { id: dto.participantId },
relations: ['split'],
});
if (!participant) {
throw new NotFoundException({
message: 'Participant not found',
code: InvitationErrorCode.GUEST_NOT_FOUND,
});
}
// Check if already a registered user (not a guest)
const wasGuest = participant.userId.startsWith('guest-');
if (!wasGuest) {
throw new BadRequestException({
message: 'This participant is already a registered user',
code: InvitationErrorCode.USER_ALREADY_EXISTS,
});
}
// Check if user with this email already exists
const existingUser = await this.userRepository.findOne({
where: { email: dto.email },
});
if (existingUser) {
// Link the participant to the existing user
participant.userId = existingUser.id;
await this.participantRepository.save(participant);
this.logger.log(`Linked guest participant ${participant.id} to existing user ${existingUser.id}`);
return {
participant,
user: existingUser,
wasGuest: true,
};
}
// Create a new user
const newUser = this.userRepository.create({
email: dto.email,
emailPreferences: {
invitations: true,
reminders: true,
receivedConfirmation: true,
completion: true,
},
});
const savedUser = await this.userRepository.save(newUser);
// Update participant's userId to the new user's ID
participant.userId = savedUser.id;
await this.participantRepository.save(participant);
this.logger.log(`Upgraded guest participant ${participant.id} to registered user ${savedUser.id}`);
return {
participant,
user: savedUser,
wasGuest: true,
};
}
/**
* Get all active (non-expired, non-used) invitations for a split.
*/
async getActiveInvitations(splitId: string): Promise<Invitation[]> {
return this.invitationRepository.find({
where: {
splitId,
expiresAt: MoreThan(new Date()),
usedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
}
/**
* Invalidate an invitation (revoke it).
*/
async invalidate(invitationId: string): Promise<void> {
const invitation = await this.invitationRepository.findOne({
where: { id: invitationId },
});
if (!invitation) {
throw new NotFoundException(`Invitation ${invitationId} not found`);
}
// Increment token version to invalidate the token
invitation.tokenVersion += 1;
invitation.expiresAt = new Date(); // Set to now to expire immediately
await this.invitationRepository.save(invitation);
this.logger.log(`Invalidated invitation ${invitationId}`);
}
}