feat: melhora página de perfil e integra preferências de notificação
- Atualiza cores das badges para padrão cyan do projeto - Adiciona degradê no header do card de perfil - Implementa upload de foto de perfil via API Convex - Integra notificações do Convex com preferências do usuário - Cria API /api/notifications/send para verificar preferências - Melhora layout das páginas de login/recuperação com degradê - Adiciona badge "Helpdesk" e título "Raven" consistente 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1bc08d3a5f
commit
ab7dfa81ca
8 changed files with 543 additions and 59 deletions
|
|
@ -8,6 +8,45 @@ import { v } from "convex/values"
|
|||
import { renderSimpleNotificationEmailHtml } from "./reactEmail"
|
||||
import { buildBaseUrl } from "./url"
|
||||
|
||||
// API do Next.js para verificar preferências
|
||||
async function sendViaNextApi(params: {
|
||||
type: string
|
||||
to: { email: string; name?: string; userId?: string }
|
||||
subject: string
|
||||
data: Record<string, unknown>
|
||||
tenantId?: string
|
||||
}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
|
||||
|
||||
if (!token) {
|
||||
console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente")
|
||||
return { success: false, reason: "no_token" }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/notifications/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error("[ticketNotifications] Erro na API:", error)
|
||||
return { success: false, reason: "api_error" }
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("[ticketNotifications] Erro ao chamar API:", error)
|
||||
return { success: false, reason: "fetch_error" }
|
||||
}
|
||||
}
|
||||
|
||||
function b64(input: string) {
|
||||
return Buffer.from(input, "utf8").toString("base64")
|
||||
}
|
||||
|
|
@ -284,17 +323,15 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html:
|
|||
export const sendTicketCreatedEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
userId: v.optional(v.string()),
|
||||
userName: v.optional(v.string()),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
priority: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject, priority }) => {
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket created email")
|
||||
return { skipped: true }
|
||||
}
|
||||
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
|
||||
|
|
@ -305,8 +342,34 @@ export const sendTicketCreatedEmail = action({
|
|||
URGENT: "Urgente",
|
||||
}
|
||||
const priorityLabel = priorityLabels[priority] ?? priority
|
||||
|
||||
const mailSubject = `Novo chamado #${reference} aberto`
|
||||
|
||||
// Tenta usar a API do Next.js para verificar preferências
|
||||
const apiResult = await sendViaNextApi({
|
||||
type: "ticket_created",
|
||||
to: { email: to, name: userName, userId },
|
||||
subject: mailSubject,
|
||||
data: {
|
||||
reference,
|
||||
subject,
|
||||
status: "Pendente",
|
||||
priority: priorityLabel,
|
||||
viewUrl: url,
|
||||
},
|
||||
tenantId,
|
||||
})
|
||||
|
||||
if (apiResult.success || apiResult.skipped) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
// Fallback: envia diretamente se a API falhar
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket created email")
|
||||
return { skipped: true }
|
||||
}
|
||||
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Novo chamado #${reference} aberto`,
|
||||
message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`,
|
||||
|
|
@ -321,22 +384,45 @@ export const sendTicketCreatedEmail = action({
|
|||
export const sendPublicCommentEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
userId: v.optional(v.string()),
|
||||
userName: v.optional(v.string()),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
||||
|
||||
// Tenta usar a API do Next.js para verificar preferências
|
||||
const apiResult = await sendViaNextApi({
|
||||
type: "comment_public",
|
||||
to: { email: to, name: userName, userId },
|
||||
subject: mailSubject,
|
||||
data: {
|
||||
reference,
|
||||
subject,
|
||||
viewUrl: url,
|
||||
},
|
||||
tenantId,
|
||||
})
|
||||
|
||||
if (apiResult.success || apiResult.skipped) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
// Fallback: envia diretamente se a API falhar
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket comment email")
|
||||
return { skipped: true }
|
||||
}
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
||||
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Nova atualização no seu chamado #${reference}`,
|
||||
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
|
||||
message: `Um novo comentário foi adicionado ao chamado "${subject}". Clique abaixo para visualizar e responder pelo portal.`,
|
||||
ctaLabel: "Abrir e responder",
|
||||
ctaUrl: url,
|
||||
})
|
||||
|
|
@ -348,22 +434,45 @@ export const sendPublicCommentEmail = action({
|
|||
export const sendResolvedEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
userId: v.optional(v.string()),
|
||||
userName: v.optional(v.string()),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||
|
||||
// Tenta usar a API do Next.js para verificar preferências
|
||||
const apiResult = await sendViaNextApi({
|
||||
type: "ticket_resolved",
|
||||
to: { email: to, name: userName, userId },
|
||||
subject: mailSubject,
|
||||
data: {
|
||||
reference,
|
||||
subject,
|
||||
viewUrl: url,
|
||||
},
|
||||
tenantId,
|
||||
})
|
||||
|
||||
if (apiResult.success || apiResult.skipped) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
// Fallback: envia diretamente se a API falhar
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket resolution email")
|
||||
return { skipped: true }
|
||||
}
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Chamado #${reference} encerrado`,
|
||||
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
||||
message: `O chamado "${subject}" foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
||||
ctaLabel: "Ver detalhes",
|
||||
ctaUrl: url,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2464,10 +2464,13 @@ export const create = mutation({
|
|||
if (typeof schedulerRunAfter === "function") {
|
||||
await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
|
||||
to: requesterEmail,
|
||||
userId: String(requester._id),
|
||||
userName: requester.name ?? undefined,
|
||||
ticketId: String(id),
|
||||
reference: nextRef,
|
||||
subject,
|
||||
priority: args.priority,
|
||||
tenantId: args.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2870,15 +2873,19 @@ export const addComment = mutation({
|
|||
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
|
||||
// Notificação por e-mail: comentário público para o solicitante
|
||||
try {
|
||||
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
|
||||
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
|
||||
const snapshotEmail = requesterSnapshot?.email
|
||||
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
|
||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||
if (typeof schedulerRunAfter === "function") {
|
||||
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
|
||||
to: snapshotEmail,
|
||||
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||
userName: requesterSnapshot?.name ?? undefined,
|
||||
ticketId: String(ticketDoc._id),
|
||||
reference: ticketDoc.reference ?? 0,
|
||||
subject: ticketDoc.subject ?? "",
|
||||
tenantId: ticketDoc.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -3146,16 +3153,21 @@ export async function resolveTicketHandler(
|
|||
|
||||
// Notificação por e-mail: encerramento do chamado
|
||||
try {
|
||||
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
|
||||
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
|
||||
const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null
|
||||
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
|
||||
const email = requesterDoc?.email || requesterSnapshot?.email || null
|
||||
const userName = requesterDoc?.name || requesterSnapshot?.name || undefined
|
||||
if (email) {
|
||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||
if (typeof schedulerRunAfter === "function") {
|
||||
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
|
||||
to: email,
|
||||
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||
userName,
|
||||
ticketId: String(ticketId),
|
||||
reference: ticketDoc.reference ?? 0,
|
||||
subject: ticketDoc.subject ?? "",
|
||||
tenantId: ticketDoc.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue