11import { ConflictException , ForbiddenException , Injectable , NotFoundException } from '@nestjs/common' ;
2- import { GroupDto , GroupMemberSummaryDto , GroupSummaryDto } from '@fairshare/shared-types' ;
2+ import { GroupDefaultSplitDto , GroupDto , GroupMemberSummaryDto , GroupSummaryDto } from '@fairshare/shared-types' ;
33import { PrismaService } from '../common/prisma.service' ;
44import { RedisService } from '../redis/redis.service' ;
55import { NotificationsService } from '../notifications/notifications.service' ;
66import { RealtimeService } from '../realtime/realtime.service' ;
77import { CreateGroupDto } from './dto/create-group.dto' ;
88import { InviteMemberDto } from './dto/invite-member.dto' ;
9+ import { UpdateGroupDefaultSplitDto } from './dto/update-group-default-split.dto' ;
910
1011@Injectable ( )
1112export class GroupsService {
@@ -53,6 +54,7 @@ export class GroupsService {
5354 currency : group . currency as 'USD' | 'EUR' | 'INR' ,
5455 createdBy : group . createdBy ,
5556 createdAt : group . createdAt . toISOString ( ) ,
57+ defaultSplitPreference : null ,
5658 } ;
5759 }
5860
@@ -74,6 +76,7 @@ export class GroupsService {
7476 currency : group . currency as 'USD' | 'EUR' | 'INR' ,
7577 createdBy : group . createdBy ,
7678 createdAt : group . createdAt . toISOString ( ) ,
79+ defaultSplitPreference : this . parseDefaultSplitPreference ( group . defaultSplitType , group . defaultSplitConfig ) ,
7780 } ) ) ;
7881 }
7982
@@ -100,6 +103,7 @@ export class GroupsService {
100103 currency : group . currency as 'USD' | 'EUR' | 'INR' ,
101104 createdBy : group . createdBy ,
102105 createdAt : group . createdAt . toISOString ( ) ,
106+ defaultSplitPreference : this . parseDefaultSplitPreference ( group . defaultSplitType , group . defaultSplitConfig ) ,
103107 members : group . members . map ( ( member ) => ( {
104108 id : member . id ,
105109 userId : member . userId ,
@@ -209,13 +213,79 @@ export class GroupsService {
209213 } ;
210214 }
211215
216+ async updateDefaultSplit ( groupId : string , actorUserId : string , dto : UpdateGroupDefaultSplitDto ) : Promise < GroupDto > {
217+ await this . assertMembership ( groupId , actorUserId ) ;
218+
219+ let normalizedPreference : GroupDefaultSplitDto | null = null ;
220+ if ( dto . defaultSplitPreference ) {
221+ const members = await this . prisma . groupMember . findMany ( {
222+ where : { groupId } ,
223+ select : { userId : true } ,
224+ } ) ;
225+ const memberIds = new Set ( members . map ( ( member ) => member . userId ) ) ;
226+ const participantUserIds = Array . from ( new Set ( dto . defaultSplitPreference . participantUserIds ) ) ;
227+
228+ if ( participantUserIds . length === 0 ) {
229+ throw new ForbiddenException ( 'Default split must include at least one participant' ) ;
230+ }
231+
232+ const invalidParticipant = participantUserIds . find ( ( userId ) => ! memberIds . has ( userId ) ) ;
233+ if ( invalidParticipant ) {
234+ throw new ForbiddenException ( 'Default split participants must belong to the group' ) ;
235+ }
236+
237+ normalizedPreference = {
238+ splitType : dto . defaultSplitPreference . splitType ,
239+ participantUserIds,
240+ exactAmountsCentsByUser : dto . defaultSplitPreference . exactAmountsCentsByUser ,
241+ percentagesByUser : dto . defaultSplitPreference . percentagesByUser ,
242+ } ;
243+ }
244+
245+ await this . prisma . group . update ( {
246+ where : { id : groupId } ,
247+ data : {
248+ defaultSplitType : normalizedPreference ?. splitType ?? null ,
249+ defaultSplitConfig : normalizedPreference
250+ ? JSON . stringify ( {
251+ participantUserIds : normalizedPreference . participantUserIds ,
252+ exactAmountsCentsByUser : normalizedPreference . exactAmountsCentsByUser ?? { } ,
253+ percentagesByUser : normalizedPreference . percentagesByUser ?? { } ,
254+ } )
255+ : null ,
256+ } ,
257+ } ) ;
258+
259+ const group = await this . prisma . group . findUniqueOrThrow ( {
260+ where : { id : groupId } ,
261+ include : { members : true } ,
262+ } ) ;
263+
264+ await this . redis . invalidateGroupCache ( groupId ) ;
265+
266+ return {
267+ id : group . id ,
268+ name : group . name ,
269+ currency : group . currency as 'USD' | 'EUR' | 'INR' ,
270+ createdBy : group . createdBy ,
271+ createdAt : group . createdAt . toISOString ( ) ,
272+ defaultSplitPreference : this . parseDefaultSplitPreference ( group . defaultSplitType , group . defaultSplitConfig ) ,
273+ members : group . members . map ( ( member ) => ( {
274+ id : member . id ,
275+ userId : member . userId ,
276+ groupId : member . groupId ,
277+ role : member . role ,
278+ joinedAt : member . joinedAt . toISOString ( ) ,
279+ } ) ) ,
280+ } ;
281+ }
282+
212283 async invite ( groupId : string , actorUserId : string , dto : InviteMemberDto ) : Promise < { success : true } > {
213284 await this . assertMembership ( groupId , actorUserId ) ;
214285 const email = dto . email . toLowerCase ( ) ;
215286
216287 const user = await this . prisma . user . findUnique ( { where : { email } } ) ;
217288 if ( ! user ) {
218- // Create or update pending invitation
219289 await this . prisma . groupInvite . upsert ( {
220290 where : {
221291 groupId_email : {
@@ -229,7 +299,7 @@ export class GroupsService {
229299 invitedBy : actorUserId ,
230300 role : 'MEMBER' ,
231301 } ,
232- update : { } , // Already invited, no-op
302+ update : { } ,
233303 } ) ;
234304 return { success : true } ;
235305 }
@@ -298,7 +368,6 @@ export class GroupsService {
298368
299369 await this . prisma . $transaction ( async ( tx ) => {
300370 for ( const invite of invites ) {
301- // Create membership
302371 await tx . groupMember . create ( {
303372 data : {
304373 groupId : invite . groupId ,
@@ -307,7 +376,6 @@ export class GroupsService {
307376 } ,
308377 } ) ;
309378
310- // Add activity
311379 await tx . activity . create ( {
312380 data : {
313381 groupId : invite . groupId ,
@@ -318,17 +386,46 @@ export class GroupsService {
318386 } ,
319387 } ) ;
320388
321- // Delete invite
322389 await tx . groupInvite . delete ( {
323390 where : { id : invite . id } ,
324391 } ) ;
325392
326- // Invalidate cache for each group
327393 await this . redis . invalidateGroupCache ( invite . groupId ) ;
328394 }
329395 } ) ;
330396 }
331397
398+ private parseDefaultSplitPreference ( splitType : string | null , splitConfig : string | null ) : GroupDefaultSplitDto | null {
399+ if ( ! splitType || ! splitConfig ) {
400+ return null ;
401+ }
402+
403+ try {
404+ const config = JSON . parse ( splitConfig ) as {
405+ participantUserIds ?: unknown ;
406+ exactAmountsCentsByUser ?: unknown ;
407+ percentagesByUser ?: unknown ;
408+ } ;
409+
410+ return {
411+ splitType : splitType as GroupDefaultSplitDto [ 'splitType' ] ,
412+ participantUserIds : Array . isArray ( config . participantUserIds )
413+ ? config . participantUserIds . filter ( ( value ) : value is string => typeof value === 'string' )
414+ : [ ] ,
415+ exactAmountsCentsByUser :
416+ config . exactAmountsCentsByUser && typeof config . exactAmountsCentsByUser === 'object' && ! Array . isArray ( config . exactAmountsCentsByUser )
417+ ? ( config . exactAmountsCentsByUser as Record < string , string > )
418+ : undefined ,
419+ percentagesByUser :
420+ config . percentagesByUser && typeof config . percentagesByUser === 'object' && ! Array . isArray ( config . percentagesByUser )
421+ ? ( config . percentagesByUser as Record < string , string > )
422+ : undefined ,
423+ } ;
424+ } catch {
425+ return null ;
426+ }
427+ }
428+
332429 private async assertMembership ( groupId : string , userId : string ) : Promise < void > {
333430 const membership = await this . prisma . groupMember . findUnique ( {
334431 where : {
0 commit comments