Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ model Group {
name String
createdBy String
currency String
defaultSplitType String?
defaultSplitConfig String?
createdAt DateTime @default(now())
creator User @relation("group_creator", fields: [createdBy], references: [id])
members GroupMember[]
Expand Down Expand Up @@ -93,6 +95,7 @@ model Expense {
description String
totalAmountCents BigInt
currency String
category String?
createdAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id])
payer User @relation("expense_payer", fields: [payerId], references: [id])
Expand Down Expand Up @@ -235,3 +238,5 @@ model Payment {
@@index([receiverId])
@@map("payments")
}


9 changes: 7 additions & 2 deletions apps/backend/src/expenses/dto/create-expense.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { IsArray, IsIn, IsString, MinLength, ValidateNested } from 'class-validator';
import { IsArray, IsIn, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { CreateExpenseRequestDto, CreateExpenseSplitDto } from '@fairshare/shared-types';
import { CreateExpenseRequestDto, CreateExpenseSplitDto, EXPENSE_CATEGORIES } from '@fairshare/shared-types';
import { sanitizeText } from '../../common/utils/sanitize.util';

export class CreateExpenseSplitInputDto implements CreateExpenseSplitDto {
Expand Down Expand Up @@ -30,6 +30,11 @@ export class CreateExpenseDto implements CreateExpenseRequestDto {
@IsIn(['USD', 'EUR', 'INR'])
currency!: 'USD' | 'EUR' | 'INR';

@IsOptional()
@IsString()
@IsIn([...EXPENSE_CATEGORIES])
category?: (typeof EXPENSE_CATEGORIES)[number];

@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateExpenseSplitInputDto)
Expand Down
9 changes: 7 additions & 2 deletions apps/backend/src/expenses/dto/update-expense.dto.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
import { UpdateExpenseRequestDto } from '@fairshare/shared-types';
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
import { EXPENSE_CATEGORIES, UpdateExpenseRequestDto } from '@fairshare/shared-types';

export class UpdateExpenseDto implements UpdateExpenseRequestDto {
@IsOptional()
@IsString()
@MinLength(2)
description?: string;

@IsOptional()
@IsString()
@IsIn([...EXPENSE_CATEGORIES])
category?: (typeof EXPENSE_CATEGORIES)[number] | null;
}
9 changes: 8 additions & 1 deletion apps/backend/src/expenses/expenses.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class ExpensesService {
description: dto.description,
totalAmountCents: totalAmount,
currency: dto.currency,
category: dto.category ?? null,
},
});

Expand Down Expand Up @@ -105,6 +106,7 @@ export class ExpensesService {
metadata: {
payerId: dto.payerId,
totalAmountCents: totalAmount.toString(),
category: dto.category ?? null,
},
},
});
Expand All @@ -131,6 +133,7 @@ export class ExpensesService {
expenseId: expense.id,
payerId: expense.payerId,
totalAmountCents: expense.totalAmountCents.toString(),
category: expense.category as ExpenseDto['category'],
});
incrementExpenseCreated(groupId);

Expand Down Expand Up @@ -180,6 +183,7 @@ export class ExpensesService {
where: { id },
data: {
description: dto.description,
category: dto.category,
},
include: { splits: true },
});
Expand All @@ -189,7 +193,7 @@ export class ExpensesService {
actorUserId,
type: 'expense_updated',
entityId: expense.id,
metadata: { description: dto.description ?? null },
metadata: { description: dto.description ?? null, category: dto.category ?? null },
});

