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
24 changes: 19 additions & 5 deletions apps/api/src/routes/announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,24 +111,34 @@ 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,
orderBy: [{ isPinned: 'desc' }, { createdAt: 'desc' }],
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,
Expand All @@ -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) {
Expand Down
74 changes: 74 additions & 0 deletions apps/api/tests/announcements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ScrollView,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
Platform,
} from 'react-native'
import { NativeStackScreenProps } from '@react-navigation/native-stack'
Expand Down Expand Up @@ -64,6 +65,9 @@ export function AnnouncementsListScreen({ route, navigation }: Props) {
const [announcements, setAnnouncements] = useState<Announcement[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' | 'info' } | null>(null)

const load = useCallback(
Expand All @@ -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 {
Expand All @@ -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 (
Expand Down Expand Up @@ -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 ? (
<ActivityIndicator
size="small"
color={Colors.primary}
style={{ paddingVertical: 16 }}
/>
) : null
}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
Expand Down
16 changes: 12 additions & 4 deletions apps/mobile/src/services/announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,19 @@ export interface CreateAnnouncementInput {

export async function listAnnouncements(
societyId: string,
category?: AnnouncementCategory,
): Promise<Announcement[]> {
const params = category ? { category } : {}
category?: string,
before?: string,
limit = 20,
): Promise<{
announcements: Announcement[]
hasMore: boolean
nextCursor: string | null
}> {
const params: Record<string, string | number> = { 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<Announcement> {
Expand Down
Loading