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
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client";

import { useState } from "react";
import { HiPaperAirplane } from "react-icons/hi";
import type { I18nRecord } from "@/features/i18n/i18n.service.types";
import { tr } from "@/features/i18n/tr.service";
import type { Conversation } from "../conversation.types";
import { sendTextMessage } from "../inbox-data-service";

/**
* Agent reply box at the foot of the thread. Free-text is only valid inside WhatsApp's 24h window, so
* when {@code windowOpen} is false it collapses to a hint (a template would be needed to reopen). The
* reply carries the thread's service code so it lands on the same service thread. On success it clears
* and asks the parent to refresh; a rejected send shows the modulith's reason inline.
*/
export default function ComposeBox({
conversation,
windowOpen,
onSent,
dict,
}: {
readonly conversation: Conversation;
readonly windowOpen: boolean;
readonly onSent: () => void;
readonly dict: I18nRecord;
}) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);

const canSend = windowOpen && !sending && text.trim().length > 0;

async function handleSend() {
if (!canSend) {
return;
}
setSending(true);
setError(null);
try {
await sendTextMessage({
to: conversation.phoneE164,
body: text.trim(),
serviceCode: conversation.contextServiceCode,
driverId: conversation.driverId,
});
setText("");
onSent();
} catch (e) {
setError(e instanceof Error ? e.message : tr("sendError", dict));
} finally {
setSending(false);
}
}
Comment on lines +33 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Guard against duplicate sends from Enter-key auto-repeat.

canSend is a render-time snapshot; rapid/held Enter presses can fire handleSend again before the sending state update commits, bypassing the button's disabled guard and issuing a duplicate WhatsApp send. A synchronous ref check closes this gap.

