Implement ticket reply functionality
Backend:
- POST /:id/comment endpoint accepting {body, internal?, creator_id?}
- internal=false → Correspond (public reply), internal=true → Comment
- Runs scrip engine on the new transaction so notifications fire
- CommentSchema zod validation
Frontend:
- sendComment() API function in lib/api.ts
- Send button wired with onClick, sending spinner, disabled state
- Error display below reply box, clears on new typing
- Refreshes transaction list after successful send
- Reply/Internal note mode passed as internal flag
This commit is contained in:
@@ -14,3 +14,9 @@ export const UpdateTicketSchema = z.object({
|
|||||||
status: z.string().min(1).optional(),
|
status: z.string().min(1).optional(),
|
||||||
owner_id: z.string().uuid().optional(),
|
owner_id: z.string().uuid().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CommentSchema = z.object({
|
||||||
|
body: z.string().min(1),
|
||||||
|
creator_id: z.string().optional().default('00000000-0000-0000-0000-000000000000'),
|
||||||
|
internal: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { HTTPException } from 'hono/http-exception';
|
|||||||
import type { Db } from '../db/index.ts';
|
import type { Db } from '../db/index.ts';
|
||||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles } from '../db/schema.ts';
|
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles } from '../db/schema.ts';
|
||||||
import { eq, asc } from 'drizzle-orm';
|
import { eq, asc } from 'drizzle-orm';
|
||||||
import { CreateTicketSchema, UpdateTicketSchema } from '../models/ticket.ts';
|
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
||||||
import { ScripEngine } from '../scrip/engine.ts';
|
import { ScripEngine } from '../scrip/engine.ts';
|
||||||
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
||||||
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
||||||
@@ -231,5 +231,40 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
return c.json(result);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /:id/comment — add a comment (reply or internal note)
|
||||||
|
router.post('/:id/comment', async (c) => {
|
||||||
|
const id = Number(c.req.param('id'));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = CommentSchema.parse(body);
|
||||||
|
|
||||||
|
const ticket = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionType = parsed.internal ? 'Comment' : 'Correspond';
|
||||||
|
|
||||||
|
const [tx] = await db.insert(transactions).values({
|
||||||
|
ticket_id: id,
|
||||||
|
transaction_type: transactionType,
|
||||||
|
data: { body: parsed.body },
|
||||||
|
creator_id: parsed.creator_id,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!tx) {
|
||||||
|
throw new HTTPException(500, { message: 'Failed to create comment' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run scrips
|
||||||
|
const txList = [tx];
|
||||||
|
const prepared = await scripEngine.prepare(id, txList as any);
|
||||||
|
await scripEngine.commit(prepared);
|
||||||
|
|
||||||
|
return c.json(tx, 201);
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getQueues,
|
getQueues,
|
||||||
previewTicket,
|
previewTicket,
|
||||||
updateTicket,
|
updateTicket,
|
||||||
|
sendComment,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
Ticket,
|
Ticket,
|
||||||
@@ -164,6 +165,8 @@ export default function TicketDetailPage({
|
|||||||
// Reply
|
// Reply
|
||||||
const [replyText, setReplyText] = useState("");
|
const [replyText, setReplyText] = useState("");
|
||||||
const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
|
const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Status change
|
// Status change
|
||||||
const [statusSelectOpen, setStatusSelectOpen] = useState(false);
|
const [statusSelectOpen, setStatusSelectOpen] = useState(false);
|
||||||
@@ -258,6 +261,28 @@ export default function TicketDetailPage({
|
|||||||
setScripResults(null);
|
setScripResults(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendComment = async () => {
|
||||||
|
if (!replyText.trim() || sending) return;
|
||||||
|
setSending(true);
|
||||||
|
setSendError(null);
|
||||||
|
|
||||||
|
const { error } = await sendComment(id, {
|
||||||
|
body: replyText.trim(),
|
||||||
|
internal: replyMode === "internal",
|
||||||
|
});
|
||||||
|
|
||||||
|
setSending(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setSendError(error);
|
||||||
|
} else {
|
||||||
|
setReplyText("");
|
||||||
|
setSendError(null);
|
||||||
|
const txRes = await getTicketTransactions(id);
|
||||||
|
if (txRes.data) setTransactions(txRes.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -378,7 +403,10 @@ export default function TicketDetailPage({
|
|||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
value={replyText}
|
value={replyText}
|
||||||
onChange={(e) => setReplyText(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setReplyText(e.target.value);
|
||||||
|
setSendError(null);
|
||||||
|
}}
|
||||||
placeholder="Reply to this ticket..."
|
placeholder="Reply to this ticket..."
|
||||||
rows={2}
|
rows={2}
|
||||||
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary resize-none transition-all duration-150"
|
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary resize-none transition-all duration-150"
|
||||||
@@ -388,18 +416,30 @@ export default function TicketDetailPage({
|
|||||||
<PaperclipIcon className="w-4 h-4" />
|
<PaperclipIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={!replyText.trim()}
|
onClick={handleSendComment}
|
||||||
|
disabled={!replyText.trim() || sending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-150",
|
"w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-150",
|
||||||
replyText.trim()
|
replyText.trim() && !sending
|
||||||
? "bg-primary text-primary-foreground hover:bg-primary/80"
|
? "bg-primary text-primary-foreground hover:bg-primary/80"
|
||||||
: "bg-muted text-muted-foreground cursor-not-allowed"
|
: "bg-muted text-muted-foreground cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{sending ? (
|
||||||
|
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
<SendIcon className="w-4 h-4" />
|
<SendIcon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sendError && (
|
||||||
|
<p className="text-xs text-destructive mt-2">{sendError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export async function getTicketTransactions(id: number): Promise<{ data: Transac
|
|||||||
return request<Transaction[]>(`/tickets/${id}/transactions`);
|
return request<Transaction[]>(`/tickets/${id}/transactions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendComment(id: number, data: { body: string; internal?: boolean }): Promise<{ data: Transaction | null; error: string | null }> {
|
||||||
|
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
|
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
|
||||||
return request<Queue[]>("/queues");
|
return request<Queue[]>("/queues");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user