await this.redis.invalidateGroupCache(expense.groupId);
Expand Down Expand Up @@ -245,6 +249,7 @@ export class ExpensesService {
description: string;
totalAmountCents: bigint;
currency: string;
category: string | null;
createdAt: Date;
splits: Array<{
id: string;
Expand All @@ -260,6 +265,7 @@ export class ExpensesService {
description: expense.description,
totalAmountCents: expense.totalAmountCents.toString(),
currency: expense.currency as 'USD' | 'EUR' | 'INR',
category: expense.category as ExpenseDto['category'],
createdAt: expense.createdAt.toISOString(),
splits: expense.splits.map((split) => ({
id: split.id,
Expand All @@ -270,3 +276,4 @@ export class ExpensesService {
};
}
}

28 changes: 28 additions & 0 deletions apps/backend/src/groups/dto/update-group-default-split.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Type } from 'class-transformer';
import { IsArray, IsIn, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { EXPENSE_SPLIT_TYPES, GroupDefaultSplitDto, UpdateGroupDefaultSplitRequestDto } from '@fairshare/shared-types';

class GroupDefaultSplitPreferenceDto implements GroupDefaultSplitDto {
@IsString()
@IsIn([...EXPENSE_SPLIT_TYPES])
splitType!: (typeof EXPENSE_SPLIT_TYPES)[number];

@IsArray()
@IsString({ each: true })
participantUserIds!: string[];

@IsOptional()
@IsObject()
exactAmountsCentsByUser?: Record<string, string>;

@IsOptional()
@IsObject()
percentagesByUser?: Record<string, string>;
}

export class UpdateGroupDefaultSplitDto implements UpdateGroupDefaultSplitRequestDto {
@IsOptional()
@ValidateNested()
@Type(() => GroupDefaultSplitPreferenceDto)
defaultSplitPreference!: GroupDefaultSplitPreferenceDto | null;
}
10 changes: 8 additions & 2 deletions apps/backend/src/groups/groups.controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { JwtPayload } from '../auth/types/auth.types';
import { GroupsService } from './groups.service';
import { CreateGroupDto } from './dto/create-group.dto';
import { InviteMemberDto } from './dto/invite-member.dto';
import { UpdateGroupDefaultSplitDto } from './dto/update-group-default-split.dto';

@Controller('groups')
@UseGuards(JwtAuthGuard)
Expand Down Expand Up @@ -42,9 +43,14 @@ export class GroupsController {
return this.groupsService.summary(id, user.sub);
}

@Patch(':id/default-split')
updateDefaultSplit(@Param('id') id: string, @CurrentUser() user: JwtPayload, @Body() dto: UpdateGroupDefaultSplitDto) {
return this.groupsService.updateDefaultSplit(id, user.sub, dto);
}

@Post(':id/invite')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
invite(@Param('id') id: string, @CurrentUser() user: JwtPayload, @Body() dto: InviteMemberDto) {
return this.groupsService.invite(id, user.sub, dto);
}
}
}
111 changes: 104 additions & 7 deletions apps/backend/src/groups/groups.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { GroupDto, GroupMemberSummaryDto, GroupSummaryDto } from '@fairshare/shared-types';
import { GroupDefaultSplitDto, GroupDto, GroupMemberSummaryDto, GroupSummaryDto } from '@fairshare/shared-types';
import { PrismaService } from '../common/prisma.service';
import { RedisService } from '../redis/redis.service';
import { NotificationsService } from '../notifications/notifications.service';
import { RealtimeService } from '../realtime/realtime.service';
import { CreateGroupDto } from './dto/create-group.dto';
import { InviteMemberDto } from './dto/invite-member.dto';
import { UpdateGroupDefaultSplitDto } from './dto/update-group-default-split.dto';

@Injectable()
export class GroupsService {
Expand Down Expand Up @@ -53,6 +54,7 @@ export class GroupsService {
currency: group.currency as 'USD' | 'EUR' | 'INR',
createdBy: group.createdBy,
createdAt: group.createdAt.toISOString(),
defaultSplitPreference: null,
};
}

Expand All @@ -74,6 +76,7 @@ export class GroupsService {
currency: group.currency as 'USD' | 'EUR' | 'INR',
createdBy: group.createdBy,
createdAt: group.createdAt.toISOString(),
defaultSplitPreference: this.parseDefaultSplitPreference(group.defaultSplitType, group.defaultSplitConfig),
}));
}

