Phase 4 Stage 10 backend: Chat-tab feeds (pending payments + cursor history)

Backend half of Stage 10 — the new Chat tab in the customer app that
replaces /chat/history with a 3-sub-tab list (Aktif / Pembayaran /
Selesai).

- New GET /api/client/payment-sessions/pending — returns the customer's
  pending initial + extension payment sessions. Filter is status='pending'
  AND expires_at > NOW(). Mitra info comes from session_extensions →
  chat_sessions for extension rows, payment_sessions.targeted_mitra_id
  for targeted-curhat-lagi initial rows. TTL reuses the existing
  payment_session_timeout_minutes app_config row (default 20m) — no new
  config row needed since payment is still mocked.

- getCustomerHistory migrated from offset (page/limit) to cursor
  pagination. Cursor is base64url(`<endedAtIso>|<id>`) with id-tiebreak
  in ORDER BY so rows with identical timestamps don't duplicate or skip
  across pages. SELECT now JOINs payment_sessions to surface `mode`
  (chat/call) for the Selesai-row voice-call pill.

- requirement/flow_customer.mermaid.md: new §7 Chat Tab subgraph + Figma
  cross-ref entry for SChatList.

- requirement/phase4-customer-flow-plan.md: Stage 10 plan section. Also
  carries forward earlier uncommitted "Post-Stage-8 corrections" notes
  from the Stage 9 sweep (boot path / SHome1st / onboarding fixes).

Tests: +7 for getCustomerPendingPayments (initial null mitra,
targeted-mitra fill, extension-via-session JOIN, mixed-newest-first,
expired excluded, non-pending excluded, customer scoping). +10 for
cursor history (empty, exact-fit, multi-page walk, same-timestamp
tiebreak, limit clamp, customer scoping, CLOSING+COMPLETED only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:04:58 +08:00
parent 770f61074c
commit 350b92f1f3
8 changed files with 924 additions and 81 deletions

View File

@@ -201,12 +201,13 @@ export const clientChatRoutes = async (app) => {
return reply.send({ success: true, data: extension })
})
// Chat history
// Phase 4 Stage 10 — Chat Tab Selesai feed. Cursor-paginated; old `page`
// param removed. Response shape: { items, next_cursor, has_more }.
app.get('/history', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { page, limit } = request.query
const { cursor, limit } = request.query
const history = await getCustomerHistory(request.customer.id, {
page: page ? parseInt(page) : 1,
limit: limit ? parseInt(limit) : 20,
cursor: cursor ?? null,
limit: limit ? parseInt(limit, 10) : 20,
})
return reply.send({ success: true, data: history })
})

View File

