Merge pull request #16 from esdrasrenan/feat/convex-tickets-core
feat: implement invite onboarding and dynamic ticket fields
This commit is contained in:
commit
80013e6959
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 fields from "../fields.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 rbac from "../rbac.js";
|
||||
import type * as reports from "../reports.js";
|
||||
|
|
@ -40,6 +41,7 @@ declare const fullApi: ApiFromModules<{
|
|||
categories: typeof categories;
|
||||
fields: typeof fields;
|
||||
files: typeof files;
|
||||
invites: typeof invites;
|
||||
queues: typeof queues;
|
||||
rbac: typeof rbac;
|
||||
reports: typeof reports;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type { MutationCtx } from "./_generated/server"
|
|||
import { ConvexError, v } from "convex/values"
|
||||
import { Id } from "./_generated/dataModel"
|
||||
|
||||
import { requireAdmin } from "./rbac"
|
||||
|
||||
type CategorySeed = {
|
||||
name: string
|
||||
description?: string
|
||||
|
|
@ -295,10 +297,13 @@ export const ensureDefaults = mutation({
|
|||
export const createCategory = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
name: 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()
|
||||
if (trimmed.length < 2) {
|
||||
throw new ConvexError("Informe um nome válido para a categoria")
|
||||
|
|
@ -321,6 +326,31 @@ export const createCategory = mutation({
|
|||
createdAt: 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
|
||||
},
|
||||
})
|
||||
|
|
@ -329,10 +359,12 @@ export const updateCategory = mutation({
|
|||
args: {
|
||||
categoryId: v.id("ticketCategories"),
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
name: 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)
|
||||
if (!category || category.tenantId !== tenantId) {
|
||||
throw new ConvexError("Categoria não encontrada")
|
||||
|
|
@ -354,9 +386,11 @@ export const deleteCategory = mutation({
|
|||
args: {
|
||||
categoryId: v.id("ticketCategories"),
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
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)
|
||||
if (!category || category.tenantId !== tenantId) {
|
||||
throw new ConvexError("Categoria não encontrada")
|
||||
|
|
@ -412,10 +446,12 @@ export const deleteCategory = mutation({
|
|||
export const createSubcategory = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
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)
|
||||
if (!category || category.tenantId !== tenantId) {
|
||||
throw new ConvexError("Categoria não encontrada")
|
||||
|
|
@ -449,10 +485,12 @@ export const createSubcategory = mutation({
|
|||
export const updateSubcategory = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
subcategoryId: v.id("ticketSubcategories"),
|
||||
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)
|
||||
if (!subcategory || subcategory.tenantId !== tenantId) {
|
||||
throw new ConvexError("Subcategoria não encontrada")
|
||||
|
|
@ -471,10 +509,12 @@ export const updateSubcategory = mutation({
|
|||
export const deleteSubcategory = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
subcategoryId: 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)
|
||||
if (!subcategory || subcategory.tenantId !== tenantId) {
|
||||
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 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;
|
||||
|
||||
|
|
@ -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({
|
||||
args: {
|
||||
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(),
|
||||
createdAt: v.number(),
|
||||
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()),
|
||||
activeSessionId: v.optional(v.id("ticketWorkSessions")),
|
||||
})
|
||||
|
|
@ -153,4 +165,25 @@ export default defineSchema({
|
|||
.index("by_tenant_key", ["tenantId", "key"])
|
||||
.index("by_tenant_order", ["tenantId", "order"])
|
||||
.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 type { MutationCtx } from "./_generated/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Id, type Doc } from "./_generated/dataModel";
|
||||
|
||||
|
|
@ -37,6 +38,136 @@ function normalizeTeams(teams?: string[] | null): string[] {
|
|||
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({
|
||||
args: {
|
||||
viewerId: v.optional(v.id("users")),
|
||||
|
|
@ -199,6 +330,10 @@ export const getById = query({
|
|||
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
||||
.collect();
|
||||
|
||||
const customFieldsRecord = mapCustomFieldsToRecord(
|
||||
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
|
||||
);
|
||||
|
||||
const commentsHydrated = await Promise.all(
|
||||
comments.map(async (c) => {
|
||||
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
|
||||
|
|
@ -290,7 +425,7 @@ export const getById = query({
|
|||
: null,
|
||||
},
|
||||
description: undefined,
|
||||
customFields: {},
|
||||
customFields: customFieldsRecord,
|
||||
timeline: timeline.map((ev) => {
|
||||
let payload = ev.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"),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
subcategoryId: v.id("ticketSubcategories"),
|
||||
customFields: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
fieldId: v.id("ticketFields"),
|
||||
value: v.any(),
|
||||
})
|
||||
)
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
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) {
|
||||
throw new ConvexError("Subcategoria inválida");
|
||||
}
|
||||
|
||||
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
|
||||
// compute next reference (simple monotonic counter per tenant)
|
||||
const existing = await ctx.db
|
||||
.query("tickets")
|
||||
|
|
@ -374,6 +519,7 @@ export const create = mutation({
|
|||
tags: [],
|
||||
slaPolicyId: undefined,
|
||||
dueAt: undefined,
|
||||
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
||||
});
|
||||
const requester = await ctx.db.get(args.requesterId);
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
|
|
|
|||
|
|
@ -231,6 +231,43 @@ model AuthAccount {
|
|||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
identifier String
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
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 default function AdminChannelsPage() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
||||
<header className="mb-8 space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Filas e canais</h1>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento.
|
||||
</p>
|
||||
</header>
|
||||
<QueuesManager />
|
||||
</main>
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Filas e canais"
|
||||
lead="Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
|
||||
<QueuesManager />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
import { CategoriesManager } from "@/components/admin/categories/categories-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 default function AdminFieldsPage() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
||||
<header className="mb-8 space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Campos personalizados</h1>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Defina quais informações adicionais devem ser coletadas nos tickets de cada tenant.
|
||||
</p>
|
||||
</header>
|
||||
<FieldsManager />
|
||||
</main>
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Categorias e campos personalizados"
|
||||
lead="Administre as categorias primárias/secundárias e os campos adicionais aplicados aos tickets."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-6xl space-y-8 px-6 lg:px-8">
|
||||
<CategoriesManager />
|
||||
<FieldsManager />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
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 { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
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() {
|
||||
const users = await loadUsers()
|
||||
const invites = await loadInvites()
|
||||
const invitesForClient = invites.map((invite) => {
|
||||
const { events, ...rest } = invite
|
||||
void events
|
||||
return rest
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Administração</h1>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento.
|
||||
</p>
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Administração"
|
||||
lead="Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<AdminUsersManager initialUsers={users} roleOptions={ROLE_OPTIONS} defaultTenantId={DEFAULT_TENANT_ID} />
|
||||
</main>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
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 default function AdminSlasPage() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
||||
<header className="mb-8 space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Políticas de SLA</h1>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço.
|
||||
</p>
|
||||
</header>
|
||||
<SlasManager />
|
||||
</main>
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Políticas de SLA"
|
||||
lead="Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
|
||||
<SlasManager />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
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 default function AdminTeamsPage() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
||||
<header className="mb-8 space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Times e agentes</h1>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs.
|
||||
</p>
|
||||
</header>
|
||||
<TeamsManager />
|
||||
</main>
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Times e agentes"
|
||||
lead="Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
|
||||
<TeamsManager />
|
||||
</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() {
|
||||
const router = useRouter()
|
||||
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 create = useMutation(api.tickets.create)
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useMemo, useState, useTransition } from "react"
|
|||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
|
@ -28,8 +29,28 @@ type AdminUser = {
|
|||
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 = {
|
||||
initialUsers: AdminUser[]
|
||||
initialInvites: AdminInvite[]
|
||||
roleOptions: readonly RoleOption[]
|
||||
defaultTenantId: string
|
||||
}
|
||||
|
|
@ -42,29 +63,59 @@ function formatDate(dateIso: string) {
|
|||
}).format(date)
|
||||
}
|
||||
|
||||
export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }: Props) {
|
||||
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
||||
function formatStatus(status: AdminInvite["status"]) {
|
||||
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 [name, setName] = useState("")
|
||||
const [role, setRole] = useState<RoleOption>("agent")
|
||||
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 normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!email || !email.includes("@")) {
|
||||
toast.error("Informe um e-mail válido")
|
||||
return
|
||||
}
|
||||
|
||||
const payload = { email, name, role, tenantId }
|
||||
const payload = {
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
tenantId,
|
||||
expiresInDays: Number.parseInt(expiresInDays, 10),
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/users", {
|
||||
const response = await fetch("/api/admin/invites", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
|
|
@ -72,44 +123,89 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
|
|||
|
||||
if (!response.ok) {
|
||||
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 {
|
||||
user: AdminUser
|
||||
temporaryPassword: string
|
||||
}
|
||||
|
||||
setUsers((previous) => [data.user, ...previous])
|
||||
setLastInvite({ email: data.user.email, password: data.temporaryPassword })
|
||||
const data = (await response.json()) as { invite: AdminInvite }
|
||||
const nextInvite = sanitizeInvite(data.invite)
|
||||
setInvites((previous) => [nextInvite, ...previous.filter((item) => item.id !== nextInvite.id)])
|
||||
setEmail("")
|
||||
setName("")
|
||||
setRole("agent")
|
||||
setTenantId(defaultTenantId)
|
||||
toast.success("Usuário criado com sucesso")
|
||||
setExpiresInDays("7")
|
||||
setLastInviteLink(nextInvite.inviteUrl)
|
||||
toast.success("Convite criado com sucesso")
|
||||
} 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
|
||||
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
|
||||
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
|
||||
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users" className="mt-6 space-y-6">
|
||||
<TabsContent value="invites" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Convidar novo usuário</CardTitle>
|
||||
<CardDescription>Crie um acesso provisório e compartilhe a senha inicial com o colaborador.</CardDescription>
|
||||
<CardTitle>Gerar convite</CardTitle>
|
||||
<CardDescription>
|
||||
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<Label htmlFor="invite-email">E-mail corporativo</Label>
|
||||
<Input
|
||||
|
|
@ -142,7 +238,15 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
|
|||
<SelectContent>
|
||||
{normalizedRoles.map((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>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -156,29 +260,115 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
|
|||
onChange={(event) => setTenantId(event.target.value)}
|
||||
/>
|
||||
</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">
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? "Criando..." : "Criar acesso"}
|
||||
{isPending ? "Gerando..." : "Gerar convite"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{lastInvite ? (
|
||||
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
|
||||
<p className="font-medium">Acesso provisório gerado</p>
|
||||
<p className="mt-1 text-neutral-600">
|
||||
Envie para <span className="font-semibold">{lastInvite.email}</span> a senha inicial
|
||||
<span className="font-mono text-neutral-900"> {lastInvite.password}</span>.
|
||||
Solicite que altere após o primeiro login.
|
||||
</p>
|
||||
{lastInviteLink ? (
|
||||
<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">
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Link de convite pronto</p>
|
||||
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo definido.</p>
|
||||
<p className="mt-2 truncate font-mono text-xs text-neutral-500">{lastInviteLink}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => handleCopy(lastInviteLink)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<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>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<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,
|
||||
Plug,
|
||||
Layers3,
|
||||
UserPlus,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
|
@ -78,6 +79,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
|||
title: "Administração",
|
||||
requiredRole: "admin",
|
||||
items: [
|
||||
{ title: "Convites e acessos", url: "/admin", icon: UserPlus, requiredRole: "admin" },
|
||||
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
||||
{ title: "Times & papéis", url: "/admin/teams", icon: Users, 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",
|
||||
})
|
||||
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 create = useMutation(api.tickets.create)
|
||||
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) {
|
||||
const router = useRouter()
|
||||
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 [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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
||||
interface TicketQueueSummaryProps {
|
||||
queues?: TicketQueueSummary[]
|
||||
}
|
||||
|
||||
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) ?? [])
|
||||
|
||||
if (!queues && fromServer === undefined) {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||
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 [status] = useState<TicketStatus>(ticket.status)
|
||||
const workSummaryRemote = useQuery(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function TicketsView() {
|
|||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
const queues = useQuery(
|
||||
api.queues.summary,
|
||||
convexUserId ? api.queues.summary : "skip",
|
||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as TicketQueueSummary[] | undefined
|
||||
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(),
|
||||
});
|
||||
|
||||
const serverCustomFieldValueSchema = z.object({
|
||||
label: z.string(),
|
||||
type: z.string(),
|
||||
value: z.any().optional(),
|
||||
displayValue: z.string().optional(),
|
||||
});
|
||||
|
||||
const serverTicketWithDetailsSchema = serverTicketSchema.extend({
|
||||
description: z.string().optional().nullable(),
|
||||
customFields: z.record(z.string(), z.any()).optional(),
|
||||
customFields: z.record(z.string(), serverCustomFieldValueSchema).optional(),
|
||||
timeline: z.array(serverEventSchema),
|
||||
comments: z.array(serverCommentSchema),
|
||||
});
|
||||
|
|
@ -126,9 +133,27 @@ export function mapTicketsFromServerList(arr: unknown[]) {
|
|||
|
||||
export function mapTicketWithDetailsFromServer(input: unknown) {
|
||||
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 = {
|
||||
...s,
|
||||
customFields: (s.customFields ?? {}) as Record<string, unknown>,
|
||||
customFields,
|
||||
category: s.category ?? undefined,
|
||||
subcategory: s.subcategory ?? undefined,
|
||||
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
|
||||
|
|
|
|||
|
|
@ -276,9 +276,9 @@ export const ticketDetails = tickets.map((ticket) => ({
|
|||
description:
|
||||
"Incidente reportado automaticamente pelo monitoramento. Logs indicam aumento de latência em chamadas ao servico de autenticação.",
|
||||
customFields: {
|
||||
ambiente: "Produção",
|
||||
categoria: "Incidente",
|
||||
impacto: "Alto",
|
||||
ambiente: { label: "Ambiente", type: "select", value: "producao", displayValue: "Produção" },
|
||||
categoria: { label: "Categoria", type: "text", value: "Incidente" },
|
||||
impacto: { label: "Impacto", type: "select", value: "alto", displayValue: "Alto" },
|
||||
},
|
||||
timeline:
|
||||
timelineByTicket[ticket.id] ??
|
||||
|
|
|
|||
|
|
@ -78,6 +78,17 @@ export const ticketEventSchema = z.object({
|
|||
})
|
||||
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({
|
||||
id: z.string(),
|
||||
reference: z.number(),
|
||||
|
|
@ -131,7 +142,7 @@ export type Ticket = z.infer<typeof ticketSchema>
|
|||
|
||||
export const ticketWithDetailsSchema = ticketSchema.extend({
|
||||
description: z.string().optional(),
|
||||
customFields: z.record(z.string(), z.any()).optional(),
|
||||
customFields: z.record(z.string(), ticketCustomFieldValueSchema).optional(),
|
||||
timeline: z.array(ticketEventSchema),
|
||||
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",
|
||||
globals: true,
|
||||
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