Expand All @@ -100,6 +103,7 @@ export class GroupsService {
currency: group.currency as 'USD' | 'EUR' | 'INR',
createdBy: group.createdBy,
createdAt: group.createdAt.toISOString(),
defaultSplitPreference: this.parseDefaultSplitPreference(group.defaultSplitType, group.defaultSplitConfig),
members: group.members.map((member) => ({
id: member.id,
userId: member.userId,
Expand Down Expand Up @@ -209,13 +213,79 @@ export class GroupsService {
};
}

async updateDefaultSplit(groupId: string, actorUserId: string, dto: UpdateGroupDefaultSplitDto): Promise<GroupDto> {
await this.assertMembership(groupId, actorUserId);

let normalizedPreference: GroupDefaultSplitDto | null = null;
if (dto.defaultSplitPreference) {
const members = await this.prisma.groupMember.findMany({
where: { groupId },
select: { userId: true },
});
const memberIds = new Set(members.map((member) => member.userId));
const participantUserIds = Array.from(new Set(dto.defaultSplitPreference.participantUserIds));

if (participantUserIds.length === 0) {
throw new ForbiddenException('Default split must include at least one participant');
}

const invalidParticipant = participantUserIds.find((userId) => !memberIds.has(userId));
if (invalidParticipant) {
throw new ForbiddenException('Default split participants must belong to the group');
}

normalizedPreference = {
splitType: dto.defaultSplitPreference.splitType,
participantUserIds,
exactAmountsCentsByUser: dto.defaultSplitPreference.exactAmountsCentsByUser,
percentagesByUser: dto.defaultSplitPreference.percentagesByUser,
};
}

await this.prisma.group.update({
where: { id: groupId },
data: {
defaultSplitType: normalizedPreference?.splitType ?? null,
defaultSplitConfig: normalizedPreference
? JSON.stringify({
participantUserIds: normalizedPreference.participantUserIds,
exactAmountsCentsByUser: normalizedPreference.exactAmountsCentsByUser ?? {},
percentagesByUser: normalizedPreference.percentagesByUser ?? {},
})
: null,
},
});

const group = await this.prisma.group.findUniqueOrThrow({
where: { id: groupId },
include: { members: true },
});

await this.redis.invalidateGroupCache(groupId);

return {
id: group.id,
name: group.name,
currency: group.currency as 'USD' | 'EUR' | 'INR',
createdBy: group.createdBy,
createdAt: group.createdAt.toISOString(),
defaultSplitPreference: this.parseDefaultSplitPreference(group.defaultSplitType, group.defaultSplitConfig),
members: group.members.map((member) => ({
id: member.id,
userId: member.userId,
groupId: member.groupId,
role: member.role,
joinedAt: member.joinedAt.toISOString(),
})),
};
}

async invite(groupId: string, actorUserId: string, dto: InviteMemberDto): Promise<{ success: true }> {
await this.assertMembership(groupId, actorUserId);
const email = dto.email.toLowerCase();

const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
// Create or update pending invitation
await this.prisma.groupInvite.upsert({
where: {
groupId_email: {
Expand All @@ -229,7 +299,7 @@ export class GroupsService {
invitedBy: actorUserId,
role: 'MEMBER',
},
update: {}, // Already invited, no-op
update: {},
});
return { success: true };
}
Expand Down Expand Up @@ -298,7 +368,6 @@ export class GroupsService {

await this.prisma.$transaction(async (tx) => {
for (const invite of invites) {
// Create membership
await tx.groupMember.create({
data: {
groupId: invite.groupId,
Expand All @@ -307,7 +376,6 @@ export class GroupsService {
},
});

// Add activity
await tx.activity.create({
data: {
groupId: invite.groupId,
Expand All @@ -318,17 +386,46 @@ export class GroupsService {
},
});

// Delete invite
await tx.groupInvite.delete({
where: { id: invite.id },
});

// Invalidate cache for each group
await this.redis.invalidateGroupCache(invite.groupId);
}
});
}

private parseDefaultSplitPreference(splitType: string | null, splitConfig: string | null): GroupDefaultSplitDto | null {
if (!splitType || !splitConfig) {
return null;
}

try {
const config = JSON.parse(splitConfig) as {
participantUserIds?: unknown;
exactAmountsCentsByUser?: unknown;
percentagesByUser?: unknown;
};

return {
splitType: splitType as GroupDefaultSplitDto['splitType'],
participantUserIds: Array.isArray(config.participantUserIds)
? config.participantUserIds.filter((value): value is string => typeof value === 'string')
: [],
exactAmountsCentsByUser:
config.exactAmountsCentsByUser && typeof config.exactAmountsCentsByUser === 'object' && !Array.isArray(config.exactAmountsCentsByUser)
? (config.exactAmountsCentsByUser as Record<string, string>)
: undefined,
percentagesByUser:
config.percentagesByUser && typeof config.percentagesByUser === 'object' && !Array.isArray(config.percentagesByUser)
? (config.percentagesByUser as Record<string, string>)
: undefined,
};
} catch {
return null;
}
}

private async assertMembership(groupId: string, userId: string): Promise<void> {
const membership = await this.prisma.groupMember.findUnique({
where: {
Expand Down
Loading
Loading