@@ -5,6 +5,7 @@ import {
confirmPaymentSession,
abandonPaymentSession,
getPaymentSession,
getCustomerPendingPayments,
} from '../../services/payment.service.js'
import {
isCustomerEligibleForFirstSessionDiscount,
@@ -172,6 +173,13 @@ export const clientPaymentRoutes = async (app) => {
})
})
// Phase 4 Stage 10 — Chat Tab Pembayaran feed. Static path; registered
// before `/:id` so find-my-way matches this and not the wildcard.
app.get('/pending', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const data = await getCustomerPendingPayments(request.customer.id)
return reply.send({ success: true, data })
})
app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await getPaymentSession(request.params.id)
if (!session) {

View File

@@ -304,3 +304,41 @@ export const getPaymentSession = async (id) => {
`
return row || null
}
/**
* Phase 4 Stage 10 — Chat Tab Pembayaran feed.
*
* Returns the customer's pending payment sessions (initial + extension) that
* haven't paid AND haven't expired. The `expires_at > NOW()` filter is
* defensive: the background sweeper flips stale pending rows to `expired`,
* but rows can be stale between sweeps, so we filter inline too.
*
* Extension rows resolve mitra info via session_extensions → chat_sessions →
* mitras. Initial rows fall back to `payment_sessions.targeted_mitra_id`
* (set for targeted "Curhat lagi" flows); for general-blast initial rows
* the mitra is unknown until pairing succeeds, so mitra fields are null.
*/
export const getCustomerPendingPayments = async (customerId) => {
const items = await sql`
SELECT
ps.id,
ps.is_extension,
ps.amount,
ps.duration_minutes,
ps.mode,
ps.created_at,
ps.expires_at,
COALESCE(ext_m.id, tgt_m.id) AS mitra_id,
COALESCE(ext_m.display_name, tgt_m.display_name) AS mitra_display_name
FROM payment_sessions ps
LEFT JOIN session_extensions se ON se.payment_session_id = ps.id
LEFT JOIN chat_sessions cs ON cs.id = se.session_id
LEFT JOIN mitras ext_m ON ext_m.id = cs.mitra_id
LEFT JOIN mitras tgt_m ON tgt_m.id = ps.targeted_mitra_id
WHERE ps.customer_id = ${customerId}
AND ps.status = ${PaymentSessionStatus.PENDING}
AND ps.expires_at > NOW()
ORDER BY ps.created_at DESC
`
return { items, total: items.length }
}

View File

@@ -204,31 +204,87 @@ export const getActiveSessionsByMitraWithUnread = async (mitraId) => {
return sessions
}
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit
const items = await sql`
SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
m.display_name AS mitra_display_name,
COALESCE(mos.is_online, false) AS mitra_is_online,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message,
(SELECT COUNT(*) FROM chat_sessions x
WHERE x.customer_id = ${customerId} AND x.mitra_id = cs.mitra_id
AND x.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})) AS sessions_count
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC
LIMIT ${limit} OFFSET ${offset}
`
const [{ count }] = await sql`
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId}
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
`
return { items, total: Number(count), page, limit }
/**
* Phase 4 Stage 10 — Selesai sub-tab uses cursor pagination on this endpoint.
*
* Cursor is a base64-encoded `<isoTimestamp>|<id>` of the last row's
* `COALESCE(ended_at, created_at)` and `id`. The next page reads strictly
* older rows, breaking ties on `id` so adjacent rows with the same timestamp
* don't duplicate or skip across pages.
*/
const encodeHistoryCursor = (row) => {
const ts = (row.ended_at ?? row.created_at).toISOString
? (row.ended_at ?? row.created_at).toISOString()
: new Date(row.ended_at ?? row.created_at).toISOString()
return Buffer.from(`${ts}|${row.id}`, 'utf8').toString('base64url')
}
const decodeHistoryCursor = (cursor) => {
if (!cursor) return null
try {
const decoded = Buffer.from(cursor, 'base64url').toString('utf8')
const [ts, id] = decoded.split('|')
if (!ts || !id) return null
return { ts, id }
} catch {
return null
}
}
export const getCustomerHistory = async (customerId, { cursor = null, limit = 20 } = {}) => {
const cap = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 50)
const decoded = decodeHistoryCursor(cursor)
// Fetch one extra to determine has_more without a second query
const fetch = cap + 1
const items = decoded
? await sql`
SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
ps.mode AS mode,
m.display_name AS mitra_display_name,
COALESCE(mos.is_online, false) AS mitra_is_online,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message,
(SELECT COUNT(*)::int FROM chat_sessions x
WHERE x.customer_id = ${customerId} AND x.mitra_id = cs.mitra_id
AND x.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})) AS sessions_count
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
AND (
COALESCE(cs.ended_at, cs.created_at) < ${decoded.ts}::timestamptz
OR (COALESCE(cs.ended_at, cs.created_at) = ${decoded.ts}::timestamptz AND cs.id < ${decoded.id})
)
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC
LIMIT ${fetch}
`
: await sql`
SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
ps.mode AS mode,
m.display_name AS mitra_display_name,
COALESCE(mos.is_online, false) AS mitra_is_online,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message,
(SELECT COUNT(*)::int FROM chat_sessions x
WHERE x.customer_id = ${customerId} AND x.mitra_id = cs.mitra_id
AND x.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})) AS sessions_count
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC
LIMIT ${fetch}
`
const hasMore = items.length > cap
const page = hasMore ? items.slice(0, cap) : items
const nextCursor = hasMore ? encodeHistoryCursor(page[page.length - 1]) : null
return { items: page, next_cursor: nextCursor, has_more: hasMore }
}
export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) => {

View File

@@ -3,10 +3,11 @@ import {
createPaymentSession,
confirmPaymentSession,
getPaymentSession,
getCustomerPendingPayments,
} from '../../src/services/payment.service.js'
import { PaymentSessionStatus } from '../../src/constants.js'
import { resetDb, resetAppConfig } from '../helpers/db.js'
import { createCustomer } from '../helpers/fixtures.js'
import { PaymentSessionStatus, SessionStatus } from '../../src/constants.js'
import { resetDb, resetAppConfig, db } from '../helpers/db.js'
import { createCustomer, createMitra } from '../helpers/fixtures.js'
describe('payment.service', () => {
let customer
@@ -83,4 +84,167 @@ describe('payment.service', () => {
expect(reloaded.status).toBe(PaymentSessionStatus.PENDING)
expect(reloaded.confirmed_at).toBeNull()
})
// Phase 4 Stage 10 — Chat Tab Pembayaran feed.
describe('getCustomerPendingPayments', () => {
it('returns empty when customer has no payments', async () => {
const result = await getCustomerPendingPayments(customer.id)
expect(result.items).toEqual([])
expect(result.total).toBe(0)
})
it('returns pending initial-session payment with null mitra info', async () => {
const pay = await createPaymentSession({
customerId: customer.id,
durationMinutes: 15,
amount: 5000,
})
const result = await getCustomerPendingPayments(customer.id)
expect(result.total).toBe(1)
expect(result.items[0]).toMatchObject({
id: pay.id,
is_extension: false,
amount: 5000,
duration_minutes: 15,
mode: 'chat',
mitra_id: null,
mitra_display_name: null,
})
})
it('fills mitra info from targeted_mitra_id for targeted initial payments', async () => {
const mitra = await createMitra({ callName: 'kak Dimas' })
await createPaymentSession({
customerId: customer.id,
durationMinutes: 30,
amount: 10000,
targetedMitraId: mitra.id,
})
const result = await getCustomerPendingPayments(customer.id)
expect(result.total).toBe(1)
expect(result.items[0].mitra_id).toBe(mitra.id)
expect(result.items[0].mitra_display_name).toBe('kak Dimas')
})
it('fills mitra info via session_extensions → chat_sessions for extension payments', async () => {
const sql = db()
const mitra = await createMitra({ callName: 'kak Sari' })
// The initial chat session this extension belongs to.
const [chatSession] = await sql`
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes)
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.ACTIVE}, 12)
RETURNING id
`
const extPay = await createPaymentSession({
customerId: customer.id,
durationMinutes: 10,
amount: 2500,
isExtension: true,
})
await sql`
INSERT INTO session_extensions (
session_id, requested_duration_minutes, requested_price, status, payment_session_id
)
VALUES (${chatSession.id}, 10, 2500, 'pending', ${extPay.id})
`
const result = await getCustomerPendingPayments(customer.id)
expect(result.total).toBe(1)
expect(result.items[0]).toMatchObject({
id: extPay.id,
is_extension: true,
amount: 2500,
mitra_id: mitra.id,
mitra_display_name: 'kak Sari',
})
})
it('orders newest first and returns mixed initial + extension rows', async () => {
const sql = db()
const mitra = await createMitra({ callName: 'kak Sari' })
// Initial first
const initial = await createPaymentSession({
customerId: customer.id,
durationMinutes: 15,
amount: 5000,
})
// Then extension (newer)
const [chatSession] = await sql`
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes)
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.ACTIVE}, 12)
RETURNING id
`
const extension = await createPaymentSession({
customerId: customer.id,
durationMinutes: 10,
amount: 2500,
isExtension: true,
})
await sql`
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_session_id)
VALUES (${chatSession.id}, 10, 2500, 'pending', ${extension.id})
`
const result = await getCustomerPendingPayments(customer.id)
expect(result.total).toBe(2)
// Newest first
expect(result.items[0].id).toBe(extension.id)
expect(result.items[1].id).toBe(initial.id)
})
it('excludes expired pending rows (defensive filter on expires_at)', async () => {
const sql = db()
const pay = await createPaymentSession({
customerId: customer.id,
durationMinutes: 15,
amount: 5000,
})
// Manually move expires_at into the past — leaves status pending so this
// simulates the gap between TTL expiry and the next sweep tick.
await sql`
UPDATE payment_sessions
SET expires_at = NOW() - INTERVAL '1 second'
WHERE id = ${pay.id}
`
const result = await getCustomerPendingPayments(customer.id)
expect(result.total).toBe(0)
})
it('excludes non-pending statuses', async () => {
const pay = await createPaymentSession({
customerId: customer.id,
durationMinutes: 15,
amount: 5000,
})
await confirmPaymentSession(pay.id, customer.id) // → confirmed
const result = await getCustomerPendingPayments(customer.id)
expect(result.total).toBe(0)
})
it('scopes by customer — does not leak other customers payments', async () => {
await createPaymentSession({
customerId: customer.id,
durationMinutes: 15,
amount: 5000,
})
await createPaymentSession({
customerId: otherCustomer.id,
durationMinutes: 30,
amount: 10000,
})
const result = await getCustomerPendingPayments(customer.id)
expect(result.total).toBe(1)
expect(result.items[0].amount).toBe(5000)
})
})
})

View File

@@ -0,0 +1,167 @@
import { describe, it, expect, beforeAll, beforeEach } from 'vitest'
import { getCustomerHistory } from '../../src/services/session.service.js'
import { SessionStatus } from '../../src/constants.js'
import { resetDb, resetAppConfig, db } from '../helpers/db.js'
import { createCustomer, createMitra } from '../helpers/fixtures.js'
// Phase 4 Stage 10 — Chat Tab Selesai feed uses cursor pagination.
// Cursor is base64url(`<endedAtIso>|<id>`); response is { items, next_cursor, has_more }.
describe('session.service.getCustomerHistory (cursor paginated)', () => {
let customer
let mitra
beforeAll(async () => {
await resetAppConfig()
})
beforeEach(async () => {
await resetDb()
customer = await createCustomer({ callName: 'Alice' })
mitra = await createMitra({ callName: 'kak Sari' })
})
// Seed N completed sessions ended at deterministically spaced times.
// i=0 is the OLDEST, i=N-1 is the NEWEST.
const seedCompleted = async (count) => {
const sql = db()
const now = Date.now()
const ids = []
for (let i = 0; i < count; i++) {
const endedAt = new Date(now - (count - i) * 60_000) // 1 min apart
const [row] = await sql`
INSERT INTO chat_sessions (
customer_id, mitra_id, status, duration_minutes, price, ended_at, created_at
)
VALUES (
${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000,
${endedAt}, ${endedAt}
)
RETURNING id
`
ids.push(row.id)
}
return ids
}
it('returns empty + has_more=false when there is no history', async () => {
const result = await getCustomerHistory(customer.id, { limit: 10 })
expect(result.items).toEqual([])
expect(result.has_more).toBe(false)
expect(result.next_cursor).toBeNull()
})
it('returns all items + has_more=false when count <= limit', async () => {
await seedCompleted(5)
const result = await getCustomerHistory(customer.id, { limit: 10 })
expect(result.items).toHaveLength(5)
expect(result.has_more).toBe(false)
expect(result.next_cursor).toBeNull()
})
it('first page returns `limit` items, has_more=true, and a usable cursor', async () => {
await seedCompleted(7)
const page1 = await getCustomerHistory(customer.id, { limit: 3 })
expect(page1.items).toHaveLength(3)
expect(page1.has_more).toBe(true)
expect(page1.next_cursor).toBeTruthy()
})
it('walks across pages without duplicates or skips', async () => {
const seeded = await seedCompleted(7) // ids[0]=oldest .. ids[6]=newest
const collected = []
let cursor = null
do {
const page = await getCustomerHistory(customer.id, { cursor, limit: 3 })
collected.push(...page.items.map((r) => r.id))
cursor = page.next_cursor
if (!page.has_more) break
} while (cursor)
// Newest → oldest = reverse of seeded
expect(collected).toEqual([...seeded].reverse())
// No duplicates
expect(new Set(collected).size).toBe(collected.length)
})
it('orders by ended_at DESC with id DESC tiebreak (no gaps for same-timestamp rows)', async () => {
const sql = db()
const sameTime = new Date()
// Insert 3 rows with the EXACT same ended_at — only id distinguishes them.
const ids = []
for (let i = 0; i < 3; i++) {
const [row] = await sql`
INSERT INTO chat_sessions (
customer_id, mitra_id, status, duration_minutes, price, ended_at, created_at
)
VALUES (
${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000,
${sameTime}, ${sameTime}
)
RETURNING id
`
ids.push(row.id)
}
// Page through them 1 at a time.
const collected = []
let cursor = null
for (let i = 0; i < 5; i++) {
const page = await getCustomerHistory(customer.id, { cursor, limit: 1 })
collected.push(...page.items.map((r) => r.id))
cursor = page.next_cursor
if (!page.has_more) break
}
expect(collected).toHaveLength(3)
expect(new Set(collected).size).toBe(3)
})
it('clamps limit to 50 max', async () => {
await seedCompleted(60)
const result = await getCustomerHistory(customer.id, { limit: 999 })
expect(result.items.length).toBeLessThanOrEqual(50)
})
it('scopes by customer — does not leak other customers history', async () => {
const other = await createCustomer({ callName: 'Bob' })
const sql = db()
await sql`
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at)
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, NOW())
`
await sql`
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at)
VALUES (${other.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, NOW())
`
const result = await getCustomerHistory(customer.id, { limit: 20 })
expect(result.items).toHaveLength(1)
})
it('includes CLOSING status (grace period) alongside COMPLETED', async () => {
const sql = db()
await sql`
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at)
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.CLOSING}, 12, 5000, NOW())
`
await sql`
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at)
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, NOW())
`
const result = await getCustomerHistory(customer.id, { limit: 20 })
expect(result.items).toHaveLength(2)
})
it('excludes ACTIVE / PENDING_PAYMENT / EXTENDING', async () => {
const sql = db()
for (const status of [SessionStatus.ACTIVE, SessionStatus.PENDING_PAYMENT, SessionStatus.EXTENDING]) {
await sql`
INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price)
VALUES (${customer.id}, ${mitra.id}, ${status}, 12, 5000)
`
}
const result = await getCustomerHistory(customer.id, { limit: 20 })
expect(result.items).toHaveLength(0)
})
})