🔒 Proposed fix
+  const sendingRef = useRef(false);
+
   async function handleSend() {
-    if (!canSend) {
+    if (!canSend || sendingRef.current) {
       return;
     }
+    sendingRef.current = true;
     setSending(true);
     setError(null);
     try {
       await sendTextMessage({
         to: conversation.phoneE164,
         body: text.trim(),
         serviceCode: conversation.contextServiceCode,
         driverId: conversation.driverId,
       });
       setText("");
       onSent();
     } catch (e) {
       setError(e instanceof Error ? e.message : tr("sendError", dict));
     } finally {
       setSending(false);
+      sendingRef.current = false;
     }
   }

(Add useRef to the react import.)

Also applies to: 70-75

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@turbo-repo/apps/app/src/features/whatsapp-inbox/components/compose-box.tsx`
around lines 33 - 53, Prevent duplicate WhatsApp sends in handleSend by adding a
synchronous in-flight guard with a ref, since canSend and sending are
render-time state and Enter auto-repeat can re-enter before disable commits.
Update compose-box.tsx by using a useRef-based lock inside handleSend (and clear
it in finally) alongside the existing sending state, and add useRef to the react
import so the guard is checked before calling sendTextMessage.


if (!windowOpen) {
return (
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{tr("windowClosedHint", dict)}
</div>
);
}

return (
<div className="border-t border-gray-200 dark:border-gray-700 p-3">
{error && <p className="mb-1 text-xs text-red-600 dark:text-red-400">{error}</p>}
<div className="flex items-end gap-2">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
rows={1}
placeholder={tr("composePlaceholder", dict)}
className="flex-1 resize-none rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-green-500"
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={!canSend}
aria-label={tr("send", dict)}
className="shrink-0 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-green-500 text-white hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<HiPaperAirplane className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import { tr } from "@/features/i18n/tr.service";
import type { Conversation, Message, MessageStatus } from "../conversation.types";
import { conversationName, formatClockTime } from "../format";
import { useMessages } from "../use-messages";
import { useSessionWindow } from "../use-session-window";
import { markConversationRead } from "../inbox-data-service";
import ServiceBadge from "./service-badge";
import SessionCountdown from "./session-countdown";
import ComposeBox from "./compose-box";

interface MessageThreadProps {
readonly conversationId: string | null;
Expand All @@ -33,7 +36,8 @@ export default function MessageThread({
locale,
onRead,
}: MessageThreadProps) {
const { messages, isLoading, error } = useMessages(conversationId);
const { messages, isLoading, error, refresh: refreshMessages } = useMessages(conversationId);
const windowInfo = useSessionWindow(conversation?.sessionExpiresAt ?? null);

const unreadCount = conversation?.unreadCount ?? 0;
useEffect(() => {
Expand Down Expand Up @@ -63,6 +67,7 @@ export default function MessageThread({
{conversation.contextServiceCode && (
<ServiceBadge code={conversation.contextServiceCode} dict={dict} />
)}
{windowInfo && <SessionCountdown state={windowInfo} dict={dict} />}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">{conversation.phoneE164}</p>
</div>
Expand All @@ -80,6 +85,16 @@ export default function MessageThread({
<MessageBubble key={message.id} message={message} dict={dict} locale={locale} />
))}
</div>

<ComposeBox
conversation={conversation}
windowOpen={windowInfo === null ? true : windowInfo.status !== "closed"}
onSent={() => {
void refreshMessages();
onRead();
}}
dict={dict}
/>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { HiOutlineClock } from "react-icons/hi";
import type { I18nRecord } from "@/features/i18n/i18n.service.types";
import { tr } from "@/features/i18n/tr.service";
import { formatRemaining, type WindowState, type WindowStatus } from "../session-window";

const STYLES: Record<WindowStatus, string> = {
open: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
closingSoon: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
closed: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
};

const LABEL_KEY: Record<WindowStatus, string> = {
open: "windowOpen",
closingSoon: "windowClosingSoon",
closed: "windowClosed",
};

/**
* Header pill for the 24h reply window: green while comfortably open (with a live `Hh Mm` countdown),
* amber in the last 2h, grey once closed. Purely presentational — {@link useSessionWindow} owns the
* clock.
*/
export default function SessionCountdown({
state,
dict,
}: {
readonly state: WindowState;
readonly dict: I18nRecord;
}) {
const label =
state.status === "closed"
? tr("windowClosed", dict)
: `${tr(LABEL_KEY[state.status], dict)} · ${formatRemaining(state.remainingMs)}`;
return (
<span
className={`shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold ${STYLES[state.status]}`}
title={tr("windowHint", dict)}
>
<HiOutlineClock className="h-3 w-3" aria-hidden />
{label}
</span>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,41 @@ export async function markConversationRead(conversationId: string): Promise<void
throw new Error(`Mark-read for ${conversationId} failed (${res.status})`);
}
}

export interface SendTextInput {
readonly to: string;
readonly body: string;
/** Kept so the reply lands on the same service thread (null = the unassigned thread). */
readonly serviceCode: string | null;
readonly driverId: string | null;
}

/**
* Sends a free-text agent reply for the active org (proxied to `POST …/whatsapp/messages`). Surfaces
* the modulith's error message on failure — e.g. a test-mode allow-list rejection — so the operator
* sees why. Note the modulith persists a FAILED row on a rejected send, which the thread poll surfaces.
*/
export async function sendTextMessage(input: SendTextInput): Promise<Message> {
const res = await fetch("/app/api/whatsapp/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: input.to,
type: "TEXT",
body: input.body,
serviceCode: input.serviceCode,
driverId: input.driverId,
}),
});
if (!res.ok) {
let message = `Send failed (${res.status})`;
try {
const data = (await res.json()) as { error?: string };
if (data?.error) message = data.error;
} catch {
/* non-JSON error body — keep the status-based message */
}
throw new Error(message);
}
return (await res.json()) as Message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { formatRemaining, windowState } from "./session-window";

const NOW = Date.parse("2026-06-30T12:00:00Z");
const hours = (h: number) => new Date(NOW + h * 60 * 60 * 1000).toISOString();

describe("windowState", () => {
it("is closed when there is no session (never opened by an inbound)", () => {
expect(windowState(null, NOW).status).toBe("closed");
});

it("is closed when the expiry is in the past", () => {
expect(windowState(hours(-1), NOW).status).toBe("closed");
});

it("is closed for an unparseable timestamp", () => {
expect(windowState("not-a-date", NOW).status).toBe("closed");
});

it("is open with more than 2h remaining", () => {
const state = windowState(hours(5), NOW);
expect(state.status).toBe("open");
expect(state.remainingMs).toBe(5 * 60 * 60 * 1000);
});

it("is closingSoon within the last 2h", () => {
expect(windowState(hours(1), NOW).status).toBe("closingSoon");
});
});

describe("formatRemaining", () => {
it("shows hours and minutes when an hour or more is left", () => {
expect(formatRemaining(5 * 60 * 60 * 1000 + 12 * 60 * 1000)).toBe("5h 12m");
});

it("shows only minutes under an hour", () => {
expect(formatRemaining(45 * 60 * 1000)).toBe("45m");
});

it("is empty once closed", () => {
expect(formatRemaining(0)).toBe("");
});
});
43 changes: 43 additions & 0 deletions turbo-repo/apps/app/src/features/whatsapp-inbox/session-window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* WhatsApp's customer-service rule: free-form (non-template) messages are only allowed within 24h of
* the driver's last inbound message. The modulith stores that expiry as `sessionExpiresAt` (bumped
* on every inbound). These pure helpers turn it into a UI state so the compose box can enable/disable
* free-text and the header can show a live countdown.
*/

export type WindowStatus = "open" | "closingSoon" | "closed";

/** Amber threshold — warn the operator when under 2h of the window remain. */
const CLOSING_SOON_MS = 2 * 60 * 60 * 1000;

export interface WindowState {
readonly status: WindowStatus;
/** Milliseconds until the window closes; 0 once closed. */
readonly remainingMs: number;
}

export function windowState(sessionExpiresAt: string | null, nowMs: number): WindowState {
if (!sessionExpiresAt) {
return { status: "closed", remainingMs: 0 };
}
const expiry = new Date(sessionExpiresAt).getTime();
if (Number.isNaN(expiry)) {
return { status: "closed", remainingMs: 0 };
}
const remainingMs = expiry - nowMs;
if (remainingMs <= 0) {
return { status: "closed", remainingMs: 0 };
}
return { status: remainingMs <= CLOSING_SOON_MS ? "closingSoon" : "open", remainingMs };
}

/** Compact remaining label: `Hh Mm` when an hour or more is left, otherwise `Mm`. Empty when closed. */
export function formatRemaining(remainingMs: number): string {
if (remainingMs <= 0) {
return "";
}
const totalMinutes = Math.floor(remainingMs / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { useEffect, useState } from "react";
import { windowState, type WindowState } from "./session-window";

const TICK_MS = 30_000;

/**
* Live 24h-session state, recomputed on mount and every 30s. Returns {@code null} until mounted so
* the server and first client render agree on this time-based value (no hydration mismatch); callers
* treat {@code null} as "not yet known".
*/
export function useSessionWindow(sessionExpiresAt: string | null): WindowState | null {
const [nowMs, setNowMs] = useState<number | null>(null);

useEffect(() => {
setNowMs(Date.now());
const id = setInterval(() => setNowMs(Date.now()), TICK_MS);
return () => clearInterval(id);
}, []);

return nowMs === null ? null : windowState(sessionExpiresAt, nowMs);
}
10 changes: 9 additions & 1 deletion turbo-repo/apps/app/src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4020,7 +4020,15 @@
"statusSent": "Sent",
"statusDelivered": "Delivered",
"statusRead": "Read",
"statusFailed": "Failed to deliver"
"statusFailed": "Failed to deliver",
"composePlaceholder": "Type a reply…",
"send": "Send",
"sendError": "Couldn't send the message.",
"windowOpen": "Open",
"windowClosingSoon": "Closing soon",
"windowClosed": "Window closed",
"windowHint": "Free replies are allowed for 24h after the driver's last message.",
"windowClosedHint": "The 24h reply window has closed — only an approved template can reopen the conversation."
},
"ext_tasks": {
"processing": "Processing your request...",
Expand Down
10 changes: 9 additions & 1 deletion turbo-repo/apps/app/src/lang/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -4020,7 +4020,15 @@
"statusSent": "Enviado",
"statusDelivered": "Entregado",
"statusRead": "Leído",
"statusFailed": "Falló el envío"
"statusFailed": "Falló el envío",
"composePlaceholder": "Escribe una respuesta…",
"send": "Enviar",
"sendError": "No se pudo enviar el mensaje.",
"windowOpen": "Abierta",
"windowClosingSoon": "Por cerrar",
"windowClosed": "Ventana cerrada",
"windowHint": "Puedes responder libremente hasta 24 h después del último mensaje del conductor.",
"windowClosedHint": "La ventana de 24 h se cerró — solo una plantilla aprobada puede reabrir la conversación."
},
"ext_tasks": {
"processing": "Procesando tu solicitud...",
Expand Down
Loading