Skip to content

Commit bdd1873

Browse files
authored
Merge pull request StellarSplit#262 from feyishola/feat/summary-activity
Dashboard Summary and Activity Endpoints exposed and history endpoin…
2 parents 30be42d + aca6194 commit bdd1873

9 files changed

Lines changed: 505 additions & 1 deletion

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { ProfileModule } from "./profile/profile.module";
4444
import { InvitationsModule } from "./invitations/invitations.module";
4545
import { CommonModule } from "./common/common.module";
4646
import { DebtSimplificationModule } from "./debt-simplification/debt-simplification.module";
47+
import { DashboardModule } from "./dashboard/dashboard.module";
4748
// Load environment variables
4849
dotenv.config({
4950
path: path.resolve(__dirname, '../.env'),
@@ -126,6 +127,7 @@ dotenv.config({
126127
InvitationsModule,
127128
CommonModule,
128129
DebtSimplificationModule,
130+
DashboardModule,
129131
],
130132
})
131133
export class AppModule { }
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
Controller,
3+
Get,
4+
Query,
5+
Req,
6+
UseGuards,
7+
ParseIntPipe,
8+
DefaultValuePipe,
9+
} from '@nestjs/common';
10+
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
11+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
12+
import { DashboardService } from './dashboard.service';
13+
import { DashboardSummaryDto, DashboardActivityDto } from './dto/dashboard.dto';
14+
15+
@ApiTags('Dashboard')
16+
@ApiBearerAuth()
17+
@UseGuards(JwtAuthGuard)
18+
@Controller('dashboard')
19+
export class DashboardController {
20+
constructor(private readonly dashboardService: DashboardService) {}
21+
22+
@Get('summary')
23+
@ApiOperation({ summary: 'Get dashboard summary cards for the authenticated user' })
24+
@ApiResponse({ status: 200, description: 'Summary stats', type: DashboardSummaryDto })
25+
async getSummary(@Req() req: any): Promise<DashboardSummaryDto> {
26+
return this.dashboardService.getSummary(req.user.id);
27+
}
28+
29+
@Get('activity')
30+
@ApiOperation({ summary: 'Get recent activity feed for the authenticated user' })
31+
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
32+
@ApiQuery({ name: 'limit', required: false, type: Number, example: 20 })
33+
@ApiResponse({ status: 200, description: 'Paginated activity list', type: DashboardActivityDto })
34+
async getActivity(
35+
@Req() req: any,
36+
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
37+
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
38+
): Promise<DashboardActivityDto> {
39+
const safeLimit = Math.min(limit, 100);
40+
return this.dashboardService.getActivity(req.user.id, page, safeLimit);
41+
}
42+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { Participant } from '../entities/participant.entity';
4+
import { Split } from '../entities/split.entity';
5+
import { Activity } from '../entities/activity.entity';
6+
import { DashboardService } from './dashboard.service';
7+
import { DashboardController } from './dashboard.controller';
8+
9+
@Module({
10+
imports: [TypeOrmModule.forFeature([Participant, Split, Activity])],
11+
controllers: [DashboardController],
12+
providers: [DashboardService],
13+
})
14+
export class DashboardModule {}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { Participant } from '../entities/participant.entity';
5+
import { Split } from '../entities/split.entity';
6+
import { Activity } from '../entities/activity.entity';
7+
import { DashboardSummaryDto, DashboardActivityDto, QuickAction } from './dto/dashboard.dto';
8+
9+
@Injectable()
10+
export class DashboardService {
11+
constructor(
12+
@InjectRepository(Participant)
13+
private readonly participantRepo: Repository<Participant>,
14+
@InjectRepository(Split)
15+
private readonly splitRepo: Repository<Split>,
16+
@InjectRepository(Activity)
17+
private readonly activityRepo: Repository<Activity>,
18+
) {}
19+
20+
async getSummary(userId: string): Promise<DashboardSummaryDto> {
21+
// Run all aggregation queries in parallel for efficiency
22+
const [owedResult, owedToUserResult, activeSplitsCount, splitsCreatedCount, unreadCount] =
23+
await Promise.all([
24+
// Total the user owes (amountOwed - amountPaid) across non-completed splits
25+
this.participantRepo
26+
.createQueryBuilder('p')
27+
.select('COALESCE(SUM((p.amountOwed - p.amountPaid)::numeric), 0)', 'total')
28+
.innerJoin(Split, 's', 's.id = p.splitId')
29+
.where('p.userId = :userId', { userId })
30+
.andWhere("p.status != 'paid'")
31+
.andWhere("s.status != 'completed'")
32+
.andWhere('s.deletedAt IS NULL')
33+
.getRawOne<{ total: string }>(),
34+
35+
// Total owed to the user: sum of what others owe on splits the user created
36+
this.participantRepo
37+
.createQueryBuilder('p')
38+
.select('COALESCE(SUM((p.amountOwed - p.amountPaid)::numeric), 0)', 'total')
39+
.innerJoin(Split, 's', 's.id = p.splitId')
40+
.where('s.creatorWalletAddress = :userId', { userId })
41+
.andWhere('p.userId != :userId', { userId })
42+
.andWhere("p.status != 'paid'")
43+
.andWhere("s.status != 'completed'")
44+
.andWhere('s.deletedAt IS NULL')
45+
.getRawOne<{ total: string }>(),
46+
47+
// Active splits the user participates in
48+
this.participantRepo
49+
.createQueryBuilder('p')
50+
.innerJoin(Split, 's', 's.id = p.splitId')
51+
.where('p.userId = :userId', { userId })
52+
.andWhere("s.status != 'completed'")
53+
.andWhere('s.deletedAt IS NULL')
54+
.getCount(),
55+
56+
// Splits the user created that are still active
57+
this.splitRepo
58+
.createQueryBuilder('s')
59+
.where('s.creatorWalletAddress = :userId', { userId })
60+
.andWhere("s.status != 'completed'")
61+
.andWhere('s.deletedAt IS NULL')
62+
.getCount(),
63+
64+
// Unread activity count
65+
this.activityRepo.count({ where: { userId, isRead: false } }),
66+
]);
67+
68+
const totalOwed = parseFloat(owedResult?.total ?? '0');
69+
const totalOwedToUser = parseFloat(owedToUserResult?.total ?? '0');
70+
71+
const quickActions: QuickAction[] = [
72+
{ id: 'new-split', label: 'New Split', route: '/splits/new' },
73+
{ id: 'my-splits', label: 'My Splits', route: '/splits', badge: activeSplitsCount },
74+
{ id: 'activity', label: 'Activity', route: '/activity', badge: unreadCount || undefined },
75+
{ id: 'analytics', label: 'Analytics', route: '/analytics' },
76+
];
77+
78+
return {
79+
totalOwed,
80+
totalOwedToUser,
81+
activeSplits: activeSplitsCount,
82+
splitsCreated: splitsCreatedCount,
83+
unreadNotifications: unreadCount,
84+
quickActions,
85+
};
86+
}
87+
88+
async getActivity(
89+
userId: string,
90+
page: number,
91+
limit: number,
92+
): Promise<DashboardActivityDto> {
93+
const [data, total] = await this.activityRepo.findAndCount({
94+
where: { userId },
95+
order: { createdAt: 'DESC' },
96+
skip: (page - 1) * limit,
97+
take: limit,
98+
});
99+
100+
const unreadCount = await this.activityRepo.count({
101+
where: { userId, isRead: false },
102+
});
103+
104+
return {
105+
data: data.map((a) => ({
106+
id: a.id,
107+
activityType: a.activityType,
108+
splitId: a.splitId,
109+
metadata: a.metadata,
110+
isRead: a.isRead,
111+
createdAt: a.createdAt,
112+
})),
113+
total,
114+
page,
115+
limit,
116+
hasMore: page * limit < total,
117+
unreadCount,
118+
};
119+
}
120+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export class DashboardSummaryDto {
2+
/** Total amount the user owes across all active splits */
3+
totalOwed: number;
4+
/** Total amount owed to the user across all active splits */
5+
totalOwedToUser: number;
6+
/** Number of active (non-completed) splits the user participates in */
7+
activeSplits: number;
8+
/** Number of splits the user created that are still active */
9+
splitsCreated: number;
10+
/** Number of unread activity notifications */
11+
unreadNotifications: number;
12+
/** Quick-action metadata for the frontend */
13+
quickActions: QuickAction[];
14+
}
15+
16+
export class QuickAction {
17+
id: string;
18+
label: string;
19+
route: string;
20+
badge?: number;
21+
}
22+
23+
export class DashboardActivityItem {
24+
id: string;
25+
activityType: string;
26+
splitId?: string;
27+
metadata: Record<string, any>;
28+
isRead: boolean;
29+
createdAt: Date;
30+
}
31+
32+
export class DashboardActivityDto {
33+
data: DashboardActivityItem[];
34+
total: number;
35+
page: number;
36+
limit: number;
37+
hasMore: boolean;
38+
unreadCount: number;
39+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {
2+
IsOptional,
3+
IsEnum,
4+
IsString,
5+
IsDateString,
6+
IsInt,
7+
Min,
8+
Max,
9+
} from 'class-validator';
10+
import { Type } from 'class-transformer';
11+
import { ApiPropertyOptional } from '@nestjs/swagger';
12+
import { SplitRole } from '../entities/split-history.entity';
13+
14+
export enum HistoryStatusFilter {
15+
ACTIVE = 'active',
16+
COMPLETED = 'completed',
17+
PARTIAL = 'partial',
18+
ARCHIVED = 'archived',
19+
ALL = 'all',
20+
}
21+
22+
export class HistoryQueryDto {
23+
@ApiPropertyOptional({ enum: SplitRole, description: 'Filter by user role in the split' })
24+
@IsOptional()
25+
@IsEnum(SplitRole)
26+
role?: SplitRole;
27+
28+
@ApiPropertyOptional({ enum: HistoryStatusFilter, default: HistoryStatusFilter.ALL })
29+
@IsOptional()
30+
@IsEnum(HistoryStatusFilter)
31+
status?: HistoryStatusFilter = HistoryStatusFilter.ALL;
32+
33+
@ApiPropertyOptional({ description: 'Search by split description or ID' })
34+
@IsOptional()
35+
@IsString()
36+
search?: string;
37+
38+
@ApiPropertyOptional({ description: 'Filter from date (ISO 8601)', example: '2024-01-01' })
39+
@IsOptional()
40+
@IsDateString()
41+
dateFrom?: string;
42+
43+
@ApiPropertyOptional({ description: 'Filter to date (ISO 8601)', example: '2024-12-31' })
44+
@IsOptional()
45+
@IsDateString()
46+
dateTo?: string;
47+
48+
@ApiPropertyOptional({ default: 1, minimum: 1 })
49+
@IsOptional()
50+
@Type(() => Number)
51+
@IsInt()
52+
@Min(1)
53+
page: number = 1;
54+
55+
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
56+
@IsOptional()
57+
@Type(() => Number)
58+
@IsInt()
59+
@Min(1)
60+
@Max(100)
61+
limit: number = 20;
62+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { SplitRole } from '../entities/split-history.entity';
2+
3+
export class HistoryItemDto {
4+
id!: string;
5+
splitId!: string;
6+
role!: SplitRole;
7+
/** Positive = received, negative = paid out */
8+
finalAmount!: number;
9+
status!: string;
10+
description?: string;
11+
preferredCurrency?: string;
12+
totalAmount!: number;
13+
completionTime!: Date;
14+
comment?: string;
15+
isArchived!: boolean;
16+
}
17+
18+
export class HistorySummaryDto {
19+
totalSplitsCreated!: number;
20+
totalSplitsParticipated!: number;
21+
totalAmountPaid!: number;
22+
totalAmountReceived!: number;
23+
netAmount!: number;
24+
}
25+
26+
export class HistoryResponseDto {
27+
data!: HistoryItemDto[];
28+
total!: number;
29+
page!: number;
30+
limit!: number;
31+
hasMore!: boolean;
32+
summary!: HistorySummaryDto;
33+
/** Opaque token for triggering an export of this result set */
34+
exportHint!: {
35+
endpoint: string;
36+
supportedFormats: string[];
37+
};
38+
}

backend/src/split-history/split-history.controller.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,58 @@
1-
import { Controller, Get, Param } from '@nestjs/common';
1+
import {
2+
Controller,
3+
Get,
4+
Param,
5+
Query,
6+
Req,
7+
UseGuards,
8+
ValidationPipe,
9+
} from '@nestjs/common';
10+
import {
11+
ApiTags,
12+
ApiOperation,
13+
ApiResponse,
14+
ApiBearerAuth,
15+
ApiParam,
16+
} from '@nestjs/swagger';
17+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
218
import { SplitHistoryService } from './split-history.service';
19+
import { HistoryQueryDto } from './dto/history-query.dto';
20+
import { HistoryResponseDto } from './dto/history-response.dto';
321

22+
@ApiTags('Split History')
23+
@ApiBearerAuth()
24+
@UseGuards(JwtAuthGuard)
425
@Controller('api/split-history')
526
export class SplitHistoryController {
627
constructor(private readonly service: SplitHistoryService) {}
728

29+
/**
30+
* Paginated history with role, status, search, and date filters.
31+
* This is the primary endpoint for the frontend history page.
32+
*/
33+
@Get()
34+
@ApiOperation({ summary: 'Get paginated split history for the authenticated user' })
35+
@ApiResponse({ status: 200, description: 'Paginated history with summary', type: HistoryResponseDto })
36+
getHistory(
37+
@Req() req: any,
38+
@Query(new ValidationPipe({ transform: true })) query: HistoryQueryDto,
39+
): Promise<HistoryResponseDto> {
40+
return this.service.getHistory(req.user.id, query);
41+
}
42+
43+
/**
44+
* Kept for backwards compatibility — resolves to the same data unfiltered.
45+
*/
846
@Get('user/:walletAddress')
47+
@ApiOperation({ summary: 'Get full history by wallet address (legacy)' })
48+
@ApiParam({ name: 'walletAddress', description: 'Stellar wallet address' })
949
getUserHistory(@Param('walletAddress') wallet: string) {
1050
return this.service.getUserHistory(wallet);
1151
}
1252

1353
@Get('stats/:walletAddress')
54+
@ApiOperation({ summary: 'Get aggregate stats by wallet address' })
55+
@ApiParam({ name: 'walletAddress', description: 'Stellar wallet address' })
1456
getUserStats(@Param('walletAddress') wallet: string) {
1557
return this.service.getUserStats(wallet);
1658
}

0 commit comments

Comments
 (0)