From e275a7ea2ca9f7795c82d30faee6950eec595d65 Mon Sep 17 00:00:00 2001 From: Mouhannad Date: Mon, 20 Jul 2026 14:32:14 +0300 Subject: [PATCH 01/11] =?UTF-8?q?fix(web):=20render=20Payments=20+=20activ?= =?UTF-8?q?ity=20feeds=20=E2=80=94=20align=20PaginatedResponse=20to=20`ite?= =?UTF-8?q?ms`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared PaginatedResponse typed its array field as `data`, but the backend list services (payments.service, timeline.service) return `items`. The client `unwrap` transform keys on a top-level `data` property only to peel the optional ApiEnvelope, so it passed the `{items,total,page,limit}` object through untouched — and 5 FE reads of `?.data ?? []` then resolved to `undefined ?? []`, rendering the Payments list and every "Recent activity" feed empty on production. Fix: flip the contract array field to `items`, update the 5 paginated reads (payments-page, finance/staff/tenant dashboards, timeline-feed), and add BE regression specs asserting the list envelope is keyed by `items`. The two notification reads keep `.data` — NotificationListResponse is a distinct shape with a real `data` field. Web-only (packages/contracts + apps/web). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modules/payments/payments.service.spec.ts | 45 +++++++++++++++++++ .../modules/timeline/timeline.service.spec.ts | 36 +++++++++++++++ .../dashboard/finance-dashboard.tsx | 2 +- .../components/dashboard/payments-page.tsx | 2 +- .../components/dashboard/staff-dashboard.tsx | 2 +- .../components/dashboard/tenant-dashboard.tsx | 2 +- .../components/dashboard/timeline-feed.tsx | 2 +- packages/contracts/src/index.ts | 9 +++- 8 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/modules/payments/payments.service.spec.ts create mode 100644 apps/api/src/modules/timeline/timeline.service.spec.ts diff --git a/apps/api/src/modules/payments/payments.service.spec.ts b/apps/api/src/modules/payments/payments.service.spec.ts new file mode 100644 index 00000000..407de712 --- /dev/null +++ b/apps/api/src/modules/payments/payments.service.spec.ts @@ -0,0 +1,45 @@ +import { PaymentsService } from './payments.service'; + +/** + * Regression guard for the paginated-list contract drift bug: the backend list + * envelope MUST expose its rows under `items` (matching PaginatedResponse in + * @repo/contracts and the FE reads), never `data`. A `data` array here silently + * renders empty lists on the client (the FE reads `.items`), and would also be + * mis-peeled by the client's `unwrap` ApiEnvelope transform. + */ +describe('PaymentsService', () => { + const orgId = 'org-1'; + + function makeService(rows: unknown[] = [], total = 0) { + const prisma: any = { + payment: { + findMany: jest.fn().mockResolvedValue(rows), + count: jest.fn().mockResolvedValue(total), + }, + }; + return { service: new PaymentsService(prisma), prisma }; + } + + it('returns the paginated envelope keyed by `items`, not `data`', async () => { + const rows = [{ id: 'pay-1' }, { id: 'pay-2' }]; + const { service } = makeService(rows, 2); + + const result = await service.findAll(orgId, 1, 20); + + expect(result).toEqual({ items: rows, total: 2, page: 1, limit: 20 }); + expect(result.items).toEqual(rows); + expect(result).not.toHaveProperty('data'); + }); + + it('applies page/limit as skip/take and echoes them back', async () => { + const { service, prisma } = makeService([], 0); + + const result = await service.findAll(orgId, 3, 10); + + expect(prisma.payment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId }, skip: 20, take: 10 }), + ); + expect(result.page).toBe(3); + expect(result.limit).toBe(10); + }); +}); diff --git a/apps/api/src/modules/timeline/timeline.service.spec.ts b/apps/api/src/modules/timeline/timeline.service.spec.ts new file mode 100644 index 00000000..13460e0d --- /dev/null +++ b/apps/api/src/modules/timeline/timeline.service.spec.ts @@ -0,0 +1,36 @@ +import { TimelineService } from './timeline.service'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; + +/** + * Regression guard for the paginated-list contract drift bug: the timeline feed + * envelope MUST expose its rows under `items` (matching PaginatedResponse in + * @repo/contracts and the FE reads), never `data`. A `data` array here silently + * renders every "Recent activity" feed empty on the client. + */ +describe('TimelineService', () => { + const orgId = 'org-1'; + + function makeService(rows: unknown[] = [], total = 0) { + const prisma: any = { + event: { + findMany: jest.fn().mockResolvedValue(rows), + count: jest.fn().mockResolvedValue(total), + }, + }; + return { service: new TimelineService(prisma), prisma }; + } + + const caller = (roles: string[]): AuthenticatedUser => + ({ sub: 'user-1', roles } as unknown as AuthenticatedUser); + + it('returns the paginated envelope keyed by `items`, not `data`', async () => { + const rows = [{ id: 'evt-1' }, { id: 'evt-2' }]; + const { service } = makeService(rows, 2); + + const result = await service.findForCaller(orgId, caller(['org_admin']), 1, 20); + + expect(result).toEqual({ items: rows, total: 2, page: 1, limit: 20 }); + expect(result.items).toEqual(rows); + expect(result).not.toHaveProperty('data'); + }); +}); diff --git a/apps/web/src/components/dashboard/finance-dashboard.tsx b/apps/web/src/components/dashboard/finance-dashboard.tsx index 81d50e52..49b8fd15 100644 --- a/apps/web/src/components/dashboard/finance-dashboard.tsx +++ b/apps/web/src/components/dashboard/finance-dashboard.tsx @@ -57,7 +57,7 @@ export function FinanceDashboard({ me, locale, dict }: FinanceDashboardProps) { const { data: timelineData, isLoading: timelineLoading } = useListTimelineQuery({ limit: 5 }); - const timelineEvents = (timelineData?.data ?? []).slice(0, 5); + const timelineEvents = (timelineData?.items ?? []).slice(0, 5); const overdueTotal = overdueOutstanding(overdue); const overdueCount = overdue?.length ?? 0; const netMtd = summary ? Number(summary.mtdNet) : 0; diff --git a/apps/web/src/components/dashboard/payments-page.tsx b/apps/web/src/components/dashboard/payments-page.tsx index 0a1b651a..613a3c38 100644 --- a/apps/web/src/components/dashboard/payments-page.tsx +++ b/apps/web/src/components/dashboard/payments-page.tsx @@ -55,7 +55,7 @@ export function PaymentsPage({ locale, readonly = false }: PaymentsPageProps) { const dateLocale = locale === 'ar' ? ar : undefined; const { data: paymentsData, isLoading } = useListPaymentsQuery(); - const payments = paymentsData?.data ?? []; + const payments = paymentsData?.items ?? []; const now = new Date(); const currentMonth = now.getMonth(); diff --git a/apps/web/src/components/dashboard/staff-dashboard.tsx b/apps/web/src/components/dashboard/staff-dashboard.tsx index 8fc98d35..e6bcb012 100644 --- a/apps/web/src/components/dashboard/staff-dashboard.tsx +++ b/apps/web/src/components/dashboard/staff-dashboard.tsx @@ -51,7 +51,7 @@ export function StaffDashboard({ me, locale, dict }: StaffDashboardProps) { const stats = maintenanceStats(requests); const attention = topActiveRequests(requests, 5); - const timelineEvents = timelineData?.data ?? []; + const timelineEvents = timelineData?.items ?? []; const userName = me?.user?.fullName ?? ''; return ( diff --git a/apps/web/src/components/dashboard/tenant-dashboard.tsx b/apps/web/src/components/dashboard/tenant-dashboard.tsx index 6866af52..c266eebe 100644 --- a/apps/web/src/components/dashboard/tenant-dashboard.tsx +++ b/apps/web/src/components/dashboard/tenant-dashboard.tsx @@ -161,7 +161,7 @@ export function TenantDashboard({ me, locale, dict }: TenantDashboardProps) { defaultValues: EMPTY_VALUES, }); - const events = timelineData?.data ?? []; + const events = timelineData?.items ?? []; const requests = overview?.maintenanceRequests ?? []; const openCount = requests.filter((r) => OPEN_STATUSES.includes(r.status), diff --git a/apps/web/src/components/dashboard/timeline-feed.tsx b/apps/web/src/components/dashboard/timeline-feed.tsx index 5cdb8839..b320f57f 100644 --- a/apps/web/src/components/dashboard/timeline-feed.tsx +++ b/apps/web/src/components/dashboard/timeline-feed.tsx @@ -41,7 +41,7 @@ export function TimelineFeed({ locale, limit = 10 }: TimelineFeedProps) { ); } - const events = data?.data ?? []; + const events = data?.items ?? []; if (events.length === 0) { return ( diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1dae59fd..d927dab2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -21,8 +21,15 @@ export type ApiEnvelope = { message?: string; }; +/** + * Paginated list envelope. The array field is `items` (NOT `data`) — this must + * match the backend list services (payments.service, timeline.service) exactly, + * or FE consumers reading the array get an empty list. Do not rename to `data`: + * the `unwrap` transform on the client keys on a top-level `data` property to + * peel the optional ApiEnvelope, so a `data` array here would be mis-unwrapped. + */ export type PaginatedResponse = { - data: T[]; + items: T[]; total: number; page: number; limit: number; From a5a9bf073efe26c8462f00920509b52105547aed Mon Sep 17 00:00:00 2001 From: Mouhannad Date: Tue, 21 Jul 2026 01:16:35 +0300 Subject: [PATCH 02/11] =?UTF-8?q?fix(F1):=20quick-win=20correctness=20?= =?UTF-8?q?=E2=80=94=20logout=20origin,=20subscription-payments=20nav,=20s?= =?UTF-8?q?ign-out=20reachability,=20lease=20date=20guard,=20task=20renter?= =?UTF-8?q?=20autofill,=20expense=E2=86=92WO=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint F1 of the Property Manager behavior-fix roadmap (workflow-anchored fixes from docs/propertymanager_bugs.txt). All six are contained, verified changes: - F1.1 [Q34/35] Logout returns to the PUBLIC origin. federated-logout now builds post_logout_redirect_uri from x-forwarded-host/host + x-forwarded-proto instead of request.url (which leaked the Next container host behind nginx). KC client prorentallb-web now also has post.logout.redirect.uris="+" (whitelists the redirect) — set additively out-of-band. - F1.2 [Q11/24] Nav "Payments" → "Subscription Payments" (en/ar) and moved beside Subscription in the sidebar; the page is subscription-only. - F1.3 [Q17] Sidebar Sign-out always reachable: dashboard layout is now a fixed app-shell (h-screen/overflow-hidden),
scrolls,