Implement company provisioning codes and session tweaks
This commit is contained in:
parent
0fb9bf59b2
commit
2cba553efa
28 changed files with 1407 additions and 534 deletions
|
|
@ -33,8 +33,9 @@ export const ensureProvisioned = mutation({
|
|||
tenantId: v.string(),
|
||||
slug: v.string(),
|
||||
name: v.string(),
|
||||
provisioningCode: v.string(),
|
||||
},
|
||||
handler: async (ctx, { tenantId, slug, name }) => {
|
||||
handler: async (ctx, { tenantId, slug, name, provisioningCode }) => {
|
||||
const normalizedSlug = normalizeSlug(slug)
|
||||
if (!normalizedSlug) {
|
||||
throw new ConvexError("Slug inválido")
|
||||
|
|
@ -50,6 +51,9 @@ export const ensureProvisioned = mutation({
|
|||
.unique()
|
||||
|
||||
if (existing) {
|
||||
if (existing.provisioningCode !== provisioningCode) {
|
||||
await ctx.db.patch(existing._id, { provisioningCode })
|
||||
}
|
||||
return {
|
||||
id: existing._id,
|
||||
slug: existing.slug,
|
||||
|
|
@ -62,6 +66,7 @@ export const ensureProvisioned = mutation({
|
|||
tenantId,
|
||||
name: trimmedName,
|
||||
slug: normalizedSlug,
|
||||
provisioningCode,
|
||||
isAvulso: false,
|
||||
contractedHoursPerMonth: undefined,
|
||||
cnpj: undefined,
|
||||
|
|
|
|||
|
|
@ -17,28 +17,6 @@ type NormalizedIdentifiers = {
|
|||
serials: string[]
|
||||
}
|
||||
|
||||
function normalizeCompanySlug(input?: string | null): string | undefined {
|
||||
if (!input) return undefined
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return undefined
|
||||
const ascii = trimmed
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[\u2013\u2014]/g, "-")
|
||||
const sanitized = ascii.replace(/[^\w\s-]/g, "").replace(/[_\s]+/g, "-")
|
||||
const collapsed = sanitized.replace(/-+/g, "-").toLowerCase()
|
||||
const normalized = collapsed.replace(/^-+|-+$/g, "")
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function getProvisioningSecret() {
|
||||
const secret = process.env["MACHINE_PROVISIONING_SECRET"]
|
||||
if (!secret) {
|
||||
throw new ConvexError("Provisionamento de máquinas não configurado")
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
function getTokenTtlMs(): number {
|
||||
const raw = process.env["MACHINE_TOKEN_TTL_MS"]
|
||||
if (!raw) return DEFAULT_TOKEN_TTL_MS
|
||||
|
|
@ -122,23 +100,6 @@ function hashToken(token: string) {
|
|||
return toHex(sha256(token))
|
||||
}
|
||||
|
||||
async function ensureCompany(
|
||||
ctx: MutationCtx,
|
||||
tenantId: string,
|
||||
companySlug?: string
|
||||
): Promise<{ companyId?: Id<"companies">; companySlug?: string }> {
|
||||
const normalized = normalizeCompanySlug(companySlug)
|
||||
if (!normalized) return {}
|
||||
const company = await ctx.db
|
||||
.query("companies")
|
||||
.withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", normalized))
|
||||
.unique()
|
||||
if (!company) {
|
||||
throw new ConvexError("Empresa não encontrada para o tenant informado")
|
||||
}
|
||||
return { companyId: company._id, companySlug: company.slug }
|
||||
}
|
||||
|
||||
async function getActiveToken(
|
||||
ctx: MutationCtx,
|
||||
tokenValue: string
|
||||
|
|
@ -332,9 +293,7 @@ async function evaluatePostureAndMaybeRaise(
|
|||
|
||||
export const register = mutation({
|
||||
args: {
|
||||
provisioningSecret: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
companySlug: v.optional(v.string()),
|
||||
provisioningCode: v.string(),
|
||||
hostname: v.string(),
|
||||
os: v.object({
|
||||
name: v.string(),
|
||||
|
|
@ -347,16 +306,21 @@ export const register = mutation({
|
|||
registeredBy: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const secret = getProvisioningSecret()
|
||||
if (args.provisioningSecret !== secret) {
|
||||
const normalizedCode = args.provisioningCode.trim().toLowerCase()
|
||||
const companyRecord = await ctx.db
|
||||
.query("companies")
|
||||
.withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode))
|
||||
.unique()
|
||||
|
||||
if (!companyRecord) {
|
||||
throw new ConvexError("Código de provisionamento inválido")
|
||||
}
|
||||
|
||||
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
||||
const normalizedCompanySlug = normalizeCompanySlug(args.companySlug)
|
||||
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
|
||||
const companyId = companyRecord._id
|
||||
const companySlug = companyRecord.slug
|
||||
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers)
|
||||
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug)
|
||||
const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
|
||||
const now = Date.now()
|
||||
const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record<string, unknown>) : undefined
|
||||
|
||||
|
|
@ -469,9 +433,7 @@ export const register = mutation({
|
|||
|
||||
export const upsertInventory = mutation({
|
||||
args: {
|
||||
provisioningSecret: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
companySlug: v.optional(v.string()),
|
||||
provisioningCode: v.string(),
|
||||
hostname: v.string(),
|
||||
os: v.object({
|
||||
name: v.string(),
|
||||
|
|
@ -485,16 +447,21 @@ export const upsertInventory = mutation({
|
|||
registeredBy: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const secret = getProvisioningSecret()
|
||||
if (args.provisioningSecret !== secret) {
|
||||
const normalizedCode = args.provisioningCode.trim().toLowerCase()
|
||||
const companyRecord = await ctx.db
|
||||
.query("companies")
|
||||
.withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode))
|
||||
.unique()
|
||||
|
||||
if (!companyRecord) {
|
||||
throw new ConvexError("Código de provisionamento inválido")
|
||||
}
|
||||
|
||||
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
||||
const normalizedCompanySlug = normalizeCompanySlug(args.companySlug)
|
||||
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
|
||||
const companyId = companyRecord._id
|
||||
const companySlug = companyRecord.slug
|
||||
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers)
|
||||
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug)
|
||||
const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
|
||||
const now = Date.now()
|
||||
|
||||
const metadataPatch: Record<string, unknown> = {}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { randomBytes } from "@noble/hashes/utils"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
|
||||
import { mutation, query } from "./_generated/server"
|
||||
|
|
@ -13,6 +14,10 @@ function normalizeEmail(value: string) {
|
|||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function generateProvisioningCode() {
|
||||
return Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
type ImportedUser = {
|
||||
email: string
|
||||
name: string
|
||||
|
|
@ -30,6 +35,7 @@ type ImportedQueue = {
|
|||
type ImportedCompany = {
|
||||
slug: string
|
||||
name: string
|
||||
provisioningCode?: string | null
|
||||
isAvulso?: boolean | null
|
||||
cnpj?: string | null
|
||||
domain?: string | null
|
||||
|
|
@ -185,6 +191,7 @@ async function ensureCompany(
|
|||
tenantId,
|
||||
name: data.name,
|
||||
slug,
|
||||
provisioningCode: data.provisioningCode ?? existing?.provisioningCode ?? generateProvisioningCode(),
|
||||
isAvulso: data.isAvulso ?? undefined,
|
||||
cnpj: data.cnpj ?? undefined,
|
||||
domain: data.domain ?? undefined,
|
||||
|
|
@ -204,7 +211,8 @@ async function ensureCompany(
|
|||
existing.domain !== (payload.domain ?? undefined) ||
|
||||
existing.phone !== (payload.phone ?? undefined) ||
|
||||
existing.description !== (payload.description ?? undefined) ||
|
||||
existing.address !== (payload.address ?? undefined)
|
||||
existing.address !== (payload.address ?? undefined) ||
|
||||
existing.provisioningCode !== payload.provisioningCode
|
||||
if (needsPatch) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
name: payload.name,
|
||||
|
|
@ -214,6 +222,7 @@ async function ensureCompany(
|
|||
phone: payload.phone,
|
||||
description: payload.description,
|
||||
address: payload.address,
|
||||
provisioningCode: payload.provisioningCode,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export default defineSchema({
|
|||
tenantId: v.string(),
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
provisioningCode: v.string(),
|
||||
isAvulso: v.optional(v.boolean()),
|
||||
contractedHoursPerMonth: v.optional(v.number()),
|
||||
cnpj: v.optional(v.string()),
|
||||
|
|
@ -31,7 +32,8 @@ export default defineSchema({
|
|||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_tenant_slug", ["tenantId", "slug"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_provisioning_code", ["provisioningCode"]),
|
||||
|
||||
alerts: defineTable({
|
||||
tenantId: v.string(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { randomBytes } from "@noble/hashes/utils"
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
import { mutation } from "./_generated/server"
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ export const seedDemo = mutation({
|
|||
phone?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
provisioningCode?: string;
|
||||
}): Promise<Id<"companies">> {
|
||||
const slug = def.slug ?? slugify(def.name);
|
||||
const existing = await ctx.db
|
||||
|
|
@ -97,6 +99,7 @@ export const seedDemo = mutation({
|
|||
tenantId,
|
||||
name: def.name,
|
||||
slug,
|
||||
provisioningCode: def.provisioningCode ?? existing?.provisioningCode ?? generateCode(),
|
||||
cnpj: def.cnpj ?? undefined,
|
||||
domain: def.domain ?? undefined,
|
||||
phone: def.phone ?? undefined,
|
||||
|
|
@ -113,6 +116,7 @@ export const seedDemo = mutation({
|
|||
if (existing.phone !== payload.phone) updates.phone = payload.phone;
|
||||
if (existing.description !== payload.description) updates.description = payload.description;
|
||||
if (existing.address !== payload.address) updates.address = payload.address;
|
||||
if (existing.provisioningCode !== payload.provisioningCode) updates.provisioningCode = payload.provisioningCode;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedAt = now;
|
||||
await ctx.db.patch(existing._id, updates);
|
||||
|
|
@ -157,7 +161,16 @@ export const seedDemo = mutation({
|
|||
});
|
||||
}
|
||||
|
||||
const companiesSeed = [
|
||||
const companiesSeed: Array<{
|
||||
name: string;
|
||||
slug: string;
|
||||
cnpj?: string;
|
||||
domain?: string;
|
||||
phone?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
provisioningCode?: string;
|
||||
}> = [
|
||||
{
|
||||
name: "Atlas Engenharia Digital",
|
||||
slug: "atlas-engenharia",
|
||||
|
|
@ -387,3 +400,4 @@ export const seedDemo = mutation({
|
|||
});
|
||||
},
|
||||
});
|
||||
const generateCode = () => Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("")
|
||||
|
|
|
|||
|
|
@ -424,11 +424,61 @@ export const getById = query({
|
|||
const visibleComments = canViewInternalComments
|
||||
? comments
|
||||
: comments.filter((comment) => comment.visibility !== "INTERNAL");
|
||||
const timeline = await ctx.db
|
||||
const visibleCommentKeys = new Set(
|
||||
visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`)
|
||||
)
|
||||
const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt))
|
||||
|
||||
let timelineRecords = await ctx.db
|
||||
.query("ticketEvents")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
||||
.collect();
|
||||
|
||||
if (!(role === "ADMIN" || role === "AGENT")) {
|
||||
timelineRecords = timelineRecords.filter((event) => {
|
||||
const payload = (event.payload ?? {}) as Record<string, unknown>
|
||||
switch (event.type) {
|
||||
case "CREATED":
|
||||
return true
|
||||
case "QUEUE_CHANGED":
|
||||
return true
|
||||
case "ASSIGNEE_CHANGED":
|
||||
return true
|
||||
case "CATEGORY_CHANGED":
|
||||
return true
|
||||
case "COMMENT_ADDED": {
|
||||
const authorIdRaw = (payload as { authorId?: string }).authorId
|
||||
if (typeof authorIdRaw === "string" && authorIdRaw.trim().length > 0) {
|
||||
const key = `${event.createdAt}:${authorIdRaw}`
|
||||
if (visibleCommentKeys.has(key)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return visibleCommentTimestamps.has(event.createdAt)
|
||||
}
|
||||
case "STATUS_CHANGED": {
|
||||
const toLabelRaw = (payload as { toLabel?: string }).toLabel
|
||||
const toRaw = (payload as { to?: string }).to
|
||||
const normalized = (typeof toLabelRaw === "string" && toLabelRaw.trim().length > 0
|
||||
? toLabelRaw.trim()
|
||||
: typeof toRaw === "string"
|
||||
? toRaw.trim()
|
||||
: "").toUpperCase()
|
||||
if (!normalized) return false
|
||||
return (
|
||||
normalized === "RESOLVED" ||
|
||||
normalized === "RESOLVIDO" ||
|
||||
normalized === "CLOSED" ||
|
||||
normalized === "FINALIZADO" ||
|
||||
normalized === "FINALIZED"
|
||||
)
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const customFieldsRecord = mapCustomFieldsToRecord(
|
||||
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
|
||||
);
|
||||
|
|
@ -529,7 +579,7 @@ export const getById = query({
|
|||
},
|
||||
description: undefined,
|
||||
customFields: customFieldsRecord,
|
||||
timeline: timeline.map((ev) => {
|
||||
timeline: timelineRecords.map((ev) => {
|
||||
let payload = ev.payload;
|
||||
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
|
||||
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
|
||||
|
|
@ -712,14 +762,19 @@ export const addComment = mutation({
|
|||
|
||||
const normalizedRole = (author.role ?? "AGENT").toUpperCase()
|
||||
|
||||
const requestedVisibility = (args.visibility ?? "").toUpperCase()
|
||||
if (requestedVisibility !== "PUBLIC" && requestedVisibility !== "INTERNAL") {
|
||||
throw new ConvexError("Visibilidade inválida")
|
||||
}
|
||||
|
||||
if (normalizedRole === "MANAGER") {
|
||||
await ensureManagerTicketAccess(ctx, author, ticketDoc)
|
||||
if (args.visibility !== "PUBLIC") {
|
||||
if (requestedVisibility !== "PUBLIC") {
|
||||
throw new ConvexError("Gestores só podem registrar comentários públicos")
|
||||
}
|
||||
}
|
||||
const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
|
||||
if (args.visibility === "INTERNAL" && !canUseInternalComments) {
|
||||
if (requestedVisibility === "INTERNAL" && !canUseInternalComments) {
|
||||
throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos")
|
||||
}
|
||||
|
||||
|
|
@ -731,13 +786,24 @@ export const addComment = mutation({
|
|||
await requireTicketStaff(ctx, args.authorId, ticketDoc)
|
||||
}
|
||||
|
||||
const attachments = args.attachments ?? []
|
||||
if (attachments.length > 5) {
|
||||
throw new ConvexError("É permitido anexar no máximo 5 arquivos por comentário")
|
||||
}
|
||||
const maxAttachmentSize = 5 * 1024 * 1024
|
||||
for (const attachment of attachments) {
|
||||
if (typeof attachment.size === "number" && attachment.size > maxAttachmentSize) {
|
||||
throw new ConvexError("Cada anexo pode ter até 5MB")
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const id = await ctx.db.insert("ticketComments", {
|
||||
ticketId: args.ticketId,
|
||||
authorId: args.authorId,
|
||||
visibility: args.visibility,
|
||||
visibility: requestedVisibility,
|
||||
body: args.body,
|
||||
attachments: args.attachments ?? [],
|
||||
attachments,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue