284 lines
8.5 KiB
TypeScript
284 lines
8.5 KiB
TypeScript
import { mutation, query } from "./_generated/server"
|
|
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
|
import { ConvexError, v } from "convex/values"
|
|
import type { Id } from "./_generated/dataModel"
|
|
|
|
import { requireAdmin, requireUser } from "./rbac"
|
|
import { ensureMobileDeviceFields } from "./deviceFieldDefaults"
|
|
|
|
const FIELD_TYPES = ["text", "number", "select", "multiselect", "date", "boolean"] as const
|
|
type FieldType = (typeof FIELD_TYPES)[number]
|
|
|
|
type AnyCtx = MutationCtx | QueryCtx
|
|
|
|
function normalizeKey(label: string) {
|
|
return label
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize("NFD")
|
|
.replace(/[^\w\s-]/g, "")
|
|
.replace(/\s+/g, "_")
|
|
.replace(/_+/g, "_")
|
|
}
|
|
|
|
async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"deviceFields">) {
|
|
const existing = await ctx.db
|
|
.query("deviceFields")
|
|
.withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key))
|
|
.first()
|
|
if (existing && (!excludeId || existing._id !== excludeId)) {
|
|
throw new ConvexError("Já existe um campo com este identificador")
|
|
}
|
|
}
|
|
|
|
function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) {
|
|
if ((type === "select" || type === "multiselect") && (!options || options.length === 0)) {
|
|
throw new ConvexError("Campos de seleção precisam de pelo menos uma opção")
|
|
}
|
|
}
|
|
|
|
function matchesScope(fieldScope: string | undefined, scope: string | undefined) {
|
|
if (!scope || scope === "all") return true
|
|
if (!fieldScope || fieldScope === "all") return true
|
|
return fieldScope === scope
|
|
}
|
|
|
|
function matchesCompany(fieldCompanyId: Id<"companies"> | undefined, companyId: Id<"companies"> | undefined, includeScoped?: boolean) {
|
|
if (!companyId) {
|
|
if (includeScoped) return true
|
|
return fieldCompanyId ? false : true
|
|
}
|
|
return !fieldCompanyId || fieldCompanyId === companyId
|
|
}
|
|
|
|
export const list = query({
|
|
args: {
|
|
tenantId: v.string(),
|
|
viewerId: v.id("users"),
|
|
companyId: v.optional(v.id("companies")),
|
|
scope: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, { tenantId, viewerId, companyId, scope }) => {
|
|
await requireAdmin(ctx, viewerId, tenantId)
|
|
const fieldsQuery = ctx.db
|
|
.query("deviceFields")
|
|
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
|
|
|
const fields = await fieldsQuery.collect()
|
|
return fields
|
|
.filter((field) => matchesCompany(field.companyId, companyId, true))
|
|
.filter((field) => matchesScope(field.scope, scope))
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((field) => ({
|
|
id: field._id,
|
|
key: field.key,
|
|
label: field.label,
|
|
description: field.description ?? "",
|
|
type: field.type as FieldType,
|
|
required: Boolean(field.required),
|
|
options: field.options ?? [],
|
|
order: field.order,
|
|
scope: field.scope ?? "all",
|
|
companyId: field.companyId ?? null,
|
|
}))
|
|
},
|
|
})
|
|
|
|
export const listForTenant = query({
|
|
args: {
|
|
tenantId: v.string(),
|
|
viewerId: v.id("users"),
|
|
companyId: v.optional(v.id("companies")),
|
|
scope: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, { tenantId, viewerId, companyId, scope }) => {
|
|
await requireUser(ctx, viewerId, tenantId)
|
|
const fields = await ctx.db
|
|
.query("deviceFields")
|
|
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
|
.collect()
|
|
|
|
return fields
|
|
.filter((field) => matchesCompany(field.companyId, companyId, false))
|
|
.filter((field) => matchesScope(field.scope, scope))
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((field) => ({
|
|
id: field._id,
|
|
key: field.key,
|
|
label: field.label,
|
|
description: field.description ?? "",
|
|
type: field.type as FieldType,
|
|
required: Boolean(field.required),
|
|
options: field.options ?? [],
|
|
order: field.order,
|
|
scope: field.scope ?? "all",
|
|
companyId: field.companyId ?? null,
|
|
}))
|
|
},
|
|
})
|
|
|
|
export const create = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
label: v.string(),
|
|
description: v.optional(v.string()),
|
|
type: v.string(),
|
|
required: v.optional(v.boolean()),
|
|
options: v.optional(
|
|
v.array(
|
|
v.object({
|
|
value: v.string(),
|
|
label: v.string(),
|
|
})
|
|
)
|
|
),
|
|
scope: v.optional(v.string()),
|
|
companyId: v.optional(v.id("companies")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await requireAdmin(ctx, args.actorId, args.tenantId)
|
|
const normalizedLabel = args.label.trim()
|
|
if (normalizedLabel.length < 2) {
|
|
throw new ConvexError("Informe um rótulo para o campo")
|
|
}
|
|
if (!FIELD_TYPES.includes(args.type as FieldType)) {
|
|
throw new ConvexError("Tipo de campo inválido")
|
|
}
|
|
validateOptions(args.type as FieldType, args.options ?? undefined)
|
|
|
|
const key = normalizeKey(normalizedLabel)
|
|
await ensureUniqueKey(ctx, args.tenantId, key)
|
|
|
|
const existing = await ctx.db
|
|
.query("deviceFields")
|
|
.withIndex("by_tenant_order", (q) => q.eq("tenantId", args.tenantId))
|
|
.collect()
|
|
const maxOrder = existing.reduce((acc, item) => Math.max(acc, item.order ?? 0), 0)
|
|
const now = Date.now()
|
|
|
|
const id = await ctx.db.insert("deviceFields", {
|
|
tenantId: args.tenantId,
|
|
key,
|
|
label: normalizedLabel,
|
|
description: args.description ?? undefined,
|
|
type: args.type,
|
|
required: Boolean(args.required),
|
|
options: args.options ?? undefined,
|
|
scope: args.scope ?? "all",
|
|
companyId: args.companyId ?? undefined,
|
|
order: maxOrder + 1,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
createdBy: args.actorId,
|
|
updatedBy: args.actorId,
|
|
})
|
|
|
|
return id
|
|
},
|
|
})
|
|
|
|
export const update = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
fieldId: v.id("deviceFields"),
|
|
label: v.string(),
|
|
description: v.optional(v.string()),
|
|
type: v.string(),
|
|
required: v.optional(v.boolean()),
|
|
options: v.optional(
|
|
v.array(
|
|
v.object({
|
|
value: v.string(),
|
|
label: v.string(),
|
|
})
|
|
)
|
|
),
|
|
scope: v.optional(v.string()),
|
|
companyId: v.optional(v.id("companies")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await requireAdmin(ctx, args.actorId, args.tenantId)
|
|
const field = await ctx.db.get(args.fieldId)
|
|
if (!field || field.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Campo não encontrado")
|
|
}
|
|
if (!FIELD_TYPES.includes(args.type as FieldType)) {
|
|
throw new ConvexError("Tipo de campo inválido")
|
|
}
|
|
const normalizedLabel = args.label.trim()
|
|
if (normalizedLabel.length < 2) {
|
|
throw new ConvexError("Informe um rótulo para o campo")
|
|
}
|
|
validateOptions(args.type as FieldType, args.options ?? undefined)
|
|
|
|
let key = field.key
|
|
if (field.label !== normalizedLabel) {
|
|
key = normalizeKey(normalizedLabel)
|
|
await ensureUniqueKey(ctx, args.tenantId, key, args.fieldId)
|
|
}
|
|
|
|
await ctx.db.patch(args.fieldId, {
|
|
key,
|
|
label: normalizedLabel,
|
|
description: args.description ?? undefined,
|
|
type: args.type,
|
|
required: Boolean(args.required),
|
|
options: args.options ?? undefined,
|
|
scope: args.scope ?? "all",
|
|
companyId: args.companyId ?? undefined,
|
|
updatedAt: Date.now(),
|
|
updatedBy: args.actorId,
|
|
})
|
|
},
|
|
})
|
|
|
|
export const remove = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
fieldId: v.id("deviceFields"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await requireAdmin(ctx, args.actorId, args.tenantId)
|
|
const field = await ctx.db.get(args.fieldId)
|
|
if (!field || field.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Campo não encontrado")
|
|
}
|
|
await ctx.db.delete(args.fieldId)
|
|
},
|
|
})
|
|
|
|
export const reorder = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
orderedIds: v.array(v.id("deviceFields")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await requireAdmin(ctx, args.actorId, args.tenantId)
|
|
const now = Date.now()
|
|
await Promise.all(
|
|
args.orderedIds.map((fieldId, index) =>
|
|
ctx.db.patch(fieldId, {
|
|
order: index + 1,
|
|
updatedAt: now,
|
|
updatedBy: args.actorId,
|
|
})
|
|
)
|
|
)
|
|
},
|
|
})
|
|
|
|
export const ensureDefaults = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { tenantId, actorId }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
await ensureMobileDeviceFields(ctx, tenantId)
|
|
return { ok: true }
|
|
},
|
|
})
|