feat: implement invite onboarding and dynamic ticket fields
This commit is contained in:
parent
29a647f6c6
commit
f24a7f68ca
34 changed files with 2240 additions and 97 deletions
2
web/convex/_generated/api.d.ts
vendored
2
web/convex/_generated/api.d.ts
vendored
|
|
@ -12,6 +12,7 @@ import type * as bootstrap from "../bootstrap.js";
|
||||||
import type * as categories from "../categories.js";
|
import type * as categories from "../categories.js";
|
||||||
import type * as fields from "../fields.js";
|
import type * as fields from "../fields.js";
|
||||||
import type * as files from "../files.js";
|
import type * as files from "../files.js";
|
||||||
|
import type * as invites from "../invites.js";
|
||||||
import type * as queues from "../queues.js";
|
import type * as queues from "../queues.js";
|
||||||
import type * as rbac from "../rbac.js";
|
import type * as rbac from "../rbac.js";
|
||||||
import type * as reports from "../reports.js";
|
import type * as reports from "../reports.js";
|
||||||
|
|
@ -40,6 +41,7 @@ declare const fullApi: ApiFromModules<{
|
||||||
categories: typeof categories;
|
categories: typeof categories;
|
||||||
fields: typeof fields;
|
fields: typeof fields;
|
||||||
files: typeof files;
|
files: typeof files;
|
||||||
|
invites: typeof invites;
|
||||||
queues: typeof queues;
|
queues: typeof queues;
|
||||||
rbac: typeof rbac;
|
rbac: typeof rbac;
|
||||||
reports: typeof reports;
|
reports: typeof reports;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import type { MutationCtx } from "./_generated/server"
|
||||||
import { ConvexError, v } from "convex/values"
|
import { ConvexError, v } from "convex/values"
|
||||||
import { Id } from "./_generated/dataModel"
|
import { Id } from "./_generated/dataModel"
|
||||||
|
|
||||||
|
import { requireAdmin } from "./rbac"
|
||||||
|
|
||||||
type CategorySeed = {
|
type CategorySeed = {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
|
|
@ -295,10 +297,13 @@ export const ensureDefaults = mutation({
|
||||||
export const createCategory = mutation({
|
export const createCategory = mutation({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
actorId: v.id("users"),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
|
secondary: v.optional(v.array(v.string())),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, name, description }) => {
|
handler: async (ctx, { tenantId, actorId, name, description, secondary }) => {
|
||||||
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (trimmed.length < 2) {
|
if (trimmed.length < 2) {
|
||||||
throw new ConvexError("Informe um nome válido para a categoria")
|
throw new ConvexError("Informe um nome válido para a categoria")
|
||||||
|
|
@ -321,6 +326,31 @@ export const createCategory = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (secondary?.length) {
|
||||||
|
let subOrder = 0
|
||||||
|
for (const item of secondary) {
|
||||||
|
const value = item.trim()
|
||||||
|
if (value.length < 2) continue
|
||||||
|
const subSlug = await ensureUniqueSlug(
|
||||||
|
ctx,
|
||||||
|
"ticketSubcategories",
|
||||||
|
tenantId,
|
||||||
|
slugify(value),
|
||||||
|
{ categoryId: id }
|
||||||
|
)
|
||||||
|
await ctx.db.insert("ticketSubcategories", {
|
||||||
|
tenantId,
|
||||||
|
categoryId: id,
|
||||||
|
name: value,
|
||||||
|
slug: subSlug,
|
||||||
|
order: subOrder,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
subOrder += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -329,10 +359,12 @@ export const updateCategory = mutation({
|
||||||
args: {
|
args: {
|
||||||
categoryId: v.id("ticketCategories"),
|
categoryId: v.id("ticketCategories"),
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
actorId: v.id("users"),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { categoryId, tenantId, name, description }) => {
|
handler: async (ctx, { categoryId, tenantId, actorId, name, description }) => {
|
||||||
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
const category = await ctx.db.get(categoryId)
|
const category = await ctx.db.get(categoryId)
|
||||||
if (!category || category.tenantId !== tenantId) {
|
if (!category || category.tenantId !== tenantId) {
|
||||||
throw new ConvexError("Categoria não encontrada")
|
throw new ConvexError("Categoria não encontrada")
|
||||||
|
|
@ -354,9 +386,11 @@ export const deleteCategory = mutation({
|
||||||
args: {
|
args: {
|
||||||
categoryId: v.id("ticketCategories"),
|
categoryId: v.id("ticketCategories"),
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
actorId: v.id("users"),
|
||||||
transferTo: v.optional(v.id("ticketCategories")),
|
transferTo: v.optional(v.id("ticketCategories")),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { categoryId, tenantId, transferTo }) => {
|
handler: async (ctx, { categoryId, tenantId, actorId, transferTo }) => {
|
||||||
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
const category = await ctx.db.get(categoryId)
|
const category = await ctx.db.get(categoryId)
|
||||||
if (!category || category.tenantId !== tenantId) {
|
if (!category || category.tenantId !== tenantId) {
|
||||||
throw new ConvexError("Categoria não encontrada")
|
throw new ConvexError("Categoria não encontrada")
|
||||||
|
|
@ -412,10 +446,12 @@ export const deleteCategory = mutation({
|
||||||
export const createSubcategory = mutation({
|
export const createSubcategory = mutation({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
actorId: v.id("users"),
|
||||||
categoryId: v.id("ticketCategories"),
|
categoryId: v.id("ticketCategories"),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, categoryId, name }) => {
|
handler: async (ctx, { tenantId, actorId, categoryId, name }) => {
|
||||||
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
const category = await ctx.db.get(categoryId)
|
const category = await ctx.db.get(categoryId)
|
||||||
if (!category || category.tenantId !== tenantId) {
|
if (!category || category.tenantId !== tenantId) {
|
||||||
throw new ConvexError("Categoria não encontrada")
|
throw new ConvexError("Categoria não encontrada")
|
||||||
|
|
@ -449,10 +485,12 @@ export const createSubcategory = mutation({
|
||||||
export const updateSubcategory = mutation({
|
export const updateSubcategory = mutation({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
actorId: v.id("users"),
|
||||||
subcategoryId: v.id("ticketSubcategories"),
|
subcategoryId: v.id("ticketSubcategories"),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, subcategoryId, name }) => {
|
handler: async (ctx, { tenantId, actorId, subcategoryId, name }) => {
|
||||||
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
const subcategory = await ctx.db.get(subcategoryId)
|
const subcategory = await ctx.db.get(subcategoryId)
|
||||||
if (!subcategory || subcategory.tenantId !== tenantId) {
|
if (!subcategory || subcategory.tenantId !== tenantId) {
|
||||||
throw new ConvexError("Subcategoria não encontrada")
|
throw new ConvexError("Subcategoria não encontrada")
|
||||||
|
|
@ -471,10 +509,12 @@ export const updateSubcategory = mutation({
|
||||||
export const deleteSubcategory = mutation({
|
export const deleteSubcategory = mutation({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
actorId: v.id("users"),
|
||||||
subcategoryId: v.id("ticketSubcategories"),
|
subcategoryId: v.id("ticketSubcategories"),
|
||||||
transferTo: v.optional(v.id("ticketSubcategories")),
|
transferTo: v.optional(v.id("ticketSubcategories")),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, subcategoryId, transferTo }) => {
|
handler: async (ctx, { tenantId, actorId, subcategoryId, transferTo }) => {
|
||||||
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
const subcategory = await ctx.db.get(subcategoryId)
|
const subcategory = await ctx.db.get(subcategoryId)
|
||||||
if (!subcategory || subcategory.tenantId !== tenantId) {
|
if (!subcategory || subcategory.tenantId !== tenantId) {
|
||||||
throw new ConvexError("Subcategoria não encontrada")
|
throw new ConvexError("Subcategoria não encontrada")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
import { ConvexError, v } from "convex/values";
|
import { ConvexError, v } from "convex/values";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireAdmin } from "./rbac";
|
import { requireAdmin, requireUser } from "./rbac";
|
||||||
|
|
||||||
const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const;
|
const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const;
|
||||||
|
|
||||||
|
|
@ -63,6 +63,30 @@ export const list = query({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listForTenant = query({
|
||||||
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
|
handler: async (ctx, { tenantId, viewerId }) => {
|
||||||
|
await requireUser(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,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const create = mutation({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
|
|
||||||
115
web/convex/invites.ts
Normal file
115
web/convex/invites.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
import { requireAdmin } from "./rbac";
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
|
handler: async (ctx, { tenantId, viewerId }) => {
|
||||||
|
await requireAdmin(ctx, viewerId, tenantId);
|
||||||
|
|
||||||
|
const invites = await ctx.db
|
||||||
|
.query("userInvites")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return invites
|
||||||
|
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
|
||||||
|
.map((invite) => ({
|
||||||
|
id: invite._id,
|
||||||
|
inviteId: invite.inviteId,
|
||||||
|
email: invite.email,
|
||||||
|
name: invite.name ?? null,
|
||||||
|
role: invite.role,
|
||||||
|
status: invite.status,
|
||||||
|
token: invite.token,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
createdAt: invite.createdAt,
|
||||||
|
createdById: invite.createdById ?? null,
|
||||||
|
acceptedAt: invite.acceptedAt ?? null,
|
||||||
|
acceptedById: invite.acceptedById ?? null,
|
||||||
|
revokedAt: invite.revokedAt ?? null,
|
||||||
|
revokedById: invite.revokedById ?? null,
|
||||||
|
revokedReason: invite.revokedReason ?? null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sync = mutation({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
inviteId: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
name: v.optional(v.string()),
|
||||||
|
role: v.string(),
|
||||||
|
status: v.string(),
|
||||||
|
token: v.string(),
|
||||||
|
expiresAt: v.number(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
createdById: v.optional(v.string()),
|
||||||
|
acceptedAt: v.optional(v.number()),
|
||||||
|
acceptedById: v.optional(v.string()),
|
||||||
|
revokedAt: v.optional(v.number()),
|
||||||
|
revokedById: v.optional(v.string()),
|
||||||
|
revokedReason: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("userInvites")
|
||||||
|
.withIndex("by_invite", (q) => q.eq("tenantId", args.tenantId).eq("inviteId", args.inviteId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const id = await ctx.db.insert("userInvites", {
|
||||||
|
tenantId: args.tenantId,
|
||||||
|
inviteId: args.inviteId,
|
||||||
|
email: args.email,
|
||||||
|
name: args.name,
|
||||||
|
role: args.role,
|
||||||
|
status: args.status,
|
||||||
|
token: args.token,
|
||||||
|
expiresAt: args.expiresAt,
|
||||||
|
createdAt: args.createdAt,
|
||||||
|
createdById: args.createdById,
|
||||||
|
acceptedAt: args.acceptedAt,
|
||||||
|
acceptedById: args.acceptedById,
|
||||||
|
revokedAt: args.revokedAt,
|
||||||
|
revokedById: args.revokedById,
|
||||||
|
revokedReason: args.revokedReason,
|
||||||
|
});
|
||||||
|
return await ctx.db.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
email: args.email,
|
||||||
|
name: args.name,
|
||||||
|
role: args.role,
|
||||||
|
status: args.status,
|
||||||
|
token: args.token,
|
||||||
|
expiresAt: args.expiresAt,
|
||||||
|
createdAt: args.createdAt,
|
||||||
|
createdById: args.createdById,
|
||||||
|
acceptedAt: args.acceptedAt,
|
||||||
|
acceptedById: args.acceptedById,
|
||||||
|
revokedAt: args.revokedAt,
|
||||||
|
revokedById: args.revokedById,
|
||||||
|
revokedReason: args.revokedReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await ctx.db.get(existing._id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: { tenantId: v.string(), inviteId: v.string() },
|
||||||
|
handler: async (ctx, { tenantId, inviteId }) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("userInvites")
|
||||||
|
.withIndex("by_invite", (q) => q.eq("tenantId", tenantId).eq("inviteId", inviteId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.delete(existing._id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -60,6 +60,18 @@ export default defineSchema({
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
tags: v.optional(v.array(v.string())),
|
tags: v.optional(v.array(v.string())),
|
||||||
|
customFields: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
fieldId: v.id("ticketFields"),
|
||||||
|
fieldKey: v.string(),
|
||||||
|
label: v.string(),
|
||||||
|
type: v.string(),
|
||||||
|
value: v.any(),
|
||||||
|
displayValue: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
totalWorkedMs: v.optional(v.number()),
|
totalWorkedMs: v.optional(v.number()),
|
||||||
activeSessionId: v.optional(v.id("ticketWorkSessions")),
|
activeSessionId: v.optional(v.id("ticketWorkSessions")),
|
||||||
})
|
})
|
||||||
|
|
@ -153,4 +165,25 @@ export default defineSchema({
|
||||||
.index("by_tenant_key", ["tenantId", "key"])
|
.index("by_tenant_key", ["tenantId", "key"])
|
||||||
.index("by_tenant_order", ["tenantId", "order"])
|
.index("by_tenant_order", ["tenantId", "order"])
|
||||||
.index("by_tenant", ["tenantId"]),
|
.index("by_tenant", ["tenantId"]),
|
||||||
|
|
||||||
|
userInvites: defineTable({
|
||||||
|
tenantId: v.string(),
|
||||||
|
inviteId: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
name: v.optional(v.string()),
|
||||||
|
role: v.string(),
|
||||||
|
status: v.string(),
|
||||||
|
token: v.string(),
|
||||||
|
expiresAt: v.number(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
createdById: v.optional(v.string()),
|
||||||
|
acceptedAt: v.optional(v.number()),
|
||||||
|
acceptedById: v.optional(v.string()),
|
||||||
|
revokedAt: v.optional(v.number()),
|
||||||
|
revokedById: v.optional(v.string()),
|
||||||
|
revokedReason: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
.index("by_tenant", ["tenantId"])
|
||||||
|
.index("by_token", ["tenantId", "token"])
|
||||||
|
.index("by_invite", ["tenantId", "inviteId"]),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { mutation, query } from "./_generated/server";
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import type { MutationCtx } from "./_generated/server";
|
||||||
import { ConvexError, v } from "convex/values";
|
import { ConvexError, v } from "convex/values";
|
||||||
import { Id, type Doc } from "./_generated/dataModel";
|
import { Id, type Doc } from "./_generated/dataModel";
|
||||||
|
|
||||||
|
|
@ -37,6 +38,136 @@ function normalizeTeams(teams?: string[] | null): string[] {
|
||||||
return teams.map((team) => renameQueueString(team) ?? team);
|
return teams.map((team) => renameQueueString(team) ?? team);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomFieldInput = {
|
||||||
|
fieldId: Id<"ticketFields">;
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NormalizedCustomField = {
|
||||||
|
fieldId: Id<"ticketFields">;
|
||||||
|
fieldKey: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
value: unknown;
|
||||||
|
displayValue?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { value: unknown; displayValue?: string } {
|
||||||
|
switch (field.type) {
|
||||||
|
case "text":
|
||||||
|
return { value: String(raw).trim() };
|
||||||
|
case "number": {
|
||||||
|
const value = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."));
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new ConvexError(`Valor numérico inválido para o campo ${field.label}`);
|
||||||
|
}
|
||||||
|
return { value };
|
||||||
|
}
|
||||||
|
case "date": {
|
||||||
|
if (typeof raw === "number") {
|
||||||
|
if (!Number.isFinite(raw)) {
|
||||||
|
throw new ConvexError(`Data inválida para o campo ${field.label}`);
|
||||||
|
}
|
||||||
|
return { value: raw };
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(String(raw));
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
throw new ConvexError(`Data inválida para o campo ${field.label}`);
|
||||||
|
}
|
||||||
|
return { value: parsed };
|
||||||
|
}
|
||||||
|
case "boolean": {
|
||||||
|
if (typeof raw === "boolean") {
|
||||||
|
return { value: raw };
|
||||||
|
}
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const normalized = raw.toLowerCase();
|
||||||
|
if (normalized === "true" || normalized === "1") return { value: true };
|
||||||
|
if (normalized === "false" || normalized === "0") return { value: false };
|
||||||
|
}
|
||||||
|
throw new ConvexError(`Valor inválido para o campo ${field.label}`);
|
||||||
|
}
|
||||||
|
case "select": {
|
||||||
|
if (!field.options || field.options.length === 0) {
|
||||||
|
throw new ConvexError(`Campo ${field.label} sem opções configuradas`);
|
||||||
|
}
|
||||||
|
const value = String(raw);
|
||||||
|
const option = field.options.find((opt) => opt.value === value);
|
||||||
|
if (!option) {
|
||||||
|
throw new ConvexError(`Seleção inválida para o campo ${field.label}`);
|
||||||
|
}
|
||||||
|
return { value: option.value, displayValue: option.label ?? option.value };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { value: raw };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeCustomFieldValues(
|
||||||
|
ctx: Pick<MutationCtx, "db">,
|
||||||
|
tenantId: string,
|
||||||
|
inputs: CustomFieldInput[] | undefined
|
||||||
|
): Promise<NormalizedCustomField[]> {
|
||||||
|
const definitions = await ctx.db
|
||||||
|
.query("ticketFields")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if (!definitions.length) {
|
||||||
|
if (inputs && inputs.length > 0) {
|
||||||
|
throw new ConvexError("Nenhum campo personalizado configurado para este tenant");
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const provided = new Map<Id<"ticketFields">, unknown>();
|
||||||
|
for (const entry of inputs ?? []) {
|
||||||
|
provided.set(entry.fieldId, entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: NormalizedCustomField[] = [];
|
||||||
|
|
||||||
|
for (const definition of definitions.sort((a, b) => a.order - b.order)) {
|
||||||
|
const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined;
|
||||||
|
const isMissing =
|
||||||
|
raw === undefined ||
|
||||||
|
raw === null ||
|
||||||
|
(typeof raw === "string" && raw.trim().length === 0);
|
||||||
|
|
||||||
|
if (isMissing) {
|
||||||
|
if (definition.required) {
|
||||||
|
throw new ConvexError(`Preencha o campo obrigatório: ${definition.label}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value, displayValue } = coerceCustomFieldValue(definition, raw);
|
||||||
|
normalized.push({
|
||||||
|
fieldId: definition._id,
|
||||||
|
fieldKey: definition.key,
|
||||||
|
label: definition.label,
|
||||||
|
type: definition.type,
|
||||||
|
value,
|
||||||
|
displayValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
|
||||||
|
if (!entries || entries.length === 0) return {};
|
||||||
|
return entries.reduce<Record<string, { label: string; type: string; value: unknown; displayValue?: string }>>((acc, entry) => {
|
||||||
|
acc[entry.fieldKey] = {
|
||||||
|
label: entry.label,
|
||||||
|
type: entry.type,
|
||||||
|
value: entry.value,
|
||||||
|
displayValue: entry.displayValue,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
viewerId: v.optional(v.id("users")),
|
viewerId: v.optional(v.id("users")),
|
||||||
|
|
@ -199,6 +330,10 @@ export const getById = query({
|
||||||
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
const customFieldsRecord = mapCustomFieldsToRecord(
|
||||||
|
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
const commentsHydrated = await Promise.all(
|
const commentsHydrated = await Promise.all(
|
||||||
comments.map(async (c) => {
|
comments.map(async (c) => {
|
||||||
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
|
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
|
||||||
|
|
@ -290,7 +425,7 @@ export const getById = query({
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
description: undefined,
|
description: undefined,
|
||||||
customFields: {},
|
customFields: customFieldsRecord,
|
||||||
timeline: timeline.map((ev) => {
|
timeline: timeline.map((ev) => {
|
||||||
let payload = ev.payload;
|
let payload = ev.payload;
|
||||||
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
|
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
|
||||||
|
|
@ -323,6 +458,14 @@ export const create = mutation({
|
||||||
requesterId: v.id("users"),
|
requesterId: v.id("users"),
|
||||||
categoryId: v.id("ticketCategories"),
|
categoryId: v.id("ticketCategories"),
|
||||||
subcategoryId: v.id("ticketSubcategories"),
|
subcategoryId: v.id("ticketSubcategories"),
|
||||||
|
customFields: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
fieldId: v.id("ticketFields"),
|
||||||
|
value: v.any(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const { role } = await requireUser(ctx, args.actorId, args.tenantId)
|
const { role } = await requireUser(ctx, args.actorId, args.tenantId)
|
||||||
|
|
@ -342,6 +485,8 @@ export const create = mutation({
|
||||||
if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
|
if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
|
||||||
throw new ConvexError("Subcategoria inválida");
|
throw new ConvexError("Subcategoria inválida");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
|
||||||
// compute next reference (simple monotonic counter per tenant)
|
// compute next reference (simple monotonic counter per tenant)
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
|
|
@ -374,6 +519,7 @@ export const create = mutation({
|
||||||
tags: [],
|
tags: [],
|
||||||
slaPolicyId: undefined,
|
slaPolicyId: undefined,
|
||||||
dueAt: undefined,
|
dueAt: undefined,
|
||||||
|
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
||||||
});
|
});
|
||||||
const requester = await ctx.db.get(args.requesterId);
|
const requester = await ctx.db.get(args.requesterId);
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,43 @@ model AuthAccount {
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AuthInvite {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String
|
||||||
|
name String?
|
||||||
|
role String @default("agent")
|
||||||
|
tenantId String
|
||||||
|
token String @unique
|
||||||
|
status String @default("pending")
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String?
|
||||||
|
acceptedAt DateTime?
|
||||||
|
acceptedById String?
|
||||||
|
revokedAt DateTime?
|
||||||
|
revokedById String?
|
||||||
|
revokedReason String?
|
||||||
|
|
||||||
|
events AuthInviteEvent[]
|
||||||
|
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([tenantId, email])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthInviteEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
inviteId String
|
||||||
|
type String
|
||||||
|
payload Json?
|
||||||
|
actorId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
invite AuthInvite @relation(fields: [inviteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([inviteId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model AuthVerification {
|
model AuthVerification {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
identifier String
|
identifier String
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
import { QueuesManager } from "@/components/admin/queues/queues-manager"
|
import { QueuesManager } from "@/components/admin/queues/queues-manager"
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default function AdminChannelsPage() {
|
export default function AdminChannelsPage() {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
<AppShell
|
||||||
<header className="mb-8 space-y-2">
|
header={
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Filas e canais</h1>
|
<SiteHeader
|
||||||
<p className="text-sm text-neutral-600">
|
title="Filas e canais"
|
||||||
Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento.
|
lead="Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento."
|
||||||
</p>
|
/>
|
||||||
</header>
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
|
||||||
<QueuesManager />
|
<QueuesManager />
|
||||||
</main>
|
</div>
|
||||||
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
|
import { CategoriesManager } from "@/components/admin/categories/categories-manager"
|
||||||
import { FieldsManager } from "@/components/admin/fields/fields-manager"
|
import { FieldsManager } from "@/components/admin/fields/fields-manager"
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default function AdminFieldsPage() {
|
export default function AdminFieldsPage() {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
<AppShell
|
||||||
<header className="mb-8 space-y-2">
|
header={
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Campos personalizados</h1>
|
<SiteHeader
|
||||||
<p className="text-sm text-neutral-600">
|
title="Categorias e campos personalizados"
|
||||||
Defina quais informações adicionais devem ser coletadas nos tickets de cada tenant.
|
lead="Administre as categorias primárias/secundárias e os campos adicionais aplicados aos tickets."
|
||||||
</p>
|
/>
|
||||||
</header>
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl space-y-8 px-6 lg:px-8">
|
||||||
|
<CategoriesManager />
|
||||||
<FieldsManager />
|
<FieldsManager />
|
||||||
</main>
|
</div>
|
||||||
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { AdminUsersManager } from "@/components/admin/admin-users-manager"
|
import { AdminUsersManager } from "@/components/admin/admin-users-manager"
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz"
|
import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
@ -31,18 +34,46 @@ async function loadUsers() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadInvites(): Promise<NormalizedInvite[]> {
|
||||||
|
const invites = await prisma.authInvite.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
events: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
return invites.map((invite) => normalizeInvite(invite, now))
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const users = await loadUsers()
|
const users = await loadUsers()
|
||||||
|
const invites = await loadInvites()
|
||||||
|
const invitesForClient = invites.map((invite) => {
|
||||||
|
const { events, ...rest } = invite
|
||||||
|
void events
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
<AppShell
|
||||||
<div className="mb-8">
|
header={
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Administração</h1>
|
<SiteHeader
|
||||||
<p className="mt-2 text-sm text-neutral-600">
|
title="Administração"
|
||||||
Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento.
|
lead="Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento."
|
||||||
</p>
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
|
||||||
|
<AdminUsersManager
|
||||||
|
initialUsers={users}
|
||||||
|
initialInvites={invitesForClient}
|
||||||
|
roleOptions={ROLE_OPTIONS}
|
||||||
|
defaultTenantId={DEFAULT_TENANT_ID}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AdminUsersManager initialUsers={users} roleOptions={ROLE_OPTIONS} defaultTenantId={DEFAULT_TENANT_ID} />
|
</AppShell>
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
import { SlasManager } from "@/components/admin/slas/slas-manager"
|
import { SlasManager } from "@/components/admin/slas/slas-manager"
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default function AdminSlasPage() {
|
export default function AdminSlasPage() {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
<AppShell
|
||||||
<header className="mb-8 space-y-2">
|
header={
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Políticas de SLA</h1>
|
<SiteHeader
|
||||||
<p className="text-sm text-neutral-600">
|
title="Políticas de SLA"
|
||||||
Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço.
|
lead="Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço."
|
||||||
</p>
|
/>
|
||||||
</header>
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
|
||||||
<SlasManager />
|
<SlasManager />
|
||||||
</main>
|
</div>
|
||||||
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
import { TeamsManager } from "@/components/admin/teams/teams-manager"
|
import { TeamsManager } from "@/components/admin/teams/teams-manager"
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default function AdminTeamsPage() {
|
export default function AdminTeamsPage() {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
<AppShell
|
||||||
<header className="mb-8 space-y-2">
|
header={
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Times e agentes</h1>
|
<SiteHeader
|
||||||
<p className="text-sm text-neutral-600">
|
title="Times e agentes"
|
||||||
Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs.
|
lead="Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs."
|
||||||
</p>
|
/>
|
||||||
</header>
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
|
||||||
<TeamsManager />
|
<TeamsManager />
|
||||||
</main>
|
</div>
|
||||||
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
93
web/src/app/api/admin/invites/[id]/route.ts
Normal file
93
web/src/app/api/admin/invites/[id]/route.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
|
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { assertAdminSession } from "@/lib/auth-server"
|
||||||
|
import { env } from "@/lib/env"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
||||||
|
|
||||||
|
type RevokePayload = {
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncInvite(invite: NormalizedInvite) {
|
||||||
|
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||||
|
if (!convexUrl) return
|
||||||
|
const client = new ConvexHttpClient(convexUrl)
|
||||||
|
await client.mutation(api.invites.sync, {
|
||||||
|
tenantId: invite.tenantId,
|
||||||
|
inviteId: invite.id,
|
||||||
|
email: invite.email,
|
||||||
|
name: invite.name ?? undefined,
|
||||||
|
role: invite.role.toUpperCase(),
|
||||||
|
status: invite.status,
|
||||||
|
token: invite.token,
|
||||||
|
expiresAt: Date.parse(invite.expiresAt),
|
||||||
|
createdAt: Date.parse(invite.createdAt),
|
||||||
|
createdById: invite.createdById ?? undefined,
|
||||||
|
acceptedAt: invite.acceptedAt ? Date.parse(invite.acceptedAt) : undefined,
|
||||||
|
acceptedById: invite.acceptedById ?? undefined,
|
||||||
|
revokedAt: invite.revokedAt ? Date.parse(invite.revokedAt) : undefined,
|
||||||
|
revokedById: invite.revokedById ?? undefined,
|
||||||
|
revokedReason: invite.revokedReason ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
|
||||||
|
const session = await assertAdminSession()
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as Partial<RevokePayload> | null
|
||||||
|
const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null
|
||||||
|
|
||||||
|
const invite = await prisma.authInvite.findUnique({
|
||||||
|
where: { id: params.id },
|
||||||
|
include: { events: { orderBy: { createdAt: "asc" } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const status = computeInviteStatus(invite, now)
|
||||||
|
|
||||||
|
if (status === "accepted") {
|
||||||
|
return NextResponse.json({ error: "Convite já aceito" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "revoked") {
|
||||||
|
const normalized = normalizeInvite(invite, now)
|
||||||
|
await syncInvite(normalized)
|
||||||
|
return NextResponse.json({ invite: normalized })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.authInvite.update({
|
||||||
|
where: { id: invite.id },
|
||||||
|
data: {
|
||||||
|
status: "revoked",
|
||||||
|
revokedAt: now,
|
||||||
|
revokedById: session.user.id ?? null,
|
||||||
|
revokedReason: reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = await prisma.authInviteEvent.create({
|
||||||
|
data: {
|
||||||
|
inviteId: invite.id,
|
||||||
|
type: "revoked",
|
||||||
|
payload: reason ? { reason } : null,
|
||||||
|
actorId: session.user.id ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalized = normalizeInvite({ ...updated, events: [...invite.events, event] }, now)
|
||||||
|
await syncInvite(normalized)
|
||||||
|
|
||||||
|
return NextResponse.json({ invite: normalized })
|
||||||
|
}
|
||||||
205
web/src/app/api/admin/invites/route.ts
Normal file
205
web/src/app/api/admin/invites/route.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { randomBytes } from "crypto"
|
||||||
|
|
||||||
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
|
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { assertAdminSession } from "@/lib/auth-server"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||||
|
import { env } from "@/lib/env"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
|
||||||
|
|
||||||
|
const DEFAULT_EXPIRATION_DAYS = 7
|
||||||
|
|
||||||
|
function normalizeRole(input: string | null | undefined): RoleOption {
|
||||||
|
const role = (input ?? "agent").toLowerCase() as RoleOption
|
||||||
|
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken() {
|
||||||
|
return randomBytes(32).toString("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncInviteWithConvex(invite: NormalizedInvite) {
|
||||||
|
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||||
|
if (!convexUrl) return
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(convexUrl)
|
||||||
|
await client.mutation(api.invites.sync, {
|
||||||
|
tenantId: invite.tenantId,
|
||||||
|
inviteId: invite.id,
|
||||||
|
email: invite.email,
|
||||||
|
name: invite.name ?? undefined,
|
||||||
|
role: invite.role.toUpperCase(),
|
||||||
|
status: invite.status,
|
||||||
|
token: invite.token,
|
||||||
|
expiresAt: Date.parse(invite.expiresAt),
|
||||||
|
createdAt: Date.parse(invite.createdAt),
|
||||||
|
createdById: invite.createdById ?? undefined,
|
||||||
|
acceptedAt: invite.acceptedAt ? Date.parse(invite.acceptedAt) : undefined,
|
||||||
|
acceptedById: invite.acceptedById ?? undefined,
|
||||||
|
revokedAt: invite.revokedAt ? Date.parse(invite.revokedAt) : undefined,
|
||||||
|
revokedById: invite.revokedById ?? undefined,
|
||||||
|
revokedReason: invite.revokedReason ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendEvent(inviteId: string, type: string, actorId: string | null, payload: unknown = null) {
|
||||||
|
return prisma.authInviteEvent.create({
|
||||||
|
data: {
|
||||||
|
inviteId,
|
||||||
|
type,
|
||||||
|
payload,
|
||||||
|
actorId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshInviteStatus(invite: InviteWithEvents, now: Date) {
|
||||||
|
const computedStatus = computeInviteStatus(invite, now)
|
||||||
|
if (computedStatus === invite.status) {
|
||||||
|
return invite
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.authInvite.update({
|
||||||
|
where: { id: invite.id },
|
||||||
|
data: { status: computedStatus },
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = await appendEvent(invite.id, computedStatus, null)
|
||||||
|
|
||||||
|
const inviteWithEvents: InviteWithEvents = {
|
||||||
|
...updated,
|
||||||
|
events: [...invite.events, event],
|
||||||
|
}
|
||||||
|
return inviteWithEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInvitePayload(invite: InviteWithEvents, now: Date) {
|
||||||
|
const normalized = normalizeInvite(invite, now)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await assertAdminSession()
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const invites = await prisma.authInvite.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
events: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const results: NormalizedInvite[] = []
|
||||||
|
|
||||||
|
for (const invite of invites) {
|
||||||
|
const updatedInvite = await refreshInviteStatus(invite, now)
|
||||||
|
const normalized = buildInvitePayload(updatedInvite, now)
|
||||||
|
await syncInviteWithConvex(normalized)
|
||||||
|
results.push(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ invites: results })
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInvitePayload = {
|
||||||
|
email: string
|
||||||
|
name?: string
|
||||||
|
role?: RoleOption
|
||||||
|
tenantId?: string
|
||||||
|
expiresInDays?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await assertAdminSession()
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as Partial<CreateInvitePayload> | null
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""
|
||||||
|
if (!email || !email.includes("@")) {
|
||||||
|
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof body.name === "string" ? body.name.trim() : undefined
|
||||||
|
const role = normalizeRole(body.role)
|
||||||
|
const tenantId = typeof body.tenantId === "string" && body.tenantId.trim() ? body.tenantId.trim() : session.user.tenantId || DEFAULT_TENANT_ID
|
||||||
|
const expiresInDays = Number.isFinite(body.expiresInDays) ? Math.max(1, Number(body.expiresInDays)) : DEFAULT_EXPIRATION_DAYS
|
||||||
|
|
||||||
|
const existing = await prisma.authInvite.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
status: { in: ["pending", "accepted"] },
|
||||||
|
},
|
||||||
|
include: { events: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
if (existing) {
|
||||||
|
const computed = computeInviteStatus(existing, now)
|
||||||
|
if (computed === "pending") {
|
||||||
|
return NextResponse.json({ error: "Já existe um convite pendente para este e-mail" }, { status: 409 })
|
||||||
|
}
|
||||||
|
if (computed === "accepted") {
|
||||||
|
return NextResponse.json({ error: "Este e-mail já possui acesso ativo" }, { status: 409 })
|
||||||
|
}
|
||||||
|
if (existing.status !== computed) {
|
||||||
|
await prisma.authInvite.update({ where: { id: existing.id }, data: { status: computed } })
|
||||||
|
await appendEvent(existing.id, computed, session.user.id ?? null)
|
||||||
|
const refreshed = await prisma.authInvite.findUnique({
|
||||||
|
where: { id: existing.id },
|
||||||
|
include: { events: true },
|
||||||
|
})
|
||||||
|
if (refreshed) {
|
||||||
|
const normalizedExisting = buildInvitePayload(refreshed, now)
|
||||||
|
await syncInviteWithConvex(normalizedExisting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken()
|
||||||
|
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
const invite = await prisma.authInvite.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
tenantId,
|
||||||
|
token,
|
||||||
|
status: "pending",
|
||||||
|
expiresAt,
|
||||||
|
createdById: session.user.id ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = await appendEvent(invite.id, "created", session.user.id ?? null, {
|
||||||
|
role,
|
||||||
|
tenantId,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const inviteWithEvents: InviteWithEvents = {
|
||||||
|
...invite,
|
||||||
|
events: [event],
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = buildInvitePayload(inviteWithEvents, now)
|
||||||
|
await syncInviteWithConvex(normalized)
|
||||||
|
|
||||||
|
return NextResponse.json({ invite: normalized })
|
||||||
|
}
|
||||||
196
web/src/app/api/invites/[token]/route.ts
Normal file
196
web/src/app/api/invites/[token]/route.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { hashPassword } from "better-auth/crypto"
|
||||||
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
|
// @ts-expect-error Convex generated API lacks types in Next routes
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { env } from "@/lib/env"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import {
|
||||||
|
computeInviteStatus,
|
||||||
|
normalizeInvite,
|
||||||
|
normalizeRoleOption,
|
||||||
|
type NormalizedInvite,
|
||||||
|
} from "@/server/invite-utils"
|
||||||
|
|
||||||
|
type AcceptInvitePayload = {
|
||||||
|
name?: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePassword(password: string) {
|
||||||
|
return password.length >= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncInvite(invite: NormalizedInvite) {
|
||||||
|
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||||
|
if (!convexUrl) return
|
||||||
|
const client = new ConvexHttpClient(convexUrl)
|
||||||
|
await client.mutation(api.invites.sync, {
|
||||||
|
tenantId: invite.tenantId,
|
||||||
|
inviteId: invite.id,
|
||||||
|
email: invite.email,
|
||||||
|
name: invite.name ?? undefined,
|
||||||
|
role: invite.role.toUpperCase(),
|
||||||
|
status: invite.status,
|
||||||
|
token: invite.token,
|
||||||
|
expiresAt: Date.parse(invite.expiresAt),
|
||||||
|
createdAt: Date.parse(invite.createdAt),
|
||||||
|
createdById: invite.createdById ?? undefined,
|
||||||
|
acceptedAt: invite.acceptedAt ? Date.parse(invite.acceptedAt) : undefined,
|
||||||
|
acceptedById: invite.acceptedById ?? undefined,
|
||||||
|
revokedAt: invite.revokedAt ? Date.parse(invite.revokedAt) : undefined,
|
||||||
|
revokedById: invite.revokedById ?? undefined,
|
||||||
|
revokedReason: invite.revokedReason ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: { token: string } }) {
|
||||||
|
const invite = await prisma.authInvite.findUnique({
|
||||||
|
where: { token: params.token },
|
||||||
|
include: { events: { orderBy: { createdAt: "asc" } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const status = computeInviteStatus(invite, now)
|
||||||
|
|
||||||
|
if (status !== invite.status) {
|
||||||
|
await prisma.authInvite.update({ where: { id: invite.id }, data: { status } })
|
||||||
|
const event = await prisma.authInviteEvent.create({
|
||||||
|
data: {
|
||||||
|
inviteId: invite.id,
|
||||||
|
type: status,
|
||||||
|
payload: null,
|
||||||
|
actorId: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
invite.status = status
|
||||||
|
invite.events.push(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeInvite(invite, now)
|
||||||
|
await syncInvite(normalized)
|
||||||
|
|
||||||
|
return NextResponse.json({ invite: normalized })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: { params: { token: string } }) {
|
||||||
|
const payload = (await request.json().catch(() => null)) as Partial<AcceptInvitePayload> | null
|
||||||
|
if (!payload || typeof payload.password !== "string") {
|
||||||
|
return NextResponse.json({ error: "Senha inválida" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validatePassword(payload.password)) {
|
||||||
|
return NextResponse.json({ error: "Senha deve conter pelo menos 8 caracteres" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const invite = await prisma.authInvite.findUnique({
|
||||||
|
where: { token: params.token },
|
||||||
|
include: { events: { orderBy: { createdAt: "asc" } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const status = computeInviteStatus(invite, now)
|
||||||
|
|
||||||
|
if (status === "expired") {
|
||||||
|
await prisma.authInvite.update({ where: { id: invite.id }, data: { status: "expired" } })
|
||||||
|
const event = await prisma.authInviteEvent.create({
|
||||||
|
data: {
|
||||||
|
inviteId: invite.id,
|
||||||
|
type: "expired",
|
||||||
|
payload: null,
|
||||||
|
actorId: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
invite.status = "expired"
|
||||||
|
invite.events.push(event)
|
||||||
|
const normalizedExpired = normalizeInvite(invite, now)
|
||||||
|
await syncInvite(normalizedExpired)
|
||||||
|
return NextResponse.json({ error: "Convite expirado" }, { status: 410 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "revoked") {
|
||||||
|
return NextResponse.json({ error: "Convite revogado" }, { status: 410 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "accepted") {
|
||||||
|
return NextResponse.json({ error: "Convite já utilizado" }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await prisma.authUser.findUnique({ where: { email: invite.email } })
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json({ error: "Usuário já registrado" }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : invite.name || invite.email
|
||||||
|
const tenantId = invite.tenantId || DEFAULT_TENANT_ID
|
||||||
|
const role = normalizeRoleOption(invite.role)
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(payload.password)
|
||||||
|
|
||||||
|
const user = await prisma.authUser.create({
|
||||||
|
data: {
|
||||||
|
email: invite.email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
tenantId,
|
||||||
|
accounts: {
|
||||||
|
create: {
|
||||||
|
providerId: "credential",
|
||||||
|
accountId: invite.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedInvite = await prisma.authInvite.update({
|
||||||
|
where: { id: invite.id },
|
||||||
|
data: {
|
||||||
|
status: "accepted",
|
||||||
|
acceptedAt: now,
|
||||||
|
acceptedById: user.id,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = await prisma.authInviteEvent.create({
|
||||||
|
data: {
|
||||||
|
inviteId: invite.id,
|
||||||
|
type: "accepted",
|
||||||
|
payload: { userId: user.id },
|
||||||
|
actorId: user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalized = normalizeInvite({ ...updatedInvite, events: [...invite.events, event] }, now)
|
||||||
|
await syncInvite(normalized)
|
||||||
|
|
||||||
|
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||||
|
if (convexUrl) {
|
||||||
|
try {
|
||||||
|
const convex = new ConvexHttpClient(convexUrl)
|
||||||
|
await convex.mutation(api.users.ensureUser, {
|
||||||
|
tenantId,
|
||||||
|
email: invite.email,
|
||||||
|
name,
|
||||||
|
avatarUrl: undefined,
|
||||||
|
role: role.toUpperCase(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Falha ao sincronizar usuário no Convex", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
40
web/src/app/invite/[token]/page.tsx
Normal file
40
web/src/app/invite/[token]/page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { normalizeInvite } from "@/server/invite-utils"
|
||||||
|
import { InviteAcceptForm } from "@/components/invite/invite-accept-form"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function InvitePage({ params }: { params: { token: string } }) {
|
||||||
|
const invite = await prisma.authInvite.findUnique({
|
||||||
|
where: { token: params.token },
|
||||||
|
include: { events: { orderBy: { createdAt: "asc" } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeInvite(invite, new Date())
|
||||||
|
const { events: unusedEvents, inviteUrl: unusedInviteUrl, ...summary } = normalized
|
||||||
|
void unusedEvents
|
||||||
|
void unusedInviteUrl
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-4 py-16">
|
||||||
|
<Card className="w-full border border-border/70 shadow-sm">
|
||||||
|
<CardHeader className="space-y-2 text-center">
|
||||||
|
<CardTitle className="text-2xl font-semibold text-neutral-900">Aceitar convite</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-neutral-600">
|
||||||
|
Conclua seu cadastro para acessar a plataforma Sistema de chamados.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<InviteAcceptForm invite={summary} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,13 @@ import { CategorySelectFields } from "@/components/tickets/category-select"
|
||||||
export default function NewTicketPage() {
|
export default function NewTicketPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { convexUserId } = useAuth()
|
const { convexUserId } = useAuth()
|
||||||
const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
|
const queueArgs = convexUserId
|
||||||
|
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
|
||||||
|
: "skip"
|
||||||
|
const queuesRaw = useQuery(
|
||||||
|
convexUserId ? api.queues.summary : "skip",
|
||||||
|
queueArgs
|
||||||
|
) as TicketQueueSummary[] | undefined
|
||||||
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
|
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
|
||||||
const create = useMutation(api.tickets.create)
|
const create = useMutation(api.tickets.create)
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMemo, useState, useTransition } from "react"
|
||||||
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
@ -28,8 +29,28 @@ type AdminUser = {
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminInvite = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string | null
|
||||||
|
role: RoleOption
|
||||||
|
tenantId: string
|
||||||
|
status: "pending" | "accepted" | "revoked" | "expired"
|
||||||
|
token: string
|
||||||
|
inviteUrl: string
|
||||||
|
expiresAt: string
|
||||||
|
createdAt: string
|
||||||
|
createdById: string | null
|
||||||
|
acceptedAt: string | null
|
||||||
|
acceptedById: string | null
|
||||||
|
revokedAt: string | null
|
||||||
|
revokedById: string | null
|
||||||
|
revokedReason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialUsers: AdminUser[]
|
initialUsers: AdminUser[]
|
||||||
|
initialInvites: AdminInvite[]
|
||||||
roleOptions: readonly RoleOption[]
|
roleOptions: readonly RoleOption[]
|
||||||
defaultTenantId: string
|
defaultTenantId: string
|
||||||
}
|
}
|
||||||
|
|
@ -42,29 +63,59 @@ function formatDate(dateIso: string) {
|
||||||
}).format(date)
|
}).format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }: Props) {
|
function formatStatus(status: AdminInvite["status"]) {
|
||||||
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "Pendente"
|
||||||
|
case "accepted":
|
||||||
|
return "Aceito"
|
||||||
|
case "revoked":
|
||||||
|
return "Revogado"
|
||||||
|
case "expired":
|
||||||
|
return "Expirado"
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite {
|
||||||
|
const { events: unusedEvents, ...rest } = invite
|
||||||
|
void unusedEvents
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) {
|
||||||
|
const [users] = useState<AdminUser[]>(initialUsers)
|
||||||
|
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [role, setRole] = useState<RoleOption>("agent")
|
const [role, setRole] = useState<RoleOption>("agent")
|
||||||
const [tenantId, setTenantId] = useState(defaultTenantId)
|
const [tenantId, setTenantId] = useState(defaultTenantId)
|
||||||
const [lastInvite, setLastInvite] = useState<{ email: string; password: string } | null>(null)
|
const [expiresInDays, setExpiresInDays] = useState<string>("7")
|
||||||
|
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
|
||||||
|
const [revokingId, setRevokingId] = useState<string | null>(null)
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
|
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!email || !email.includes("@")) {
|
if (!email || !email.includes("@")) {
|
||||||
toast.error("Informe um e-mail válido")
|
toast.error("Informe um e-mail válido")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = { email, name, role, tenantId }
|
const payload = {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
tenantId,
|
||||||
|
expiresInDays: Number.parseInt(expiresInDays, 10),
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/users", {
|
const response = await fetch("/api/admin/invites", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|
@ -72,44 +123,89 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
throw new Error(data.error ?? "Não foi possível criar o usuário")
|
throw new Error(data.error ?? "Não foi possível gerar o convite")
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as { invite: AdminInvite }
|
||||||
user: AdminUser
|
const nextInvite = sanitizeInvite(data.invite)
|
||||||
temporaryPassword: string
|
setInvites((previous) => [nextInvite, ...previous.filter((item) => item.id !== nextInvite.id)])
|
||||||
}
|
|
||||||
|
|
||||||
setUsers((previous) => [data.user, ...previous])
|
|
||||||
setLastInvite({ email: data.user.email, password: data.temporaryPassword })
|
|
||||||
setEmail("")
|
setEmail("")
|
||||||
setName("")
|
setName("")
|
||||||
setRole("agent")
|
setRole("agent")
|
||||||
setTenantId(defaultTenantId)
|
setTenantId(defaultTenantId)
|
||||||
toast.success("Usuário criado com sucesso")
|
setExpiresInDays("7")
|
||||||
|
setLastInviteLink(nextInvite.inviteUrl)
|
||||||
|
toast.success("Convite criado com sucesso")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Falha ao criar usuário"
|
const message = error instanceof Error ? error.message : "Falha ao criar convite"
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCopy(link: string) {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(link)
|
||||||
|
.then(() => toast.success("Link de convite copiado"))
|
||||||
|
.catch(() => toast.error("Não foi possível copiar o link"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(inviteId: string) {
|
||||||
|
const invite = invites.find((item) => item.id === inviteId)
|
||||||
|
if (!invite || invite.status !== "pending") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm("Deseja revogar este convite?")
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setRevokingId(inviteId)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/invites/${inviteId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reason: "Revogado manualmente" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(data.error ?? "Falha ao revogar convite")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { invite: AdminInvite }
|
||||||
|
const updated = sanitizeInvite(data.invite)
|
||||||
|
setInvites((previous) => previous.map((item) => (item.id === updated.id ? updated : item)))
|
||||||
|
toast.success("Convite revogado")
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao revogar convite"
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setRevokingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="users" className="w-full">
|
<Tabs defaultValue="invites" className="w-full">
|
||||||
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
|
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
|
||||||
|
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
|
||||||
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
|
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
|
||||||
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
|
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
|
||||||
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
|
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="users" className="mt-6 space-y-6">
|
<TabsContent value="invites" className="mt-6 space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Convidar novo usuário</CardTitle>
|
<CardTitle>Gerar convite</CardTitle>
|
||||||
<CardDescription>Crie um acesso provisório e compartilhe a senha inicial com o colaborador.</CardDescription>
|
<CardDescription>
|
||||||
|
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_200px_200px_auto]">
|
<form
|
||||||
|
onSubmit={handleInviteSubmit}
|
||||||
|
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
|
||||||
|
>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="invite-email">E-mail corporativo</Label>
|
<Label htmlFor="invite-email">E-mail corporativo</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -142,7 +238,15 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{normalizedRoles.map((item) => (
|
{normalizedRoles.map((item) => (
|
||||||
<SelectItem key={item} value={item}>
|
<SelectItem key={item} value={item}>
|
||||||
{item === "customer" ? "Cliente" : item === "admin" ? "Administrador" : item}
|
{item === "customer"
|
||||||
|
? "Cliente"
|
||||||
|
: item === "admin"
|
||||||
|
? "Administrador"
|
||||||
|
: item === "manager"
|
||||||
|
? "Gestor"
|
||||||
|
: item === "agent"
|
||||||
|
? "Agente"
|
||||||
|
: "Colaborador"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -156,29 +260,115 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
|
||||||
onChange={(event) => setTenantId(event.target.value)}
|
onChange={(event) => setTenantId(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Expira em</Label>
|
||||||
|
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
|
||||||
|
<SelectTrigger id="invite-expiration">
|
||||||
|
<SelectValue placeholder="7 dias" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">7 dias</SelectItem>
|
||||||
|
<SelectItem value="14">14 dias</SelectItem>
|
||||||
|
<SelectItem value="30">30 dias</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<Button type="submit" disabled={isPending} className="w-full">
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
{isPending ? "Criando..." : "Criar acesso"}
|
{isPending ? "Gerando..." : "Gerar convite"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{lastInvite ? (
|
{lastInviteLink ? (
|
||||||
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
|
<div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<p className="font-medium">Acesso provisório gerado</p>
|
<div>
|
||||||
<p className="mt-1 text-neutral-600">
|
<p className="font-medium text-neutral-900">Link de convite pronto</p>
|
||||||
Envie para <span className="font-semibold">{lastInvite.email}</span> a senha inicial
|
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo definido.</p>
|
||||||
<span className="font-mono text-neutral-900"> {lastInvite.password}</span>.
|
<p className="mt-2 truncate font-mono text-xs text-neutral-500">{lastInviteLink}</p>
|
||||||
Solicite que altere após o primeiro login.
|
</div>
|
||||||
</p>
|
<Button type="button" variant="outline" onClick={() => handleCopy(lastInviteLink)}>
|
||||||
|
Copiar link
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Convites emitidos</CardTitle>
|
||||||
|
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||||
|
<th className="py-3 pr-4 font-medium">Colaborador</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Tenant</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Expira em</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Status</th>
|
||||||
|
<th className="py-3 font-medium">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<tr key={invite.id} className="hover:bg-slate-50">
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
||||||
|
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 uppercase text-neutral-600">{invite.role}</td>
|
||||||
|
<td className="py-3 pr-4 text-neutral-600">{invite.tenantId}</td>
|
||||||
|
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<Badge
|
||||||
|
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
|
||||||
|
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{formatStatus(invite.status)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||||
|
Copiar link
|
||||||
|
</Button>
|
||||||
|
{invite.status === "pending" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() => handleRevoke(invite.id)}
|
||||||
|
disabled={revokingId === invite.id}
|
||||||
|
>
|
||||||
|
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-6 text-center text-neutral-500">
|
||||||
|
Nenhum convite emitido até o momento.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="users" className="mt-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Equipe cadastrada</CardTitle>
|
<CardTitle>Equipe cadastrada</CardTitle>
|
||||||
<CardDescription>Lista completa de usuários autenticáveis pela Better Auth.</CardDescription>
|
<CardDescription>Usuários ativos e provisionados via convites aceitos.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="overflow-x-auto">
|
<CardContent className="overflow-x-auto">
|
||||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||||
|
|
|
||||||
555
web/src/components/admin/categories/categories-manager.tsx
Normal file
555
web/src/components/admin/categories/categories-manager.tsx
Normal file
|
|
@ -0,0 +1,555 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useMutation, useQuery } from "convex/react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
// @ts-expect-error Convex runtime API lacks generated types
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import type { TicketCategory, TicketSubcategory } from "@/lib/schemas/category"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
type DeleteState<T extends "category" | "subcategory"> =
|
||||||
|
| { type: T; targetId: string; reason: string }
|
||||||
|
| null
|
||||||
|
|
||||||
|
export function CategoriesManager() {
|
||||||
|
const { session, convexUserId } = useAuth()
|
||||||
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
const categories = useQuery(api.categories.list, { tenantId }) as TicketCategory[] | undefined
|
||||||
|
const [categoryName, setCategoryName] = useState("")
|
||||||
|
const [categoryDescription, setCategoryDescription] = useState("")
|
||||||
|
const [subcategoryDraft, setSubcategoryDraft] = useState("")
|
||||||
|
const [subcategoryList, setSubcategoryList] = useState<string[]>([])
|
||||||
|
const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null)
|
||||||
|
const createCategory = useMutation(api.categories.createCategory)
|
||||||
|
const deleteCategory = useMutation(api.categories.deleteCategory)
|
||||||
|
const updateCategory = useMutation(api.categories.updateCategory)
|
||||||
|
const createSubcategory = useMutation(api.categories.createSubcategory)
|
||||||
|
const updateSubcategory = useMutation(api.categories.updateSubcategory)
|
||||||
|
const deleteSubcategory = useMutation(api.categories.deleteSubcategory)
|
||||||
|
|
||||||
|
const isCreatingCategory = useMemo(
|
||||||
|
() => categoryName.trim().length < 2,
|
||||||
|
[categoryName]
|
||||||
|
)
|
||||||
|
|
||||||
|
function addSubcategory() {
|
||||||
|
const value = subcategoryDraft.trim()
|
||||||
|
if (value.length < 2) {
|
||||||
|
toast.error("Informe um nome válido para a subcategoria")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalized = value.toLowerCase()
|
||||||
|
const exists = subcategoryList.some((item) => item.toLowerCase() === normalized)
|
||||||
|
if (exists) {
|
||||||
|
toast.error("Essa subcategoria já foi adicionada")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubcategoryList((items) => [...items, value])
|
||||||
|
setSubcategoryDraft("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSubcategory(target: string) {
|
||||||
|
setSubcategoryList((items) => items.filter((item) => item !== target))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateCategory() {
|
||||||
|
if (!convexUserId) return
|
||||||
|
const name = categoryName.trim()
|
||||||
|
if (name.length < 2) {
|
||||||
|
toast.error("Informe um nome válido para a categoria")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.loading("Criando categoria...", { id: "category:create" })
|
||||||
|
try {
|
||||||
|
await createCategory({
|
||||||
|
tenantId,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
name,
|
||||||
|
description: categoryDescription.trim() || undefined,
|
||||||
|
secondary: subcategoryList,
|
||||||
|
})
|
||||||
|
toast.success("Categoria criada!", { id: "category:create" })
|
||||||
|
setCategoryName("")
|
||||||
|
setCategoryDescription("")
|
||||||
|
setSubcategoryDraft("")
|
||||||
|
setSubcategoryList([])
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível criar a categoria", { id: "category:create" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteCategory(id: string) {
|
||||||
|
if (!convexUserId) return
|
||||||
|
toast.loading("Removendo categoria...", { id: "category:delete" })
|
||||||
|
try {
|
||||||
|
await deleteCategory({
|
||||||
|
tenantId,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
categoryId: id as Id<"ticketCategories">,
|
||||||
|
})
|
||||||
|
toast.success("Categoria removida", { id: "category:delete" })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível remover a categoria", { id: "category:delete" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateCategory(target: TicketCategory, next: { name: string; description: string }) {
|
||||||
|
if (!convexUserId) return
|
||||||
|
const name = next.name.trim()
|
||||||
|
if (name.length < 2) {
|
||||||
|
toast.error("Informe um nome válido")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.loading("Atualizando categoria...", { id: `category:update:${target.id}` })
|
||||||
|
try {
|
||||||
|
await updateCategory({
|
||||||
|
tenantId,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
categoryId: target.id as Id<"ticketCategories">,
|
||||||
|
name,
|
||||||
|
description: next.description.trim() || undefined,
|
||||||
|
})
|
||||||
|
toast.success("Categoria atualizada", { id: `category:update:${target.id}` })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível atualizar a categoria", { id: `category:update:${target.id}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateSubcategory(categoryId: string, payload: { name: string }) {
|
||||||
|
if (!convexUserId) return
|
||||||
|
const name = payload.name.trim()
|
||||||
|
if (name.length < 2) {
|
||||||
|
toast.error("Informe um nome válido para a subcategoria")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.loading("Adicionando subcategoria...", { id: `subcategory:create:${categoryId}` })
|
||||||
|
try {
|
||||||
|
await createSubcategory({
|
||||||
|
tenantId,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
categoryId: categoryId as Id<"ticketCategories">,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
toast.success("Subcategoria criada", { id: `subcategory:create:${categoryId}` })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível criar a subcategoria", { id: `subcategory:create:${categoryId}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateSubcategory(target: TicketSubcategory, name: string) {
|
||||||
|
if (!convexUserId) return
|
||||||
|
const trimmed = name.trim()
|
||||||
|
if (trimmed.length < 2) {
|
||||||
|
toast.error("Informe um nome válido")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.loading("Atualizando subcategoria...", { id: `subcategory:update:${target.id}` })
|
||||||
|
try {
|
||||||
|
await updateSubcategory({
|
||||||
|
tenantId,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
subcategoryId: target.id as Id<"ticketSubcategories">,
|
||||||
|
name: trimmed,
|
||||||
|
})
|
||||||
|
toast.success("Subcategoria atualizada", { id: `subcategory:update:${target.id}` })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível atualizar a subcategoria", { id: `subcategory:update:${target.id}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteSubcategory(id: string) {
|
||||||
|
if (!convexUserId) return
|
||||||
|
toast.loading("Removendo subcategoria...", { id: `subcategory:delete:${id}` })
|
||||||
|
try {
|
||||||
|
await deleteSubcategory({
|
||||||
|
tenantId,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
subcategoryId: id as Id<"ticketSubcategories">,
|
||||||
|
})
|
||||||
|
toast.success("Subcategoria removida", { id: `subcategory:delete:${id}` })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível remover a subcategoria", { id: `subcategory:delete:${id}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingDelete = deleteState
|
||||||
|
const isDisabled = !convexUserId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900">Categorias</h3>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Organize a classificação primária e secundária utilizada nos tickets. Todas as alterações entram em vigor
|
||||||
|
imediatamente para novos atendimentos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
|
<div className="space-y-3 rounded-xl border border-dashed border-slate-200 bg-white/80 p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category-name">Nome da categoria</Label>
|
||||||
|
<Input
|
||||||
|
id="category-name"
|
||||||
|
value={categoryName}
|
||||||
|
onChange={(event) => setCategoryName(event.target.value)}
|
||||||
|
placeholder="Ex.: Incidentes"
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category-description">Descrição (opcional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="category-description"
|
||||||
|
value={categoryDescription}
|
||||||
|
onChange={(event) => setCategoryDescription(event.target.value)}
|
||||||
|
placeholder="Contextualize quando usar esta categoria"
|
||||||
|
rows={3}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subcategory-name">Subcategorias (opcional)</Label>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
id="subcategory-name"
|
||||||
|
value={subcategoryDraft}
|
||||||
|
onChange={(event) => setSubcategoryDraft(event.target.value)}
|
||||||
|
placeholder="Ex.: Lentidão"
|
||||||
|
disabled={isDisabled}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
addSubcategory()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={addSubcategory}
|
||||||
|
disabled={isDisabled || subcategoryDraft.trim().length < 2}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
Adicionar subcategoria
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{subcategoryList.length ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{subcategoryList.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="group inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs text-neutral-700"
|
||||||
|
>
|
||||||
|
<span>{item}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeSubcategory(item)}
|
||||||
|
className="rounded-full p-1 text-neutral-500 transition hover:bg-white hover:text-neutral-900"
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateCategory}
|
||||||
|
disabled={isDisabled || isCreatingCategory}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Adicionar categoria
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 rounded-xl border border-slate-200 bg-slate-50/60 p-4 text-sm text-neutral-600">
|
||||||
|
<p className="font-medium text-neutral-800">Boas práticas</p>
|
||||||
|
<ul className="list-disc space-y-1 pl-4">
|
||||||
|
<li>Mantenha nomes concisos e fáceis de entender.</li>
|
||||||
|
<li>Use a descrição para orientar a equipe sobre quando aplicar cada categoria.</li>
|
||||||
|
<li>Subcategorias devem ser específicas e mutuamente exclusivas.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{categories?.length ? (
|
||||||
|
categories.map((category) => (
|
||||||
|
<CategoryItem
|
||||||
|
key={category.id}
|
||||||
|
category={category}
|
||||||
|
onUpdate={handleUpdateCategory}
|
||||||
|
onDelete={() => setDeleteState({ type: "category", targetId: category.id, reason: "" })}
|
||||||
|
onCreateSubcategory={handleCreateSubcategory}
|
||||||
|
onUpdateSubcategory={handleUpdateSubcategory}
|
||||||
|
onDeleteSubcategory={(subcategoryId) =>
|
||||||
|
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
|
||||||
|
}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
|
||||||
|
Nenhuma categoria cadastrada ainda.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(pendingDelete)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleteState(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmar remoção</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
A remoção é permanente. Certifique-se de que não há tickets em aberto associados.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<Label htmlFor="delete-reason">Motivo (opcional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="delete-reason"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Descreva o motivo da remoção"
|
||||||
|
value={pendingDelete?.reason ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDeleteState((current) =>
|
||||||
|
current ? { ...current, reason: event.target.value } : current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Caso existam tickets vinculados, será necessário mover para outra categoria antes de continuar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setDeleteState(null)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
const target = pendingDelete
|
||||||
|
if (!target) return
|
||||||
|
if (target.type === "category") {
|
||||||
|
await handleDeleteCategory(target.targetId)
|
||||||
|
} else {
|
||||||
|
await handleDeleteSubcategory(target.targetId)
|
||||||
|
}
|
||||||
|
setDeleteState(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryItemProps {
|
||||||
|
category: TicketCategory
|
||||||
|
disabled?: boolean
|
||||||
|
onUpdate: (category: TicketCategory, next: { name: string; description: string }) => Promise<void>
|
||||||
|
onDelete: () => void
|
||||||
|
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
|
||||||
|
onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void>
|
||||||
|
onDeleteSubcategory: (subcategoryId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryItem({
|
||||||
|
category,
|
||||||
|
disabled,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onCreateSubcategory,
|
||||||
|
onUpdateSubcategory,
|
||||||
|
onDeleteSubcategory,
|
||||||
|
}: CategoryItemProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [name, setName] = useState(category.name)
|
||||||
|
const [description, setDescription] = useState(category.description ?? "")
|
||||||
|
const [subcategoryDraft, setSubcategoryDraft] = useState("")
|
||||||
|
const hasSubcategories = category.secondary.length > 0
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
await onUpdate(category, { name, description })
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={disabled} />
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Descrição"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h4 className="text-base font-semibold text-neutral-900">{category.name}</h4>
|
||||||
|
{category.description ? (
|
||||||
|
<p className="text-sm text-neutral-600">{category.description}</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasSubcategories ? (
|
||||||
|
<Badge variant="outline" className="rounded-full border-slate-200 px-3 py-1 text-xs text-neutral-600">
|
||||||
|
{category.secondary.length} subcategorias
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={disabled}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={onDelete} disabled={disabled}>
|
||||||
|
Remover
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Subcategorias</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{category.secondary.length ? (
|
||||||
|
category.secondary.map((subcategory) => (
|
||||||
|
<SubcategoryItem
|
||||||
|
key={subcategory.id}
|
||||||
|
subcategory={subcategory}
|
||||||
|
disabled={disabled}
|
||||||
|
onUpdate={(value) => onUpdateSubcategory(subcategory, value)}
|
||||||
|
onDelete={() => onDeleteSubcategory(subcategory.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50/60 p-3 text-sm text-neutral-600">
|
||||||
|
Nenhuma subcategoria cadastrada.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-slate-200 bg-white p-3">
|
||||||
|
<Label className="text-xs uppercase tracking-wide text-neutral-500">Nova subcategoria</Label>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={subcategoryDraft}
|
||||||
|
onChange={(event) => setSubcategoryDraft(event.target.value)}
|
||||||
|
placeholder="Ex.: Configuração"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="sm:w-auto"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!subcategoryDraft.trim()) return
|
||||||
|
await onCreateSubcategory(category.id, { name: subcategoryDraft })
|
||||||
|
setSubcategoryDraft("")
|
||||||
|
}}
|
||||||
|
disabled={disabled || subcategoryDraft.trim().length < 2}
|
||||||
|
>
|
||||||
|
Adicionar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubcategoryItemProps {
|
||||||
|
subcategory: TicketSubcategory
|
||||||
|
disabled?: boolean
|
||||||
|
onUpdate: (nextValue: string) => Promise<void>
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubcategoryItem({ subcategory, disabled, onUpdate, onDelete }: SubcategoryItemProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [name, setName] = useState(subcategory.name)
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
await onUpdate(name)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 shadow-sm">
|
||||||
|
{isEditing ? (
|
||||||
|
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={disabled} className="max-w-sm" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium text-neutral-800">{subcategory.name}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={disabled}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
|
||||||
|
Renomear
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={onDelete} disabled={disabled}>
|
||||||
|
Remover
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
Timer,
|
Timer,
|
||||||
Plug,
|
Plug,
|
||||||
Layers3,
|
Layers3,
|
||||||
|
UserPlus,
|
||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
|
|
@ -78,6 +79,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
||||||
title: "Administração",
|
title: "Administração",
|
||||||
requiredRole: "admin",
|
requiredRole: "admin",
|
||||||
items: [
|
items: [
|
||||||
|
{ title: "Convites e acessos", url: "/admin", icon: UserPlus, requiredRole: "admin" },
|
||||||
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
||||||
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
|
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
|
||||||
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
|
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
|
||||||
|
|
|
||||||
174
web/src/components/invite/invite-accept-form.tsx
Normal file
174
web/src/components/invite/invite-accept-form.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState, useTransition } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import type { RoleOption } from "@/lib/authz"
|
||||||
|
|
||||||
|
type InviteStatus = "pending" | "accepted" | "revoked" | "expired"
|
||||||
|
|
||||||
|
type InviteSummary = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string | null
|
||||||
|
role: RoleOption
|
||||||
|
tenantId: string
|
||||||
|
status: InviteStatus
|
||||||
|
token: string
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateIso: string) {
|
||||||
|
return new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
dateStyle: "long",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(dateIso))
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: InviteStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "Pendente"
|
||||||
|
case "accepted":
|
||||||
|
return "Aceito"
|
||||||
|
case "revoked":
|
||||||
|
return "Revogado"
|
||||||
|
case "expired":
|
||||||
|
return "Expirado"
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(status: InviteStatus) {
|
||||||
|
if (status === "pending") return "secondary"
|
||||||
|
if (status === "accepted") return "default"
|
||||||
|
if (status === "revoked") return "destructive"
|
||||||
|
return "outline"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteAcceptForm({ invite }: { invite: InviteSummary }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [name, setName] = useState(invite.name ?? "")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("")
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const formattedExpiry = useMemo(() => formatDate(invite.expiresAt), [invite.expiresAt])
|
||||||
|
const isDisabled = invite.status !== "pending"
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
toast.error("A senha deve ter pelo menos 8 caracteres")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
toast.error("As senhas não coincidem")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (isDisabled) return
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invites/${invite.token}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(data.error ?? "Não foi possível aceitar o convite")
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Convite aceito! Faça login para começar.")
|
||||||
|
router.push(`/login?email=${encodeURIComponent(invite.email)}`)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Falha ao aceitar convite"
|
||||||
|
toast.error(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<Badge variant={statusVariant(invite.status)} className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
|
||||||
|
{statusLabel(invite.status)}
|
||||||
|
</Badge>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Convite direcionado para <span className="font-semibold text-neutral-900">{invite.email}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Papel previsto: <span className="uppercase text-neutral-700">{invite.role}</span> • Tenant: <span className="uppercase text-neutral-700">{invite.tenantId}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500">Válido até {formattedExpiry}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDisabled ? (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-600">
|
||||||
|
<p>
|
||||||
|
Este convite encontra-se <span className="font-semibold text-neutral-900">{statusLabel(invite.status).toLowerCase()}</span>.
|
||||||
|
Solicite um novo convite à equipe administradora caso precise de acesso.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="invite-name">Nome completo</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-name"
|
||||||
|
placeholder="Seu nome"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
autoComplete="name"
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="invite-password">Defina uma senha</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Mínimo de 8 caracteres"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isDisabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="invite-password-confirm">Confirme a senha</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-password-confirm"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isDisabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={isDisabled || isPending}>
|
||||||
|
{isPending ? "Processando..." : "Ativar acesso"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,13 @@ export function NewTicketDialog() {
|
||||||
mode: "onTouched",
|
mode: "onTouched",
|
||||||
})
|
})
|
||||||
const { convexUserId } = useAuth()
|
const { convexUserId } = useAuth()
|
||||||
const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
|
const queueArgs = convexUserId
|
||||||
|
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
|
||||||
|
: "skip"
|
||||||
|
const queuesRaw = useQuery(
|
||||||
|
convexUserId ? api.queues.summary : "skip",
|
||||||
|
queueArgs
|
||||||
|
) as TicketQueueSummary[] | undefined
|
||||||
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
|
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
|
||||||
const create = useMutation(api.tickets.create)
|
const create = useMutation(api.tickets.create)
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,12 @@ const secondaryButtonClass = "inline-flex items-center gap-2 rounded-lg border b
|
||||||
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { convexUserId } = useAuth()
|
const { convexUserId } = useAuth()
|
||||||
const queueSummary = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
|
const queueArgs = convexUserId
|
||||||
|
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
|
||||||
|
: "skip"
|
||||||
|
const queueSummary = (
|
||||||
|
useQuery(convexUserId ? api.queues.summary : "skip", queueArgs) as TicketQueueSummary[] | undefined
|
||||||
|
) ?? []
|
||||||
const playNext = useMutation(api.tickets.playNext)
|
const playNext = useMutation(api.tickets.playNext)
|
||||||
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)
|
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,19 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
||||||
interface TicketQueueSummaryProps {
|
interface TicketQueueSummaryProps {
|
||||||
queues?: TicketQueueSummary[]
|
queues?: TicketQueueSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
|
const { convexUserId } = useAuth()
|
||||||
|
const queueArgs = convexUserId
|
||||||
|
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
|
||||||
|
: "skip"
|
||||||
|
const fromServer = useQuery(convexUserId ? api.queues.summary : "skip", queueArgs)
|
||||||
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
|
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
|
||||||
|
|
||||||
if (!queues && fromServer === undefined) {
|
if (!queues && fromServer === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const pauseWork = useMutation(api.tickets.pauseWork)
|
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||||
const updateCategories = useMutation(api.tickets.updateCategories)
|
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||||
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||||
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
|
const queueArgs = convexUserId
|
||||||
|
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
|
||||||
|
: "skip"
|
||||||
|
const queues = (
|
||||||
|
useQuery(convexUserId ? api.queues.summary : "skip", queueArgs) as TicketQueueSummary[] | undefined
|
||||||
|
) ?? []
|
||||||
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
|
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
|
||||||
const [status] = useState<TicketStatus>(ticket.status)
|
const [status] = useState<TicketStatus>(ticket.status)
|
||||||
const workSummaryRemote = useQuery(
|
const workSummaryRemote = useQuery(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export function TicketsView() {
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
const queues = useQuery(
|
const queues = useQuery(
|
||||||
api.queues.summary,
|
convexUserId ? api.queues.summary : "skip",
|
||||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
) as TicketQueueSummary[] | undefined
|
) as TicketQueueSummary[] | undefined
|
||||||
const ticketsRaw = useQuery(
|
const ticketsRaw = useQuery(
|
||||||
|
|
|
||||||
19
web/src/components/ui/textarea.tsx
Normal file
19
web/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-neutral-400 selection:bg-neutral-900 selection:text-white aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 flex min-h-[80px] w-full min-w-0 rounded-lg border border-slate-300 bg-white px-3 py-2 text-base text-neutral-800 shadow-sm transition-colors outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60 md:text-sm",
|
||||||
|
"focus-visible:border-[#00d6eb] focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
|
|
@ -86,9 +86,16 @@ const serverEventSchema = z.object({
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const serverCustomFieldValueSchema = z.object({
|
||||||
|
label: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
value: z.any().optional(),
|
||||||
|
displayValue: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const serverTicketWithDetailsSchema = serverTicketSchema.extend({
|
const serverTicketWithDetailsSchema = serverTicketSchema.extend({
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
customFields: z.record(z.string(), z.any()).optional(),
|
customFields: z.record(z.string(), serverCustomFieldValueSchema).optional(),
|
||||||
timeline: z.array(serverEventSchema),
|
timeline: z.array(serverEventSchema),
|
||||||
comments: z.array(serverCommentSchema),
|
comments: z.array(serverCommentSchema),
|
||||||
});
|
});
|
||||||
|
|
@ -126,9 +133,27 @@ export function mapTicketsFromServerList(arr: unknown[]) {
|
||||||
|
|
||||||
export function mapTicketWithDetailsFromServer(input: unknown) {
|
export function mapTicketWithDetailsFromServer(input: unknown) {
|
||||||
const s = serverTicketWithDetailsSchema.parse(input);
|
const s = serverTicketWithDetailsSchema.parse(input);
|
||||||
|
const customFields = Object.entries(s.customFields ?? {}).reduce<
|
||||||
|
Record<string, { label: string; type: string; value?: unknown; displayValue?: string }>
|
||||||
|
>(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
let parsedValue: unknown = value.value;
|
||||||
|
if (value.type === "date" && typeof value.value === "number") {
|
||||||
|
parsedValue = new Date(value.value);
|
||||||
|
}
|
||||||
|
acc[key] = {
|
||||||
|
label: value.label,
|
||||||
|
type: value.type,
|
||||||
|
value: parsedValue,
|
||||||
|
displayValue: value.displayValue,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
const ui = {
|
const ui = {
|
||||||
...s,
|
...s,
|
||||||
customFields: (s.customFields ?? {}) as Record<string, unknown>,
|
customFields,
|
||||||
category: s.category ?? undefined,
|
category: s.category ?? undefined,
|
||||||
subcategory: s.subcategory ?? undefined,
|
subcategory: s.subcategory ?? undefined,
|
||||||
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
|
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
|
||||||
|
|
|
||||||
|
|
@ -276,9 +276,9 @@ export const ticketDetails = tickets.map((ticket) => ({
|
||||||
description:
|
description:
|
||||||
"Incidente reportado automaticamente pelo monitoramento. Logs indicam aumento de latência em chamadas ao servico de autenticação.",
|
"Incidente reportado automaticamente pelo monitoramento. Logs indicam aumento de latência em chamadas ao servico de autenticação.",
|
||||||
customFields: {
|
customFields: {
|
||||||
ambiente: "Produção",
|
ambiente: { label: "Ambiente", type: "select", value: "producao", displayValue: "Produção" },
|
||||||
categoria: "Incidente",
|
categoria: { label: "Categoria", type: "text", value: "Incidente" },
|
||||||
impacto: "Alto",
|
impacto: { label: "Impacto", type: "select", value: "alto", displayValue: "Alto" },
|
||||||
},
|
},
|
||||||
timeline:
|
timeline:
|
||||||
timelineByTicket[ticket.id] ??
|
timelineByTicket[ticket.id] ??
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,17 @@ export const ticketEventSchema = z.object({
|
||||||
})
|
})
|
||||||
export type TicketEvent = z.infer<typeof ticketEventSchema>
|
export type TicketEvent = z.infer<typeof ticketEventSchema>
|
||||||
|
|
||||||
|
export const ticketFieldTypeSchema = z.enum(["text", "number", "select", "date", "boolean"])
|
||||||
|
export type TicketFieldType = z.infer<typeof ticketFieldTypeSchema>
|
||||||
|
|
||||||
|
export const ticketCustomFieldValueSchema = z.object({
|
||||||
|
label: z.string(),
|
||||||
|
type: ticketFieldTypeSchema,
|
||||||
|
value: z.union([z.string(), z.number(), z.boolean(), z.coerce.date()]).optional(),
|
||||||
|
displayValue: z.string().optional(),
|
||||||
|
})
|
||||||
|
export type TicketCustomFieldValue = z.infer<typeof ticketCustomFieldValueSchema>
|
||||||
|
|
||||||
export const ticketSchema = z.object({
|
export const ticketSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
reference: z.number(),
|
reference: z.number(),
|
||||||
|
|
@ -131,7 +142,7 @@ export type Ticket = z.infer<typeof ticketSchema>
|
||||||
|
|
||||||
export const ticketWithDetailsSchema = ticketSchema.extend({
|
export const ticketWithDetailsSchema = ticketSchema.extend({
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
customFields: z.record(z.string(), z.any()).optional(),
|
customFields: z.record(z.string(), ticketCustomFieldValueSchema).optional(),
|
||||||
timeline: z.array(ticketEventSchema),
|
timeline: z.array(ticketEventSchema),
|
||||||
comments: z.array(ticketCommentSchema),
|
comments: z.array(ticketCommentSchema),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
53
web/src/server/__tests__/invite-utils.test.ts
Normal file
53
web/src/server/__tests__/invite-utils.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { computeInviteStatus, normalizeInvite, normalizeRoleOption } from "@/server/invite-utils"
|
||||||
|
|
||||||
|
const baseInvite = {
|
||||||
|
id: "invite-1",
|
||||||
|
email: "user@sistema.dev",
|
||||||
|
name: "Usuário Teste",
|
||||||
|
role: "agent",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
token: "token",
|
||||||
|
status: "pending",
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
createdById: "admin-1",
|
||||||
|
acceptedAt: null,
|
||||||
|
acceptedById: null,
|
||||||
|
revokedAt: null,
|
||||||
|
revokedById: null,
|
||||||
|
revokedReason: null,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
describe("invite-utils", () => {
|
||||||
|
it("computes pending status when invite is valid", () => {
|
||||||
|
const status = computeInviteStatus(baseInvite)
|
||||||
|
expect(status).toBe("pending")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("computes expired status when invite is past expiration", () => {
|
||||||
|
const expiredInvite = { ...baseInvite, expiresAt: new Date(Date.now() - 1) }
|
||||||
|
const status = computeInviteStatus(expiredInvite)
|
||||||
|
expect(status).toBe("expired")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("honors revoked status regardless of expiration", () => {
|
||||||
|
const revoked = { ...baseInvite, status: "revoked" as const, expiresAt: new Date(Date.now() - 1) }
|
||||||
|
const status = computeInviteStatus(revoked)
|
||||||
|
expect(status).toBe("revoked")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("normalizes invite payload with defaults", () => {
|
||||||
|
const normalized = normalizeInvite({ ...baseInvite, events: [] })
|
||||||
|
expect(normalized.email).toBe(baseInvite.email)
|
||||||
|
expect(normalized.status).toBe("pending")
|
||||||
|
expect(normalized.events).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("normalizes role falling back to agent", () => {
|
||||||
|
expect(normalizeRoleOption("admin")).toBe("admin")
|
||||||
|
expect(normalizeRoleOption("unknown")).toBe("agent")
|
||||||
|
})
|
||||||
|
})
|
||||||
98
web/src/server/invite-utils.ts
Normal file
98
web/src/server/invite-utils.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import type { AuthInvite, AuthInviteEvent } from "@prisma/client"
|
||||||
|
|
||||||
|
import { ROLE_OPTIONS, type RoleOption, normalizeRole } from "@/lib/authz"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { env } from "@/lib/env"
|
||||||
|
|
||||||
|
export type InviteStatus = "pending" | "accepted" | "revoked" | "expired"
|
||||||
|
|
||||||
|
export type InviteWithEvents = AuthInvite & {
|
||||||
|
events: AuthInviteEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InviteEventPayload = {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
createdAt: string
|
||||||
|
actorId: string | null
|
||||||
|
payload: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NormalizedInvite = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string | null
|
||||||
|
role: RoleOption
|
||||||
|
tenantId: string
|
||||||
|
status: InviteStatus
|
||||||
|
token: string
|
||||||
|
inviteUrl: string
|
||||||
|
expiresAt: string
|
||||||
|
createdAt: string
|
||||||
|
createdById: string | null
|
||||||
|
acceptedAt: string | null
|
||||||
|
acceptedById: string | null
|
||||||
|
revokedAt: string | null
|
||||||
|
revokedById: string | null
|
||||||
|
revokedReason: string | null
|
||||||
|
events: InviteEventPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_APP_URL = env.NEXT_PUBLIC_APP_URL ?? env.BETTER_AUTH_URL ?? "http://localhost:3000"
|
||||||
|
|
||||||
|
export function computeInviteStatus(invite: AuthInvite, now: Date = new Date()): InviteStatus {
|
||||||
|
if (invite.status === "revoked") return "revoked"
|
||||||
|
if (invite.status === "accepted") return "accepted"
|
||||||
|
if (invite.status === "expired") return "expired"
|
||||||
|
if (invite.expiresAt.getTime() <= now.getTime()) {
|
||||||
|
return "expired"
|
||||||
|
}
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInviteUrl(token: string) {
|
||||||
|
const base = DEFAULT_APP_URL.endsWith("/") ? DEFAULT_APP_URL.slice(0, -1) : DEFAULT_APP_URL
|
||||||
|
return `${base}/invite/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRoleOption(role?: string | null): RoleOption {
|
||||||
|
const normalized = normalizeRole(role)
|
||||||
|
if (normalized && (ROLE_OPTIONS as readonly string[]).includes(normalized)) {
|
||||||
|
return normalized as RoleOption
|
||||||
|
}
|
||||||
|
return "agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeInvite(invite: InviteWithEvents, now: Date = new Date()): NormalizedInvite {
|
||||||
|
const status = computeInviteStatus(invite, now)
|
||||||
|
const inviteUrl = buildInviteUrl(invite.token)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: invite.id,
|
||||||
|
email: invite.email,
|
||||||
|
name: invite.name ?? null,
|
||||||
|
role: normalizeRoleOption(invite.role),
|
||||||
|
tenantId: invite.tenantId ?? DEFAULT_TENANT_ID,
|
||||||
|
status,
|
||||||
|
token: invite.token,
|
||||||
|
inviteUrl,
|
||||||
|
expiresAt: invite.expiresAt.toISOString(),
|
||||||
|
createdAt: invite.createdAt.toISOString(),
|
||||||
|
createdById: invite.createdById ?? null,
|
||||||
|
acceptedAt: invite.acceptedAt ? invite.acceptedAt.toISOString() : null,
|
||||||
|
acceptedById: invite.acceptedById ?? null,
|
||||||
|
revokedAt: invite.revokedAt ? invite.revokedAt.toISOString() : null,
|
||||||
|
revokedById: invite.revokedById ?? null,
|
||||||
|
revokedReason: invite.revokedReason ?? null,
|
||||||
|
events: invite.events
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
|
.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
type: event.type,
|
||||||
|
createdAt: event.createdAt.toISOString(),
|
||||||
|
actorId: event.actorId ?? null,
|
||||||
|
payload: event.payload,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export default defineConfig({
|
||||||
environment: "node",
|
environment: "node",
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts"],
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
3
web/vitest.setup.ts
Normal file
3
web/vitest.setup.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "test-secret"
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
|
||||||
|
process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL
|
||||||
Loading…
Add table
Add a link
Reference in a new issue