diff --git a/apps/api/src/routes/announcements.ts b/apps/api/src/routes/announcements.ts index 4eb64b0..5955e60 100644 --- a/apps/api/src/routes/announcements.ts +++ b/apps/api/src/routes/announcements.ts @@ -111,14 +111,20 @@ router.get( async (req: AuthRequest, res: Response) => { try { const { id: orgId } = req.params - const { category } = req.query + const { category, before, limit } = req.query if (category && !VALID_CATEGORIES.includes(category as string)) { return sendError(res, 'invalid_category', 400) } - const where: any = { orgId, deletedAt: null } - if (category) where.category = category + const limitNum = Math.min(50, parseInt(limit as string) || 20) + + const where: any = { + orgId, + deletedAt: null, + ...(category ? { category } : {}), + ...(before ? { createdAt: { lt: new Date(before as string) } } : {}) + } const announcements = await prisma.announcement.findMany({ where, @@ -126,9 +132,13 @@ router.get( include: { images: { select: { id: true, imageUrl: true } }, creator: { include: { person: true } } - } + }, + take: limitNum + 1 }) + const hasMore = announcements.length > limitNum + if (hasMore) announcements.pop() + return sendSuccess(res, { announcements: announcements.map(a => ({ id: a.id, @@ -139,7 +149,11 @@ router.get( images: a.images, createdBy: { name: a.creator.person?.fullName ?? 'Unknown' }, createdAt: a.createdAt, - })) + })), + hasMore, + nextCursor: hasMore + ? announcements[announcements.length - 1].createdAt.toISOString() + : null }) } catch (error) { diff --git a/apps/api/tests/announcements.test.ts b/apps/api/tests/announcements.test.ts index 890abcc..96d9f4e 100644 --- a/apps/api/tests/announcements.test.ts +++ b/apps/api/tests/announcements.test.ts @@ -277,4 +277,78 @@ describe('Announcements', () => { expect(res.body.error).toBe('announcement_not_found') }) }) + + // ───────────────────────────────────────────── + // GET /societies/:id/announcements — pagination + // ───────────────────────────────────────────── + describe('GET /announcements — pagination', () => { + beforeAll(async () => { + await prisma.announcement.deleteMany({ where: { orgId: societyId } }) + const builder = await prisma.user.findFirst({ where: { phone: '+919111111111' } }) + const now = Date.now() + for (let i = 0; i < 3; i++) { + await prisma.announcement.create({ + data: { + orgId: societyId, + createdBy: builder!.id, + title: `Paginated ${i + 1}`, + body: `Body ${i + 1}`, + category: 'GENERAL', + createdAt: new Date(now - i * 1000), + } + }) + } + }) + + afterAll(async () => { + await prisma.announcement.deleteMany({ where: { orgId: societyId } }) + }) + + it('returns hasMore false when announcements are within limit', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements?limit=20`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + expect(res.body.data.hasMore).toBe(false) + expect(res.body.data.nextCursor).toBeNull() + expect(res.body.data.announcements).toHaveLength(3) + }) + + it('returns hasMore true when announcements exceed limit', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements?limit=2`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + expect(res.body.data.hasMore).toBe(true) + expect(res.body.data.nextCursor).not.toBeNull() + expect(res.body.data.announcements).toHaveLength(2) + }) + + it('returns nextCursor equal to createdAt of last item on page', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements?limit=2`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + const lastItem = res.body.data.announcements[res.body.data.announcements.length - 1] + expect(res.body.data.nextCursor).toBe(lastItem.createdAt) + }) + + it('respects before cursor and returns non-overlapping second page', async () => { + const page1 = await request(app) + .get(`/api/societies/${societyId}/announcements?limit=2`) + .set('Authorization', `Bearer ${builderToken}`) + const cursor = page1.body.data.nextCursor + + const page2 = await request(app) + .get(`/api/societies/${societyId}/announcements?limit=2&before=${encodeURIComponent(cursor)}`) + .set('Authorization', `Bearer ${builderToken}`) + expect(page2.status).toBe(200) + expect(page2.body.data.announcements.length).toBeGreaterThan(0) + expect(page2.body.data.hasMore).toBe(false) + + const page1Ids = page1.body.data.announcements.map((a: any) => a.id) + const page2Ids = page2.body.data.announcements.map((a: any) => a.id) + expect(page1Ids.some((id: string) => page2Ids.includes(id))).toBe(false) + }) + }) }) diff --git a/apps/mobile/src/screens/announcements/AnnouncementsListScreen.tsx b/apps/mobile/src/screens/announcements/AnnouncementsListScreen.tsx index d25e8e4..c332050 100644 --- a/apps/mobile/src/screens/announcements/AnnouncementsListScreen.tsx +++ b/apps/mobile/src/screens/announcements/AnnouncementsListScreen.tsx @@ -8,6 +8,7 @@ import { ScrollView, StyleSheet, TouchableOpacity, + ActivityIndicator, Platform, } from 'react-native' import { NativeStackScreenProps } from '@react-navigation/native-stack' @@ -64,6 +65,9 @@ export function AnnouncementsListScreen({ route, navigation }: Props) { const [announcements, setAnnouncements] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isRefreshing, setIsRefreshing] = useState(false) + const [hasMore, setHasMore] = useState(false) + const [nextCursor, setNextCursor] = useState(null) + const [isLoadingMore, setIsLoadingMore] = useState(false) const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' | 'info' } | null>(null) const load = useCallback( @@ -72,7 +76,9 @@ export function AnnouncementsListScreen({ route, navigation }: Props) { if (!refreshing) setIsLoading(true) const category = categoryFilter === 'ALL' ? undefined : categoryFilter const data = await listAnnouncements(societyId, category) - setAnnouncements(data) + setAnnouncements(data.announcements) + setHasMore(data.hasMore) + setNextCursor(data.nextCursor) } catch { setToast({ message: 'Could not load announcements. Pull to retry.', type: 'error' }) } finally { @@ -94,6 +100,22 @@ export function AnnouncementsListScreen({ route, navigation }: Props) { load(true) }, [load]) + const loadMore = useCallback(async () => { + if (!hasMore || isLoadingMore || !nextCursor) return + setIsLoadingMore(true) + try { + const category = categoryFilter === 'ALL' ? undefined : categoryFilter + const data = await listAnnouncements(societyId, category, nextCursor) + setAnnouncements(prev => [...prev, ...data.announcements]) + setHasMore(data.hasMore) + setNextCursor(data.nextCursor) + } catch { + // fail silently — user can scroll again + } finally { + setIsLoadingMore(false) + } + }, [hasMore, isLoadingMore, nextCursor, societyId, categoryFilter]) + const renderItem = ({ item }: { item: Announcement }) => { const colors = CATEGORY_COLORS[item.category] return ( @@ -168,6 +190,17 @@ export function AnnouncementsListScreen({ route, navigation }: Props) { data={announcements} keyExtractor={(item) => item.id} renderItem={renderItem} + onEndReached={loadMore} + onEndReachedThreshold={0.3} + ListFooterComponent={ + isLoadingMore ? ( + + ) : null + } refreshControl={ { - const params = category ? { category } : {} + category?: string, + before?: string, + limit = 20, +): Promise<{ + announcements: Announcement[] + hasMore: boolean + nextCursor: string | null +}> { + const params: Record = { limit } + if (category) params.category = category + if (before) params.before = before const res = await api.get(`/societies/${societyId}/announcements`, { params }) - return res.data.data.announcements + return res.data.data } export async function getAnnouncement(societyId: string, announcementId: string): Promise {