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
13 changes: 12 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,15 @@ skills-lock.json
.turbo
.turbo/*
.agent
.agent/*
.agent/*
full_build.log
backend_build.log
backend_build2.log
backend_build3.log
web_build.log
mobile_build.log
lint.log
.log
.log*
/infra/terraform/.turbo
/infra/terraform/.terraform
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ pnpm dev:mobile
### Web
- Next.js dashboard and marketing pages
- Theme is controlled via `data-theme` and local storage
- Auth tokens for the web app are stored only in httpOnly cookies managed by `apps/web/app/api/auth/*`, `apps/web/src/lib/backend.ts`, and `apps/web/middleware.ts`
- `localStorage` is not part of the web auth flow
- Build: `pnpm --filter web build`

### Mobile
Expand Down
83 changes: 81 additions & 2 deletions apps/backend/src/activity/activity.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,86 @@
import { ActivityService } from './activity.service';

describe('ActivityService', () => {
it('should be defined', () => {
expect(ActivityService).toBeDefined();
it('maps actor and group names for group activity', async () => {
const prisma = {
activity: {
findMany: jest.fn().mockResolvedValue([
{
id: 'activity-1',
groupId: 'group-1',
actorUserId: 'user-1',
type: 'expense_created',
entityId: 'expense-1',
metadata: { totalAmountCents: '1234', currency: 'USD' },
createdAt: new Date('2026-04-07T00:00:00.000Z'),
actor: { name: 'Ava' },
group: { name: 'Trip Fund' },
},
]),
},
user: {
findMany: jest.fn().mockResolvedValue([
{ id: 'user-1', name: 'Ava' },
]),
},
};

const service = new ActivityService(prisma as any);
const result = await service.getGroupActivity('group-1');

expect(prisma.activity.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { groupId: 'group-1' },
include: {
actor: { select: { name: true } },
group: { select: { name: true } },
},
}),
);
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: { id: { in: ['user-1'] } },
select: { id: true, name: true },
});
expect(result.items).toEqual([
expect.objectContaining({
actorUserId: 'user-1',
actorName: 'Ava',
groupName: 'Trip Fund',
}),
]);
});

it('keeps responses backward compatible when names are missing', async () => {
const prisma = {
activity: {
findMany: jest.fn().mockResolvedValue([
{
id: 'activity-2',
groupId: 'group-2',
actorUserId: 'user-2',
type: 'member_joined',
entityId: 'member-1',
metadata: {},
createdAt: new Date('2026-04-07T00:00:00.000Z'),
actor: null,
group: null,
},
]),
},
user: {
findMany: jest.fn().mockResolvedValue([]),
},
};

const service = new ActivityService(prisma as any);
const result = await service.getUserActivity('user-2');

expect(result.items[0]).toEqual(
expect.objectContaining({
actorUserId: 'user-2',
actorName: undefined,
groupName: undefined,
}),
);
});
});
92 changes: 72 additions & 20 deletions apps/backend/src/activity/activity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,66 @@ import { PrismaService } from '../common/prisma.service';
export class ActivityService {
constructor(private readonly prisma: PrismaService) {}

private enrichMetadata(metadata: Record<string, unknown>, userNamesById: Record<string, string>): Record<string, unknown> {
const payerId = typeof metadata.payerId === 'string' ? metadata.payerId : null;
const receiverId = typeof metadata.receiverId === 'string' ? metadata.receiverId : null;

return {
...metadata,
...(payerId && userNamesById[payerId] ? { payerName: userNamesById[payerId] } : {}),
...(receiverId && userNamesById[receiverId] ? { receiverName: userNamesById[receiverId] } : {}),
};
}

private async mapActivities(events: Array<{
id: string;
groupId: string;
actorUserId: string;
type: ActivityType;
entityId: string;
metadata: Prisma.JsonValue;
createdAt: Date;
actor?: { name: string | null } | null;
group?: { name: string | null } | null;
}>): Promise<ActivityDto[]> {
const userIds = new Set<string>();

events.forEach((event) => {
userIds.add(event.actorUserId);
const metadata = event.metadata as Record<string, unknown>;
if (typeof metadata.payerId === 'string') {
userIds.add(metadata.payerId);
}
if (typeof metadata.receiverId === 'string') {
userIds.add(metadata.receiverId);
}
});

const users = userIds.size
? await this.prisma.user.findMany({
where: { id: { in: [...userIds] } },
select: { id: true, name: true },
})
: [];
const userNamesById = Object.fromEntries(users.map((user) => [user.id, user.name]));

return events.map((event) => {
const metadata = event.metadata as Record<string, unknown>;

return {
id: event.id,
groupId: event.groupId,
actorUserId: event.actorUserId,
actorName: userNamesById[event.actorUserId] ?? event.actor?.name ?? undefined,
groupName: event.group?.name ?? undefined,
type: event.type,
entityId: event.entityId,
metadata: this.enrichMetadata(metadata, userNamesById),
createdAt: event.createdAt.toISOString(),
};
});
}

async log(params: {
groupId: string;
actorUserId: string;
Expand All @@ -18,7 +78,7 @@ export class ActivityService {
data: {
groupId: params.groupId,
actorUserId: params.actorUserId,
type: params.type,
type: params.type as any,
entityId: params.entityId,
metadata: (params.metadata ?? {}) as Prisma.InputJsonValue,
},
Expand All @@ -38,17 +98,13 @@ export class ActivityService {
orderBy: { createdAt: 'desc' },
skip: safeCursor,
take: safeLimit,
include: {
actor: { select: { name: true } },
group: { select: { name: true } },
},
});

const items = events.map((event) => ({
id: event.id,
groupId: event.groupId,
actorUserId: event.actorUserId,
type: event.type,
entityId: event.entityId,
metadata: event.metadata as Record<string, unknown>,
createdAt: event.createdAt.toISOString(),
}));
const items = await this.mapActivities(events);

return {
items,
Expand All @@ -75,21 +131,17 @@ export class ActivityService {
orderBy: { createdAt: 'desc' },
skip: safeCursor,
take: safeLimit,
include: {
actor: { select: { name: true } },
group: { select: { name: true } },
},
});

const items = events.map((event) => ({
id: event.id,
groupId: event.groupId,
actorUserId: event.actorUserId,
type: event.type,
entityId: event.entityId,
metadata: event.metadata as Record<string, unknown>,
createdAt: event.createdAt.toISOString(),
}));
const items = await this.mapActivities(events);

return {
items,
nextCursor: events.length === safeLimit ? safeCursor + safeLimit : null,
};
}
}
}
12 changes: 4 additions & 8 deletions apps/backend/src/common/utils/money.util.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CurrencyCode, formatCurrencyFromCents } from '@fairshare/shared-types';

export const sumMoney = (values: Array<bigint | number | string>): bigint =>
values.reduce<bigint>((acc, value) => acc + BigInt(value), 0n);

Expand All @@ -7,11 +9,5 @@ export const assertMoneyEquality = (left: bigint, right: bigint, message = 'Mone
}
};

export const formatMoney = (amountCents: bigint | number | string, currency = 'USD'): string => {
const value = Number(BigInt(amountCents)) / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(value);
};
export const formatMoney = (amountCents: bigint | number | string, currency: CurrencyCode = 'USD'): string =>
formatCurrencyFromCents(amountCents, currency);
4 changes: 4 additions & 0 deletions apps/backend/src/groups/groups.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export class GroupsService {
currency: group.currency as 'USD' | 'EUR' | 'INR',
createdBy: group.createdBy,
createdAt: group.createdAt.toISOString(),
shareEnabled: group.shareEnabled,
shareToken: group.shareToken,
defaultSplitPreference: this.parseDefaultSplitPreference(
group.defaultSplitType,
group.defaultSplitConfig,
Expand Down Expand Up @@ -500,6 +502,8 @@ export class GroupsService {
currency: group.currency as 'USD' | 'EUR' | 'INR',
createdBy: group.createdBy,
createdAt: group.createdAt.toISOString(),
shareEnabled: group.shareEnabled,
shareToken: group.shareToken,
defaultSplitPreference: this.parseDefaultSplitPreference(
group.defaultSplitType,
group.defaultSplitConfig,
Expand Down
23 changes: 22 additions & 1 deletion apps/backend/src/receipts/receipts.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { PresignedReceiptUrlResponseDto } from '@fairshare/shared-types';
import { PrismaService } from '../common/prisma.service';
Expand All @@ -13,6 +13,27 @@ const RECEIPT_MIME_TYPES: Record<ReceiptExtension, string> = {
webp: 'image/webp',
};

@Injectable()
export class ReceiptsService {
constructor(
private readonly prisma: PrismaService,
private readonly jobsQueue: JobsQueueService,
private readonly s3: S3Service,
) {}

async createUploadUrl(
expenseId: string,
dto: CreateReceiptUrlDto,
): Promise<PresignedReceiptUrlResponseDto> {
const expense = await this.prisma.expense.findUnique({
where: { id: expenseId },
select: { groupId: true },
});

if (!expense) {
throw new NotFoundException('Expense not found');
}

const extension = (dto.extension ?? 'jpg').toLowerCase() as ReceiptExtension;
if (!RECEIPT_MIME_TYPES[extension]) {
throw new BadRequestException(`Unsupported file extension: ${extension}`);
Expand Down
4 changes: 0 additions & 4 deletions apps/mobile/.turbo/turbo-build.log

This file was deleted.

4 changes: 0 additions & 4 deletions apps/mobile/.turbo/turbo-lint.log

This file was deleted.

Loading
Loading