diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 8ea6fe0..184d166 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -221,11 +221,17 @@ function App() { async function register() { if (!profile) return if (!provisioningSecret.trim()) { setError("Informe o código de provisionamento."); return } + const normalizedEmail = collabEmail.trim().toLowerCase() + if (!normalizedEmail) { + setError("Informe o e-mail do colaborador ou gestor para vincular esta máquina.") + return + } setBusy(true); setError(null) try { - const collaboratorPayload = collabEmail.trim() - ? { email: collabEmail.trim(), name: collabName.trim() || undefined } - : undefined + const collaboratorPayload = { + email: normalizedEmail, + name: collabName.trim() || undefined, + } const collaboratorMetadata = collaboratorPayload ? { ...collaboratorPayload, role: accessRole } : undefined @@ -437,7 +443,9 @@ function App() {

- + setCollabEmail(e.target.value)} />
@@ -524,7 +532,9 @@ function App() {
- + setCollabEmail(e.target.value)} />
diff --git a/convex/commentTemplates.ts b/convex/commentTemplates.ts index 59baa5e..713f5a0 100644 --- a/convex/commentTemplates.ts +++ b/convex/commentTemplates.ts @@ -50,20 +50,24 @@ export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users"), + kind: v.optional(v.string()), }, - handler: async (ctx, { tenantId, viewerId }) => { + handler: async (ctx, { tenantId, viewerId, kind }) => { await requireStaff(ctx, viewerId, tenantId) + const normalizedKind = (kind ?? "comment").toLowerCase() const templates = await ctx.db .query("commentTemplates") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() return templates + .filter((template) => (template.kind ?? "comment") === normalizedKind) .sort((a, b) => a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" })) .map((template) => ({ id: template._id, title: template.title, body: template.body, + kind: template.kind ?? "comment", createdAt: template.createdAt, updatedAt: template.updatedAt, createdBy: template.createdBy, @@ -78,8 +82,9 @@ export const create = mutation({ actorId: v.id("users"), title: v.string(), body: v.string(), + kind: v.optional(v.string()), }, - handler: async (ctx, { tenantId, actorId, title, body }) => { + handler: async (ctx, { tenantId, actorId, title, body, kind }) => { await requireStaff(ctx, actorId, tenantId) const normalizedTitle = normalizeTitle(title) if (!normalizedTitle || normalizedTitle.length < 3) { @@ -89,19 +94,21 @@ export const create = mutation({ if (!sanitizedBody) { throw new ConvexError("Informe o conteúdo do template") } + const normalizedKind = (kind ?? "comment").toLowerCase() const existing = await ctx.db .query("commentTemplates") .withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle)) .first() - if (existing) { + if (existing && (existing.kind ?? "comment") === normalizedKind) { throw new ConvexError("Já existe um template com este título") } const now = Date.now() const id = await ctx.db.insert("commentTemplates", { tenantId, + kind: normalizedKind, title: normalizedTitle, body: sanitizedBody, createdBy: actorId, @@ -121,8 +128,9 @@ export const update = mutation({ actorId: v.id("users"), title: v.string(), body: v.string(), + kind: v.optional(v.string()), }, - handler: async (ctx, { templateId, tenantId, actorId, title, body }) => { + handler: async (ctx, { templateId, tenantId, actorId, title, body, kind }) => { await requireStaff(ctx, actorId, tenantId) const template = await ctx.db.get(templateId) if (!template || template.tenantId !== tenantId) { @@ -137,17 +145,19 @@ export const update = mutation({ if (!sanitizedBody) { throw new ConvexError("Informe o conteúdo do template") } + const normalizedKind = (kind ?? "comment").toLowerCase() const duplicate = await ctx.db .query("commentTemplates") .withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle)) .first() - if (duplicate && duplicate._id !== templateId) { + if (duplicate && duplicate._id !== templateId && (duplicate.kind ?? "comment") === normalizedKind) { throw new ConvexError("Já existe um template com este título") } const now = Date.now() await ctx.db.patch(templateId, { + kind: normalizedKind, title: normalizedTitle, body: sanitizedBody, updatedBy: actorId, diff --git a/convex/machines.ts b/convex/machines.ts index db67369..12c3e51 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -65,6 +65,24 @@ function computeFingerprint(tenantId: string, companySlug: string | undefined, h return toHex(sha256(payload)) } +function extractCollaboratorEmail(metadata: unknown): string | null { + if (!metadata || typeof metadata !== "object") return null + const record = metadata as Record + const collaborator = record["collaborator"] + if (!collaborator || typeof collaborator !== "object") return null + const email = (collaborator as { email?: unknown }).email + if (typeof email !== "string") return null + const trimmed = email.trim().toLowerCase() + return trimmed || null +} + +function matchesExistingHardware(existing: Doc<"machines">, identifiers: NormalizedIdentifiers, hostname: string): boolean { + const intersectsMac = existing.macAddresses.some((mac) => identifiers.macs.includes(mac)) + const intersectsSerial = existing.serialNumbers.some((serial) => identifiers.serials.includes(serial)) + const sameHostname = existing.hostname.trim().toLowerCase() === hostname.trim().toLowerCase() + return intersectsMac || intersectsSerial || sameHostname +} + function hashToken(token: string) { return toHex(sha256(token)) } @@ -305,11 +323,24 @@ export const register = mutation({ const now = Date.now() const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record) : undefined - const existing = await ctx.db + let existing = await ctx.db .query("machines") .withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint)) .first() + if (!existing) { + const collaboratorEmail = extractCollaboratorEmail(metadataPatch ?? args.metadata) + if (collaboratorEmail) { + const candidate = await ctx.db + .query("machines") + .withIndex("by_tenant_assigned_email", (q) => q.eq("tenantId", tenantId).eq("assignedUserEmail", collaboratorEmail)) + .first() + if (candidate && matchesExistingHardware(candidate, identifiers, args.hostname)) { + existing = candidate + } + } + } + let machineId: Id<"machines"> if (existing) { @@ -323,6 +354,7 @@ export const register = mutation({ architecture: args.os.architecture, macAddresses: identifiers.macs, serialNumbers: identifiers.serials, + fingerprint, metadata: metadataPatch ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata, lastHeartbeatAt: now, updatedAt: now, @@ -834,6 +866,28 @@ export const getContext = query({ }, }) +export const findByAuthEmail = query({ + args: { + authEmail: v.string(), + }, + handler: async (ctx, args) => { + const normalizedEmail = args.authEmail.trim().toLowerCase() + + const machine = await ctx.db + .query("machines") + .withIndex("by_auth_email", (q) => q.eq("authEmail", normalizedEmail)) + .first() + + if (!machine) { + return null + } + + return { + id: machine._id, + } + }, +}) + export const linkAuthAccount = mutation({ args: { machineId: v.id("machines"), diff --git a/convex/schema.ts b/convex/schema.ts index 7968186..70dd1ba 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -146,6 +146,7 @@ export default defineSchema({ commentTemplates: defineTable({ tenantId: v.string(), + kind: v.optional(v.string()), title: v.string(), body: v.string(), createdBy: v.id("users"), @@ -154,7 +155,8 @@ export default defineSchema({ updatedAt: v.number(), }) .index("by_tenant", ["tenantId"]) - .index("by_tenant_title", ["tenantId", "title"]), + .index("by_tenant_title", ["tenantId", "title"]) + .index("by_tenant_kind", ["tenantId", "kind"]), ticketWorkSessions: defineTable({ ticketId: v.id("tickets"), @@ -267,6 +269,7 @@ export default defineSchema({ .index("by_tenant", ["tenantId"]) .index("by_tenant_company", ["tenantId", "companyId"]) .index("by_tenant_fingerprint", ["tenantId", "fingerprint"]) + .index("by_tenant_assigned_email", ["tenantId", "assignedUserEmail"]) .index("by_auth_email", ["authEmail"]), machineTokens: defineTable({ diff --git a/convex/tickets.ts b/convex/tickets.ts index 35ea9d2..cd5e12b 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -3,7 +3,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import { Id, type Doc } from "./_generated/dataModel"; -import { requireStaff, requireUser } from "./rbac"; +import { requireAdmin, requireStaff, requireUser } from "./rbac"; const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]); const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); @@ -1343,8 +1343,13 @@ export const playNext = mutation({ }); export const remove = mutation({ - args: { ticketId: v.id("tickets") }, - handler: async (ctx, { ticketId }) => { + args: { ticketId: v.id("tickets"), actorId: v.id("users") }, + handler: async (ctx, { ticketId, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + await requireAdmin(ctx, actorId, ticket.tenantId) // delete comments (and attachments) const comments = await ctx.db .query("ticketComments") diff --git a/package.json b/package.json index 761be3a..903ad33 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "next": "15.5.4", "next-themes": "^0.4.6", "pdfkit": "^0.17.2", + "@react-pdf/renderer": "^4.1.5", "postcss": "^8.5.6", "react": "19.2.0", "react-dom": "19.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f57c85..b7dd1c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@react-pdf/renderer': + specifier: ^4.1.5 + version: 4.3.1(react@19.2.0) '@react-three/fiber': specifier: ^9.3.0 version: 9.3.0(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.180.0) @@ -1519,6 +1522,49 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-pdf/fns@3.1.2': + resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==} + + '@react-pdf/font@4.0.3': + resolution: {integrity: sha512-N1qQDZr6phXYQOp033Hvm2nkUkx2LkszjGPbmRavs9VOYzi4sp31MaccMKptL24ii6UhBh/z9yPUhnuNe/qHwA==} + + '@react-pdf/image@3.0.3': + resolution: {integrity: sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ==} + + '@react-pdf/layout@4.4.1': + resolution: {integrity: sha512-GVzdlWoZWldRDzlWj3SttRXmVDxg7YfraAohwy+o9gb9hrbDJaaAV6jV3pc630Evd3K46OAzk8EFu8EgPDuVuA==} + + '@react-pdf/pdfkit@4.0.4': + resolution: {integrity: sha512-/nITLggsPlB66bVLnm0X7MNdKQxXelLGZG6zB5acF5cCgkFwmXHnLNyxYOUD4GMOMg1HOPShXDKWrwk2ZeHsvw==} + + '@react-pdf/png-js@3.0.0': + resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==} + + '@react-pdf/primitives@4.1.1': + resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==} + + '@react-pdf/reconciler@1.1.4': + resolution: {integrity: sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-pdf/render@4.3.1': + resolution: {integrity: sha512-v1WAaAhQShQZGcBxfjkEThGCHVH9CSuitrZ1bIOLvB5iBKM14abYK5D6djKhWCwF6FTzYeT2WRjRMVgze/ND2A==} + + '@react-pdf/renderer@4.3.1': + resolution: {integrity: sha512-dPKHiwGTaOsKqNWCHPYYrx8CDfAGsUnV4tvRsEu0VPGxuot1AOq/M+YgfN/Pb+MeXCTe2/lv6NvA8haUtj3tsA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-pdf/stylesheet@6.1.1': + resolution: {integrity: sha512-Iyw0A3wRIeQLN4EkaKf8yF9MvdMxiZ8JjoyzLzDHSxnKYoOA4UGu84veCb8dT9N8MxY5x7a0BUv/avTe586Plg==} + + '@react-pdf/textkit@6.0.0': + resolution: {integrity: sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw==} + + '@react-pdf/types@2.9.1': + resolution: {integrity: sha512-5GoCgG0G5NMgpPuHbKG2xcVRQt7+E5pg3IyzVIIozKG3nLcnsXW4zy25vG1ZBQA0jmo39q34au/sOnL/0d1A4w==} + '@react-three/fiber@9.3.0': resolution: {integrity: sha512-myPe3YL/C8+Eq939/4qIVEPBW/uxV0iiUbmjfwrs9sGKYDG8ib8Dz3Okq7BQt8P+0k4igedONbjXMQy84aDFmQ==} peerDependencies: @@ -2320,6 +2366,9 @@ packages: '@webgpu/types@0.1.65': resolution: {integrity: sha512-cYrHab4d6wuVvDW5tdsfI6/o6vcLMDe6w2Citd1oS51Xxu2ycLCnVo4fqwujfKWijrZMInTJIKcXxteoy21nVA==} + abs-svg-path@0.1.1: + resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2453,6 +2502,9 @@ packages: better-call@1.0.19: resolution: {integrity: sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2466,6 +2518,9 @@ packages: brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + browserslist@4.26.3: resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2549,6 +2604,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2746,6 +2804,9 @@ packages: electron-to-chromium@1.5.234: resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -2936,6 +2997,10 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.2.2: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} @@ -3108,9 +3173,18 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hsl-to-hex@1.0.0: + resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==} + + hsl-to-rgb-for-reals@1.1.1: + resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + hyphen@1.10.6: + resolution: {integrity: sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3130,6 +3204,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -3142,6 +3219,9 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -3233,6 +3313,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -3260,6 +3343,9 @@ packages: peerDependencies: react: ^19.0.0 + jay-peg@1.1.1: + resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3438,6 +3524,9 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-engine@1.0.3: + resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3520,6 +3609,9 @@ packages: node-releases@2.0.23: resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} + normalize-svg-path@1.1.0: + resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==} + nypm@0.6.2: resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==} engines: {node: ^14.16.0 || >=16.10.0} @@ -3582,6 +3674,9 @@ packages: pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3589,6 +3684,9 @@ packages: parse-srcset@1.0.2: resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3637,6 +3735,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -3746,6 +3847,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -3856,6 +3960,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3897,6 +4005,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3911,6 +4022,9 @@ packages: scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + scheduler@0.25.0-rc-603e6108-20241029: + resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3969,6 +4083,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -4015,6 +4132,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4049,6 +4169,9 @@ packages: peerDependencies: react: '>=17.0' + svg-arc-to-cubic-bezier@3.2.0: + resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==} + tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} @@ -4220,6 +4343,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: @@ -4229,6 +4355,10 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-compatible-readable-stream@3.6.1: + resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} + engines: {node: '>= 6'} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4374,6 +4504,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod@4.1.11: resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} @@ -5554,6 +5687,107 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-pdf/fns@3.1.2': {} + + '@react-pdf/font@4.0.3': + dependencies: + '@react-pdf/pdfkit': 4.0.4 + '@react-pdf/types': 2.9.1 + fontkit: 2.0.4 + is-url: 1.2.4 + + '@react-pdf/image@3.0.3': + dependencies: + '@react-pdf/png-js': 3.0.0 + jay-peg: 1.1.1 + + '@react-pdf/layout@4.4.1': + dependencies: + '@react-pdf/fns': 3.1.2 + '@react-pdf/image': 3.0.3 + '@react-pdf/primitives': 4.1.1 + '@react-pdf/stylesheet': 6.1.1 + '@react-pdf/textkit': 6.0.0 + '@react-pdf/types': 2.9.1 + emoji-regex-xs: 1.0.0 + queue: 6.0.2 + yoga-layout: 3.2.1 + + '@react-pdf/pdfkit@4.0.4': + dependencies: + '@babel/runtime': 7.28.4 + '@react-pdf/png-js': 3.0.0 + browserify-zlib: 0.2.0 + crypto-js: 4.2.0 + fontkit: 2.0.4 + jay-peg: 1.1.1 + linebreak: 1.1.0 + vite-compatible-readable-stream: 3.6.1 + + '@react-pdf/png-js@3.0.0': + dependencies: + browserify-zlib: 0.2.0 + + '@react-pdf/primitives@4.1.1': {} + + '@react-pdf/reconciler@1.1.4(react@19.2.0)': + dependencies: + object-assign: 4.1.1 + react: 19.2.0 + scheduler: 0.25.0-rc-603e6108-20241029 + + '@react-pdf/render@4.3.1': + dependencies: + '@babel/runtime': 7.28.4 + '@react-pdf/fns': 3.1.2 + '@react-pdf/primitives': 4.1.1 + '@react-pdf/textkit': 6.0.0 + '@react-pdf/types': 2.9.1 + abs-svg-path: 0.1.1 + color-string: 1.9.1 + normalize-svg-path: 1.1.0 + parse-svg-path: 0.1.2 + svg-arc-to-cubic-bezier: 3.2.0 + + '@react-pdf/renderer@4.3.1(react@19.2.0)': + dependencies: + '@babel/runtime': 7.28.4 + '@react-pdf/fns': 3.1.2 + '@react-pdf/font': 4.0.3 + '@react-pdf/layout': 4.4.1 + '@react-pdf/pdfkit': 4.0.4 + '@react-pdf/primitives': 4.1.1 + '@react-pdf/reconciler': 1.1.4(react@19.2.0) + '@react-pdf/render': 4.3.1 + '@react-pdf/types': 2.9.1 + events: 3.3.0 + object-assign: 4.1.1 + prop-types: 15.8.1 + queue: 6.0.2 + react: 19.2.0 + + '@react-pdf/stylesheet@6.1.1': + dependencies: + '@react-pdf/fns': 3.1.2 + '@react-pdf/types': 2.9.1 + color-string: 1.9.1 + hsl-to-hex: 1.0.0 + media-engine: 1.0.3 + postcss-value-parser: 4.2.0 + + '@react-pdf/textkit@6.0.0': + dependencies: + '@react-pdf/fns': 3.1.2 + bidi-js: 1.0.3 + hyphen: 1.10.6 + unicode-properties: 1.4.1 + + '@react-pdf/types@2.9.1': + dependencies: + '@react-pdf/font': 4.0.3 + '@react-pdf/primitives': 4.1.1 + '@react-pdf/stylesheet': 6.1.1 + '@react-three/fiber@9.3.0(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.180.0)': dependencies: '@babel/runtime': 7.28.4 @@ -6319,6 +6553,8 @@ snapshots: '@webgpu/types@0.1.65': {} + abs-svg-path@0.1.1: {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -6467,6 +6703,10 @@ snapshots: set-cookie-parser: 2.7.1 uncrypto: 0.1.3 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6484,6 +6724,10 @@ snapshots: dependencies: base64-js: 1.5.1 + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + browserslist@4.26.3: dependencies: baseline-browser-mapping: 2.8.16 @@ -6576,6 +6820,11 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + concat-map@0.0.1: {} confbox@0.2.2: {} @@ -6746,6 +6995,8 @@ snapshots: electron-to-chromium@1.5.234: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@9.2.2: {} empathic@2.0.0: {} @@ -7121,6 +7372,8 @@ snapshots: eventemitter3@4.0.7: {} + events@3.3.0: {} + expect-type@1.2.2: {} exsolve@1.0.7: {} @@ -7301,6 +7554,12 @@ snapshots: dependencies: function-bind: 1.1.2 + hsl-to-hex@1.0.0: + dependencies: + hsl-to-rgb-for-reals: 1.1.1 + + hsl-to-rgb-for-reals@1.1.1: {} + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -7308,6 +7567,8 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + hyphen@1.10.6: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -7321,6 +7582,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -7335,6 +7598,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-arrayish@0.3.4: {} + is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -7432,6 +7697,8 @@ snapshots: dependencies: which-typed-array: 1.1.19 + is-url@1.2.4: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -7463,6 +7730,10 @@ snapshots: transitivePeerDependencies: - '@types/react' + jay-peg@1.1.1: + dependencies: + restructure: 3.0.2 + jiti@2.6.1: {} jose@6.1.0: {} @@ -7610,6 +7881,8 @@ snapshots: mdurl@2.0.0: {} + media-engine@1.0.3: {} + merge2@1.4.1: {} meshoptimizer@0.22.0: {} @@ -7677,6 +7950,10 @@ snapshots: node-releases@2.0.23: {} + normalize-svg-path@1.1.0: + dependencies: + svg-arc-to-cubic-bezier: 3.2.0 + nypm@0.6.2: dependencies: citty: 0.1.6 @@ -7756,12 +8033,16 @@ snapshots: pako@0.2.9: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 parse-srcset@1.0.2: {} + parse-svg-path@0.1.2: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -7800,6 +8081,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-value-parser@4.2.0: {} + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -7948,6 +8231,10 @@ snapshots: queue-microtask@1.2.3: {} + queue@6.0.2: + dependencies: + inherits: 2.0.4 + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -8066,6 +8353,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8130,6 +8419,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -8152,6 +8443,8 @@ snapshots: scheduler@0.25.0: {} + scheduler@0.25.0-rc-603e6108-20241029: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -8248,6 +8541,10 @@ snapshots: siginfo@2.0.0: {} + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 @@ -8316,6 +8613,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -8335,6 +8636,8 @@ snapshots: dependencies: react: 19.2.0 + svg-arc-to-cubic-bezier@3.2.0: {} + tailwind-merge@3.3.1: {} tailwindcss@4.1.14: {} @@ -8521,6 +8824,8 @@ snapshots: dependencies: react: 19.2.0 + util-deprecate@1.0.2: {} + vaul@1.1.2(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -8547,6 +8852,12 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-compatible-readable-stream@3.6.1: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + vite-node@2.1.9(@types/node@20.19.19)(lightningcss@1.30.1): dependencies: cac: 6.7.14 @@ -8684,6 +8995,8 @@ snapshots: yocto-queue@0.1.0: {} + yoga-layout@3.2.1: {} + zod@4.1.11: {} zustand@5.0.8(@types/react@19.2.0)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): diff --git a/src/app/api/machines/session/route.ts b/src/app/api/machines/session/route.ts index 11f54a0..a6412d0 100644 --- a/src/app/api/machines/session/route.ts +++ b/src/app/api/machines/session/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server" +import { NextRequest, NextResponse } from "next/server" import { cookies } from "next/headers" import { ConvexHttpClient } from "convex/browser" @@ -25,33 +25,55 @@ function decodeMachineCookie(value: string) { } } -export async function GET() { +function encodeMachineCookie(payload: { + machineId: string + persona: string | null + assignedUserId: string | null + assignedUserEmail: string | null + assignedUserName: string | null + assignedUserRole: string | null +}) { + return Buffer.from(JSON.stringify(payload)).toString("base64url") +} + +export async function GET(request: NextRequest) { const session = await assertAuthenticatedSession() if (!session || session.user?.role !== "machine") { return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 }) } - const cookieStore = await cookies() - const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value - if (!cookieValue) { - return NextResponse.json({ error: "Contexto da máquina ausente." }, { status: 404 }) - } - - const decoded = decodeMachineCookie(cookieValue) - if (!decoded?.machineId) { - return NextResponse.json({ error: "Contexto da máquina inválido." }, { status: 400 }) - } - const convexUrl = env.NEXT_PUBLIC_CONVEX_URL if (!convexUrl) { return NextResponse.json({ error: "Convex não configurado." }, { status: 500 }) } const client = new ConvexHttpClient(convexUrl) + const cookieStore = await cookies() + const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value ?? null + + const decoded = cookieValue ? decodeMachineCookie(cookieValue) : null + let machineId: Id<"machines"> | null = decoded?.machineId ? (decoded.machineId as Id<"machines">) : null + + if (!machineId) { + try { + const lookup = (await client.query(api.machines.findByAuthEmail, { + authEmail: session.user.email.toLowerCase(), + })) as { id: string } | null + + if (!lookup?.id) { + return NextResponse.json({ error: "Máquina não vinculada à sessão atual." }, { status: 404 }) + } + + machineId = lookup.id as Id<"machines"> + } catch (error) { + console.error("[machines.session] Falha ao localizar máquina por e-mail", error) + return NextResponse.json({ error: "Não foi possível localizar a máquina." }, { status: 500 }) + } + } try { const context = (await client.query(api.machines.getContext, { - machineId: decoded.machineId as Id<"machines">, + machineId, })) as { id: string tenantId: string @@ -66,10 +88,32 @@ export async function GET() { authEmail: string | null } - return NextResponse.json({ + const responsePayload = { + machineId: context.id, + persona: context.persona, + assignedUserId: context.assignedUserId, + assignedUserEmail: context.assignedUserEmail, + assignedUserName: context.assignedUserName, + assignedUserRole: context.assignedUserRole, + } + + const response = NextResponse.json({ machine: context, - cookie: decoded, + cookie: responsePayload, }) + + const isSecure = request.nextUrl.protocol === "https:" + response.cookies.set({ + name: MACHINE_CTX_COOKIE, + value: encodeMachineCookie(responsePayload), + httpOnly: true, + sameSite: "lax", + secure: isSecure, + path: "/", + maxAge: 60 * 60 * 24 * 30, + }) + + return response } catch (error) { console.error("[machines.session] Falha ao obter contexto da máquina", error) return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 }) diff --git a/src/app/api/machines/sessions/route.ts b/src/app/api/machines/sessions/route.ts index 756d184..de17f21 100644 --- a/src/app/api/machines/sessions/route.ts +++ b/src/app/api/machines/sessions/route.ts @@ -55,12 +55,13 @@ export async function POST(request: Request) { assignedUserName: session.machine.assignedUserName, assignedUserRole: session.machine.assignedUserRole, } + const isSecure = new URL(request.url).protocol === "https:" response.cookies.set({ name: "machine_ctx", value: Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url"), httpOnly: true, sameSite: "lax", - secure: true, + secure: isSecure, path: "/", maxAge: 60 * 60 * 24 * 30, }) diff --git a/src/app/api/tickets/[id]/export/pdf/route.ts b/src/app/api/tickets/[id]/export/pdf/route.ts index a27665a..0373455 100644 --- a/src/app/api/tickets/[id]/export/pdf/route.ts +++ b/src/app/api/tickets/[id]/export/pdf/route.ts @@ -1,13 +1,6 @@ -import { NextResponse } from "next/server" -// Use the standalone build to avoid AFM filesystem lookups -// and ensure compatibility in serverless/traced environments. -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore – no ambient types for this path; declared in types/ -import PDFDocument from "pdfkit/js/pdfkit.standalone.js" -import { format } from "date-fns" -import fs from "fs" import path from "path" -import { ptBR } from "date-fns/locale" +import fs from "fs" +import { NextResponse } from "next/server" import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" @@ -16,161 +9,19 @@ import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels" +import { renderTicketPdfBuffer } from "@/server/pdf/ticket-pdf-template" -// Force Node.js runtime for pdfkit compatibility export const runtime = "nodejs" -const statusLabel: Record = { - PENDING: "Pendente", - AWAITING_ATTENDANCE: "Aguardando atendimento", - PAUSED: "Pausado", - RESOLVED: "Resolvido", -} - -const statusColors: Record = { - PENDING: "#64748B", // slate-500 - AWAITING_ATTENDANCE: "#0EA5E9", // sky-500 - PAUSED: "#F59E0B", // amber-500 - RESOLVED: "#10B981", // emerald-500 -} - -const priorityLabel: Record = { - LOW: "Baixa", - MEDIUM: "Média", - HIGH: "Alta", - URGENT: "Urgente", - CRITICAL: "Crítica", -} - -const channelLabel: Record = { - EMAIL: "E-mail", - PHONE: "Telefone", - CHAT: "Chat", - PORTAL: "Portal", - WEB: "Portal", - API: "API", - SOCIAL: "Redes sociais", - OTHER: "Outro", -} - - -function formatDateTime(date: Date | null | undefined) { - if (!date) return "—" - return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR }) -} - -function htmlToPlainText(html?: string | null) { - if (!html) return "" - const withBreaks = html - .replace(/<\s*br\s*\/?>/gi, "\n") - .replace(/<\/p>/gi, "\n\n") - const stripped = withBreaks.replace(/<[^>]+>/g, "") - return decodeHtmlEntities(stripped).replace(/\u00A0/g, " ").trim() -} - -function decodeHtmlEntities(input: string) { - return input - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/ /g, " ") -} - -function stringifyPayload(payload: unknown): string | null { - if (!payload) return null - if (typeof payload === "object") { - if (Array.isArray(payload)) { - if (payload.length === 0) return null - } else if (payload) { - if (Object.keys(payload as Record).length === 0) return null - } - } - if (typeof payload === "string" && payload.trim() === "") return null +async function readLogoAsDataUrl() { + const logoPath = path.join(process.cwd(), "public", "raven.png") try { - return JSON.stringify(payload, null, 2) - } catch { - return String(payload) - } -} - -function formatDurationMs(ms: number | null | undefined) { - if (!ms || ms <= 0) return null - const totalSeconds = Math.floor(ms / 1000) - const hours = Math.floor(totalSeconds / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = totalSeconds % 60 - if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m` - if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s` - return `${seconds}s` -} - -function buildTimelineMessage(type: string, payload: Record | null | undefined): string | null { - const p = payload ?? {} - const to = (p.toLabel as string | undefined) ?? (p.to as string | undefined) - const assignee = (p.assigneeName as string | undefined) ?? (p.assigneeId as string | undefined) - const queue = (p.queueName as string | undefined) ?? (p.queueId as string | undefined) - const requester = p.requesterName as string | undefined - const author = (p.authorName as string | undefined) ?? (p.authorId as string | undefined) - const actor = (p.actorName as string | undefined) ?? (p.actorId as string | undefined) - const attachmentName = p.attachmentName as string | undefined - const subjectTo = p.to as string | undefined - const pauseReason = (p.pauseReasonLabel as string | undefined) ?? (p.pauseReason as string | undefined) - const pauseNote = p.pauseNote as string | undefined - const sessionDuration = formatDurationMs((p.sessionDurationMs as number | undefined) ?? null) - const categoryName = p.categoryName as string | undefined - const subcategoryName = p.subcategoryName as string | undefined - - switch (type) { - case "STATUS_CHANGED": - return to ? `Status alterado para ${to}` : "Status alterado" - case "ASSIGNEE_CHANGED": - return assignee ? `Responsável alterado para ${assignee}` : "Responsável alterado" - case "QUEUE_CHANGED": - return queue ? `Fila alterada para ${queue}` : "Fila alterada" - case "PRIORITY_CHANGED": - return to ? `Prioridade alterada para ${to}` : "Prioridade alterada" - case "CREATED": - return requester ? `Criado por ${requester}` : "Criado" - case "COMMENT_ADDED": - return author ? `Comentário adicionado por ${author}` : "Comentário adicionado" - case "COMMENT_EDITED": { - const who = actor ?? author - return who ? `Comentário editado por ${who}` : "Comentário editado" - } - case "SUBJECT_CHANGED": - return subjectTo ? `Assunto alterado para "${subjectTo}"` : "Assunto alterado" - case "SUMMARY_CHANGED": - return "Resumo atualizado" - case "ATTACHMENT_REMOVED": - return attachmentName ? `Anexo removido: ${attachmentName}` : "Anexo removido" - case "WORK_PAUSED": { - const parts: string[] = [] - if (pauseReason) parts.push(`Motivo: ${pauseReason}`) - if (sessionDuration) parts.push(`Tempo registrado: ${sessionDuration}`) - if (pauseNote) parts.push(`Observação: ${pauseNote}`) - return parts.length > 0 ? parts.join(" • ") : "Atendimento pausado" - } - case "WORK_STARTED": - return "Atendimento iniciado" - case "CATEGORY_CHANGED": { - if (categoryName || subcategoryName) { - return `Categoria alterada para ${categoryName ?? ""}${subcategoryName ? ` • ${subcategoryName}` : ""}`.trim() - } - return "Categoria removida" - } - case "MANAGER_NOTIFIED": - return "Gestor notificado" - case "VISIT_SCHEDULED": - return "Visita agendada" - case "CSAT_RECEIVED": - return "CSAT recebido" - case "CSAT_RATED": - return "CSAT avaliado" - default: - return null + const buffer = await fs.promises.readFile(logoPath) + const base64 = buffer.toString("base64") + return `data:image/png;base64,${base64}` + } catch (error) { + console.warn("[tickets.export.pdf] Logo não encontrado, seguindo sem imagem", error) + return null } } @@ -188,6 +39,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st const client = new ConvexHttpClient(convexUrl) const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + let viewerId: string | null = null try { const ensuredUser = await client.mutation(api.users.ensureUser, { @@ -199,8 +51,8 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st }) viewerId = ensuredUser?._id ?? null } catch (error) { - console.error("Failed to synchronize user with Convex for PDF export", error) - return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + console.error("[tickets.export.pdf] Falha ao sincronizar usuário no Convex", error) + return NextResponse.json({ error: "Não foi possível preparar a exportação" }, { status: 500 }) } if (!viewerId) { @@ -215,12 +67,8 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st viewerId: viewerId as unknown as Id<"users">, }) } catch (error) { - console.error("Failed to load ticket from Convex for PDF export", error, { - tenantId, - ticketId, - viewerId, - }) - return NextResponse.json({ error: "Falha ao carregar ticket no Convex" }, { status: 500 }) + console.error("[tickets.export.pdf] Falha ao carregar ticket", error, { tenantId, ticketId, viewerId }) + return NextResponse.json({ error: "Não foi possível carregar o ticket" }, { status: 500 }) } if (!ticketRaw) { @@ -228,284 +76,20 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st } const ticket = mapTicketWithDetailsFromServer(ticketRaw) - const doc = new PDFDocument({ size: "A4", margin: 56 }) - const chunks: Buffer[] = [] - doc.on("data", (chunk: unknown) => { - const buf = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer) - chunks.push(buf) - }) - - const pdfBufferPromise = new Promise((resolve, reject) => { - doc.on("end", () => resolve(Buffer.concat(chunks))) - doc.on("error", reject) - }) - - // Register custom fonts (Inter) if available + const logoDataUrl = await readLogoAsDataUrl() try { - const pubRegular = path.join(process.cwd(), "public", "fonts", "Inter-Regular.ttf") - const pubBold = path.join(process.cwd(), "public", "fonts", "Inter-Bold.ttf") - const fontRegular = fs.existsSync(pubRegular) - ? pubRegular - : path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Regular.ttf") - const fontBold = fs.existsSync(pubBold) - ? pubBold - : path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Bold.ttf") - const D = doc as unknown as { - registerFont?: (name: string, src: string) => void - _fontFamilies?: Record - roundedRect?: (x: number, y: number, w: number, h: number, r: number) => void - } - if (fs.existsSync(fontRegular)) { - D.registerFont?.("Inter", fontRegular) - } - if (fs.existsSync(fontBold)) { - D.registerFont?.("Inter-Bold", fontBold) - } - } catch {} - - const D = doc as unknown as { _fontFamilies?: Record; roundedRect?: (x:number,y:number,w:number,h:number,r:number)=>void } - const hasInter = Boolean(D._fontFamilies && (D._fontFamilies as Record)["Inter-Bold"]) - - // Header with logo and brand bar - try { - const logoPath = path.join(process.cwd(), "public", "raven.png") - if (fs.existsSync(logoPath)) { - doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 }) - } - } catch {} - doc.moveDown(0.5) - doc - .fillColor("#00e8ff") - .rect(doc.page.margins.left, doc.y, doc.page.width - doc.page.margins.left - doc.page.margins.right, 3) - .fill() - doc.moveDown(0.5) - - // Título - doc.fillColor("#0F172A").font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`) - doc.moveDown(0.25) - // Linha abaixo do título - doc - .strokeColor("#E2E8F0") - .moveTo(doc.page.margins.left, doc.y) - .lineTo(doc.page.width - doc.page.margins.right, doc.y) - .stroke() - - // Badge de status - doc.moveDown(0.5) - const statusText = statusLabel[ticket.status] ?? ticket.status - const badgeColor = statusColors[ticket.status] ?? "#475569" - const badgeFontSize = 10 - const badgePaddingX = 6 - const badgePaddingY = 3 - const badgeX = doc.page.margins.left - const badgeY = doc.y - doc.save() - doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(badgeFontSize) - const badgeTextWidth = doc.widthOfString(statusText) - const badgeHeight = badgeFontSize + badgePaddingY * 2 - const badgeWidth = badgeTextWidth + badgePaddingX * 2 - if (typeof D.roundedRect === "function") { - D.roundedRect(badgeX, badgeY, badgeWidth, badgeHeight, 4) - } else { - doc.rect(badgeX, badgeY, badgeWidth, badgeHeight) - } - doc.fill(badgeColor) - doc.fillColor("#FFFFFF").text(statusText, badgeX + badgePaddingX, badgeY + badgePaddingY) - doc.restore() - doc.y = badgeY + badgeHeight + 8 - - // Metadados em duas colunas - const leftX = doc.page.margins.left - const colGap = 24 - const colWidth = (doc.page.width - doc.page.margins.left - doc.page.margins.right - colGap) / 2 - const rightX = leftX + colWidth + colGap - // const startY = doc.y - const drawMeta = (x: number, lines: string[]) => { - doc.save() - doc.x = x - doc.fillColor("#0F172A").font(hasInter ? "Inter" : "Helvetica").fontSize(11) - for (const line of lines) { - doc.text(line, { width: colWidth, lineGap: 2 }) - } - const currY = doc.y - doc.restore() - return currY - } - const leftLines = [ - `Status: ${statusText}`, - `Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, - `Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, - `Fila: ${ticket.queue ?? "—"}`, - ] - const rightLines = [ - `Solicitante: ${ticket.requester.name} (${ticket.requester.email})`, - `Responsável: ${ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído"}`, - `Criado em: ${formatDateTime(ticket.createdAt)}`, - `Atualizado em: ${formatDateTime(ticket.updatedAt)}`, - ] - const leftY = drawMeta(leftX, leftLines) - const rightY = drawMeta(rightX, rightLines) - doc.y = Math.max(leftY, rightY) - doc.moveDown(0.5) - - doc.moveDown(0.75) - doc - .font(hasInter ? "Inter-Bold" : "Helvetica-Bold") - .fontSize(12) - .text("Solicitante") - doc - .strokeColor("#E2E8F0") - .moveTo(doc.page.margins.left, doc.y) - .lineTo(doc.page.width - doc.page.margins.right, doc.y) - .stroke() - doc.moveDown(0.3) - doc - .font(hasInter ? "Inter" : "Helvetica") - .fontSize(11) - .text(`${ticket.requester.name} (${ticket.requester.email})`) - - doc.moveDown(0.5) - doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Responsável") - doc - .strokeColor("#E2E8F0") - .moveTo(doc.page.margins.left, doc.y) - .lineTo(doc.page.width - doc.page.margins.right, doc.y) - .stroke() - doc.moveDown(0.3) - doc - .font(hasInter ? "Inter" : "Helvetica") - .fontSize(11) - .text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído") - - doc.moveDown(0.75) - doc.font("Helvetica-Bold").fontSize(12).text("Datas") - doc - .strokeColor("#E2E8F0") - .moveTo(doc.page.margins.left, doc.y) - .lineTo(doc.page.width - doc.page.margins.right, doc.y) - .stroke() - doc.moveDown(0.3) - doc - .font("Helvetica") - .fontSize(11) - .text(`Criado em: ${formatDateTime(ticket.createdAt)}`) - .moveDown(0.15) - .text(`Atualizado em: ${formatDateTime(ticket.updatedAt)}`) - .moveDown(0.15) - .text(`Resolvido em: ${formatDateTime(ticket.resolvedAt ?? null)}`) - - if (ticket.summary) { - doc.moveDown(0.75) - doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Resumo") - doc - .strokeColor("#E2E8F0") - .moveTo(doc.page.margins.left, doc.y) - .lineTo(doc.page.width - doc.page.margins.right, doc.y) - .stroke() - doc - .font(hasInter ? "Inter" : "Helvetica") - .fontSize(11) - .text(ticket.summary, { align: "justify", lineGap: 2 }) - } - - if (ticket.description) { - doc.moveDown(0.75) - doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Descrição") - doc - .strokeColor("#E2E8F0") - .moveTo(doc.page.margins.left, doc.y) - .lineTo(doc.page.width - doc.page.margins.right, doc.y) - .stroke() - doc - .font(hasInter ? "Inter" : "Helvetica") - .fontSize(11) - .text(htmlToPlainText(ticket.description), { align: "justify", lineGap: 2 }) - } - - if (ticket.comments.length > 0) { - doc.addPage() - doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Comentários") - doc.moveDown(0.6) - const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) - commentsSorted.forEach((comment, index) => { - const visibility = - comment.visibility === "PUBLIC" ? "Público" : "Interno" - doc - .font(hasInter ? "Inter-Bold" : "Helvetica-Bold") - .fontSize(11) - .text(`${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}`) - doc.moveDown(0.15) - const body = htmlToPlainText(comment.body) - if (body) { - doc - .font(hasInter ? "Inter" : "Helvetica") - .fontSize(11) - .text(body, { align: "justify", lineGap: 2, indent: 6 }) - } - if (comment.attachments.length > 0) { - doc.moveDown(0.25) - doc.font(hasInter ? "Inter" : "Helvetica").fontSize(10).text("Anexos:") - comment.attachments.forEach((attachment) => { - doc - .font(hasInter ? "Inter" : "Helvetica") - .fontSize(10) - .text(`• ${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 }) - }) - } - if (index < commentsSorted.length - 1) { - doc.moveDown(1) - doc - .strokeColor("#E2E8F0") - .moveTo(doc.x, doc.y) - .lineTo(doc.page.width - doc.page.margins.right, doc.y) - .stroke() - doc.moveDown(0.9) - } + const pdfBuffer = await renderTicketPdfBuffer({ ticket, logoDataUrl }) + const payload = pdfBuffer instanceof Uint8Array ? pdfBuffer : new Uint8Array(pdfBuffer) + return new NextResponse(payload as unknown as BodyInit, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`, + "Cache-Control": "no-store", + }, }) + } catch (error) { + console.error("[tickets.export.pdf] Falha ao renderizar PDF", error) + return NextResponse.json({ error: "Não foi possível gerar o PDF" }, { status: 500 }) } - - if (ticket.timeline.length > 0) { - doc.addPage() - doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Linha do tempo") - doc.moveDown(0.6) - const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) - timelineSorted.forEach((event) => { - const label = TICKET_TIMELINE_LABELS[event.type] ?? event.type - doc - .font(hasInter ? "Inter-Bold" : "Helvetica-Bold") - .fontSize(11) - .text(`${label} • ${formatDateTime(event.createdAt)}`) - doc.moveDown(0.15) - - const friendly = buildTimelineMessage(event.type, event.payload) - if (friendly) { - doc - .font(hasInter ? "Inter" : "Helvetica") - .fontSize(10) - .text(friendly, { indent: 16, lineGap: 1 }) - } else { - const payloadText = stringifyPayload(event.payload) - if (payloadText) { - doc - .font(hasInter ? "Inter" : "Helvetica") - .fontSize(10) - .text(payloadText, { indent: 16, lineGap: 1 }) - } - } - doc.moveDown(0.7) - }) - } - - doc.end() - const pdfBuffer = await pdfBufferPromise - const pdfBytes = new Uint8Array(pdfBuffer) - - return new NextResponse(pdfBytes, { - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`, - "Cache-Control": "no-store", - }, - }) } diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 0000000..b1c7cf7 Binary files /dev/null and b/src/app/icon.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a088d2c..9933fe9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,10 +18,10 @@ const jetBrainsMono = JetBrains_Mono({ }) export const metadata: Metadata = { - title: "Raven", + title: "Raven - Sistema de chamados", description: "Plataforma Raven da Rever", icons: { - icon: "/raven.png", + icon: "/icon.png", }, } diff --git a/src/app/machines/handshake/route.ts b/src/app/machines/handshake/route.ts index 3185228..7d2bcb6 100644 --- a/src/app/machines/handshake/route.ts +++ b/src/app/machines/handshake/route.ts @@ -67,12 +67,13 @@ export async function GET(request: NextRequest) { assignedUserRole: session.machine.assignedUserRole, } const encodedContext = Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url") + const isSecure = redirectUrl.protocol === "https:" response.cookies.set({ name: "machine_ctx", value: encodedContext, httpOnly: true, sameSite: "lax", - secure: true, + secure: isSecure, path: "/", maxAge: 60 * 60 * 24 * 30, }) diff --git a/src/app/tickets/resolved/page.tsx b/src/app/tickets/resolved/page.tsx new file mode 100644 index 0000000..83c275a --- /dev/null +++ b/src/app/tickets/resolved/page.tsx @@ -0,0 +1,5 @@ +import { TicketsResolvedPageClient } from "./tickets-resolved-page-client" + +export default function TicketsResolvedPage() { + return +} diff --git a/src/app/tickets/resolved/tickets-resolved-page-client.tsx b/src/app/tickets/resolved/tickets-resolved-page-client.tsx new file mode 100644 index 0000000..295d3bb --- /dev/null +++ b/src/app/tickets/resolved/tickets-resolved-page-client.tsx @@ -0,0 +1,31 @@ +"use client" + +import dynamic from "next/dynamic" + +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" + +const TicketsView = dynamic( + () => + import("@/components/tickets/tickets-view").then((module) => ({ + default: module.TicketsView, + })), + { ssr: false } +) + +export function TicketsResolvedPageClient() { + return ( + + } + > +
+ +
+
+ ) +} diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index 883989f..32c8ee2 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -55,6 +55,25 @@ type Props = { defaultTenantId: string } +const ROLE_LABELS: Record = { + admin: "Administrador", + manager: "Gestor", + agent: "Agente", + collaborator: "Colaborador", + machine: "Agente de máquina", +} + +function formatRole(role: string) { + const key = role?.toLowerCase?.() ?? "" + return ROLE_LABELS[key] ?? role +} + +function formatTenantLabel(tenantId: string, defaultTenantId: string) { + if (!tenantId) return "Principal" + if (tenantId === defaultTenantId) return "Principal" + return tenantId +} + function formatDate(dateIso: string) { const date = new Date(dateIso) return new Intl.DateTimeFormat("pt-BR", { @@ -206,8 +225,6 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d Convites Usuários - Filas - Categorias @@ -255,25 +272,23 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d {normalizedRoles.map((item) => ( - {item === "admin" - ? "Administrador" - : item === "manager" - ? "Gestor" - : item === "agent" - ? "Agente" - : "Colaborador"} + {formatRole(item)} ))}
- + setTenantId(event.target.value)} + placeholder="ex.: principal" /> +

+ Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão. +

@@ -377,7 +392,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d Colaborador Papel - Tenant + Espaço Expira em Status Ações @@ -392,8 +407,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d {invite.email}
- {invite.role} - {invite.tenantId} + {formatRole(invite.role)} + {formatTenantLabel(invite.tenantId, defaultTenantId)} {formatDate(invite.expiresAt)} Nome E-mail Papel - Tenant + Espaço Criado em @@ -458,8 +473,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d {user.name || "—"} {user.email} - {user.role} - {user.tenantId} + {formatRole(user.role)} + {formatTenantLabel(user.tenantId, defaultTenantId)} {formatDate(user.createdAt)} ))} @@ -476,27 +491,6 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
- - - - Gestão de filas - - Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets. - - - - - - - - - Gestão de categorias - - Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo. - - - - ) } diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index a9eccdd..2037dc5 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -18,11 +18,12 @@ import { Layers3, UserPlus, BellRing, + ChevronDown, } from "lucide-react" -import { usePathname } from "next/navigation" - -import { SearchForm } from "@/components/search-form" -import { VersionSwitcher } from "@/components/version-switcher" +import { usePathname } from "next/navigation" + +import { SearchForm } from "@/components/search-form" +import { VersionSwitcher } from "@/components/version-switcher" import { Sidebar, SidebarContent, @@ -39,6 +40,7 @@ import { import { Skeleton } from "@/components/ui/skeleton" import { NavUser } from "@/components/nav-user" import { useAuth } from "@/lib/auth-client" +import { cn } from "@/lib/utils" import type { LucideIcon } from "lucide-react" @@ -47,9 +49,10 @@ type NavRoleRequirement = "staff" | "admin" type NavigationItem = { title: string url: string - icon: LucideIcon + icon?: LucideIcon requiredRole?: NavRoleRequirement exact?: boolean + children?: NavigationItem[] } type NavigationGroup = { @@ -65,7 +68,13 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = { title: "Operação", items: [ { title: "Dashboard", url: "/dashboard", icon: LayoutDashboard, requiredRole: "staff" }, - { title: "Tickets", url: "/tickets", icon: Ticket, requiredRole: "staff" }, + { + title: "Tickets", + url: "/tickets", + icon: Ticket, + requiredRole: "staff", + children: [{ title: "Resolvidos", url: "/tickets/resolved", requiredRole: "staff" }], + }, { title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" }, { title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" }, ], @@ -105,9 +114,34 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = { } export function AppSidebar({ ...props }: React.ComponentProps) { - const pathname = usePathname() + const pathname = usePathname() const { session, isLoading, isAdmin, isStaff } = useAuth() const [isHydrated, setIsHydrated] = React.useState(false) + const initialExpanded = React.useMemo(() => { + const open = new Set() + navigation.navMain.forEach((group) => { + group.items.forEach((item) => { + if (!item.children || item.children.length === 0) return + const shouldOpen = item.children.some((child) => { + if (!canAccess(child.requiredRole)) return false + return pathname === child.url || pathname.startsWith(`${child.url}/`) + }) + if (shouldOpen) { + open.add(item.title) + } + }) + }) + return open + }, [pathname]) + const [expanded, setExpanded] = React.useState>(initialExpanded) + + React.useEffect(() => { + setExpanded((prev) => { + const next = new Set(prev) + initialExpanded.forEach((key) => next.add(key)) + return next + }) + }, [initialExpanded]) React.useEffect(() => { setIsHydrated(true) @@ -131,6 +165,18 @@ export function AppSidebar({ ...props }: React.ComponentProps) { if (requiredRole === "staff") return isStaff return false } + + const toggleExpanded = React.useCallback((title: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(title)) { + next.delete(title) + } else { + next.add(title) + } + return next + }) + }, []) if (!isHydrated) { return ( @@ -180,16 +226,64 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {group.title} - {visibleItems.map((item) => ( - - - - - {item.title} - - - - ))} + {visibleItems.map((item) => { + if (item.children && item.children.length > 0) { + const childItems = item.children.filter((child) => canAccess(child.requiredRole)) + const isExpanded = expanded.has(item.title) + const isChildActive = childItems.some((child) => isActive(child)) + const parentActive = isActive(item) || isChildActive + + return ( + + + + + {item.icon ? : null} + {item.title} + { + event.preventDefault() + event.stopPropagation() + toggleExpanded(item.title) + }} + className={cn( + "absolute right-1.5 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-neutral-500 transition hover:bg-slate-200 hover:text-neutral-700", + isExpanded && "rotate-180" + )} + > + + + + + + {isExpanded + ? childItems.map((child) => ( + + + + {child.title} + + + + )) + : null} + + ) + } + + return ( + + + + {item.icon ? : null} + {item.title} + + + + ) + })} diff --git a/src/components/portal/portal-shell.tsx b/src/components/portal/portal-shell.tsx index 3c3d28e..db0a04f 100644 --- a/src/components/portal/portal-shell.tsx +++ b/src/components/portal/portal-shell.tsx @@ -26,9 +26,11 @@ export function PortalShell({ children }: PortalShellProps) { const { session, machineContext } = useAuth() const [isSigningOut, setIsSigningOut] = useState(false) + const isMachineSession = session?.user.role === "machine" + const personaValue = machineContext?.persona ?? session?.user.machinePersona ?? null const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente" const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? "" - const personaLabel = machineContext?.persona === "manager" ? "Gestor" : "Colaborador" + const personaLabel = personaValue === "manager" ? "Gestor" : "Colaborador" const initials = useMemo(() => { const name = displayName || displayEmail || "Cliente" @@ -64,7 +66,7 @@ export function PortalShell({ children }: PortalShellProps) {
- + Portal do cliente Raven @@ -100,12 +102,12 @@ export function PortalShell({ children }: PortalShellProps) {
{displayName} {displayEmail} - {machineContext ? ( + {personaValue ? ( {personaLabel} ) : null}
- {!machineContext ? ( + {!isMachineSession ? ( + ))} +
+ )} +

+ Use {"{{cliente}}"} dentro do template para inserir automaticamente o nome do solicitante. +

+ +
+

Mensagem de encerramento

+ +

Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.

+
+ + +
+ O comentário será público e ficará registrado no histórico do ticket. +
+
+ + +
+
+ + ) } diff --git a/src/components/tickets/ticket-comments.rich.tsx b/src/components/tickets/ticket-comments.rich.tsx index 352977a..c185fb8 100644 --- a/src/components/tickets/ticket-comments.rich.tsx +++ b/src/components/tickets/ticket-comments.rich.tsx @@ -51,7 +51,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) { const [localBodies, setLocalBodies] = useState>({}) const templateArgs = convexUserId && isStaff - ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } + ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const } : "skip" const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as | { id: string; title: string; body: string }[] diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index 8bf339f..5556fbb 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -499,7 +499,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { ) : null} - + {isPlaying ? (