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 { renderSimpleNotificationEmailHtml } from "./reactEmail"
|
||||||
import { buildBaseUrl } from "./url"
|
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) {
|
function b64(input: string) {
|
||||||
return Buffer.from(input, "utf8").toString("base64")
|
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({
|
export const sendTicketCreatedEmail = action({
|
||||||
args: {
|
args: {
|
||||||
to: v.string(),
|
to: v.string(),
|
||||||
|
userId: v.optional(v.string()),
|
||||||
|
userName: v.optional(v.string()),
|
||||||
ticketId: v.string(),
|
ticketId: v.string(),
|
||||||
reference: v.number(),
|
reference: v.number(),
|
||||||
subject: v.string(),
|
subject: v.string(),
|
||||||
priority: v.string(),
|
priority: v.string(),
|
||||||
|
tenantId: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (_ctx, { to, ticketId, reference, subject, priority }) => {
|
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => {
|
||||||
const smtp = buildSmtpConfig()
|
|
||||||
if (!smtp) {
|
|
||||||
console.warn("SMTP not configured; skipping ticket created email")
|
|
||||||
return { skipped: true }
|
|
||||||
}
|
|
||||||
const baseUrl = buildBaseUrl()
|
const baseUrl = buildBaseUrl()
|
||||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||||
|
|
||||||
|
|
@ -305,8 +342,34 @@ export const sendTicketCreatedEmail = action({
|
||||||
URGENT: "Urgente",
|
URGENT: "Urgente",
|
||||||
}
|
}
|
||||||
const priorityLabel = priorityLabels[priority] ?? priority
|
const priorityLabel = priorityLabels[priority] ?? priority
|
||||||
|
|
||||||
const mailSubject = `Novo chamado #${reference} aberto`
|
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({
|
const html = await renderSimpleNotificationEmailHtml({
|
||||||
title: `Novo chamado #${reference} aberto`,
|
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`,
|
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({
|
export const sendPublicCommentEmail = action({
|
||||||
args: {
|
args: {
|
||||||
to: v.string(),
|
to: v.string(),
|
||||||
|
userId: v.optional(v.string()),
|
||||||
|
userName: v.optional(v.string()),
|
||||||
ticketId: v.string(),
|
ticketId: v.string(),
|
||||||
reference: v.number(),
|
reference: v.number(),
|
||||||
subject: v.string(),
|
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()
|
const smtp = buildSmtpConfig()
|
||||||
if (!smtp) {
|
if (!smtp) {
|
||||||
console.warn("SMTP not configured; skipping ticket comment email")
|
console.warn("SMTP not configured; skipping ticket comment email")
|
||||||
return { skipped: true }
|
return { skipped: true }
|
||||||
}
|
}
|
||||||
const baseUrl = buildBaseUrl()
|
|
||||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
|
||||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
|
||||||
const html = await renderSimpleNotificationEmailHtml({
|
const html = await renderSimpleNotificationEmailHtml({
|
||||||
title: `Nova atualização no seu chamado #${reference}`,
|
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",
|
ctaLabel: "Abrir e responder",
|
||||||
ctaUrl: url,
|
ctaUrl: url,
|
||||||
})
|
})
|
||||||
|
|
@ -348,22 +434,45 @@ export const sendPublicCommentEmail = action({
|
||||||
export const sendResolvedEmail = action({
|
export const sendResolvedEmail = action({
|
||||||
args: {
|
args: {
|
||||||
to: v.string(),
|
to: v.string(),
|
||||||
|
userId: v.optional(v.string()),
|
||||||
|
userName: v.optional(v.string()),
|
||||||
ticketId: v.string(),
|
ticketId: v.string(),
|
||||||
reference: v.number(),
|
reference: v.number(),
|
||||||
subject: v.string(),
|
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()
|
const smtp = buildSmtpConfig()
|
||||||
if (!smtp) {
|
if (!smtp) {
|
||||||
console.warn("SMTP not configured; skipping ticket resolution email")
|
console.warn("SMTP not configured; skipping ticket resolution email")
|
||||||
return { skipped: true }
|
return { skipped: true }
|
||||||
}
|
}
|
||||||
const baseUrl = buildBaseUrl()
|
|
||||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
|
||||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
|
||||||
const html = await renderSimpleNotificationEmailHtml({
|
const html = await renderSimpleNotificationEmailHtml({
|
||||||
title: `Chamado #${reference} encerrado`,
|
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",
|
ctaLabel: "Ver detalhes",
|
||||||
ctaUrl: url,
|
ctaUrl: url,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2464,10 +2464,13 @@ export const create = mutation({
|
||||||
if (typeof schedulerRunAfter === "function") {
|
if (typeof schedulerRunAfter === "function") {
|
||||||
await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
|
await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
|
||||||
to: requesterEmail,
|
to: requesterEmail,
|
||||||
|
userId: String(requester._id),
|
||||||
|
userName: requester.name ?? undefined,
|
||||||
ticketId: String(id),
|
ticketId: String(id),
|
||||||
reference: nextRef,
|
reference: nextRef,
|
||||||
subject,
|
subject,
|
||||||
priority: args.priority,
|
priority: args.priority,
|
||||||
|
tenantId: args.tenantId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2870,15 +2873,19 @@ export const addComment = mutation({
|
||||||
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
|
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
|
||||||
// Notificação por e-mail: comentário público para o solicitante
|
// Notificação por e-mail: comentário público para o solicitante
|
||||||
try {
|
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)) {
|
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
|
||||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||||
if (typeof schedulerRunAfter === "function") {
|
if (typeof schedulerRunAfter === "function") {
|
||||||
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
|
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
|
||||||
to: snapshotEmail,
|
to: snapshotEmail,
|
||||||
|
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||||
|
userName: requesterSnapshot?.name ?? undefined,
|
||||||
ticketId: String(ticketDoc._id),
|
ticketId: String(ticketDoc._id),
|
||||||
reference: ticketDoc.reference ?? 0,
|
reference: ticketDoc.reference ?? 0,
|
||||||
subject: ticketDoc.subject ?? "",
|
subject: ticketDoc.subject ?? "",
|
||||||
|
tenantId: ticketDoc.tenantId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3146,16 +3153,21 @@ export async function resolveTicketHandler(
|
||||||
|
|
||||||
// Notificação por e-mail: encerramento do chamado
|
// Notificação por e-mail: encerramento do chamado
|
||||||
try {
|
try {
|
||||||
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
|
const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null
|
||||||
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || 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) {
|
if (email) {
|
||||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||||
if (typeof schedulerRunAfter === "function") {
|
if (typeof schedulerRunAfter === "function") {
|
||||||
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
|
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
|
||||||
to: email,
|
to: email,
|
||||||
|
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||||
|
userName,
|
||||||
ticketId: String(ticketId),
|
ticketId: String(ticketId),
|
||||||
reference: ticketDoc.reference ?? 0,
|
reference: ticketDoc.reference ?? 0,
|
||||||
subject: ticketDoc.subject ?? "",
|
subject: ticketDoc.subject ?? "",
|
||||||
|
tenantId: ticketDoc.tenantId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
168
src/app/api/notifications/send/route.ts
Normal file
168
src/app/api/notifications/send/route.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
/**
|
||||||
|
* API de Envio de Notificações
|
||||||
|
* Chamada pelo Convex para enviar e-mails respeitando preferências do usuário
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { sendEmail, type NotificationType, type TemplateName, NOTIFICATION_TYPES } from "@/server/email"
|
||||||
|
|
||||||
|
// Token de autenticação interna (deve ser o mesmo usado no Convex)
|
||||||
|
const INTERNAL_TOKEN = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
|
||||||
|
|
||||||
|
const sendNotificationSchema = z.object({
|
||||||
|
type: z.enum([
|
||||||
|
"ticket_created",
|
||||||
|
"ticket_assigned",
|
||||||
|
"ticket_resolved",
|
||||||
|
"ticket_reopened",
|
||||||
|
"ticket_status_changed",
|
||||||
|
"ticket_priority_changed",
|
||||||
|
"comment_public",
|
||||||
|
"comment_response",
|
||||||
|
"sla_at_risk",
|
||||||
|
"sla_breached",
|
||||||
|
"automation",
|
||||||
|
]),
|
||||||
|
to: z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
subject: z.string(),
|
||||||
|
data: z.record(z.any()),
|
||||||
|
tenantId: z.string().optional(),
|
||||||
|
skipPreferenceCheck: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function shouldSendNotification(
|
||||||
|
userId: string | undefined,
|
||||||
|
notificationType: NotificationType | "automation",
|
||||||
|
tenantId?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Automações sempre passam (são configuradas separadamente)
|
||||||
|
if (notificationType === "automation") return true
|
||||||
|
|
||||||
|
// Se não tem userId, não pode verificar preferências
|
||||||
|
if (!userId) return true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prefs = await prisma.notificationPreferences.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Se não tem preferências, usa os defaults
|
||||||
|
if (!prefs) return true
|
||||||
|
|
||||||
|
// Se e-mail está desabilitado globalmente
|
||||||
|
if (!prefs.emailEnabled) return false
|
||||||
|
|
||||||
|
// Verifica se é um tipo obrigatório
|
||||||
|
const config = NOTIFICATION_TYPES[notificationType as NotificationType]
|
||||||
|
if (config?.required) return true
|
||||||
|
|
||||||
|
// Verifica preferências por tipo
|
||||||
|
const typePrefs = prefs.typePreferences
|
||||||
|
? JSON.parse(prefs.typePreferences as string)
|
||||||
|
: {}
|
||||||
|
|
||||||
|
if (notificationType in typePrefs) {
|
||||||
|
return typePrefs[notificationType] !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usa o default do tipo
|
||||||
|
return config?.defaultEnabled ?? true
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[notifications/send] Erro ao verificar preferências:", error)
|
||||||
|
// Em caso de erro, envia a notificação
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplateForType(type: string): string {
|
||||||
|
const templateMap: Record<string, string> = {
|
||||||
|
ticket_created: "ticket_created",
|
||||||
|
ticket_assigned: "ticket_assigned",
|
||||||
|
ticket_resolved: "ticket_resolved",
|
||||||
|
ticket_reopened: "ticket_status",
|
||||||
|
ticket_status_changed: "ticket_status",
|
||||||
|
ticket_priority_changed: "ticket_status",
|
||||||
|
comment_public: "ticket_comment",
|
||||||
|
comment_response: "ticket_comment",
|
||||||
|
sla_at_risk: "sla_warning",
|
||||||
|
sla_breached: "sla_breached",
|
||||||
|
automation: "automation",
|
||||||
|
}
|
||||||
|
return templateMap[type] ?? "simple_notification"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Verifica autenticação
|
||||||
|
const authHeader = request.headers.get("authorization")
|
||||||
|
const token = authHeader?.replace("Bearer ", "")
|
||||||
|
|
||||||
|
if (!INTERNAL_TOKEN || token !== INTERNAL_TOKEN) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const parsed = sendNotificationSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Dados inválidos", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, to, subject, data, tenantId, skipPreferenceCheck } = parsed.data
|
||||||
|
|
||||||
|
// Verifica preferências do usuário
|
||||||
|
if (!skipPreferenceCheck) {
|
||||||
|
const shouldSend = await shouldSendNotification(
|
||||||
|
to.userId,
|
||||||
|
type as NotificationType | "automation",
|
||||||
|
tenantId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!shouldSend) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
skipped: true,
|
||||||
|
reason: "user_preference_disabled",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envia o e-mail
|
||||||
|
const template = getTemplateForType(type)
|
||||||
|
const result = await sendEmail({
|
||||||
|
to: {
|
||||||
|
email: to.email,
|
||||||
|
name: to.name,
|
||||||
|
userId: to.userId,
|
||||||
|
},
|
||||||
|
subject,
|
||||||
|
template,
|
||||||
|
data,
|
||||||
|
notificationType: type === "automation" ? undefined : (type as NotificationType),
|
||||||
|
tenantId,
|
||||||
|
skipPreferenceCheck: true, // Já verificamos acima
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: result.success,
|
||||||
|
skipped: result.skipped,
|
||||||
|
reason: result.reason,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[notifications/send] Erro:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erro interno do servidor" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/app/api/profile/avatar/route.ts
Normal file
88
src/app/api/profile/avatar/route.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* API de Upload de Avatar
|
||||||
|
* POST - Faz upload de uma nova foto de perfil
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { getServerSession } from "@/lib/auth-server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { createConvexClient } from "@/server/convex-client"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
|
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get("file") as File | null
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "Nenhum arquivo enviado" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida tipo
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida tamanho
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Arquivo muito grande. Máximo 5MB." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const convex = createConvexClient()
|
||||||
|
|
||||||
|
// Gera URL de upload
|
||||||
|
const uploadUrl = await convex.action(api.files.generateUploadUrl, {})
|
||||||
|
|
||||||
|
// Faz upload do arquivo
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
console.error("[profile/avatar] Erro no upload:", await uploadResponse.text())
|
||||||
|
return NextResponse.json({ error: "Erro ao fazer upload" }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storageId } = (await uploadResponse.json()) as { storageId: Id<"_storage"> }
|
||||||
|
|
||||||
|
// Obtém URL pública do arquivo
|
||||||
|
const avatarUrl = await convex.action(api.files.getUrl, { storageId })
|
||||||
|
|
||||||
|
if (!avatarUrl) {
|
||||||
|
return NextResponse.json({ error: "Erro ao obter URL do avatar" }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualiza o usuário no banco
|
||||||
|
await prisma.authUser.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { image: avatarUrl },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
avatarUrl,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[profile/avatar] Erro:", error)
|
||||||
|
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,12 +54,19 @@ export function LoginPageClient() {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-svh lg:grid-cols-2">
|
<div className="grid min-h-svh lg:grid-cols-2">
|
||||||
<div className="flex flex-col gap-6 p-6 md:p-10">
|
<div className="flex flex-col gap-6 p-6 md:p-10">
|
||||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<Link href="/" className="text-xl font-semibold text-neutral-900">
|
<Link href="/" className="group flex flex-col items-center gap-2">
|
||||||
<div className="flex flex-col leading-none items-center">
|
<div className="flex items-center gap-2">
|
||||||
<span>Sistema de chamados</span>
|
<span className="text-2xl font-bold tracking-tight text-neutral-900">
|
||||||
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
|
Raven
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-cyan-100 px-2.5 py-0.5 text-xs font-medium text-cyan-700 border border-cyan-200">
|
||||||
|
Helpdesk
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-sm text-neutral-500">
|
||||||
|
Por Rever Tecnologia
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
|
@ -81,8 +88,16 @@ export function LoginPageClient() {
|
||||||
Desenvolvido por Esdras Renan
|
Desenvolvido por Esdras Renan
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative hidden overflow-hidden lg:flex">
|
<div className="relative hidden overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-700 to-neutral-600 lg:flex">
|
||||||
<ShaderBackground className="h-full w-full" />
|
<ShaderBackground className="h-full w-full opacity-50" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Bem-vindo de volta</h2>
|
||||||
|
<p className="mt-2 text-neutral-300">
|
||||||
|
Gerencie seus chamados e tickets de forma simples
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -63,12 +63,19 @@ export function ForgotPasswordPageClient() {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-svh lg:grid-cols-2">
|
<div className="grid min-h-svh lg:grid-cols-2">
|
||||||
<div className="flex flex-col gap-6 p-6 md:p-10">
|
<div className="flex flex-col gap-6 p-6 md:p-10">
|
||||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<Link href="/" className="text-xl font-semibold text-neutral-900">
|
<Link href="/" className="group flex flex-col items-center gap-2">
|
||||||
<div className="flex flex-col leading-none items-center">
|
<div className="flex items-center gap-2">
|
||||||
<span>Sistema de chamados</span>
|
<span className="text-2xl font-bold tracking-tight text-neutral-900">
|
||||||
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
|
Raven
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-cyan-100 px-2.5 py-0.5 text-xs font-medium text-cyan-700 border border-cyan-200">
|
||||||
|
Helpdesk
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-sm text-neutral-500">
|
||||||
|
Por Rever Tecnologia
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
|
@ -99,8 +106,16 @@ export function ForgotPasswordPageClient() {
|
||||||
Desenvolvido por Esdras Renan
|
Desenvolvido por Esdras Renan
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative hidden overflow-hidden lg:flex">
|
<div className="relative hidden overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-700 to-neutral-600 lg:flex">
|
||||||
<ShaderBackground className="h-full w-full" />
|
<ShaderBackground className="h-full w-full opacity-50" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Recuperar acesso</h2>
|
||||||
|
<p className="mt-2 text-neutral-300">
|
||||||
|
Enviaremos as instruções para seu e-mail
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,19 @@ export function ResetPasswordPageClient() {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-svh lg:grid-cols-2">
|
<div className="grid min-h-svh lg:grid-cols-2">
|
||||||
<div className="flex flex-col gap-6 p-6 md:p-10">
|
<div className="flex flex-col gap-6 p-6 md:p-10">
|
||||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<Link href="/" className="text-xl font-semibold text-neutral-900">
|
<Link href="/" className="group flex flex-col items-center gap-2">
|
||||||
<div className="flex flex-col leading-none items-center">
|
<div className="flex items-center gap-2">
|
||||||
<span>Sistema de chamados</span>
|
<span className="text-2xl font-bold tracking-tight text-neutral-900">
|
||||||
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
|
Raven
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-cyan-100 px-2.5 py-0.5 text-xs font-medium text-cyan-700 border border-cyan-200">
|
||||||
|
Helpdesk
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-sm text-neutral-500">
|
||||||
|
Por Rever Tecnologia
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
|
@ -135,8 +142,16 @@ export function ResetPasswordPageClient() {
|
||||||
Desenvolvido por Esdras Renan
|
Desenvolvido por Esdras Renan
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative hidden overflow-hidden lg:flex">
|
<div className="relative hidden overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-700 to-neutral-600 lg:flex">
|
||||||
<ShaderBackground className="h-full w-full" />
|
<ShaderBackground className="h-full w-full opacity-50" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Nova senha</h2>
|
||||||
|
<p className="mt-2 text-neutral-300">
|
||||||
|
Crie uma senha segura para sua conta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { FormEvent, useMemo, useState } from "react"
|
import { FormEvent, useMemo, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
Settings2,
|
|
||||||
Share2,
|
Share2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
|
@ -18,10 +17,10 @@ import {
|
||||||
Mail,
|
Mail,
|
||||||
Key,
|
Key,
|
||||||
User,
|
User,
|
||||||
Building2,
|
|
||||||
Shield,
|
Shield,
|
||||||
Clock,
|
Clock,
|
||||||
Camera,
|
Camera,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
|
@ -55,11 +54,11 @@ const ROLE_LABELS: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
admin: "bg-violet-100 text-violet-700 border-violet-200",
|
admin: "bg-cyan-100 text-cyan-800 border-cyan-300",
|
||||||
manager: "bg-blue-100 text-blue-700 border-blue-200",
|
manager: "bg-cyan-50 text-cyan-700 border-cyan-200",
|
||||||
agent: "bg-cyan-100 text-cyan-700 border-cyan-200",
|
agent: "bg-cyan-50 text-cyan-600 border-cyan-200",
|
||||||
collaborator: "bg-slate-100 text-slate-700 border-slate-200",
|
collaborator: "bg-neutral-100 text-neutral-600 border-neutral-200",
|
||||||
customer: "bg-slate-100 text-slate-700 border-slate-200",
|
customer: "bg-neutral-100 text-neutral-600 border-neutral-200",
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_ACTIONS: SettingsAction[] = [
|
const SETTINGS_ACTIONS: SettingsAction[] = [
|
||||||
|
|
@ -326,6 +325,55 @@ function ProfileEditCard({
|
||||||
const [newPassword, setNewPassword] = useState("")
|
const [newPassword, setNewPassword] = useState("")
|
||||||
const [confirmPassword, setConfirmPassword] = useState("")
|
const [confirmPassword, setConfirmPassword] = useState("")
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [localAvatarUrl, setLocalAvatarUrl] = useState(avatarUrl)
|
||||||
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
async function handleAvatarUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Valida tamanho (5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error("Arquivo muito grande. Máximo 5MB.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida tipo
|
||||||
|
if (!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(file.type)) {
|
||||||
|
toast.error("Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploadingAvatar(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
|
||||||
|
const res = await fetch("/api/profile/avatar", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({ error: "Erro ao fazer upload" }))
|
||||||
|
throw new Error(data.error || "Erro ao fazer upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
setLocalAvatarUrl(data.avatarUrl)
|
||||||
|
toast.success("Foto atualizada com sucesso!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao fazer upload:", error)
|
||||||
|
toast.error(error instanceof Error ? error.message : "Erro ao fazer upload da foto")
|
||||||
|
} finally {
|
||||||
|
setIsUploadingAvatar(false)
|
||||||
|
// Limpa o input para permitir reselecionar o mesmo arquivo
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
const nameChanged = editName.trim() !== name
|
const nameChanged = editName.trim() !== name
|
||||||
|
|
@ -387,25 +435,39 @@ function ProfileEditCard({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
|
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm overflow-hidden">
|
||||||
<CardHeader className="pb-4">
|
{/* Header com degradê */}
|
||||||
<div className="flex items-start gap-4">
|
<div className="h-20 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600" />
|
||||||
|
<CardHeader className="pb-4 -mt-10">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<Avatar className="size-16 border-2 border-white shadow-md">
|
<Avatar className="size-20 border-4 border-white shadow-lg ring-2 ring-neutral-200">
|
||||||
<AvatarImage src={avatarUrl ?? undefined} alt={name} />
|
<AvatarImage src={localAvatarUrl ?? undefined} alt={name} />
|
||||||
<AvatarFallback className="bg-gradient-to-br from-cyan-500 to-blue-600 text-lg font-semibold text-white">
|
<AvatarFallback className="bg-gradient-to-br from-cyan-500 to-cyan-600 text-xl font-semibold text-white">
|
||||||
{initials}
|
{initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 disabled:cursor-not-allowed"
|
||||||
onClick={() => toast.info("Upload de foto em breve!")}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isUploadingAvatar}
|
||||||
>
|
>
|
||||||
<Camera className="size-5 text-white" />
|
{isUploadingAvatar ? (
|
||||||
|
<Loader2 className="size-5 text-white animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Camera className="size-5 text-white" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 pb-1">
|
||||||
<CardTitle className="text-lg font-semibold text-neutral-900">{name || "Usuário"}</CardTitle>
|
<CardTitle className="text-lg font-semibold text-neutral-900">{name || "Usuário"}</CardTitle>
|
||||||
<CardDescription className="text-sm text-neutral-500">{email}</CardDescription>
|
<CardDescription className="text-sm text-neutral-500">{email}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue