Skip to content
Merged

Dev #24

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
SUPABASE_DATABASE_URL=
DIRECT_URL=
JWT_SECRET=
JWT_REFRESH_SECRET=
GOOGLE_CLIENT_ID=
Expand Down
39 changes: 37 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,49 @@ jobs:
- name: Generate Prisma client
run: pnpm --filter backend prisma:generate

- name: Build
run: pnpm build

- name: Lint
run: pnpm lint

- name: Test
run: pnpm test

- name: Build
run: pnpm build
- name: Start Database Services
run: docker compose up -d

- name: Migrate Database
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fairshare?schema=public
SUPABASE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fairshare?schema=public
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/fairshare?schema=public
JWT_SECRET: supersecret
STRIPE_SECRET_KEY: test_key
run: pnpm --filter backend exec prisma db push

- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps

- name: Run E2E Tests
env:
CI: true
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fairshare?schema=public
SUPABASE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fairshare?schema=public
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/fairshare?schema=public
JWT_SECRET: supersecret
JWT_REFRESH_SECRET: refreshsecret
GOOGLE_CLIENT_ID: googleid
GOOGLE_CLIENT_SECRET: googlesecret
REDIS_URL: redis://localhost:6380
AWS_REGION: ap-south-1
S3_BUCKET: fairshare-test-bucket
STRIPE_SECRET_KEY: sk_test_key
NEXT_PUBLIC_APP_URL: http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL: http://localhost:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY: dummy-anon-key
NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1
run: pnpm run e2e

- name: Upload coverage
if: always()
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ node_modules
dist
.next
coverage
test-results
.agents
.agent
.turbo
Expand Down
15 changes: 15 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
SUPABASE_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fairshare
DIRECT_URL=postgresql://postgres:postgres@localhost:5432/fairshare
JWT_SECRET=fairshare_dev_jwt_secret_change_me
JWT_REFRESH_SECRET=fairshare_dev_jwt_refresh_secret_change_me
GOOGLE_CLIENT_ID=fairshare_dev_google_client_id
GOOGLE_CLIENT_SECRET=fairshare_dev_google_client_secret
REDIS_URL=redis://127.0.0.1:6380
AWS_ACCESS_KEY_ID=fairshare_dev_aws_access_key
AWS_SECRET_ACCESS_KEY=fairshare_dev_aws_secret
AWS_REGION=ap-south-1
S3_BUCKET=fairshare-dev-receipts
STRIPE_SECRET_KEY=sk_test_fairshare_dummy_key
STRIPE_WEBHOOK_SECRET=whsec_fairshare_dummy_webhook_secret
CORS_ORIGINS=http://localhost:3000,http://localhost:8081,exp://*:*
SENTRY_DSN=
1 change: 1 addition & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ model Group {
shareToken String? @unique
shareEnabled Boolean @default(false)
createdAt DateTime @default(now())
deletedAt DateTime?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Group queries:"
rg -n --type=ts -C2 'prisma\.group\.(findUnique|findFirst|findMany)\(' apps/backend/src

echo
echo "2) Soft-delete filters present:"
rg -n --type=ts -C2 'deletedAt\s*:\s*null' apps/backend/src/groups apps/backend/src/balances

Repository: Arun-kushwaha007/FairShare

Length of output: 3109


🏁 Script executed:

#!/bin/bash

echo "=== Checking groups.service.ts around line 114 ==="
sed -n '110,130p' apps/backend/src/groups/groups.service.ts

echo
echo "=== Checking groups.service.ts around line 380 ==="
sed -n '375,390p' apps/backend/src/groups/groups.service.ts

echo
echo "=== Checking groups.service.ts around line 418 ==="
sed -n '415,435p' apps/backend/src/groups/groups.service.ts

echo
echo "=== Checking balances.service.ts around line 50 ==="
sed -n '45,65p' apps/backend/src/balances/balances.service.ts

Repository: Arun-kushwaha007/FairShare

Length of output: 2835


Enforce soft-delete filters on all user-facing group read operations.

Current group queries bypass soft-delete checks in several critical paths:

  • getGroupById() (line 114): No deletedAt filter; returns deleted groups
  • toggleShare() (line 380): No deletedAt filter on lookup; allows modifications to deleted groups
  • getGroupByShareToken() (line 418): No deletedAt filter; public share links to deleted groups remain accessible
  • Balance export in balances.service.ts (line 50): No deletedAt filter; deleted group data can be exported

All group reads must include where: { ..., deletedAt: null } or equivalent guard to prevent access to deleted groups.

🧰 Tools
🪛 GitHub Actions: CI

[error] Command failed with exit code 1: tsc --noEmit (backend@1.0.0 lint).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/prisma/schema.prisma` at line 70, Add a soft-delete guard to
every group read so deleted groups are never returned or modified: update the
group lookup calls in getGroupById, toggleShare, getGroupByShareToken and the
balance export logic (balances.service.ts) to include a Prisma where clause
filtering on deletedAt: null (or the equivalent query guard used across the
codebase) and ensure any single-group fetches use this same condition before
returning or performing mutations; if a lookup already combines other where
filters, merge deletedAt: null into that object so all reads and any share/token
lookups reject soft-deleted groups.

creator User @relation("group_creator", fields: [createdBy], references: [id])
members GroupMember[]
expenses Expense[]
Expand Down
7 changes: 6 additions & 1 deletion apps/backend/src/groups/groups.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, 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';
Expand Down Expand Up @@ -82,4 +82,9 @@ export class GroupsController {
) {
return this.groupsService.toggleShare(id, user.sub, enabled);
}

@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() user: JwtPayload) {
return this.groupsService.delete(id, user.sub);
}
}
49 changes: 48 additions & 1 deletion apps/backend/src/groups/groups.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export class GroupsService {
userId,
},
},
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
Expand Down Expand Up @@ -235,7 +236,10 @@ export class GroupsService {

async getUserSummary(userId: string): Promise<{ totalBalanceCents: string }> {
const balances = await this.prisma.balance.findMany({
where: { userId },
where: {
userId,
group: { deletedAt: null }
},
select: { amountCents: true },
});

Expand All @@ -254,6 +258,7 @@ export class GroupsService {
userId,
},
},
deletedAt: null,
},
include: {
_count: {
Expand Down Expand Up @@ -670,6 +675,48 @@ export class GroupsService {
};
}

async delete(groupId: string, actorUserId: string): Promise<{ success: true }> {
const group = await this.prisma.group.findUnique({
where: { id: groupId },
select: { id: true, deletedAt: true },
});

if (!group) {
throw new NotFoundException('Group not found');
}

if (group.deletedAt) {
throw new NotFoundException('Group has already been deleted');
}

const membership = await this.prisma.groupMember.findUnique({
where: {
groupId_userId: {
groupId,
userId: actorUserId,
},
},
});

if (!membership || membership.role !== 'OWNER') {
throw new ForbiddenException('Only the group owner can delete the group');
}

await this.prisma.group.update({
where: { id: groupId },
data: {
deletedAt: new Date(),
shareEnabled: false,
shareToken: null
},
});
Comment on lines +678 to +712

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Soft-deleted groups are still accessible through existing member endpoints.

This only hides deleted groups from list/dashboard-style queries. Because assertMembership() and the other groupId lookups do not check deletedAt, a member with an old URL can still fetch or mutate a deleted group after this method runs.

Suggested direction
-  private async assertMembership(groupId: string, userId: string): Promise<void> {
-    const membership = await this.prisma.groupMember.findUnique({
-      where: {
-        groupId_userId: {
-          groupId,
-          userId,
-        },
-      },
-    });
+  private async assertMembership(groupId: string, userId: string): Promise<void> {
+    const membership = await this.prisma.groupMember.findFirst({
+      where: {
+        groupId,
+        userId,
+        group: {
+          deletedAt: null,
+        },
+      },
+    });
 
     if (!membership) {
       throw new ForbiddenException('Actor is not a group member');
     }
   }

The same rule needs to be applied to direct group reads/updates in this file, otherwise deleted groups remain operational through stale clients.

🧰 Tools
🪛 Biome (2.4.10)

[error] 677-677: Expected a semicolon or an implicit semicolon after a statement, but found none

(parse)


[error] 677-677: expected ) but instead found :

(parse)


[error] 677-677: the target for a delete operator cannot be a single identifier

(parse)


[error] 677-677: Expected a semicolon or an implicit semicolon after a statement, but found none

(parse)


[error] 677-677: Expected a statement but instead found '>'.

(parse)

🪛 GitHub Actions: CI

[error] Command failed with exit code 1: tsc --noEmit (backend@1.0.0 lint).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/groups/groups.service.ts` around lines 677 - 698, The
soft-delete only sets deletedAt but other methods (e.g. assertMembership(), any
findUnique/findFirst/getById/group lookup helpers) still return groups with
deletedAt set; update those lookup functions and any direct group reads/updates
in this file to filter out soft-deleted groups by adding a condition like where:
{ id: groupId, deletedAt: null } (or include deletedAt: null in composite where
clauses used by assertMembership()), and make them throw NotFound/Forbidden when
the group is not found; ensure all usages of assertMembership(),
findUnique/findFirst on Group, and any group update/read helpers reference this
deletedAt check so deleted groups are inaccessible via member endpoints.


await this.redis.invalidateGroupCache(groupId);
await this.redis.invalidateUserDashboardCache(actorUserId);
Comment on lines +714 to +715

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Other group members' dashboard caches are not invalidated.

Only the actor's dashboard cache is invalidated. Other group members will continue seeing the deleted group in their cached dashboards until the cache TTL (120s) expires. Consider invalidating dashboard caches for all group members.

🛠️ Suggested approach
+    // Fetch all member IDs before invalidation
+    const members = await this.prisma.groupMember.findMany({
+      where: { groupId },
+      select: { userId: true },
+    });

     await this.redis.invalidateGroupCache(groupId);
-    await this.redis.invalidateUserDashboardCache(actorUserId);
+    // Invalidate dashboard cache for all members
+    await Promise.all(
+      members.map((m) => this.redis.invalidateUserDashboardCache(m.userId))
+    );

     return { success: true };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await this.redis.invalidateGroupCache(groupId);
await this.redis.invalidateUserDashboardCache(actorUserId);
// Fetch all member IDs before invalidation
const members = await this.prisma.groupMember.findMany({
where: { groupId },
select: { userId: true },
});
await this.redis.invalidateGroupCache(groupId);
// Invalidate dashboard cache for all members
await Promise.all(
members.map((m) => this.redis.invalidateUserDashboardCache(m.userId))
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/groups/groups.service.ts` around lines 701 - 702, The code
currently invalidates only the actor's dashboard cache after group changes;
update the logic in GroupsService (around the calls to invalidateGroupCache and
invalidateUserDashboardCache) to also invalidate dashboard caches for all other
group members: fetch the member IDs for the affected group (use the existing
groupId/group membership retrieval method in this service), then call
this.redis.invalidateUserDashboardCache(memberId) for each member (exclude or
include actorUserId as desired), batching or using Redis pipeline if available
to avoid N+1 latency; keep the existing await
this.redis.invalidateGroupCache(groupId) call and ensure errors are
handled/logged.


return { success: true };
}

async resolvePendingInvites(userId: string, email: string): Promise<void> {
const invites = await this.prisma.groupInvite.findMany({
where: { email: email.toLowerCase() },
Expand Down
8 changes: 4 additions & 4 deletions apps/backend/src/receipts/receipts.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('ReceiptsService', () => {
const jobsQueue: any = {
enqueueReceiptProcessing: jest.fn().mockResolvedValue(undefined),
};
const service = new ReceiptsService(prisma, s3, jobsQueue);
const service = new ReceiptsService(prisma, jobsQueue, s3);

await service.createUploadUrl('expense-1', { extension: 'jpg' });

Expand All @@ -42,11 +42,11 @@ describe('ReceiptsService', () => {
const jobsQueue: any = {
enqueueReceiptProcessing: jest.fn().mockResolvedValue(undefined),
};
const service = new ReceiptsService(prisma, s3, jobsQueue);
const service = new ReceiptsService(prisma, jobsQueue, s3);

const result = await service.createUploadUrl('expense-1', { extension: 'png' });

expect(prisma.expense.findUnique).toHaveBeenCalledWith({ where: { id: 'expense-1' } });
expect(prisma.expense.findUnique).toHaveBeenCalledWith({ where: { id: 'expense-1' }, select: { groupId: true } });
expect(prisma.receipt.upsert).toHaveBeenCalledWith({
where: { expenseId: 'expense-1' },
update: {
Expand Down Expand Up @@ -83,7 +83,7 @@ describe('ReceiptsService', () => {
const jobsQueue: any = {
enqueueReceiptProcessing: jest.fn(),
};
const service = new ReceiptsService(prisma, s3, jobsQueue);
const service = new ReceiptsService(prisma, jobsQueue, s3);

await expect(service.createUploadUrl('missing-expense', {})).rejects.toBeInstanceOf(NotFoundException);
expect(s3.getPresignedUploadUrl).not.toHaveBeenCalled();
Expand Down
8 changes: 8 additions & 0 deletions apps/backend/src/redis/redis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export class RedisService {
}
}

async invalidateUserDashboardCache(userId: string): Promise<void> {
try {
await this.redis.del(`user:${userId}:dashboard`);
} catch (error) {
this.logger.warn(`Redis invalidate skipped: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}

private async safeGet(key: string): Promise<string | null> {
try {
return await this.redis.get(key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('Settlement Flow (integration-ish)', () => {
const prisma: any = {
settlement: {
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(null),
},
groupMember: {
findMany: jest.fn().mockResolvedValue([{ userId: 'u1' }, { userId: 'u2' }]),
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/app/screens/LoginScreen.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React from 'react';
import { render } from '@testing-library/react-native';
import { PaperProvider } from 'react-native-paper';
import { LoginScreen } from './LoginScreen';
Expand All @@ -11,6 +11,6 @@ describe('LoginScreen', () => {
</PaperProvider>,
);

expect(getByText('Login')).toBeTruthy();
expect(getByText('Sign In')).toBeTruthy();
});
});
2 changes: 2 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ FAIRSHARE_API_URL=http://localhost:3001/api/v1
NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1
FAIRSHARE_S3_BASE_URL=
NEXT_PUBLIC_S3_BASE_URL=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
1 change: 1 addition & 0 deletions apps/web/app/dashboard/groups/[groupId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export default async function GroupDetailPage({ params }: GroupDetailPageProps)
<Suspense fallback={<div className="h-40 rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-card)] animate-pulse" />}>
<GroupActions
groupId={groupId}
groupName={group.name}
currency={group.currency}
members={members}
shareEnabled={group.shareEnabled}
Expand Down
Loading
Loading