feat: secure convex admin flows with real metrics\n\nCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
0ec5b49e8a
commit
29a647f6c6
43 changed files with 4992 additions and 363 deletions
209
web/convex/fields.ts
Normal file
209
web/convex/fields.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { mutation, query } from "./_generated/server";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
|
||||
import { requireAdmin } from "./rbac";
|
||||
|
||||
const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const;
|
||||
|
||||
type FieldType = (typeof FIELD_TYPES)[number];
|
||||
|
||||
function normalizeKey(label: string) {
|
||||
return label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/_+/g, "_");
|
||||
}
|
||||
|
||||
type AnyCtx = QueryCtx | MutationCtx;
|
||||
|
||||
async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"ticketFields">) {
|
||||
const existing = await ctx.db
|
||||
.query("ticketFields")
|
||||
.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" && (!options || options.length === 0)) {
|
||||
throw new ConvexError("Campos de seleção precisam de pelo menos uma opção");
|
||||
}
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId);
|
||||
const fields = await ctx.db
|
||||
.query("ticketFields")
|
||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
|
||||
return fields
|
||||
.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: field.required,
|
||||
options: field.options ?? [],
|
||||
order: field.order,
|
||||
createdAt: field.createdAt,
|
||||
updatedAt: field.updatedAt,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
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.boolean(),
|
||||
options: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
value: v.string(),
|
||||
label: v.string(),
|
||||
})
|
||||
)
|
||||
),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, label, description, type, required, options }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const normalizedLabel = label.trim();
|
||||
if (normalizedLabel.length < 2) {
|
||||
throw new ConvexError("Informe um rótulo para o campo");
|
||||
}
|
||||
if (!FIELD_TYPES.includes(type as FieldType)) {
|
||||
throw new ConvexError("Tipo de campo inválido");
|
||||
}
|
||||
validateOptions(type as FieldType, options ?? undefined);
|
||||
const key = normalizeKey(normalizedLabel);
|
||||
await ensureUniqueKey(ctx, tenantId, key);
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("ticketFields")
|
||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
const maxOrder = existing.reduce((acc: number, item: Doc<"ticketFields">) => Math.max(acc, item.order ?? 0), 0);
|
||||
|
||||
const now = Date.now();
|
||||
const id = await ctx.db.insert("ticketFields", {
|
||||
tenantId,
|
||||
key,
|
||||
label: normalizedLabel,
|
||||
description,
|
||||
type,
|
||||
required,
|
||||
options,
|
||||
order: maxOrder + 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
});
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
fieldId: v.id("ticketFields"),
|
||||
actorId: v.id("users"),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.string(),
|
||||
required: v.boolean(),
|
||||
options: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
value: v.string(),
|
||||
label: v.string(),
|
||||
})
|
||||
)
|
||||
),
|
||||
},
|
||||
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const field = await ctx.db.get(fieldId);
|
||||
if (!field || field.tenantId !== tenantId) {
|
||||
throw new ConvexError("Campo não encontrado");
|
||||
}
|
||||
if (!FIELD_TYPES.includes(type as FieldType)) {
|
||||
throw new ConvexError("Tipo de campo inválido");
|
||||
}
|
||||
const normalizedLabel = label.trim();
|
||||
if (normalizedLabel.length < 2) {
|
||||
throw new ConvexError("Informe um rótulo para o campo");
|
||||
}
|
||||
validateOptions(type as FieldType, options ?? undefined);
|
||||
|
||||
let key = field.key;
|
||||
if (field.label !== normalizedLabel) {
|
||||
key = normalizeKey(normalizedLabel);
|
||||
await ensureUniqueKey(ctx, tenantId, key, fieldId);
|
||||
}
|
||||
|
||||
await ctx.db.patch(fieldId, {
|
||||
key,
|
||||
label: normalizedLabel,
|
||||
description,
|
||||
type,
|
||||
required,
|
||||
options,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
fieldId: v.id("ticketFields"),
|
||||
actorId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, fieldId, actorId }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const field = await ctx.db.get(fieldId);
|
||||
if (!field || field.tenantId !== tenantId) {
|
||||
throw new ConvexError("Campo não encontrado");
|
||||
}
|
||||
await ctx.db.delete(fieldId);
|
||||
},
|
||||
});
|
||||
export const reorder = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
orderedIds: v.array(v.id("ticketFields")),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, orderedIds }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const fields = await Promise.all(orderedIds.map((id) => ctx.db.get(id)));
|
||||
fields.forEach((field) => {
|
||||
if (!field || field.tenantId !== tenantId) {
|
||||
throw new ConvexError("Campo inválido para reordenação");
|
||||
}
|
||||
});
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
orderedIds.map((fieldId, index) =>
|
||||
ctx.db.patch(fieldId, {
|
||||
order: index + 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue