Fix attachment previews and comment permissions

This commit is contained in:
Esdras Renan 2025-10-06 23:41:03 -03:00
parent 1cccb852a5
commit e491becbc4
4 changed files with 62 additions and 11 deletions

View file

@ -723,6 +723,10 @@ export const updateComment = mutation({
throw new ConvexError("Ticket não encontrado") throw new ConvexError("Ticket não encontrado")
} }
const ticketDoc = ticket as Doc<"tickets"> const ticketDoc = ticket as Doc<"tickets">
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
if (!actor || actor.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Autor do comentário inválido")
}
const comment = await ctx.db.get(commentId); const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) { if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado"); throw new ConvexError("Comentário não encontrado");
@ -730,8 +734,15 @@ export const updateComment = mutation({
if (comment.authorId !== actorId) { if (comment.authorId !== actorId) {
throw new ConvexError("Você não tem permissão para editar este comentário"); throw new ConvexError("Você não tem permissão para editar este comentário");
} }
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
if (ticketDoc.requesterId === actorId) { if (ticketDoc.requesterId === actorId) {
if (normalizedRole === "CUSTOMER") {
await requireCustomer(ctx, actorId, ticketDoc.tenantId) await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else if (STAFF_ROLES.has(normalizedRole)) {
await requireTicketStaff(ctx, actorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para editar")
}
} else { } else {
await requireTicketStaff(ctx, actorId, ticketDoc) await requireTicketStaff(ctx, actorId, ticketDoc)
} }
@ -742,7 +753,6 @@ export const updateComment = mutation({
updatedAt: now, updatedAt: now,
}); });
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "COMMENT_EDITED", type: "COMMENT_EDITED",
@ -772,6 +782,10 @@ export const removeCommentAttachment = mutation({
throw new ConvexError("Ticket não encontrado") throw new ConvexError("Ticket não encontrado")
} }
const ticketDoc = ticket as Doc<"tickets"> const ticketDoc = ticket as Doc<"tickets">
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
if (!actor || actor.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Autor do comentário inválido")
}
const comment = await ctx.db.get(commentId); const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) { if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado"); throw new ConvexError("Comentário não encontrado");
@ -780,8 +794,15 @@ export const removeCommentAttachment = mutation({
throw new ConvexError("Você não pode alterar anexos de outro usuário") throw new ConvexError("Você não pode alterar anexos de outro usuário")
} }
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
if (ticketDoc.requesterId === actorId) { if (ticketDoc.requesterId === actorId) {
if (normalizedRole === "CUSTOMER") {
await requireCustomer(ctx, actorId, ticketDoc.tenantId) await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else if (STAFF_ROLES.has(normalizedRole)) {
await requireTicketStaff(ctx, actorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para alterar anexos")
}
} else { } else {
await requireTicketStaff(ctx, actorId, ticketDoc) await requireTicketStaff(ctx, actorId, ticketDoc)
} }
@ -800,7 +821,6 @@ export const removeCommentAttachment = mutation({
updatedAt: now, updatedAt: now,
}); });
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "ATTACHMENT_REMOVED", type: "ATTACHMENT_REMOVED",

View file

@ -2,7 +2,7 @@ import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import { TicketDetailView } from "@/components/tickets/ticket-detail-view" import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static" import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog" import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client"
import { getTicketById } from "@/lib/mocks/tickets" import { getTicketById } from "@/lib/mocks/tickets"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"
@ -22,7 +22,7 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps
title={`Ticket #${id}`} title={`Ticket #${id}`}
lead={"Detalhes do ticket"} lead={"Detalhes do ticket"}
secondaryAction={<SiteHeader.SecondaryButton>Compartilhar</SiteHeader.SecondaryButton>} secondaryAction={<SiteHeader.SecondaryButton>Compartilhar</SiteHeader.SecondaryButton>}
primaryAction={<NewTicketDialog />} primaryAction={<NewTicketDialogDeferred />}
/> />
} }
> >

View file

@ -0,0 +1,30 @@
"use client"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { NewTicketDialog } from "./new-ticket-dialog"
export function NewTicketDialogDeferred() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<Button
size="sm"
className="rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
disabled
aria-disabled
>
Novo ticket
</Button>
)
}
return <NewTicketDialog />
}

View file

@ -31,18 +31,19 @@ export function Dropzone({
const startUpload = useCallback(async (files: FileList | File[]) => { const startUpload = useCallback(async (files: FileList | File[]) => {
const list = Array.from(files).slice(0, maxFiles); const list = Array.from(files).slice(0, maxFiles);
const url = await generateUrl({});
const uploaded: Uploaded[] = []; const uploaded: Uploaded[] = [];
for (const file of list) { for (const file of list) {
if (file.size > maxSize) continue; if (file.size > maxSize) continue;
const url = await generateUrl({});
const id = `${file.name}-${file.size}-${Date.now()}`; const id = `${file.name}-${file.size}-${Date.now()}`;
const localPreview = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined; const localPreview = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
setItems((prev) => [...prev, { id, name: file.name, progress: 0, status: "uploading" }]); setItems((prev) => [...prev, { id, name: file.name, progress: 0, status: "uploading" }]);
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const form = new FormData();
form.append("file", file);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", url); xhr.open("POST", url);
if (file.type) {
xhr.setRequestHeader("Content-Type", file.type);
}
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return; if (!e.lengthComputable) return;
const progress = Math.round((e.loaded / e.total) * 100); const progress = Math.round((e.loaded / e.total) * 100);
@ -66,7 +67,7 @@ export function Dropzone({
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it))); setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
resolve(); resolve();
}; };
xhr.send(form); xhr.send(file);
}); });
} }
if (uploaded.length) onUploaded?.(uploaded); if (uploaded.length) onUploaded?.(uploaded);