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
16 changes: 16 additions & 0 deletions backend/src/subscriptions/dto/list-subscriptions-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PaginationDto } from '../../common/dto';

export class ListSubscriptionsQueryDto extends PaginationDto {
@IsString()
@IsNotEmpty()
fan: string;

@IsOptional()
@IsString()
status?: string;

@IsOptional()
@IsString()
sort?: string;
}
15 changes: 9 additions & 6 deletions backend/src/subscriptions/subscriptions.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { ListSubscriptionsQueryDto } from './dto/list-subscriptions-query.dto';

@Controller('subscriptions')
export class SubscriptionsController {
Expand All @@ -11,12 +12,14 @@ export class SubscriptionsController {
}

@Get('list')
listSubscriptions(
@Query('fan') fan: string,
@Query('status') status?: string,
@Query('sort') sort?: string,
) {
return this.subscriptionsService.listSubscriptions(fan, status, sort);
listSubscriptions(@Query() query: ListSubscriptionsQueryDto) {
return this.subscriptionsService.listSubscriptions(
query.fan,
query.status,
query.sort,
query.page,
query.limit,
);
}

/**
Expand Down
86 changes: 86 additions & 0 deletions backend/src/subscriptions/subscriptions.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { SubscriptionsService } from './subscriptions.service';

describe('SubscriptionsService', () => {
let service: SubscriptionsService;

beforeEach(() => {
service = new SubscriptionsService();
});

describe('listSubscriptions', () => {
const fan = 'GAAAAAAAAAAAAAAA';

it('should return empty paginated response when fan has no subscriptions', () => {
const result = service.listSubscriptions(fan);

expect(result.data).toEqual([]);
expect(result.total).toBe(0);
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
expect(result.totalPages).toBe(0);
});

it('should return all subscriptions in a single page', () => {
const creator = 'GBBD47ZY6F6R7OGMW5G6C5R5P6NQ5QW5R5V5S5R5O5P5Q5R5V5S5R5O5';
const expiry = Math.floor(Date.now() / 1000) + 86400;
service.addSubscription(fan, creator, 1, expiry);

const result = service.listSubscriptions(fan);

expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.totalPages).toBe(1);
expect(result.data[0].creatorId).toBe(creator);
});

it('should paginate results across multiple pages', () => {
const expiry = Math.floor(Date.now() / 1000) + 86400;
// Add 3 subscriptions to different creators
service.addSubscription(fan, 'CREATOR_A_XXXXXXX', 1, expiry);
service.addSubscription(fan, 'CREATOR_B_XXXXXXX', 1, expiry + 100);
service.addSubscription(fan, 'CREATOR_C_XXXXXXX', 1, expiry + 200);

// Page 1 with limit 2
const page1 = service.listSubscriptions(fan, undefined, undefined, 1, 2);
expect(page1.data).toHaveLength(2);
expect(page1.total).toBe(3);
expect(page1.page).toBe(1);
expect(page1.limit).toBe(2);
expect(page1.totalPages).toBe(2);

// Page 2 with limit 2
const page2 = service.listSubscriptions(fan, undefined, undefined, 2, 2);
expect(page2.data).toHaveLength(1);
expect(page2.total).toBe(3);
expect(page2.page).toBe(2);
expect(page2.totalPages).toBe(2);
});

it('should filter by status', () => {
const expiry = Math.floor(Date.now() / 1000) + 86400;
const pastExpiry = Math.floor(Date.now() / 1000) - 86400;
service.addSubscription(fan, 'CREATOR_A_XXXXXXX', 1, expiry);
service.addSubscription(fan, 'CREATOR_B_XXXXXXX', 1, pastExpiry); // will be expired

const activeOnly = service.listSubscriptions(fan, 'active');
expect(activeOnly.data).toHaveLength(1);
expect(activeOnly.total).toBe(1);

const expiredOnly = service.listSubscriptions(fan, 'expired');
expect(expiredOnly.data).toHaveLength(1);
expect(expiredOnly.total).toBe(1);
});

it('should return empty page when page exceeds total pages', () => {
const expiry = Math.floor(Date.now() / 1000) + 86400;
service.addSubscription(fan, 'CREATOR_A_XXXXXXX', 1, expiry);

const result = service.listSubscriptions(fan, undefined, undefined, 5, 20);
expect(result.data).toEqual([]);
expect(result.total).toBe(1);
expect(result.page).toBe(5);
expect(result.totalPages).toBe(1);
});
});
});
10 changes: 8 additions & 2 deletions backend/src/subscriptions/subscriptions.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PaginatedResponseDto } from '../common/dto';

/** Checkout status enum */
export enum CheckoutStatus {
Expand Down Expand Up @@ -103,7 +104,7 @@ export class SubscriptionsService {
return this.subscriptions.get(this.getKey(fan, creator));
}

listSubscriptions(fan: string, status?: string, sort?: string) {
listSubscriptions(fan: string, status?: string, sort?: string, page: number = 1, limit: number = 20) {
// Convert map values to array for the given fan
let userSubs = Array.from(this.subscriptions.values()).filter(sub => sub.fan === fan);

Expand Down Expand Up @@ -147,7 +148,12 @@ export class SubscriptionsService {
results.sort((a, b) => new Date(a.currentPeriodEnd).getTime() - new Date(b.currentPeriodEnd).getTime());
}

return results;
// Apply pagination
const total = results.length;
const skip = (page - 1) * limit;
const paginatedResults = results.slice(skip, skip + limit);

return new PaginatedResponseDto(paginatedResults, total, page, limit);
}

// ==================== Checkout Methods ====================
Expand Down
31 changes: 19 additions & 12 deletions frontend/src/components/earnings/TransactionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,18 @@ export function TransactionHistoryCard({ limit = 20 }: TransactionHistoryProps)
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [offset, setOffset] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [total, setTotal] = useState(0);

useEffect(() => {
const load = async () => {
try {
setLoading(true);
const data = await fetchTransactionHistory(limit, offset);
setTransactions(data);
const response = await fetchTransactionHistory(page, limit);
setTransactions(response.items);
setTotalPages(response.total_pages);
setTotal(response.total);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load transactions');
Expand All @@ -43,7 +47,7 @@ export function TransactionHistoryCard({ limit = 20 }: TransactionHistoryProps)
};

load();
}, [limit, offset]);
}, [limit, page]);

if (loading) {
return (
Expand Down Expand Up @@ -73,6 +77,9 @@ export function TransactionHistoryCard({ limit = 20 }: TransactionHistoryProps)
);
}

const startItem = (page - 1) * limit + 1;
const endItem = Math.min(page * limit, total);

return (
<BaseCard padding="lg" as="section" aria-labelledby="transactions-heading">
<h2 id="transactions-heading" className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Expand Down Expand Up @@ -108,19 +115,19 @@ export function TransactionHistoryCard({ limit = 20 }: TransactionHistoryProps)
{/* Pagination */}
<div className="flex flex-col sm:flex-row justify-between items-center mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 gap-3 sm:gap-0">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={offset === 0}
className="px-4 py-3 sm:py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[44px] sm:min-h-[auto] w-full sm:w-auto"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1}
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-800"
>
Previous
</button>
<span className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 text-center">
Showing {offset + 1} - {offset + transactions.length}
<span className="text-sm text-gray-600 dark:text-gray-400">
Showing {startItem} - {endItem} of {total}
</span>
<button
onClick={() => setOffset(offset + limit)}
disabled={transactions.length < limit}
className="px-4 py-3 sm:py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[44px] sm:min-h-[auto] w-full sm:w-auto"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-800"
>
Next
</button>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/earnings/WithdrawalUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export function WithdrawalUI({ availableBalance, currency }: WithdrawalUIProps)

const loadHistory = async () => {
try {
const data = await fetchWithdrawalHistory(5);
setHistory(data);
const data = await fetchWithdrawalHistory(1, 5);
setHistory(data.items);
} catch (err) {
console.error('Failed to load withdrawal history', err);
}
Expand Down
16 changes: 12 additions & 4 deletions frontend/src/lib/earnings-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,20 @@ export async function fetchEarningsBreakdown(days: number = 30): Promise<Earning
return fetchApi(`/earnings/breakdown?days=${days}`);
}

export async function fetchTransactionHistory(limit: number = 50, offset: number = 0): Promise<Transaction[]> {
return fetchApi(`/earnings/transactions?limit=${limit}&offset=${offset}`);
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
total_pages: number;
}

export async function fetchWithdrawalHistory(limit: number = 20, offset: number = 0): Promise<Withdrawal[]> {
return fetchApi(`/earnings/withdrawals?limit=${limit}&offset=${offset}`);
export async function fetchTransactionHistory(page: number = 1, limit: number = 50): Promise<PaginatedResponse<Transaction>> {
return fetchApi(`/earnings/transactions?page=${page}&limit=${limit}`);
}

export async function fetchWithdrawalHistory(page: number = 1, limit: number = 20): Promise<PaginatedResponse<Withdrawal>> {
return fetchApi(`/earnings/withdrawals?page=${page}&limit=${limit}`);
}

export async function requestWithdrawal(data: {
Expand Down
8 changes: 4 additions & 4 deletions myfans-backend/src/comments/comments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { GetCommentsQueryDto } from './dto/get-comments-query.dto';
import { User } from '../users/entities/user.entity';
import { PaginatedResponseDto } from '../common/dto';

@Injectable()
export class CommentsService {
Expand Down Expand Up @@ -68,13 +69,12 @@ export class CommentsService {
take: limit,
});

return {
items: comments.map((c) => this.toResponse(c)),
return new PaginatedResponseDto(
comments.map((c) => this.toResponse(c)),
total,
page,
limit,
total_pages: Math.ceil(total / limit),
};
);
}

async update(user: User, commentId: string, dto: UpdateCommentDto) {
Expand Down
18 changes: 2 additions & 16 deletions myfans-backend/src/comments/dto/get-comments-query.dto.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,3 @@
import { IsOptional, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationQueryDto } from '../../common/dto';

export class GetCommentsQueryDto {
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;

@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(100)
limit?: number = 20;
}
export class GetCommentsQueryDto extends PaginationQueryDto {}
2 changes: 2 additions & 0 deletions myfans-backend/src/common/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { PaginationQueryDto } from './pagination-query.dto';
export { PaginatedResponseDto } from './paginated-response.dto';
52 changes: 52 additions & 0 deletions myfans-backend/src/common/dto/paginated-response.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { PaginatedResponseDto } from './paginated-response.dto';

describe('PaginatedResponseDto', () => {
it('should set all fields correctly', () => {
const result = new PaginatedResponseDto(['a', 'b'], 10, 1, 5);

expect(result.items).toEqual(['a', 'b']);
expect(result.total).toBe(10);
expect(result.page).toBe(1);
expect(result.limit).toBe(5);
expect(result.total_pages).toBe(2);
});

it('should calculate total_pages = 0 when total is 0', () => {
const result = new PaginatedResponseDto([], 0, 1, 20);

expect(result.items).toEqual([]);
expect(result.total).toBe(0);
expect(result.total_pages).toBe(0);
});

it('should calculate total_pages = 1 for a single item', () => {
const result = new PaginatedResponseDto([{ id: 1 }], 1, 1, 20);

expect(result.total_pages).toBe(1);
});

it('should ceil total_pages when items do not divide evenly', () => {
const result = new PaginatedResponseDto([], 21, 2, 20);

expect(result.total_pages).toBe(2);
});

it('should handle exact page boundary', () => {
const result = new PaginatedResponseDto([], 40, 1, 20);

expect(result.total_pages).toBe(2);
});

it('should work with generic types', () => {
interface User {
id: string;
name: string;
}

const users: User[] = [{ id: '1', name: 'Alice' }];
const result = new PaginatedResponseDto<User>(users, 1, 1, 10);

expect(result.items).toEqual(users);
expect(result.total_pages).toBe(1);
});
});
15 changes: 15 additions & 0 deletions myfans-backend/src/common/dto/paginated-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export class PaginatedResponseDto<T> {
items: T[];
total: number;
page: number;
limit: number;
total_pages: number;

constructor(items: T[], total: number, page: number, limit: number) {
this.items = items;
this.total = total;
this.page = page;
this.limit = limit;
this.total_pages = Math.ceil(total / limit);
}
}
Loading