refactor: quality workflow, docs, tests
This commit is contained in:
parent
a9caf36b01
commit
68ace0a858
27 changed files with 758 additions and 330 deletions
|
|
@ -1,6 +1,4 @@
|
|||
import { randomBytes } from "crypto"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { Prisma } from "@prisma/client"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
|
@ -8,6 +6,7 @@ import { env } from "@/lib/env"
|
|||
import { normalizeSlug, slugify } from "@/lib/slug"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
|
|
@ -33,10 +32,7 @@ function extractSecret(request: Request, url: URL): string | null {
|
|||
}
|
||||
|
||||
async function ensureConvexCompany(params: { tenantId: string; slug: string; name: string; provisioningCode: string }) {
|
||||
if (!env.NEXT_PUBLIC_CONVEX_URL) {
|
||||
throw new Error("Convex não configurado")
|
||||
}
|
||||
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
|
||||
const client = createConvexClient()
|
||||
await client.mutation(api.companies.ensureProvisioned, params)
|
||||
}
|
||||
|
||||
|
|
@ -161,12 +157,19 @@ export async function POST(request: Request) {
|
|||
},
|
||||
}))
|
||||
|
||||
await ensureConvexCompany({
|
||||
tenantId,
|
||||
slug: company.slug,
|
||||
name: company.name,
|
||||
provisioningCode: company.provisioningCode,
|
||||
})
|
||||
try {
|
||||
await ensureConvexCompany({
|
||||
tenantId,
|
||||
slug: company.slug,
|
||||
name: company.name,
|
||||
provisioningCode: company.provisioningCode,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return jsonWithCors(
|
||||
{
|
||||
|
|
@ -188,12 +191,19 @@ export async function POST(request: Request) {
|
|||
try {
|
||||
const fallback = await prisma.company.findFirst({ where: { tenantId, slug: normalizedSlug } })
|
||||
if (fallback) {
|
||||
await ensureConvexCompany({
|
||||
tenantId,
|
||||
slug: fallback.slug,
|
||||
name: fallback.name,
|
||||
provisioningCode: fallback.provisioningCode,
|
||||
})
|
||||
try {
|
||||
await ensureConvexCompany({
|
||||
tenantId,
|
||||
slug: fallback.slug,
|
||||
name: fallback.name,
|
||||
provisioningCode: fallback.provisioningCode,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return jsonWithCors(
|
||||
{
|
||||
company: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { env } from "@/lib/env"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
const heartbeatSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
|
|
@ -28,13 +27,19 @@ export async function OPTIONS(request: Request) {
|
|||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const origin = request.headers.get("origin")
|
||||
if (request.method !== "POST") {
|
||||
return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS)
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let payload
|
||||
|
|
@ -45,19 +50,17 @@ export async function POST(request: Request) {
|
|||
return jsonWithCors(
|
||||
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
request.headers.get("origin"),
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const response = await client.mutation(api.machines.heartbeat, payload)
|
||||
return jsonWithCors(response, 200, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors(response, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.heartbeat] Falha ao registrar heartbeat", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao registrar heartbeat", details }, 500, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors({ error: "Falha ao registrar heartbeat", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { env } from "@/lib/env"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
const tokenModeSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
|
|
@ -41,9 +40,16 @@ export async function OPTIONS(request: Request) {
|
|||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS)
|
||||
const origin = request.headers.get("origin")
|
||||
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let raw: unknown
|
||||
|
|
@ -53,13 +59,11 @@ export async function POST(request: Request) {
|
|||
return jsonWithCors(
|
||||
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
request.headers.get("origin"),
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
// Modo A: com token da máquina (usa heartbeat para juntar inventário)
|
||||
const tokenParsed = tokenModeSchema.safeParse(raw)
|
||||
if (tokenParsed.success) {
|
||||
|
|
@ -71,11 +75,11 @@ export async function POST(request: Request) {
|
|||
metrics: tokenParsed.data.metrics,
|
||||
inventory: tokenParsed.data.inventory,
|
||||
})
|
||||
return jsonWithCors({ ok: true, machineId: result.machineId, expiresAt: result.expiresAt }, 200, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors({ ok: true, machineId: result.machineId, expiresAt: result.expiresAt }, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.inventory:token] Falha ao atualizar inventário", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao atualizar inventário", details }, 500, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors({ error: "Falha ao atualizar inventário", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,16 +94,16 @@ export async function POST(request: Request) {
|
|||
macAddresses: provParsed.data.macAddresses,
|
||||
serialNumbers: provParsed.data.serialNumbers,
|
||||
inventory: provParsed.data.inventory,
|
||||
metrics: provParsed.data.metrics,
|
||||
registeredBy: provParsed.data.registeredBy ?? "agent:inventory",
|
||||
metrics: provParsed.data.metrics,
|
||||
registeredBy: provParsed.data.registeredBy ?? "agent:inventory",
|
||||
})
|
||||
return jsonWithCors({ ok: true, machineId: result.machineId, status: result.status }, 200, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors({ ok: true, machineId: result.machineId, status: result.status }, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.inventory:prov] Falha ao fazer upsert de inventário", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao fazer upsert de inventário", details }, 500, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors({ error: "Falha ao fazer upsert de inventário", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
||||
return jsonWithCors({ error: "Formato de payload não suportado" }, 400, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors({ error: "Formato de payload não suportado" }, 400, origin, CORS_METHODS)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { env } from "@/lib/env"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
|
|
@ -54,16 +52,18 @@ export async function POST(request: Request) {
|
|||
return jsonWithCors({ error: "Código de provisionamento inválido" }, 404, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
if (env.NEXT_PUBLIC_CONVEX_URL) {
|
||||
try {
|
||||
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
|
||||
await client.mutation(api.companies.ensureProvisioned, {
|
||||
tenantId: company.tenantId,
|
||||
slug: company.slug,
|
||||
name: company.name,
|
||||
provisioningCode: company.provisioningCode,
|
||||
})
|
||||
} catch (error) {
|
||||
try {
|
||||
const client = createConvexClient()
|
||||
await client.mutation(api.companies.ensureProvisioned, {
|
||||
tenantId: company.tenantId,
|
||||
slug: company.slug,
|
||||
name: company.name,
|
||||
provisioningCode: company.provisioningCode,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
console.warn("[machines.provisioning] Convex não configurado; ignorando sincronização de empresa.")
|
||||
} else {
|
||||
console.error("[machines.provisioning] Falha ao sincronizar empresa no Convex", error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { ensureCollaboratorAccount, ensureMachineAccount } from "@/server/machines-auth"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
|
|
@ -42,13 +41,19 @@ export async function OPTIONS(request: Request) {
|
|||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const origin = request.headers.get("origin")
|
||||
if (request.method !== "POST") {
|
||||
return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS)
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let payload
|
||||
|
|
@ -59,12 +64,11 @@ export async function POST(request: Request) {
|
|||
return jsonWithCors(
|
||||
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
request.headers.get("origin"),
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
try {
|
||||
const provisioningCode = payload.provisioningCode.trim().toLowerCase()
|
||||
const companyRecord = await prisma.company.findFirst({
|
||||
|
|
@ -76,7 +80,7 @@ export async function POST(request: Request) {
|
|||
return jsonWithCors(
|
||||
{ error: "Código de provisionamento inválido" },
|
||||
404,
|
||||
request.headers.get("origin"),
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
|
@ -89,7 +93,7 @@ export async function POST(request: Request) {
|
|||
return jsonWithCors(
|
||||
{ error: "Informe os dados do colaborador/gestor ao definir o perfil de acesso." },
|
||||
400,
|
||||
request.headers.get("origin"),
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
|
@ -195,7 +199,7 @@ export async function POST(request: Request) {
|
|||
collaborator: collaborator ?? null,
|
||||
},
|
||||
{ status: 201 },
|
||||
request.headers.get("origin"),
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
} catch (error) {
|
||||
|
|
@ -207,6 +211,6 @@ export async function POST(request: Request) {
|
|||
const isConvexError = msg.includes("convexerror")
|
||||
const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500
|
||||
const payload = { error: "Falha ao provisionar máquina", details }
|
||||
return jsonWithCors(payload, status, request.headers.get("origin"), CORS_METHODS)
|
||||
return jsonWithCors(payload, status, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +1,43 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
||||
const MACHINE_CTX_COOKIE = "machine_ctx"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
import {
|
||||
MACHINE_CTX_COOKIE,
|
||||
extractCollaboratorFromMetadata,
|
||||
parseMachineCookie,
|
||||
serializeMachineCookie,
|
||||
type CollaboratorMetadata,
|
||||
type MachineContextCookiePayload,
|
||||
} from "@/server/machines/context"
|
||||
|
||||
// Força runtime Node.js para leitura consistente de cookies de sessão
|
||||
export const runtime = "nodejs"
|
||||
|
||||
type CollaboratorMetadata = {
|
||||
email: string
|
||||
name: string | null
|
||||
role: "collaborator" | "manager" | null
|
||||
}
|
||||
|
||||
function decodeMachineCookie(value: string) {
|
||||
try {
|
||||
const json = Buffer.from(value, "base64url").toString("utf8")
|
||||
return JSON.parse(json) as {
|
||||
machineId: string
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
function extractCollaboratorFromMetadata(metadata: unknown): CollaboratorMetadata | null {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return null
|
||||
}
|
||||
const record = metadata as Record<string, unknown>
|
||||
const raw = record["collaborator"]
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return null
|
||||
}
|
||||
const base = raw as Record<string, unknown>
|
||||
const emailValue = base["email"]
|
||||
if (typeof emailValue !== "string") {
|
||||
return null
|
||||
}
|
||||
const email = emailValue.trim().toLowerCase()
|
||||
if (!email) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nameValue = base["name"]
|
||||
const roleValue = base["role"]
|
||||
const name = typeof nameValue === "string" ? (nameValue.trim() || null) : null
|
||||
const normalizedRole =
|
||||
typeof roleValue === "string" ? roleValue.trim().toLowerCase() : null
|
||||
const role =
|
||||
normalizedRole === "manager"
|
||||
? "manager"
|
||||
: normalizedRole === "collaborator"
|
||||
? "collaborator"
|
||||
: null
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
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 convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado." }, { status: 500 })
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value ?? null
|
||||
|
||||
const decoded = cookieValue ? decodeMachineCookie(cookieValue) : null
|
||||
const decoded = parseMachineCookie(cookieValue)
|
||||
let machineId: Id<"machines"> | null = decoded?.machineId ? (decoded.machineId as Id<"machines">) : null
|
||||
|
||||
if (!machineId) {
|
||||
|
|
@ -136,7 +74,7 @@ export async function GET(request: NextRequest) {
|
|||
authEmail: string | null
|
||||
}
|
||||
|
||||
const metadataCollaborator = extractCollaboratorFromMetadata(context.metadata)
|
||||
const metadataCollaborator: CollaboratorMetadata | null = extractCollaboratorFromMetadata(context.metadata)
|
||||
|
||||
let ensuredAssignedUserId = context.assignedUserId
|
||||
let ensuredAssignedUserEmail = context.assignedUserEmail ?? metadataCollaborator?.email ?? null
|
||||
|
|
@ -200,13 +138,13 @@ export async function GET(request: NextRequest) {
|
|||
ensuredPersona ??
|
||||
(ensuredAssignedUserRole ? ensuredAssignedUserRole.toLowerCase() : null)
|
||||
|
||||
const responsePayload = {
|
||||
const responsePayload: MachineContextCookiePayload = {
|
||||
machineId: context.id,
|
||||
persona: resolvedPersona,
|
||||
assignedUserId: ensuredAssignedUserId,
|
||||
assignedUserEmail: ensuredAssignedUserEmail,
|
||||
assignedUserName: ensuredAssignedUserName,
|
||||
assignedUserRole: ensuredAssignedUserRole,
|
||||
persona: resolvedPersona ?? null,
|
||||
assignedUserId: ensuredAssignedUserId ?? null,
|
||||
assignedUserEmail: ensuredAssignedUserEmail ?? null,
|
||||
assignedUserName: ensuredAssignedUserName ?? null,
|
||||
assignedUserRole: ensuredAssignedUserRole ?? null,
|
||||
}
|
||||
|
||||
const response = NextResponse.json({
|
||||
|
|
@ -224,7 +162,7 @@ export async function GET(request: NextRequest) {
|
|||
const isSecure = request.nextUrl.protocol === "https:"
|
||||
response.cookies.set({
|
||||
name: MACHINE_CTX_COOKIE,
|
||||
value: encodeMachineCookie(responsePayload),
|
||||
value: serializeMachineCookie(responsePayload),
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: isSecure,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ import { NextResponse } from "next/server"
|
|||
import { z } from "zod"
|
||||
import { createMachineSession } from "@/server/machines-session"
|
||||
import { applyCorsHeaders, createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import {
|
||||
MACHINE_CTX_COOKIE,
|
||||
serializeMachineCookie,
|
||||
type MachineContextCookiePayload,
|
||||
} from "@/server/machines/context"
|
||||
|
||||
const sessionSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
|
|
@ -96,18 +101,18 @@ export async function POST(request: Request) {
|
|||
response.cookies.set(name, value, options)
|
||||
}
|
||||
|
||||
const machineCookiePayload = {
|
||||
const machineCookiePayload: MachineContextCookiePayload = {
|
||||
machineId: session.machine.id,
|
||||
persona: session.machine.persona,
|
||||
assignedUserId: session.machine.assignedUserId,
|
||||
assignedUserEmail: session.machine.assignedUserEmail,
|
||||
assignedUserName: session.machine.assignedUserName,
|
||||
assignedUserRole: session.machine.assignedUserRole,
|
||||
persona: session.machine.persona ?? null,
|
||||
assignedUserId: session.machine.assignedUserId ?? null,
|
||||
assignedUserEmail: session.machine.assignedUserEmail ?? null,
|
||||
assignedUserName: session.machine.assignedUserName ?? null,
|
||||
assignedUserRole: session.machine.assignedUserRole ?? null,
|
||||
}
|
||||
const isSecure = new URL(request.url).protocol === "https:"
|
||||
response.cookies.set({
|
||||
name: "machine_ctx",
|
||||
value: Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url"),
|
||||
name: MACHINE_CTX_COOKIE,
|
||||
value: serializeMachineCookie(machineCookiePayload),
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: isSecure,
|
||||
|
|
|
|||
|
|
@ -21,23 +21,30 @@ export function LoginPageClient() {
|
|||
const { data: session, isPending } = useSession()
|
||||
const callbackUrl = searchParams?.get("callbackUrl") ?? undefined
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
const sessionUser = session?.user
|
||||
const userId = sessionUser?.id ?? null
|
||||
const normalizedRole = sessionUser?.role ? sessionUser.role.toLowerCase() : null
|
||||
const persona = typeof sessionUser?.machinePersona === "string" ? sessionUser.machinePersona.toLowerCase() : null
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
if (!session?.user) return
|
||||
const role = (session.user.role ?? "").toLowerCase()
|
||||
const persona = (session.user as any).machinePersona
|
||||
? String((session.user as any).machinePersona).toLowerCase()
|
||||
: null
|
||||
if (!userId) return
|
||||
const defaultDest =
|
||||
role === "machine"
|
||||
normalizedRole === "machine"
|
||||
? persona === "manager"
|
||||
? "/dashboard"
|
||||
: "/portal/tickets"
|
||||
: "/dashboard"
|
||||
const destination = callbackUrl ?? defaultDest
|
||||
router.replace(destination)
|
||||
}, [callbackUrl, isPending, router, session?.user])
|
||||
}, [
|
||||
callbackUrl,
|
||||
isPending,
|
||||
normalizedRole,
|
||||
persona,
|
||||
router,
|
||||
userId,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
})
|
||||
setQueueSelection(ticket.queue ?? "")
|
||||
setAssigneeSelection(ticket.assignee?.id ?? "")
|
||||
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue])
|
||||
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, ticket.assignee?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
|
|
|
|||
49
src/components/ui/copy-button.tsx
Normal file
49
src/components/ui/copy-button.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { Copy, Sparkles } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string
|
||||
onCopied?: () => void
|
||||
}
|
||||
|
||||
export function CopyButton({ value, onCopied }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
onCopied?.()
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
console.error("Falha ao copiar código", error)
|
||||
}
|
||||
}, [onCopied, value])
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="relative overflow-hidden border border-dashed border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-neutral-700 transition-all hover:border-slate-400 hover:bg-white active:scale-[0.97]"
|
||||
>
|
||||
<span className="pointer-events-none absolute inset-0 rounded-md bg-neutral-900/5 opacity-0 transition-opacity duration-100 ease-out active:opacity-100" />
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 transition-all duration-200 ease-out",
|
||||
copied ? "text-emerald-600" : "text-neutral-700"
|
||||
)}
|
||||
>
|
||||
{copied ? <Sparkles className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
{copied ? "Copiado!" : "Copiar código"}
|
||||
</span>
|
||||
<span className="sr-only">Copiar código de provisionamento</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
21
src/components/ui/crossblur.tsx
Normal file
21
src/components/ui/crossblur.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Crossblur({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 overflow-hidden rounded-xl transition-opacity duration-200 ease-out",
|
||||
active ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-[-40%] rounded-full bg-[radial-gradient(circle_at_center,_rgba(59,130,246,0.25),_transparent_70%)] blur-lg transition-transform duration-500 ease-in-out",
|
||||
active ? "scale-[1.05] rotate-6" : "scale-100 -rotate-12"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
24
src/server/convex-client.ts
Normal file
24
src/server/convex-client.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { env } from "@/lib/env"
|
||||
|
||||
export class ConvexConfigurationError extends Error {
|
||||
constructor(message = "Convex não configurado.") {
|
||||
super(message)
|
||||
this.name = "ConvexConfigurationError"
|
||||
}
|
||||
}
|
||||
|
||||
export function requireConvexUrl(): string {
|
||||
const url = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!url) {
|
||||
throw new ConvexConfigurationError()
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
export function createConvexClient(): ConvexHttpClient {
|
||||
const url = requireConvexUrl()
|
||||
return new ConvexHttpClient(url)
|
||||
}
|
||||
|
||||
70
src/server/machines/context.ts
Normal file
70
src/server/machines/context.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { z } from "zod"
|
||||
|
||||
export const MACHINE_CTX_COOKIE = "machine_ctx"
|
||||
|
||||
const machineCookieSchema = z.object({
|
||||
machineId: z.string(),
|
||||
persona: z.string().nullable().optional(),
|
||||
assignedUserId: z.string().nullable().optional(),
|
||||
assignedUserEmail: z.string().nullable().optional(),
|
||||
assignedUserName: z.string().nullable().optional(),
|
||||
assignedUserRole: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
const collaboratorSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
})
|
||||
.transform(({ email, name, role }) => {
|
||||
const trimmedRole = role?.trim().toLowerCase()
|
||||
const normalizedRole =
|
||||
trimmedRole === "manager"
|
||||
? "manager"
|
||||
: trimmedRole === "collaborator"
|
||||
? "collaborator"
|
||||
: null
|
||||
|
||||
const normalizedName = typeof name === "string" ? name.trim() || null : null
|
||||
|
||||
return {
|
||||
email: email.trim().toLowerCase(),
|
||||
name: normalizedName,
|
||||
role: normalizedRole,
|
||||
}
|
||||
})
|
||||
|
||||
const metadataSchema = z
|
||||
.object({
|
||||
collaborator: collaboratorSchema,
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export type MachineContextCookiePayload = z.infer<typeof machineCookieSchema>
|
||||
export type CollaboratorMetadata = z.output<typeof collaboratorSchema>
|
||||
|
||||
export function parseMachineCookie(value: string | null | undefined): MachineContextCookiePayload | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
const json = Buffer.from(value, "base64url").toString("utf8")
|
||||
const parsed = JSON.parse(json)
|
||||
return machineCookieSchema.parse(parsed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeMachineCookie(payload: MachineContextCookiePayload): string {
|
||||
return Buffer.from(JSON.stringify(machineCookieSchema.parse(payload))).toString("base64url")
|
||||
}
|
||||
|
||||
export function extractCollaboratorFromMetadata(metadata: unknown): CollaboratorMetadata | null {
|
||||
if (!metadata) return null
|
||||
const parsed = metadataSchema.safeParse(metadata)
|
||||
if (!parsed.success) {
|
||||
return null
|
||||
}
|
||||
return parsed.data.collaborator
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue