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:
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 } = {}) => {
|
||||
|
||||
Reference in New Issue
Block a user