diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9495ab3..a511670 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -23,6 +23,7 @@ import type * as deviceFields from "../deviceFields.js"; import type * as devices from "../devices.js"; import type * as fields from "../fields.js"; import type * as files from "../files.js"; +import type * as incidents from "../incidents.js"; import type * as invites from "../invites.js"; import type * as machines from "../machines.js"; import type * as metrics from "../metrics.js"; @@ -70,6 +71,7 @@ declare const fullApi: ApiFromModules<{ devices: typeof devices; fields: typeof fields; files: typeof files; + incidents: typeof incidents; invites: typeof invites; machines: typeof machines; metrics: typeof metrics; diff --git a/convex/categories.ts b/convex/categories.ts index e7adb2b..60f16f9 100644 --- a/convex/categories.ts +++ b/convex/categories.ts @@ -225,6 +225,8 @@ export const list = query({ slug: category.slug, description: category.description, order: category.order, + createdAt: category.createdAt, + updatedAt: category.updatedAt, secondary: subcategories .filter((item) => item.categoryId === category._id) .sort((a, b) => a.order - b.order) @@ -233,6 +235,9 @@ export const list = query({ name: item.name, slug: item.slug, order: item.order, + categoryId: String(item.categoryId), + createdAt: item.createdAt, + updatedAt: item.updatedAt, })), })) }, diff --git a/convex/crons.ts b/convex/crons.ts index 74c7fb2..75dbb06 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -1,5 +1,13 @@ import { cronJobs } from "convex/server" +import { api } from "./_generated/api" const crons = cronJobs() +crons.interval( + "report-export-runner", + { minutes: 15 }, + api.reports.triggerScheduledExports, + {} +) + export default crons diff --git a/convex/incidents.ts b/convex/incidents.ts new file mode 100644 index 0000000..a7ffdb0 --- /dev/null +++ b/convex/incidents.ts @@ -0,0 +1,178 @@ +import { ConvexError, v } from "convex/values" + +import { mutation, query } from "./_generated/server" +import type { Id } from "./_generated/dataModel" +import { requireStaff } from "./rbac" + +const DEFAULT_STATUS = "investigating" + +function timelineId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID() + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId) + const incidents = await ctx.db + .query("incidents") + .withIndex("by_tenant_updated", (q) => q.eq("tenantId", tenantId)) + .order("desc") + .collect() + return incidents + }, +}) + +export const createIncident = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + title: v.string(), + severity: v.string(), + impactSummary: v.optional(v.string()), + affectedQueues: v.optional(v.array(v.string())), + initialUpdate: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, actorId, title, severity, impactSummary, affectedQueues, initialUpdate }) => { + const viewer = await requireStaff(ctx, actorId, tenantId) + const normalizedTitle = title.trim() + if (normalizedTitle.length < 3) { + throw new ConvexError("Informe um título válido para o incidente") + } + const now = Date.now() + const timelineEntry = { + id: timelineId(), + authorId: actorId, + authorName: viewer.user.name ?? viewer.user.email ?? "Equipe", + message: initialUpdate?.trim().length ? initialUpdate.trim() : "Incidente registrado.", + type: "created", + createdAt: now, + } + const id = await ctx.db.insert("incidents", { + tenantId, + title: normalizedTitle, + status: DEFAULT_STATUS, + severity, + impactSummary: impactSummary?.trim() || undefined, + affectedQueues: affectedQueues ?? [], + ownerId: actorId, + ownerName: viewer.user.name ?? undefined, + ownerEmail: viewer.user.email ?? undefined, + startedAt: now, + updatedAt: now, + resolvedAt: undefined, + timeline: [timelineEntry], + }) + return id + }, +}) + +export const updateIncidentStatus = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + incidentId: v.id("incidents"), + status: v.string(), + }, + handler: async (ctx, { tenantId, actorId, incidentId, status }) => { + const viewer = await requireStaff(ctx, actorId, tenantId) + const incident = await ctx.db.get(incidentId) + if (!incident || incident.tenantId !== tenantId) { + throw new ConvexError("Incidente não encontrado") + } + const now = Date.now() + const timeline = [ + ...(incident.timeline ?? []), + { + id: timelineId(), + authorId: actorId, + authorName: viewer.user.name ?? viewer.user.email ?? "Equipe", + message: `Status atualizado para ${status}`, + type: "status", + createdAt: now, + }, + ] + await ctx.db.patch(incidentId, { + status, + updatedAt: now, + resolvedAt: status === "resolved" ? now : incident.resolvedAt ?? undefined, + timeline, + }) + }, +}) + +export const bulkUpdateIncidentStatus = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + incidentIds: v.array(v.id("incidents")), + status: v.string(), + }, + handler: async (ctx, { tenantId, actorId, incidentIds, status }) => { + const viewer = await requireStaff(ctx, actorId, tenantId) + const now = Date.now() + for (const incidentId of incidentIds) { + const incident = await ctx.db.get(incidentId) + if (!incident || incident.tenantId !== tenantId) continue + const timeline = [ + ...(incident.timeline ?? []), + { + id: timelineId(), + authorId: actorId, + authorName: viewer.user.name ?? viewer.user.email ?? "Equipe", + message: `Status atualizado em massa para ${status}`, + type: "status", + createdAt: now, + }, + ] + await ctx.db.patch(incidentId, { + status, + updatedAt: now, + resolvedAt: status === "resolved" ? now : incident.resolvedAt ?? undefined, + timeline, + }) + } + }, +}) + +export const addIncidentUpdate = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + incidentId: v.id("incidents"), + message: v.string(), + status: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, actorId, incidentId, message, status }) => { + const viewer = await requireStaff(ctx, actorId, tenantId) + const incident = await ctx.db.get(incidentId) + if (!incident || incident.tenantId !== tenantId) { + throw new ConvexError("Incidente não encontrado") + } + const trimmed = message.trim() + if (trimmed.length < 3) { + throw new ConvexError("Descreva a atualização do incidente") + } + const now = Date.now() + const timeline = [ + ...(incident.timeline ?? []), + { + id: timelineId(), + authorId: actorId, + authorName: viewer.user.name ?? viewer.user.email ?? "Equipe", + message: trimmed, + type: "update", + createdAt: now, + }, + ] + await ctx.db.patch(incidentId, { + timeline, + status: status ?? incident.status, + updatedAt: now, + resolvedAt: status === "resolved" ? now : incident.resolvedAt ?? undefined, + }) + }, +}) diff --git a/convex/reports.ts b/convex/reports.ts index 69d819b..281e6dd 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -1,4 +1,4 @@ -import { query } from "./_generated/server"; +import { action, query } from "./_generated/server"; import type { QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; @@ -503,6 +503,41 @@ export const slaOverview = query({ handler: slaOverviewHandler, }); +export const triggerScheduledExports = action({ + args: { + tenantId: v.optional(v.string()), + }, + handler: async (_ctx, args) => { + const secret = process.env.REPORTS_CRON_SECRET + const baseUrl = + process.env.REPORTS_CRON_BASE_URL ?? + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL + + if (!secret || !baseUrl) { + console.warn("[reports] cron skip: missing REPORTS_CRON_SECRET or base URL") + return { skipped: true } + } + + const endpoint = `${baseUrl.replace(/\/$/, "")}/api/reports/schedules/run` + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${secret}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ tenantId: args.tenantId }), + }) + + if (!response.ok) { + const detail = await response.text().catch(() => response.statusText) + throw new ConvexError(`Falha ao disparar agendamentos: ${response.status} ${detail}`) + } + + return response.json() + }, +}) + export async function csatOverviewHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } @@ -716,6 +751,101 @@ export const backlogOverview = query({ handler: backlogOverviewHandler, }); +type QueueTrendPoint = { date: string; opened: number; resolved: number } +type QueueTrendEntry = { + id: string + name: string + openedTotal: number + resolvedTotal: number + series: Map +} + +export async function queueLoadTrendHandler( + ctx: QueryCtx, + { + tenantId, + viewerId, + range, + limit, + }: { tenantId: string; viewerId: Id<"users">; range?: string; limit?: number } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const days = range === "90d" ? 90 : range === "30d" ? 30 : 14 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + const tickets = await fetchScopedTickets(ctx, tenantId, viewer) + const queues = await fetchQueues(ctx, tenantId) + + const queueNames = new Map() + queues.forEach((queue) => queueNames.set(String(queue._id), queue.name)) + queueNames.set("unassigned", "Sem fila") + + const dayKeys: string[] = [] + for (let i = days - 1; i >= 0; i--) { + const key = formatDateKey(endMs - (i + 1) * ONE_DAY_MS) + dayKeys.push(key) + } + + const stats = new Map() + const ensureEntry = (queueId: string) => { + if (!stats.has(queueId)) { + const series = new Map() + dayKeys.forEach((key) => { + series.set(key, { date: key, opened: 0, resolved: 0 }) + }) + stats.set(queueId, { + id: queueId, + name: queueNames.get(queueId) ?? "Sem fila", + openedTotal: 0, + resolvedTotal: 0, + series, + }) + } + return stats.get(queueId)! + } + + for (const ticket of tickets) { + const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned" + if (ticket.createdAt >= startMs && ticket.createdAt < endMs) { + const entry = ensureEntry(queueId) + const bucket = entry.series.get(formatDateKey(ticket.createdAt)) + if (bucket) { + bucket.opened += 1 + } + entry.openedTotal += 1 + } + if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) { + const entry = ensureEntry(queueId) + const bucket = entry.series.get(formatDateKey(ticket.resolvedAt)) + if (bucket) { + bucket.resolved += 1 + } + entry.resolvedTotal += 1 + } + } + + const maxEntries = Math.max(1, Math.min(limit ?? 3, 6)) + const queuesTrend = Array.from(stats.values()) + .sort((a, b) => b.openedTotal - a.openedTotal) + .slice(0, maxEntries) + .map((entry) => ({ + id: entry.id, + name: entry.name, + openedTotal: entry.openedTotal, + resolvedTotal: entry.resolvedTotal, + series: dayKeys.map((key) => entry.series.get(key)!), + })) + + return { rangeDays: days, queues: queuesTrend } +} + +export const queueLoadTrend = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), limit: v.optional(v.number()) }, + handler: queueLoadTrendHandler, +}) + // Touch to ensure CI convex_deploy runs and that agentProductivity is deployed export async function agentProductivityHandler( ctx: QueryCtx, diff --git a/convex/schema.ts b/convex/schema.ts index b0f1bc3..432c827 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -436,6 +436,34 @@ export default defineSchema({ .index("by_ticket_agent", ["ticketId", "agentId"]) .index("by_agent", ["agentId"]), + incidents: defineTable({ + tenantId: v.string(), + title: v.string(), + status: v.string(), + severity: v.string(), + impactSummary: v.optional(v.string()), + affectedQueues: v.array(v.string()), + ownerId: v.optional(v.id("users")), + ownerName: v.optional(v.string()), + ownerEmail: v.optional(v.string()), + startedAt: v.number(), + updatedAt: v.number(), + resolvedAt: v.optional(v.number()), + timeline: v.array( + v.object({ + id: v.string(), + authorId: v.id("users"), + authorName: v.optional(v.string()), + message: v.string(), + type: v.optional(v.string()), + createdAt: v.number(), + }) + ), + }) + .index("by_tenant_status", ["tenantId", "status"]) + .index("by_tenant_updated", ["tenantId", "updatedAt"]) + .index("by_tenant", ["tenantId"]), + ticketCategories: defineTable({ tenantId: v.string(), name: v.string(), diff --git a/eslint.config.mjs b/eslint.config.mjs index fb96187..e765ea5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ const eslintConfig = [ "apps/desktop/dist/**", "apps/desktop/src-tauri/target/**", "nova-calendar-main/**", + "referência/**", "next-env.d.ts", "convex/_generated/**", ], diff --git a/prisma/schema.prisma b/prisma/schema.prisma index de8c42f..e6953df 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -209,6 +209,52 @@ model Ticket { @@index([tenantId, companyId]) } +model ReportExportSchedule { + id String @id @default(cuid()) + tenantId String + name String + reportKeys Json + range String @default("30d") + companyId String? + companyName String? + format String @default("xlsx") + frequency String + dayOfWeek Int? + dayOfMonth Int? + hour Int @default(8) + minute Int @default(0) + timezone String @default("America/Sao_Paulo") + recipients Json + status String @default("ACTIVE") + lastRunAt DateTime? + nextRunAt DateTime? + createdBy String + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + runs ReportExportRun[] + + @@index([tenantId, status]) + @@index([tenantId, nextRunAt]) +} + +model ReportExportRun { + id String @id @default(cuid()) + tenantId String + scheduleId String + status String @default("PENDING") + startedAt DateTime @default(now()) + completedAt DateTime? + error String? + artifacts Json? + + schedule ReportExportSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + + @@index([tenantId, status]) + @@index([tenantId, scheduleId]) +} + model TicketEvent { id String @id @default(cuid()) ticketId String diff --git a/referência/sistema-de-chamados-main/.env.example b/referência/sistema-de-chamados-main/.env.example new file mode 100644 index 0000000..2fbeb51 --- /dev/null +++ b/referência/sistema-de-chamados-main/.env.example @@ -0,0 +1,28 @@ +NODE_ENV=development + +# Public app URL +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Better Auth +BETTER_AUTH_URL=http://localhost:3000 +BETTER_AUTH_SECRET=change-me-in-prod + +# Convex (dev server URL) +NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210 + +# SQLite database (local dev) +DATABASE_URL=file:./prisma/db.dev.sqlite + +# Optional SMTP (dev) +# SMTP_ADDRESS=localhost +# SMTP_PORT=1025 +# SMTP_TLS=false +# SMTP_USERNAME= +# SMTP_PASSWORD= +# SMTP_AUTHENTICATION=login +# SMTP_ENABLE_STARTTLS_AUTO=false +# MAILER_SENDER_EMAIL=no-reply@example.com + +# Dev-only bypass to simplify local testing (do NOT enable in prod) +# DEV_BYPASS_AUTH=0 +# NEXT_PUBLIC_DEV_BYPASS_AUTH=0 diff --git a/referência/sistema-de-chamados-main/.github/workflows/ci-cd-web-desktop.yml b/referência/sistema-de-chamados-main/.github/workflows/ci-cd-web-desktop.yml new file mode 100644 index 0000000..3296b69 --- /dev/null +++ b/referência/sistema-de-chamados-main/.github/workflows/ci-cd-web-desktop.yml @@ -0,0 +1,533 @@ +name: CI/CD Web + Desktop + +on: + push: + branches: [ main ] + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + force_web_deploy: + description: 'Forçar deploy do Web (ignorar filtro)?' + required: false + default: 'false' + force_convex_deploy: + description: 'Forçar deploy do Convex (ignorar filtro)?' + required: false + default: 'false' + +env: + APP_DIR: /srv/apps/sistema + VPS_UPDATES_DIR: /var/www/updates + RUN_MACHINE_SMOKE: ${{ vars.RUN_MACHINE_SMOKE || secrets.RUN_MACHINE_SMOKE || 'false' }} + +jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + outputs: + convex: ${{ steps.filter.outputs.convex }} + web: ${{ steps.filter.outputs.web }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Paths filter + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + convex: + - 'convex/**' + web: + - 'src/**' + - 'public/**' + - 'prisma/**' + - 'next.config.ts' + - 'package.json' + - 'pnpm-lock.yaml' + - 'tsconfig.json' + - 'middleware.ts' + - 'stack.yml' + + deploy: + name: Deploy (VPS Linux) + needs: changes + # Executa em qualquer push na main (independente do filtro) ou quando disparado manualmente + if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }} + runs-on: [ self-hosted, linux, vps ] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine APP_DIR (fallback safe path) + id: appdir + run: | + TS=$(date +%s) + # Use a web-specific build dir to avoid clashes with convex job + FALLBACK_DIR="$HOME/apps/web.build.$TS" + mkdir -p "$FALLBACK_DIR" + echo "Using APP_DIR (fallback)=$FALLBACK_DIR" + echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.20.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.1 + + - name: Verify Bun runtime + run: bun --version + + - name: Permissions diagnostic (server paths) + run: | + set +e + echo "== Basic context ==" + whoami || true + id || true + groups || true + umask || true + echo "HOME=$HOME" + echo "APP_DIR(default)=${APP_DIR:-/srv/apps/sistema}" + echo "EFFECTIVE_APP_DIR=$EFFECTIVE_APP_DIR" + + echo "\n== Permissions check ==" + check_path() { + P="$1" + echo "-- $P" + if [ -e "$P" ]; then + stat -c '%A %U:%G %n' "$P" 2>/dev/null || ls -ld "$P" || true + echo -n "WRITABLE? "; [ -w "$P" ] && echo yes || echo no + if command -v namei >/dev/null 2>&1; then + namei -l "$P" || true + fi + TMP="$P/.permtest.$$" + (echo test > "$TMP" 2>/dev/null && echo "CREATE_FILE: ok" && rm -f "$TMP") || echo "CREATE_FILE: failed" + else + echo "(missing)" + fi + } + check_path "/srv/apps/sistema" + check_path "/srv/apps/sistema/src/app/machines/handshake" + check_path "/srv/apps/sistema/apps/desktop/node_modules" + check_path "/srv/apps/sistema/node_modules" + check_path "$EFFECTIVE_APP_DIR" + check_path "$EFFECTIVE_APP_DIR/node_modules" + + - name: Sync workspace to APP_DIR (preserving local env) + run: | + mkdir -p "$EFFECTIVE_APP_DIR" + RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete" + # Excluir .env apenas quando copiando para o diretório padrão (/srv) para preservar segredos locais + EXCLUDE_ENV="--exclude '.env*' --exclude 'apps/desktop/.env*' --exclude 'convex/.env*'" + if [ "$EFFECTIVE_APP_DIR" != "${APP_DIR:-/srv/apps/sistema}" ]; then + EXCLUDE_ENV="" + fi + rsync $RSYNC_FLAGS \ + --filter='protect .next.old*' \ + --exclude '.next.old*' \ + --filter='protect node_modules' \ + --filter='protect node_modules/**' \ + --filter='protect .pnpm-store' \ + --filter='protect .pnpm-store/**' \ + --filter='protect .env' \ + --filter='protect .env*' \ + --filter='protect apps/desktop/.env*' \ + --filter='protect convex/.env*' \ + --exclude '.git' \ + --exclude '.next' \ + --exclude 'node_modules' \ + --exclude 'node_modules/**' \ + --exclude '.pnpm-store' \ + --exclude '.pnpm-store/**' \ + $EXCLUDE_ENV \ + ./ "$EFFECTIVE_APP_DIR"/ + + - name: Acquire Convex admin key + id: key + run: | + CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}') + if [ -z "$CID" ]; then echo "No convex container"; exit 1; fi + KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1) + echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT + echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)" + + - name: Copy production .env if present + run: | + DEFAULT_DIR="${APP_DIR:-/srv/apps/sistema}" + if [ "$EFFECTIVE_APP_DIR" != "$DEFAULT_DIR" ] && [ -f "$DEFAULT_DIR/.env" ]; then + echo "Copying production .env from $DEFAULT_DIR to $EFFECTIVE_APP_DIR" + cp -f "$DEFAULT_DIR/.env" "$EFFECTIVE_APP_DIR/.env" + fi + + - name: Prune workspace for server-only build + run: | + cd "$EFFECTIVE_APP_DIR" + # Keep only root (web) as a package in this effective workspace + printf "packages:\n - .\n\nignoredBuiltDependencies:\n - '@prisma/client'\n - '@prisma/engines'\n - '@tailwindcss/oxide'\n - esbuild\n - prisma\n - sharp\n - unrs-resolver\n" > pnpm-workspace.yaml + + - name: Ensure Next.js cache directory exists and is writable + run: | + cd "$EFFECTIVE_APP_DIR" + mkdir -p .next/cache + chmod -R u+rwX .next || true + + - name: Cache Next.js build cache (.next/cache) + uses: actions/cache@v4 + with: + path: ${{ env.EFFECTIVE_APP_DIR }}/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml', 'bun.lock') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.js', 'src/**/*.jsx', 'next.config.ts') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml', 'bun.lock') }}- + + - name: Install and build (Next.js) + run: | + cd "$EFFECTIVE_APP_DIR" + bun install --frozen-lockfile --filter '!appsdesktop' + bun run prisma:generate + bun run build:bun + + - name: Publish build to stable APP_DIR directory + run: | + set -e + DEST="$HOME/apps/sistema" + mkdir -p "$DEST" + mkdir -p "$DEST/.next/static" + # One-time fix for old root-owned files (esp. .pnpm-store) left by previous containers + docker run --rm -v "$DEST":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true' || true + # Preserve previously published static assets to keep stale chunks available for clients mid-navigation + if [ -d "$EFFECTIVE_APP_DIR/.next/static" ]; then + rsync -a \ + "$EFFECTIVE_APP_DIR/.next/static/" "$DEST/.next/static/" + fi + # Publish new build; exclude .pnpm-store to avoid Permission denied on old entries + rsync -a --delete \ + --chown=1000:1000 \ + --exclude '.pnpm-store' --exclude '.pnpm-store/**' \ + --exclude '.next/static' \ + "$EFFECTIVE_APP_DIR"/ "$DEST"/ + echo "Published build to: $DEST" + + - name: Swarm deploy (stack.yml) + run: | + cd "$EFFECTIVE_APP_DIR" + # Exporta variáveis do .env para substituição no stack (ex.: MACHINE_PROVISIONING_SECRET) + set -o allexport + if [ -f .env ]; then . ./.env; fi + set +o allexport + APP_DIR_STABLE="$HOME/apps/sistema" + if [ ! -d "$APP_DIR_STABLE" ]; then + echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1 + fi + echo "Using APP_DIR (stable)=$APP_DIR_STABLE" + APP_DIR="$APP_DIR_STABLE" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema + + - name: Ensure Convex service envs and restart + run: | + cd "$EFFECTIVE_APP_DIR" + set -o allexport + if [ -f .env ]; then . ./.env; fi + set +o allexport + echo "Ensuring Convex envs on service: sistema_convex_backend" + if [ -n "${MACHINE_PROVISIONING_SECRET:-}" ]; then + docker service update --env-add MACHINE_PROVISIONING_SECRET="${MACHINE_PROVISIONING_SECRET}" sistema_convex_backend || true + fi + if [ -n "${MACHINE_TOKEN_TTL_MS:-}" ]; then + docker service update --env-add MACHINE_TOKEN_TTL_MS="${MACHINE_TOKEN_TTL_MS}" sistema_convex_backend || true + fi + if [ -n "${FLEET_SYNC_SECRET:-}" ]; then + docker service update --env-add FLEET_SYNC_SECRET="${FLEET_SYNC_SECRET}" sistema_convex_backend || true + fi + echo "Current envs:" + docker service inspect sistema_convex_backend --format '{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' || true + echo "Forcing service restart..." + docker service update --force sistema_convex_backend || true + + - name: Smoke test — register + heartbeat + run: | + set -e + if [ "${RUN_MACHINE_SMOKE:-false}" != "true" ]; then + echo "RUN_MACHINE_SMOKE != true — pulando smoke test"; exit 0 + fi + # Load MACHINE_PROVISIONING_SECRET from production .env on the host + if [ -f /srv/apps/sistema/.env ]; then + set -o allexport + . /srv/apps/sistema/.env + set +o allexport + fi + if [ -z "${MACHINE_PROVISIONING_SECRET:-}" ]; then + echo "MACHINE_PROVISIONING_SECRET ausente — pulando smoke test"; exit 0 + fi + HOSTNAME_TEST="ci-smoke-$(date +%s)" + BODY='{"provisioningSecret":"'"$MACHINE_PROVISIONING_SECRET"'","tenantId":"tenant-atlas","hostname":"'"$HOSTNAME_TEST"'","os":{"name":"Linux","version":"6.1.0","architecture":"x86_64"},"macAddresses":["AA:BB:CC:DD:EE:FF"],"serialNumbers":[],"metadata":{"inventory":{"cpu":"i7","ramGb":16}},"registeredBy":"ci-smoke"}' + HTTP=$(curl -sS -o resp.json -w "%{http_code}" -H 'Content-Type: application/json' -d "$BODY" https://tickets.esdrasrenan.com.br/api/machines/register || true) + echo "Register HTTP=$HTTP" + if [ "$HTTP" != "201" ]; then + echo "Register failed:"; tail -c 600 resp.json || true; exit 1; fi + TOKEN=$(node -e 'try{const j=require("fs").readFileSync("resp.json","utf8");process.stdout.write(JSON.parse(j).machineToken||"");}catch(e){process.stdout.write("")}' ) + if [ -z "$TOKEN" ]; then echo "Missing token in register response"; exit 1; fi + HB=$(curl -sS -o /dev/null -w "%{http_code}" -H 'Content-Type: application/json' -d '{"machineToken":"'"$TOKEN"'","status":"online","metrics":{"cpuPct":5,"memFreePct":70}}' https://tickets.esdrasrenan.com.br/api/machines/heartbeat || true) + echo "Heartbeat HTTP=$HB" + if [ "$HB" != "200" ]; then echo "Heartbeat failed"; exit 1; fi + + - name: Cleanup old build workdirs (keep last 2) + run: | + set -e + ROOT="$HOME/apps" + KEEP=2 + PATTERN='web.build.*' + ACTIVE="$HOME/apps/sistema" + echo "Scanning $ROOT for old $PATTERN dirs" + LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true) + echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true + echo "$LIST" | sed "1,${KEEP}d" | while read dir; do + [ -z "$dir" ] && continue + if [ -n "$ACTIVE" ] && [ "$(readlink -f "$dir")" = "$ACTIVE" ]; then + echo "Skipping active dir (in use by APP_DIR): $dir"; continue + fi + echo "Removing $dir" + chmod -R u+rwX "$dir" 2>/dev/null || true + rm -rf "$dir" || { + echo "Local rm failed, falling back to docker (root) cleanup for $dir..." + docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true + rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true + } + done + echo "Disk usage (top 10 under $ROOT):" + du -sh "$ROOT"/* 2>/dev/null | sort -rh | head -n 10 || true + + - name: Restart web service with new code (skip — stack deploy already updated) + if: ${{ always() && false }} + run: | + docker service update --force sistema_web + + - name: Restart Convex backend service (optional) + run: | + # Fail the job if the convex backend cannot restart + docker service update --force sistema_convex_backend + + convex_deploy: + name: Deploy Convex functions + needs: changes + # Executa quando convex/** mudar ou via workflow_dispatch + if: ${{ github.event_name == 'workflow_dispatch' || needs.changes.outputs.convex == 'true' }} + runs-on: [ self-hosted, linux, vps ] + env: + APP_DIR: /srv/apps/sistema + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine APP_DIR (fallback safe path) + id: appdir + run: | + TS=$(date +%s) + # Use a convex-specific build dir to avoid clashes with web job + FALLBACK_DIR="$HOME/apps/convex.build.$TS" + mkdir -p "$FALLBACK_DIR" + echo "Using APP_DIR (fallback)=$FALLBACK_DIR" + echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV" + + - name: Sync workspace to APP_DIR (preserving local env) + run: | + mkdir -p "$EFFECTIVE_APP_DIR" + RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete" + rsync $RSYNC_FLAGS \ + --filter='protect .next.old*' \ + --exclude '.next.old*' \ + --exclude '.env*' \ + --exclude 'apps/desktop/.env*' \ + --exclude 'convex/.env*' \ + --filter='protect node_modules' \ + --filter='protect node_modules/**' \ + --filter='protect .pnpm-store' \ + --filter='protect .pnpm-store/**' \ + --exclude '.git' \ + --exclude '.next' \ + --exclude 'node_modules' \ + --exclude 'node_modules/**' \ + --exclude '.pnpm-store' \ + --exclude '.pnpm-store/**' \ + ./ "$EFFECTIVE_APP_DIR"/ + + - name: Acquire Convex admin key + id: key + run: | + CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}') + if [ -z "$CID" ]; then echo "No convex container"; exit 1; fi + KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1) + echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT + echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)" + + - name: Bring convex.json from live app if present + run: | + if [ -f "$APP_DIR/convex.json" ]; then + echo "Copying $APP_DIR/convex.json -> $EFFECTIVE_APP_DIR/convex.json" + cp -f "$APP_DIR/convex.json" "$EFFECTIVE_APP_DIR/convex.json" + else + echo "No existing convex.json found at $APP_DIR; convex CLI will need self-hosted vars" + fi + + - name: Set Convex env vars (self-hosted) + env: + CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br + CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }} + MACHINE_PROVISIONING_SECRET: ${{ secrets.MACHINE_PROVISIONING_SECRET }} + MACHINE_TOKEN_TTL_MS: ${{ secrets.MACHINE_TOKEN_TTL_MS }} + FLEET_SYNC_SECRET: ${{ secrets.FLEET_SYNC_SECRET }} + run: | + set -e + docker run --rm -i \ + -v "$EFFECTIVE_APP_DIR":/app \ + -w /app \ + -e CONVEX_SELF_HOSTED_URL \ + -e CONVEX_SELF_HOSTED_ADMIN_KEY \ + -e MACHINE_PROVISIONING_SECRET \ + -e MACHINE_TOKEN_TTL_MS \ + -e FLEET_SYNC_SECRET \ + node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; bun install --frozen-lockfile; \ + if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then bunx convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\"; fi; \ + if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then bunx convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\"; fi; \ + if [ -n \"$FLEET_SYNC_SECRET\" ]; then bunx convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\"; fi; \ + bunx convex env list" + + - name: Ensure .env is not present for Convex deploy + run: | + cd "$EFFECTIVE_APP_DIR" + if [ -f .env ]; then + echo "Renaming .env -> .env.bak (Convex self-hosted deploy)" + mv -f .env .env.bak + fi + - name: Deploy functions to Convex self-hosted + env: + CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br + CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }} + run: | + docker run --rm -i \ + -v "$EFFECTIVE_APP_DIR":/app \ + -w /app \ + -e CI=true \ + -e CONVEX_SELF_HOSTED_URL \ + -e CONVEX_SELF_HOSTED_ADMIN_KEY \ + node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; bun install --frozen-lockfile; bunx convex deploy" + + - name: Cleanup old convex build workdirs (keep last 2) + run: | + set -e + ROOT="$HOME/apps" + KEEP=2 + PATTERN='convex.build.*' + LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true) + echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true + echo "$LIST" | sed "1,${KEEP}d" | while read dir; do + [ -z "$dir" ] && continue + echo "Removing $dir" + chmod -R u+rwX "$dir" 2>/dev/null || true + rm -rf "$dir" || { + echo "Local rm failed, falling back to docker (root) cleanup for $dir..." + docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true + rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true + } + done + + desktop_release: + name: Desktop Release (Windows) + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + runs-on: [ self-hosted, windows, desktop ] + defaults: + run: + working-directory: apps/desktop + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.20.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install deps (desktop) + run: pnpm install --frozen-lockfile + + - name: Build with Tauri + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + with: + projectPath: apps/desktop + + + - name: Upload latest.json + bundles to VPS + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + source: | + **/bundle/**/latest.json + **/bundle/**/* + target: ${{ env.VPS_UPDATES_DIR }} + overwrite: true + + diagnose_convex: + name: Diagnose Convex (env + register test) + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: [ self-hosted, linux, vps ] + steps: + - name: Print service env and .env subset + run: | + echo "=== Convex service env ===" + docker service inspect sistema_convex_backend --format '{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' || true + echo + echo "=== /srv/apps/sistema/.env subset ===" + [ -f /srv/apps/sistema/.env ] && grep -E '^(MACHINE_PROVISIONING_SECRET|MACHINE_TOKEN_TTL_MS|FLEET_SYNC_SECRET|NEXT_PUBLIC_CONVEX_URL)=' -n /srv/apps/sistema/.env || echo '(no .env)' + - name: Acquire Convex admin key + id: key + run: | + CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}') + if [ -z "$CID" ]; then echo "No convex container"; exit 1; fi + KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1) + echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT + echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)" + - name: List Convex env and set missing + env: + CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br + ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }} + run: | + set -e + if [ -f /srv/apps/sistema/.env ]; then + set -o allexport + . /srv/apps/sistema/.env + set +o allexport + fi + docker run --rm -i \ + -v /srv/apps/sistema:/app -w /app \ + -e CONVEX_SELF_HOSTED_URL -e CONVEX_SELF_HOSTED_ADMIN_KEY="$ADMIN_KEY" \ + -e MACHINE_PROVISIONING_SECRET -e MACHINE_TOKEN_TTL_MS -e FLEET_SYNC_SECRET \ + node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; bun install --frozen-lockfile; \ + unset CONVEX_DEPLOYMENT; bunx convex env list; \ + if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then bunx convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\"; fi; \ + if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then bunx convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\"; fi; \ + if [ -n \"$FLEET_SYNC_SECRET\" ]; then bunx convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\"; fi; \ + bunx convex env list" + - name: Test register from runner + run: | + HOST="vm-teste-$(date +%s)" + DATA='{"provisioningSecret":"'"${MACHINE_PROVISIONING_SECRET:-"71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6"}"'","tenantId":"tenant-atlas","hostname":"'"$HOST"'","os":{"name":"Linux","version":"6.1.0","architecture":"x86_64"},"macAddresses":["AA:BB:CC:DD:EE:FF"],"serialNumbers":[],"metadata":{"inventario":{"cpu":"i7","ramGb":16}},"registeredBy":"diag-test"}' + HTTP=$(curl -sS -o resp.json -w "%{http_code}" -H 'Content-Type: application/json' -d "$DATA" https://tickets.esdrasrenan.com.br/api/machines/register || true) + echo "Register HTTP=$HTTP" && tail -c 400 resp.json || true diff --git a/referência/sistema-de-chamados-main/.github/workflows/desktop-release.yml b/referência/sistema-de-chamados-main/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..d0b3c21 --- /dev/null +++ b/referência/sistema-de-chamados-main/.github/workflows/desktop-release.yml @@ -0,0 +1,67 @@ +name: Desktop Release (Tauri) + +on: + workflow_dispatch: + push: + tags: + - 'desktop-v*' + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux + runner: ubuntu-latest + - platform: windows + runner: windows-latest + - platform: macos + runner: macos-latest + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Enable Corepack + run: corepack enable && corepack prepare pnpm@10.20.0 --activate + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux deps + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev build-essential curl wget file + + - name: Install pnpm deps + run: pnpm -C apps/desktop install --frozen-lockfile + + + - name: Build desktop + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + VITE_APP_URL: https://tickets.esdrasrenan.com.br + VITE_API_BASE_URL: https://tickets.esdrasrenan.com.br + run: pnpm -C apps/desktop tauri build + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-${{ matrix.platform }} + path: apps/desktop/src-tauri/target/release/bundle diff --git a/referência/sistema-de-chamados-main/.github/workflows/quality-checks.yml b/referência/sistema-de-chamados-main/.github/workflows/quality-checks.yml new file mode 100644 index 0000000..73446cb --- /dev/null +++ b/referência/sistema-de-chamados-main/.github/workflows/quality-checks.yml @@ -0,0 +1,60 @@ +name: Quality Checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + lint-test-build: + name: Lint, Test and Build + runs-on: ubuntu-latest + env: + BETTER_AUTH_SECRET: test-secret + NEXT_PUBLIC_APP_URL: http://localhost:3000 + BETTER_AUTH_URL: http://localhost:3000 + NEXT_PUBLIC_CONVEX_URL: http://localhost:3210 + DATABASE_URL: file:./prisma/db.dev.sqlite + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.1 + + - name: Verify Bun + run: bun --version + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Cache Next.js build cache + uses: actions/cache@v4 + with: + path: | + ${{ github.workspace }}/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml', 'bun.lock') }}-${{ hashFiles('**/*.{js,jsx,ts,tsx}') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml', 'bun.lock') }}- + + - name: Generate Prisma client + run: bun run prisma:generate + + - name: Lint + run: bun run lint + + - name: Test + run: bun test + + - name: Build + run: bun run build:bun diff --git a/referência/sistema-de-chamados-main/.gitignore b/referência/sistema-de-chamados-main/.gitignore new file mode 100644 index 0000000..7e0652c --- /dev/null +++ b/referência/sistema-de-chamados-main/.gitignore @@ -0,0 +1,60 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +*.sqlite +# external experiments +nova-calendar-main/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example +!apps/desktop/.env.example + +# Accidental Windows duplicate downloads (e.g., "env (1)") +env (*) +env (1) + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# backups locais +.archive/ + +# arquivos locais temporários +Captura de tela *.png +Screenshot*.png +# Ignore NTFS ADS streams accidentally committed from Windows downloads +*:*Zone.Identifier +*:\:Zone.Identifier diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/Inter-Italic-VariableFont_opsz,wght.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/Inter-Italic-VariableFont_opsz,wght.ttf new file mode 100644 index 0000000..43ed4f5 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/Inter-Italic-VariableFont_opsz,wght.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/Inter-VariableFont_opsz,wght.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/Inter-VariableFont_opsz,wght.ttf new file mode 100644 index 0000000..e31b51e Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/Inter-VariableFont_opsz,wght.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/OFL.txt b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/OFL.txt new file mode 100644 index 0000000..d05ec4b --- /dev/null +++ b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/README.txt b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/README.txt new file mode 100644 index 0000000..b92a417 --- /dev/null +++ b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/README.txt @@ -0,0 +1,118 @@ +Inter Variable Font +=================== + +This download contains Inter as both variable fonts and static fonts. + +Inter is a variable font with these axes: + opsz + wght + +This means all the styles are contained in these files: + Inter/Inter-VariableFont_opsz,wght.ttf + Inter/Inter-Italic-VariableFont_opsz,wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for Inter: + Inter/static/Inter_18pt-Thin.ttf + Inter/static/Inter_18pt-ExtraLight.ttf + Inter/static/Inter_18pt-Light.ttf + Inter/static/Inter_18pt-Regular.ttf + Inter/static/Inter_18pt-Medium.ttf + Inter/static/Inter_18pt-SemiBold.ttf + Inter/static/Inter_18pt-Bold.ttf + Inter/static/Inter_18pt-ExtraBold.ttf + Inter/static/Inter_18pt-Black.ttf + Inter/static/Inter_24pt-Thin.ttf + Inter/static/Inter_24pt-ExtraLight.ttf + Inter/static/Inter_24pt-Light.ttf + Inter/static/Inter_24pt-Regular.ttf + Inter/static/Inter_24pt-Medium.ttf + Inter/static/Inter_24pt-SemiBold.ttf + Inter/static/Inter_24pt-Bold.ttf + Inter/static/Inter_24pt-ExtraBold.ttf + Inter/static/Inter_24pt-Black.ttf + Inter/static/Inter_28pt-Thin.ttf + Inter/static/Inter_28pt-ExtraLight.ttf + Inter/static/Inter_28pt-Light.ttf + Inter/static/Inter_28pt-Regular.ttf + Inter/static/Inter_28pt-Medium.ttf + Inter/static/Inter_28pt-SemiBold.ttf + Inter/static/Inter_28pt-Bold.ttf + Inter/static/Inter_28pt-ExtraBold.ttf + Inter/static/Inter_28pt-Black.ttf + Inter/static/Inter_18pt-ThinItalic.ttf + Inter/static/Inter_18pt-ExtraLightItalic.ttf + Inter/static/Inter_18pt-LightItalic.ttf + Inter/static/Inter_18pt-Italic.ttf + Inter/static/Inter_18pt-MediumItalic.ttf + Inter/static/Inter_18pt-SemiBoldItalic.ttf + Inter/static/Inter_18pt-BoldItalic.ttf + Inter/static/Inter_18pt-ExtraBoldItalic.ttf + Inter/static/Inter_18pt-BlackItalic.ttf + Inter/static/Inter_24pt-ThinItalic.ttf + Inter/static/Inter_24pt-ExtraLightItalic.ttf + Inter/static/Inter_24pt-LightItalic.ttf + Inter/static/Inter_24pt-Italic.ttf + Inter/static/Inter_24pt-MediumItalic.ttf + Inter/static/Inter_24pt-SemiBoldItalic.ttf + Inter/static/Inter_24pt-BoldItalic.ttf + Inter/static/Inter_24pt-ExtraBoldItalic.ttf + Inter/static/Inter_24pt-BlackItalic.ttf + Inter/static/Inter_28pt-ThinItalic.ttf + Inter/static/Inter_28pt-ExtraLightItalic.ttf + Inter/static/Inter_28pt-LightItalic.ttf + Inter/static/Inter_28pt-Italic.ttf + Inter/static/Inter_28pt-MediumItalic.ttf + Inter/static/Inter_28pt-SemiBoldItalic.ttf + Inter/static/Inter_28pt-BoldItalic.ttf + Inter/static/Inter_28pt-ExtraBoldItalic.ttf + Inter/static/Inter_28pt-BlackItalic.ttf + +Get started +----------- + +1. Install the font files you want to use + +2. Use your app's font picker to view the font family and all the +available styles + +Learn more about variable fonts +------------------------------- + + https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts + https://variablefonts.typenetwork.com + https://medium.com/variable-fonts + +In desktop apps + + https://theblog.adobe.com/can-variable-fonts-illustrator-cc + https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts + +Online + + https://developers.google.com/fonts/docs/getting_started + https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide + https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts + +Installing fonts + + MacOS: https://support.apple.com/en-us/HT201749 + Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux + Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows + +Android Apps + + https://developers.google.com/fonts/docs/android + https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts + +License +------- +Please read the full license text (OFL.txt) to understand the permissions, +restrictions and requirements for usage, redistribution, and modification. + +You can use them in your products & projects – print or digital, +commercial or otherwise. + +This isn't legal advice, please consider consulting a lawyer and see the full +license for all details. diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Black.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Black.ttf new file mode 100644 index 0000000..89673de Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Black.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-BlackItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-BlackItalic.ttf new file mode 100644 index 0000000..b33602f Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-BlackItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf new file mode 100644 index 0000000..57704d1 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-BoldItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-BoldItalic.ttf new file mode 100644 index 0000000..d53a199 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-BoldItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraBold.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraBold.ttf new file mode 100644 index 0000000..e71c601 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraBold.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraBoldItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraBoldItalic.ttf new file mode 100644 index 0000000..df45062 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraBoldItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraLight.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraLight.ttf new file mode 100644 index 0000000..f9c6cfc Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraLight.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraLightItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraLightItalic.ttf new file mode 100644 index 0000000..275f305 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ExtraLightItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf new file mode 100644 index 0000000..14d3595 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Light.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Light.ttf new file mode 100644 index 0000000..acae361 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Light.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-LightItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-LightItalic.ttf new file mode 100644 index 0000000..f69e18b Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-LightItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf new file mode 100644 index 0000000..71d9017 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-MediumItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-MediumItalic.ttf new file mode 100644 index 0000000..5c8c8b1 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-MediumItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Regular.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Regular.ttf new file mode 100644 index 0000000..ce097c8 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Regular.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-SemiBold.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-SemiBold.ttf new file mode 100644 index 0000000..053185e Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-SemiBold.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-SemiBoldItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-SemiBoldItalic.ttf new file mode 100644 index 0000000..d9c9896 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-SemiBoldItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf new file mode 100644 index 0000000..e68ec47 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ThinItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ThinItalic.ttf new file mode 100644 index 0000000..134e837 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_18pt-ThinItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Black.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Black.ttf new file mode 100644 index 0000000..dbb1b3b Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Black.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-BlackItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-BlackItalic.ttf new file mode 100644 index 0000000..b89d61c Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-BlackItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf new file mode 100644 index 0000000..e974d96 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-BoldItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-BoldItalic.ttf new file mode 100644 index 0000000..1c3d251 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-BoldItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraBold.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraBold.ttf new file mode 100644 index 0000000..b775c08 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraBold.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraBoldItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraBoldItalic.ttf new file mode 100644 index 0000000..3461a92 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraBoldItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraLight.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraLight.ttf new file mode 100644 index 0000000..2ec6ca3 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraLight.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraLightItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraLightItalic.ttf new file mode 100644 index 0000000..c634a5d Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ExtraLightItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf new file mode 100644 index 0000000..1048b07 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Light.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Light.ttf new file mode 100644 index 0000000..1a2a6f2 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Light.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-LightItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-LightItalic.ttf new file mode 100644 index 0000000..ded5a75 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-LightItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf new file mode 100644 index 0000000..5c88739 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-MediumItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-MediumItalic.ttf new file mode 100644 index 0000000..be091b1 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-MediumItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Regular.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Regular.ttf new file mode 100644 index 0000000..6b088a7 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Regular.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-SemiBold.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-SemiBold.ttf new file mode 100644 index 0000000..ceb8576 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-SemiBold.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-SemiBoldItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-SemiBoldItalic.ttf new file mode 100644 index 0000000..6921df2 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-SemiBoldItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf new file mode 100644 index 0000000..3505b35 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ThinItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ThinItalic.ttf new file mode 100644 index 0000000..a3e6feb Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_24pt-ThinItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Black.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Black.ttf new file mode 100644 index 0000000..66a252f Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Black.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-BlackItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-BlackItalic.ttf new file mode 100644 index 0000000..3c8fdf9 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-BlackItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf new file mode 100644 index 0000000..14db994 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-BoldItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-BoldItalic.ttf new file mode 100644 index 0000000..704b12b Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-BoldItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraBold.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraBold.ttf new file mode 100644 index 0000000..6d87cae Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraBold.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraBoldItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraBoldItalic.ttf new file mode 100644 index 0000000..1a56735 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraBoldItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraLight.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraLight.ttf new file mode 100644 index 0000000..d42b3f5 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraLight.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraLightItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraLightItalic.ttf new file mode 100644 index 0000000..90e2f20 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ExtraLightItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf new file mode 100644 index 0000000..c2a143a Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Light.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Light.ttf new file mode 100644 index 0000000..5eeff3a Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Light.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-LightItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-LightItalic.ttf new file mode 100644 index 0000000..6b90b76 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-LightItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf new file mode 100644 index 0000000..00120fe Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-MediumItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-MediumItalic.ttf new file mode 100644 index 0000000..7481e7b Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-MediumItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Regular.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Regular.ttf new file mode 100644 index 0000000..855b6f4 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Regular.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-SemiBold.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-SemiBold.ttf new file mode 100644 index 0000000..8b84efc Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-SemiBold.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-SemiBoldItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-SemiBoldItalic.ttf new file mode 100644 index 0000000..2e22c5a Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-SemiBoldItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf new file mode 100644 index 0000000..94e6108 Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf differ diff --git a/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ThinItalic.ttf b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ThinItalic.ttf new file mode 100644 index 0000000..d3d44cd Binary files /dev/null and b/referência/sistema-de-chamados-main/Inter,Manrope/Inter/static/Inter_28pt-ThinItalic.ttf differ diff --git a/referência/sistema-de-chamados-main/README.md b/referência/sistema-de-chamados-main/README.md new file mode 100644 index 0000000..dc5db70 --- /dev/null +++ b/referência/sistema-de-chamados-main/README.md @@ -0,0 +1,127 @@ +## Sistema de Chamados + +Aplicação **Next.js 16 (App Router)** com **React 19**, **Convex** e **Better Auth** para gestão de tickets da Rever. A stack ainda inclui **Prisma 6** (SQLite padrão para DEV), **Tailwind** e **Turbopack** em desenvolvimento (o build de produção roda com o webpack padrão do Next). Todo o código-fonte fica na raiz do monorepo seguindo as convenções do App Router. + +## Requisitos + +- Bun >= 1.3 (recomendado 1.3.1). Após instalar via script oficial, adicione `export PATH="$HOME/.bun/bin:$PATH"` ao seu shell (ex.: `.bashrc`) para ter `bun` disponível globalmente. +- Node.js >= 20 (necessário para ferramentas auxiliares como Prisma CLI e Next.js em modo fallback). +- CLI do Convex (`bunx convex dev` instalará automaticamente no primeiro uso, se ainda não estiver presente). + +## Configuração rápida + +1. Instale as dependências: + ```bash + bun install + ``` +2. Ajuste o arquivo `.env` (ou crie a partir de `.env.example`) e confirme os valores de: + - `NEXT_PUBLIC_CONVEX_URL` (gerado pelo Convex Dev) + - `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DATABASE_URL` (por padrão `file:./db.dev.sqlite`, que mapeia para `prisma/db.dev.sqlite`) +3. Aplique as migrações e gere o client Prisma: + ```bash + bunx prisma migrate deploy + bun run prisma:generate + ``` +4. Popule usuários padrão do Better Auth: + ```bash + bun run auth:seed + ``` + > Sempre que trocar de máquina ou quiser “zerar” o ambiente local, basta repetir os passos 3 e 4 com a mesma `DATABASE_URL`. + +### Resetar rapidamente o ambiente local + +1. Garanta que `DATABASE_URL` aponte para o arquivo desejado (ex.: `file:./db.dev.sqlite` para desenvolvimento, `file:./db.sqlite` em produção local). +2. Aplique as migrações no arquivo informado: + ```bash + DATABASE_URL=file:./db.dev.sqlite bunx prisma migrate deploy + ``` +3. Recrie/garanta as contas padrão de login: + ```bash + DATABASE_URL=file:./db.dev.sqlite bun run auth:seed + ``` +4. Suba o servidor normalmente com `bun run dev`. Esses três comandos bastam para reconstruir o ambiente sempre que trocar de computador. + +### Subir serviços locais + +- (Opcional) Para re-sincronizar manualmente as filas padrão, execute `bun run queues:ensure`. +- Em um terminal, rode o backend em tempo real do Convex com `bun run convex:dev:bun` (ou `bun run convex:dev` para o runtime Node). +- Em outro terminal, suba o frontend Next.js (Turpoback) com `bun run dev:bun` (`bun run dev:webpack` serve como fallback). +- Com o Convex rodando, acesse `http://localhost:3000/dev/seed` uma vez para popular dados de demonstração (tickets, usuários, comentários). + +> Se o CLI perguntar sobre configuração do projeto Convex, escolha criar um novo deployment local (opção padrão) e confirme. As credenciais são armazenadas em `.convex/` automaticamente. + +### Documentação +- Índice de docs: `docs/README.md` +- Operações (produção): `docs/OPERATIONS.md` (versão EN) e `docs/OPERACAO-PRODUCAO.md` (PT-BR) +- Guia de DEV: `docs/DEV.md` +- Testes automatizados (Vitest/Playwright): `docs/testes-vitest.md` +- Stack Swarm: `stack.yml` (roteado por Traefik, rede `traefik_public`). + +### Variáveis de ambiente + +- Exemplo na raiz: `.env.example` — copie para `.env` e preencha segredos. +- App Desktop: `apps/desktop/.env.example` — copie para `apps/desktop/.env` e ajuste `VITE_APP_URL`. +- Nunca faça commit de arquivos `.env` com valores reais (já ignorados em `.gitignore`). + +### Guia de DEV (Prisma, Auth e Desktop/Tauri) + +Para fluxos detalhados de desenvolvimento — banco de dados local (SQLite/Prisma), seed do Better Auth, ajustes do Prisma CLI no DEV e build do Desktop (Tauri) — consulte `docs/DEV.md`. + +## Scripts úteis + +- `bun run dev:bun` — padrão atual para o Next.js com runtime Bun (`bun run dev:webpack` permanece como fallback). +- `bun run convex:dev:bun` — runtime Bun para o Convex (`bun run convex:dev` mantém o fluxo antigo usando Node). +- `bun run build:bun` / `bun run start:bun` — build e serve com Bun; `bun run build` mantém o fallback Node. +- `bun run dev:webpack` — fallback do Next.js em modo desenvolvimento (webpack). +- `bun run lint` — ESLint com as regras do projeto. +- `bun test` — suíte de testes unitários usando o runner do Bun (o teste de screenshot fica automaticamente ignorado se o matcher não existir). +- `bun run build` — executa `next build --webpack` (webpack padrão do Next). +- `bun run build:turbopack` — executa `next build --turbopack` para reproduzir/debugar problemas. +- `bun run auth:seed` — atualiza/cria contas padrão do Better Auth (credenciais em `agents.md`). +- `bunx prisma migrate deploy` — aplica migrações ao banco SQLite local. +- `bun run convex:dev` — roda o Convex em modo desenvolvimento com Node, gerando tipos em `convex/_generated`. + +## Transferir dispositivo entre colaboradores + +Quando uma dispositivo trocar de responsável: + +1. Abra `Admin > Dispositivos`, selecione o equipamento e clique em **Resetar agente**. +2. No equipamento, execute o reset local do agente (`rever-agent reset` ou reinstale o serviço) e reprovisione com o código da empresa. +3. Após o agente gerar um novo token, associe a dispositivo ao novo colaborador no painel. + +Sem o reset de agente, o Convex reaproveita o token anterior e o inventário continua vinculado ao usuário antigo. + +## Estrutura principal + +- `app/` dentro de `src/` — rotas e layouts do Next.js (App Router). +- `components/` — componentes reutilizáveis (UI, formulários, layouts). +- `convex/` — queries, mutations e seeds do Convex. +- `prisma/` — schema, migrações e banco SQLite (`prisma/db.sqlite`). +- `scripts/` — utilitários em Node para sincronização e seeds adicionais. +- `agents.md` — guia operacional e contexto funcional (em PT-BR). +- `PROXIMOS_PASSOS.md` — backlog de melhorias futuras. + +## Credenciais de demonstração + +Após executar `bun run auth:seed`, as credenciais padrão ficam disponíveis conforme descrito em `agents.md` (seção “Credenciais padrão”). Ajuste variáveis `SEED_USER_*` se precisar sobrepor usuários ou senhas durante o seed. + +## Próximos passos + +Consulte `PROXIMOS_PASSOS.md` para acompanhar o backlog funcional e o progresso das iniciativas planejadas. + +### Executar com Bun + +- `bun install` é o fluxo padrão (o arquivo `bun.lock` deve ser versionado; use `bun install --frozen-lockfile` em CI). +- `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun` e `bun run start:bun` já estão configurados; internamente executam `bun run --bun + + +
+ + diff --git a/referência/sistema-de-chamados-main/apps/desktop/package.json b/referência/sistema-de-chamados-main/apps/desktop/package.json new file mode 100644 index 0000000..7237530 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/package.json @@ -0,0 +1,31 @@ +{ + "name": "appsdesktop", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "node ./scripts/tauri-with-stub.mjs", + "gen:icon": "node ./scripts/build-icon.mjs" + }, + "dependencies": { + "@radix-ui/react-tabs": "^1.1.13", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2", + "@tauri-apps/plugin-store": "^2", + "@tauri-apps/plugin-updater": "^2", + "lucide-react": "^0.544.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "png-to-ico": "^3.0.1", + "@tauri-apps/cli": "^2", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.6.2", + "vite": "^6.0.3" + } +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/public/latest.json b/referência/sistema-de-chamados-main/apps/desktop/public/latest.json new file mode 100644 index 0000000..8faf2f3 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/public/latest.json @@ -0,0 +1,11 @@ +{ + "version": "0.1.6", + "notes": "Correções e melhorias do desktop", + "pub_date": "2025-10-14T12:00:00Z", + "platforms": { + "windows-x86_64": { + "signature": "ZFc1MGNuVnpkR1ZrSUdOdmJXMWxiblE2SUhOcFoyNWhkSFZ5WlNCbWNtOXRJSFJoZFhKcElITmxZM0psZENCclpYa0tVbFZVZDNFeFUwRlJRalJVUjJOU1NqUnpTVmhXU1ZoeVUwZElNSGxETW5KSE1FTnBWa3BWU1dzelVYVlRNV1JTV0Vrdk1XMUZVa0Z3YTBWc2QySnZhVnBxUWs5bVoyODNNbEZaYUZsMFVHTlRLMUFyT0hJMVdGZ3lWRkZYT1V3ekwzZG5QUXAwY25WemRHVmtJR052YlcxbGJuUTZJSFJwYldWemRHRnRjRG94TnpZd016azVOVEkzQ1dacGJHVTZVbUYyWlc1Zk1DNHhMalZmZURZMExYTmxkSFZ3TG1WNFpRcHdkME15THpOVlZtUXpiSG9yZGpRd1pFZHFhV1JvVkZCb0wzVnNabWh1ZURJdmFtUlZOalEwTkRSVVdVY3JUVGhLTUdrNU5scFNUSFZVWkRsc1lYVTJUR2dyWTNWeWJuWTVhRGh3ZVVnM1dFWjVhSFZDUVQwOUNnPT0=", + "url": "https://github.com/esdrasrenan/sistema-de-chamados/raw/main/apps/desktop/public/releases/Raven_0.1.6_x64-setup.exe" + } + } +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/public/releases/Raven_0.1.6_x64-setup.exe b/referência/sistema-de-chamados-main/apps/desktop/public/releases/Raven_0.1.6_x64-setup.exe new file mode 100644 index 0000000..2d38474 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/public/releases/Raven_0.1.6_x64-setup.exe differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/public/releases/Raven_0.1.6_x64-setup.exe.sig b/referência/sistema-de-chamados-main/apps/desktop/public/releases/Raven_0.1.6_x64-setup.exe.sig new file mode 100644 index 0000000..3ce4efb --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/public/releases/Raven_0.1.6_x64-setup.exe.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUd3ExU0FRQjRUR2NSSjRzSVhWSVhyU0dIMHlDMnJHMENpVkpVSWszUXVTMWRSWEkvMW1FUkFwa0Vsd2JvaVpqQk9mZ283MlFZaFl0UGNTK1ArOHI1WFgyVFFXOUwzL3dnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzYwMzk5NTI3CWZpbGU6UmF2ZW5fMC4xLjVfeDY0LXNldHVwLmV4ZQpwd0MyLzNVVmQzbHordjQwZEdqaWRoVFBoL3VsZmhueDIvamRVNjQ0NDRUWUcrTThKMGk5NlpSTHVUZDlsYXU2TGgrY3VybnY5aDhweUg3WEZ5aHVCQT09Cg== \ No newline at end of file diff --git a/referência/sistema-de-chamados-main/apps/desktop/scripts/build-icon.mjs b/referência/sistema-de-chamados-main/apps/desktop/scripts/build-icon.mjs new file mode 100644 index 0000000..ffb3649 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/scripts/build-icon.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { promises as fs } from 'node:fs' +import path from 'node:path' +import pngToIco from 'png-to-ico' + +async function fileExists(p) { + try { await fs.access(p); return true } catch { return false } +} + +async function main() { + const root = path.resolve(process.cwd(), 'src-tauri', 'icons') + // Inclua apenas tamanhos suportados pelo NSIS (até 256px). + // Evite 512px para não gerar ICO inválido para o instalador. + const candidates = [ + 'icon-256.png', // preferencial + '128x128@2x.png', // alias de 256 + 'icon-128.png', + 'icon-64.png', + 'icon-32.png', + ] + const sources = [] + for (const name of candidates) { + const p = path.join(root, name) + if (await fileExists(p)) sources.push(p) + } + if (sources.length === 0) { + console.error('[gen:icon] Nenhuma imagem base encontrada em src-tauri/icons') + process.exit(1) + } + + console.log('[gen:icon] Gerando icon.ico a partir de:', sources.map((s) => path.basename(s)).join(', ')) + const buffer = await pngToIco(sources) + const outPath = path.join(root, 'icon.ico') + await fs.writeFile(outPath, buffer) + console.log('[gen:icon] Escrito:', outPath) +} + +main().catch((err) => { console.error(err); process.exit(1) }) diff --git a/referência/sistema-de-chamados-main/apps/desktop/scripts/generate_icon_assets.py b/referência/sistema-de-chamados-main/apps/desktop/scripts/generate_icon_assets.py new file mode 100644 index 0000000..73b0eec --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/scripts/generate_icon_assets.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +Generate icon PNGs/ICO for the desktop installer using the high-resolution Raven artwork. + +The script reads the square logo (`logo-raven-fund-azul.png`) and resizes it to the +target sizes with a simple bilinear filter implemented with the Python standard library, +avoiding additional dependencies. +""" + +from __future__ import annotations + +import math +import struct +import zlib +from binascii import crc32 +from pathlib import Path + +ICON_DIR = Path(__file__).resolve().parents[1] / "src-tauri" / "icons" +BASE_IMAGE = ICON_DIR / "logo-raven-fund-azul.png" +TARGET_SIZES = [32, 64, 128, 256, 512] + + +def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]]]: + data = path.read_bytes() + if not data.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError(f"{path} is not a PNG") + pos = 8 + width = height = bit_depth = color_type = None + compressed_parts = [] + while pos < len(data): + length = struct.unpack(">I", data[pos : pos + 4])[0] + pos += 4 + ctype = data[pos : pos + 4] + pos += 4 + chunk = data[pos : pos + length] + pos += length + pos += 4 # CRC + if ctype == b"IHDR": + width, height, bit_depth, color_type, _, _, _ = struct.unpack(">IIBBBBB", chunk) + if bit_depth != 8 or color_type not in (2, 6): + raise ValueError("Only 8-bit RGB/RGBA PNGs are supported") + elif ctype == b"IDAT": + compressed_parts.append(chunk) + elif ctype == b"IEND": + break + if width is None or height is None or bit_depth is None or color_type is None: + raise ValueError("PNG missing IHDR chunk") + + raw = zlib.decompress(b"".join(compressed_parts)) + bpp = 4 if color_type == 6 else 3 + stride = width * bpp + rows = [] + idx = 0 + prev = bytearray(stride) + for _ in range(height): + filter_type = raw[idx] + idx += 1 + row = bytearray(raw[idx : idx + stride]) + idx += stride + if filter_type == 1: + for i in range(stride): + left = row[i - bpp] if i >= bpp else 0 + row[i] = (row[i] + left) & 0xFF + elif filter_type == 2: + for i in range(stride): + row[i] = (row[i] + prev[i]) & 0xFF + elif filter_type == 3: + for i in range(stride): + left = row[i - bpp] if i >= bpp else 0 + up = prev[i] + row[i] = (row[i] + ((left + up) // 2)) & 0xFF + elif filter_type == 4: + for i in range(stride): + left = row[i - bpp] if i >= bpp else 0 + up = prev[i] + up_left = prev[i - bpp] if i >= bpp else 0 + p = left + up - up_left + pa = abs(p - left) + pb = abs(p - up) + pc = abs(p - up_left) + if pa <= pb and pa <= pc: + pr = left + elif pb <= pc: + pr = up + else: + pr = up_left + row[i] = (row[i] + pr) & 0xFF + elif filter_type not in (0,): + raise ValueError(f"Unsupported PNG filter type {filter_type}") + rows.append(bytes(row)) + prev[:] = row + + pixels: list[list[tuple[int, int, int, int]]] = [] + for row in rows: + if color_type == 6: + pixels.append([tuple(row[i : i + 4]) for i in range(0, len(row), 4)]) + else: + pixels.append([tuple(row[i : i + 3] + b"\xff") for i in range(0, len(row), 3)]) + return width, height, pixels + + +def write_png(path: Path, width: int, height: int, pixels: list[list[tuple[int, int, int, int]]]) -> None: + raw = bytearray() + for row in pixels: + raw.append(0) # filter type 0 + for r, g, b, a in row: + raw.extend((r & 0xFF, g & 0xFF, b & 0xFF, a & 0xFF)) + compressed = zlib.compress(raw, level=9) + + def chunk(name: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + name + + payload + + struct.pack(">I", crc32(name + payload) & 0xFFFFFFFF) + ) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + out = bytearray(b"\x89PNG\r\n\x1a\n") + out += chunk(b"IHDR", ihdr) + out += chunk(b"IDAT", compressed) + out += chunk(b"IEND", b"") + path.write_bytes(out) + + +def bilinear_sample(pixels: list[list[tuple[int, int, int, int]]], x: float, y: float) -> tuple[int, int, int, int]: + height = len(pixels) + width = len(pixels[0]) + x = min(max(x, 0.0), width - 1.0) + y = min(max(y, 0.0), height - 1.0) + x0 = int(math.floor(x)) + y0 = int(math.floor(y)) + x1 = min(x0 + 1, width - 1) + y1 = min(y0 + 1, height - 1) + dx = x - x0 + dy = y - y0 + + def lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * t + + result = [] + for channel in range(4): + c00 = pixels[y0][x0][channel] + c10 = pixels[y0][x1][channel] + c01 = pixels[y1][x0][channel] + c11 = pixels[y1][x1][channel] + top = lerp(c00, c10, dx) + bottom = lerp(c01, c11, dx) + result.append(int(round(lerp(top, bottom, dy)))) + return tuple(result) + + +def resize_image(pixels: list[list[tuple[int, int, int, int]]], target: int) -> list[list[tuple[int, int, int, int]]]: + src_height = len(pixels) + src_width = len(pixels[0]) + scale = min(target / src_width, target / src_height) + dest_width = max(1, int(round(src_width * scale))) + dest_height = max(1, int(round(src_height * scale))) + offset_x = (target - dest_width) // 2 + offset_y = (target - dest_height) // 2 + + background = (0, 0, 0, 0) + canvas = [[background for _ in range(target)] for _ in range(target)] + + for dy in range(dest_height): + src_y = (dy + 0.5) / scale - 0.5 + for dx in range(dest_width): + src_x = (dx + 0.5) / scale - 0.5 + canvas[offset_y + dy][offset_x + dx] = bilinear_sample(pixels, src_x, src_y) + return canvas + + +def build_ico(output: Path, png_paths: list[Path]) -> None: + entries = [] + offset = 6 + 16 * len(png_paths) + for path in png_paths: + data = path.read_bytes() + width, height, _ = read_png(path) + entries.append( + { + "width": width if width < 256 else 0, + "height": height if height < 256 else 0, + "size": len(data), + "offset": offset, + "payload": data, + } + ) + offset += len(data) + + header = struct.pack(" None: + width, height, pixels = read_png(BASE_IMAGE) + if width != height: + raise ValueError("Base icon must be square") + + generated: list[Path] = [] + for size in TARGET_SIZES: + resized = resize_image(pixels, size) + out_path = ICON_DIR / f"icon-{size}.png" + write_png(out_path, size, size, resized) + generated.append(out_path) + print(f"Generated {out_path} ({size}x{size})") + + largest = max(generated, key=lambda p: int(p.stem.split("-")[-1])) + (ICON_DIR / "icon.png").write_bytes(largest.read_bytes()) + + ico_sources = sorted( + [p for p in generated if int(p.stem.split("-")[-1]) <= 256], + key=lambda p: int(p.stem.split("-")[-1]), + ) + build_ico(ICON_DIR / "icon.ico", ico_sources) + print("icon.ico rebuilt.") + + +if __name__ == "__main__": + main() + diff --git a/referência/sistema-de-chamados-main/apps/desktop/scripts/png_to_bmp.py b/referência/sistema-de-chamados-main/apps/desktop/scripts/png_to_bmp.py new file mode 100644 index 0000000..cc5c11e --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/scripts/png_to_bmp.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Utility script to convert a PNG file (non-interlaced, 8-bit RGBA/RGB) +into a 24-bit BMP with optional letterboxing resize. + +The script is intentionally lightweight and relies only on Python's +standard library so it can run in constrained build environments. +""" + +from __future__ import annotations + +import argparse +import struct +import sys +import zlib +from pathlib import Path + + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def parse_png(path: Path): + data = path.read_bytes() + if not data.startswith(PNG_SIGNATURE): + raise ValueError("Input is not a PNG file") + + idx = len(PNG_SIGNATURE) + width = height = bit_depth = color_type = None + compressed = bytearray() + interlaced = False + + while idx < len(data): + if idx + 8 > len(data): + raise ValueError("Corrupted PNG (unexpected EOF)") + length = struct.unpack(">I", data[idx : idx + 4])[0] + idx += 4 + chunk_type = data[idx : idx + 4] + idx += 4 + chunk_data = data[idx : idx + length] + idx += length + crc = data[idx : idx + 4] # noqa: F841 - crc skipped (validated by reader) + idx += 4 + + if chunk_type == b"IHDR": + width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack( + ">IIBBBBB", chunk_data + ) + if compression != 0 or filter_method != 0: + raise ValueError("Unsupported PNG compression/filter method") + interlaced = interlace != 0 + elif chunk_type == b"IDAT": + compressed.extend(chunk_data) + elif chunk_type == b"IEND": + break + + if interlaced: + raise ValueError("Interlaced PNGs are not supported by this script") + if bit_depth != 8: + raise ValueError(f"Unsupported bit depth: {bit_depth}") + if color_type not in (2, 6): + raise ValueError(f"Unsupported color type: {color_type}") + + raw = zlib.decompress(bytes(compressed)) + bytes_per_pixel = 3 if color_type == 2 else 4 + stride = width * bytes_per_pixel + expected = (stride + 1) * height + if len(raw) != expected: + raise ValueError("Corrupted PNG data") + + # Apply PNG scanline filters + image = bytearray(width * height * 4) # Force RGBA output + prev_row = [0] * (stride) + + def paeth(a, b, c): + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + if pa <= pb and pa <= pc: + return a + if pb <= pc: + return b + return c + + out_idx = 0 + for y in range(height): + offset = y * (stride + 1) + filter_type = raw[offset] + row = bytearray(raw[offset + 1 : offset + 1 + stride]) + if filter_type == 1: # Sub + for i in range(stride): + left = row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0 + row[i] = (row[i] + left) & 0xFF + elif filter_type == 2: # Up + for i in range(stride): + row[i] = (row[i] + prev_row[i]) & 0xFF + elif filter_type == 3: # Average + for i in range(stride): + left = row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0 + up = prev_row[i] + row[i] = (row[i] + ((left + up) >> 1)) & 0xFF + elif filter_type == 4: # Paeth + for i in range(stride): + left = row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0 + up = prev_row[i] + up_left = prev_row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0 + row[i] = (row[i] + paeth(left, up, up_left)) & 0xFF + elif filter_type != 0: + raise ValueError(f"Unsupported PNG filter type: {filter_type}") + + # Convert to RGBA + for x in range(width): + if color_type == 2: + r, g, b = row[x * 3 : x * 3 + 3] + a = 255 + else: + r, g, b, a = row[x * 4 : x * 4 + 4] + image[out_idx : out_idx + 4] = bytes((r, g, b, a)) + out_idx += 4 + + prev_row = list(row) + + return width, height, image + + +def resize_with_letterbox(image, width, height, target_w, target_h, background, scale_factor=1.0): + if width == target_w and height == target_h and abs(scale_factor - 1.0) < 1e-6: + return image, width, height + + bg_r, bg_g, bg_b = background + base_scale = min(target_w / width, target_h / height) + base_scale *= scale_factor + base_scale = max(base_scale, 1 / max(width, height)) # avoid zero / collapse + scaled_w = max(1, int(round(width * base_scale))) + scaled_h = max(1, int(round(height * base_scale))) + + output = bytearray(target_w * target_h * 4) + # Fill background + for i in range(0, len(output), 4): + output[i : i + 4] = bytes((bg_r, bg_g, bg_b, 255)) + + offset_x = (target_w - scaled_w) // 2 + offset_y = (target_h - scaled_h) // 2 + + for y in range(scaled_h): + src_y = min(height - 1, int(round(y / base_scale))) + for x in range(scaled_w): + src_x = min(width - 1, int(round(x / base_scale))) + src_idx = (src_y * width + src_x) * 4 + dst_idx = ((y + offset_y) * target_w + (x + offset_x)) * 4 + output[dst_idx : dst_idx + 4] = image[src_idx : src_idx + 4] + + return output, target_w, target_h + + +def blend_to_rgb(image): + rgb = bytearray(len(image) // 4 * 3) + for i in range(0, len(image), 4): + r, g, b, a = image[i : i + 4] + if a == 255: + rgb[(i // 4) * 3 : (i // 4) * 3 + 3] = bytes((b, g, r)) # BMP stores BGR + else: + alpha = a / 255.0 + bg = (255, 255, 255) + rr = int(round(r * alpha + bg[0] * (1 - alpha))) + gg = int(round(g * alpha + bg[1] * (1 - alpha))) + bb = int(round(b * alpha + bg[2] * (1 - alpha))) + rgb[(i // 4) * 3 : (i // 4) * 3 + 3] = bytes((bb, gg, rr)) + return rgb + + +def write_bmp(path: Path, width: int, height: int, rgb: bytearray): + row_stride = (width * 3 + 3) & ~3 # align to 4 bytes + padding = row_stride - width * 3 + pixel_data = bytearray() + + for y in range(height - 1, -1, -1): + start = y * width * 3 + end = start + width * 3 + pixel_data.extend(rgb[start:end]) + if padding: + pixel_data.extend(b"\0" * padding) + + file_size = 14 + 40 + len(pixel_data) + header = struct.pack("<2sIHHI", b"BM", file_size, 0, 0, 14 + 40) + dib_header = struct.pack( + " tuple[int, int]: + if not data.startswith(PNG_SIGNATURE): + raise ValueError("All inputs must be PNG files.") + width, height = struct.unpack(">II", data[16:24]) + return width, height + + +def build_icon(png_paths: list[Path], output: Path) -> None: + png_data = [p.read_bytes() for p in png_paths] + entries = [] + offset = 6 + 16 * len(png_data) # icon header + entries + + for data in png_data: + width, height = read_png_dimensions(data) + entry = { + "width": width if width < 256 else 0, + "height": height if height < 256 else 0, + "colors": 0, + "reserved": 0, + "planes": 1, + "bit_count": 32, + "size": len(data), + "offset": offset, + "data": data, + } + entries.append(entry) + offset += entry["size"] + + header = struct.pack(" None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("inputs", nargs="+", type=Path) + args = parser.parse_args() + + if not args.inputs: + raise SystemExit("Provide at least one PNG input.") + + build_icon(args.inputs, args.output) + + +if __name__ == "__main__": + main() diff --git a/referência/sistema-de-chamados-main/apps/desktop/scripts/tauri-with-stub.mjs b/referência/sistema-de-chamados-main/apps/desktop/scripts/tauri-with-stub.mjs new file mode 100644 index 0000000..5999888 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/scripts/tauri-with-stub.mjs @@ -0,0 +1,38 @@ +import { spawn } from "node:child_process" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const pathKey = process.platform === "win32" ? "Path" : "PATH" +const currentPath = process.env[pathKey] ?? process.env[pathKey.toUpperCase()] ?? "" +const separator = process.platform === "win32" ? ";" : ":" +const stubDir = resolve(__dirname) + +process.env[pathKey] = [stubDir, currentPath].filter(Boolean).join(separator) +if (pathKey !== "PATH") { + process.env.PATH = process.env[pathKey] +} + +if (!process.env.TAURI_BUNDLE_TARGETS) { + if (process.platform === "linux") { + process.env.TAURI_BUNDLE_TARGETS = "deb rpm" + } else if (process.platform === "win32") { + process.env.TAURI_BUNDLE_TARGETS = "nsis" + } +} + +const executable = process.platform === "win32" ? "tauri.cmd" : "tauri" +const child = spawn(executable, process.argv.slice(2), { + stdio: "inherit", + shell: process.platform === "win32", +}) + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + } else { + process.exit(code ?? 0) + } +}) diff --git a/referência/sistema-de-chamados-main/apps/desktop/scripts/xdg-open b/referência/sistema-de-chamados-main/apps/desktop/scripts/xdg-open new file mode 100644 index 0000000..b081d99 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/scripts/xdg-open @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# Minimal stub to satisfy tools that expect xdg-open during bundling. +# Fails silently when the real binary is unavailable. +if command -v xdg-open >/dev/null 2>&1; then + exec xdg-open "$@" +else + exit 0 +fi diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/.gitignore b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/Cargo.lock b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..20b4252 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5909 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "appsdesktop" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "get_if_addrs", + "hostname", + "once_cell", + "parking_lot", + "reqwest", + "serde", + "serde_json", + "sysinfo", + "tauri", + "tauri-build", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-store", + "tauri-plugin-updater", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.3", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] + +[[package]] +name = "c_linked_list" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4964518bd3b4a8190e832886cdc0da9794f12e8e6c1613a9e90ff331c4c8724b" + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.4", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.8", +] + +[[package]] +name = "cc" +version = "1.2.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dlopen2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi 0.3.9", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.8", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "flate2" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gcc" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f5f3913fa0bfe7ee1fd8248b6b9f42a5af4b9d65ec2dd2c3c26132b950ecfc2" + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "get_if_addrs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abddb55a898d32925f3148bd281174a68eeb68bbfd9a5938a57b18f506ee4ef7" +dependencies = [ + "c_linked_list", + "get_if_addrs-sys", + "libc", + "winapi 0.2.8", +] + +[[package]] +name = "get_if_addrs-sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d04f9fb746cf36b191c00f3ede8bde9c8e64f9f4b05ae2694a9ccf5e3f5ab48" +dependencies = [ + "gcc", + "libc", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi 0.3.9", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.4", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.1.3", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.4", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.11.4", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi 0.3.9", +] + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.9.4", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "serde", + "thiserror 2.0.17", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.4", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.2", + "libc", + "objc2 0.6.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.2", + "libc", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "open" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.11.4", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.17", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "regex" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d8d02cacdb176ef4678de6c052efb4b3da14b78e4db683a4252762be5433" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3160422bbd54dd5ecfdca71e5fd59b7b8fe2b1697ab2baf64f6d05dcc66d298" + +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6093cd8c01b25262b84927e0f7151692158fab02d961e04c979d3903eba7ecc5" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.0.4", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics", + "foreign-types", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle", + "redox_syscall", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.3", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.17", + "tokio", + "tray-icon", + "url", + "urlpattern", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.8", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.106", + "tauri-utils", + "thiserror 2.0.17", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9946a3cede302eac0c6eb6c6070ac47b1768e326092d32efbb91f21ed58d978f" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.8", + "walkdir", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786156aa8e89e03d271fbd3fe642207da8e65f3c961baa9e2930f332bf80a1f5" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "url", + "windows 0.61.3", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-store" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85dd80d60a76ee2c2fdce09e9ef30877b239c2a6bb76e6d7d03708aa5f13a19" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.17", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.3", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.17", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.17", + "toml 0.9.8", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" +dependencies = [ + "embed-resource", + "toml 0.9.8", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap 2.11.4", + "serde_core", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.11.4", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime 0.7.3", + "toml_parser", + "winnow 0.7.13", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "serde", + "thiserror 2.0.17", + "windows-sys 0.59.0", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi 0.3.9", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement 0.60.2", + "windows-interface 0.59.3", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +dependencies = [ + "thiserror 2.0.17", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wry" +version = "0.53.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d78ec082b80fa088569a970d043bb3050abaabf4454101d44514ee8d9a8c9f6" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.17", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "windows-sys 0.60.2", + "winnow 0.7.13", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.106", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.13", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.11.4", + "memchr", +] + +[[package]] +name = "zvariant" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.13", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.106", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.106", + "winnow 0.7.13", +] diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/Cargo.toml b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..8d5dee5 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "appsdesktop" +version = "0.1.0" +description = "A Tauri App" +authors = ["you"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "appsdesktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.4.1", features = [] } + +[dependencies] +tauri = { version = "2.8.5", features = ["wry", "devtools"] } +tauri-plugin-opener = "2.5.0" +tauri-plugin-store = "2.4.0" +tauri-plugin-updater = "2.9.0" +tauri-plugin-process = "2.3.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } +get_if_addrs = "0.5" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +once_cell = "1.19" +thiserror = "1.0" +chrono = { version = "0.4", features = ["serde"] } +parking_lot = "0.12" +hostname = "0.4" +base64 = "0.22" diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/build.rs b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/capabilities/default.json b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..9fe95d8 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,18 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + "store:default", + "store:allow-load", + "store:allow-set", + "store:allow-get", + "store:allow-save", + "store:allow-delete", + "updater:default", + "process:default" + ] +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/128x128.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..ea1b4de Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/128x128@2x.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..89582e7 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/32x32.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..96fd42a Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/64x64.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000..bcd4b9c Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Raven.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Raven.png new file mode 100644 index 0000000..ab005ba Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Raven.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square107x107Logo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..0a503d5 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square142x142Logo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..31765ac Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square150x150Logo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..8a666ce Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square284x284Logo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..9bbef05 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square30x30Logo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..df1c649 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square310x310Logo.bmp b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square310x310Logo.bmp new file mode 100644 index 0000000..2aba9f9 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square310x310Logo.bmp differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square310x310Logo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..c277c69 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square44x44Logo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..4608b37 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square71x71Logo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..e98ccdc Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square89x89Logo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..c0b6c70 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/StoreLogo.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..6b3ea98 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-128.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-128.png new file mode 100644 index 0000000..73fae2a Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-128.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-256.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-256.png new file mode 100644 index 0000000..598c10f Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-256.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-32.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-32.png new file mode 100644 index 0000000..cfd6dd7 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-32.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-512.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-512.png new file mode 100644 index 0000000..bb67dd8 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-512.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-64.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-64.png new file mode 100644 index 0000000..5c551e4 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon-64.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon.icns b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..9c729c7 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon.ico b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..4e2d151 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..bb67dd8 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/icon.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/logo-raven-fund-azul.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/logo-raven-fund-azul.png new file mode 100644 index 0000000..9b95662 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/logo-raven-fund-azul.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/logo-raven.png b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/logo-raven.png new file mode 100644 index 0000000..62b264e Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/logo-raven.png differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/nsis-header.bmp b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/nsis-header.bmp new file mode 100644 index 0000000..1313bac Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/nsis-header.bmp differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/nsis-sidebar.bmp b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/nsis-sidebar.bmp new file mode 100644 index 0000000..061adc8 Binary files /dev/null and b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/icons/nsis-sidebar.bmp differ diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/src/agent.rs b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/src/agent.rs new file mode 100644 index 0000000..67ddf75 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/src/agent.rs @@ -0,0 +1,1299 @@ +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use serde::Serialize; +use serde_json::json; +use std::collections::HashMap; +use sysinfo::{Networks, System}; +use tauri::async_runtime::{self, JoinHandle}; +use tokio::sync::Notify; + +#[derive(thiserror::Error, Debug)] +pub enum AgentError { + #[error("Falha ao obter hostname da dispositivo")] + Hostname, + #[error("Nenhum identificador de hardware disponível (MAC/serial)")] + MissingIdentifiers, + #[error("URL de API inválida")] + InvalidApiUrl, + #[error("Falha HTTP: {0}")] + Http(#[from] reqwest::Error), +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineOs { + pub name: String, + pub version: Option, + pub architecture: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineMetrics { + pub collected_at: DateTime, + pub cpu_logical_cores: usize, + pub cpu_physical_cores: Option, + pub cpu_usage_percent: f32, + pub load_average_one: Option, + pub load_average_five: Option, + pub load_average_fifteen: Option, + pub memory_total_bytes: u64, + pub memory_used_bytes: u64, + pub memory_used_percent: f32, + pub uptime_seconds: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineInventory { + pub cpu_brand: Option, + pub host_identifier: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineProfile { + pub hostname: String, + pub os: MachineOs, + pub mac_addresses: Vec, + pub serial_numbers: Vec, + pub inventory: MachineInventory, + pub metrics: MachineMetrics, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct HeartbeatPayload { + machine_token: String, + status: Option, + hostname: Option, + os: Option, + metrics: Option, + metadata: Option, +} + +fn collect_mac_addresses() -> Vec { + let mut macs = Vec::new(); + let mut networks = Networks::new(); + networks.refresh_list(); + networks.refresh(); + + for (_, data) in networks.iter() { + let bytes = data.mac_address().0; + if bytes.iter().all(|byte| *byte == 0) { + continue; + } + + let formatted = bytes + .iter() + .map(|byte| format!("{:02x}", byte)) + .collect::>() + .join(":"); + + if !macs.contains(&formatted) { + macs.push(formatted); + } + } + + macs +} + +#[cfg(target_os = "linux")] +fn collect_serials_platform() -> Vec { + let mut out = Vec::new(); + for path in [ + "/sys/class/dmi/id/product_uuid", + "/sys/class/dmi/id/product_serial", + "/sys/class/dmi/id/board_serial", + "/etc/machine-id", + ] { + if let Ok(raw) = std::fs::read_to_string(path) { + let s = raw.trim().to_string(); + if !s.is_empty() && !out.contains(&s) { + out.push(s); + } + } + } + out +} + +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn collect_serials_platform() -> Vec { + // Fase 1: sem coleta nativa; será implementada via WMI/ioreg na fase 2. + Vec::new() +} + +fn collect_serials() -> Vec { + collect_serials_platform() +} + +fn collect_network_addrs() -> Vec { + // Mapa name -> mac via sysinfo (mais estável que get_if_addrs para MAC) + let mut mac_by_name: HashMap = HashMap::new(); + let mut networks = Networks::new(); + networks.refresh_list(); + networks.refresh(); + for (name, data) in networks.iter() { + let bytes = data.mac_address().0; + if bytes.iter().any(|b| *b != 0) { + let mac = bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(":"); + mac_by_name.insert(name.to_string(), mac); + } + } + + let mut entries = Vec::new(); + if let Ok(ifaces) = get_if_addrs::get_if_addrs() { + for iface in ifaces { + let name = iface.name.clone(); + let addr = iface.ip(); + let ip = addr.to_string(); + let mac = mac_by_name.get(&name).cloned(); + entries.push(json!({ + "name": name, + "mac": mac, + "ip": ip, + })); + } + } + entries +} + +fn collect_disks(_system: &System) -> Vec { + let mut out = Vec::new(); + let disks = sysinfo::Disks::new_with_refreshed_list(); + for disk in disks.list() { + let name = disk.name().to_string_lossy().to_string(); + let mount = disk.mount_point().to_string_lossy().to_string(); + let fs = disk.file_system().to_string_lossy().to_string(); + let total = disk.total_space(); + let avail = disk.available_space(); + out.push(json!({ + "name": if name.is_empty() { mount.clone() } else { name }, + "mountPoint": mount, + "fs": fs, + "totalBytes": total, + "availableBytes": avail, + })); + } + + out +} + +fn parse_u64(value: &serde_json::Value) -> Option { + if let Some(num) = value.as_u64() { + return Some(num); + } + if let Some(num) = value.as_f64() { + if num.is_finite() && num >= 0.0 { + return Some(num as u64); + } + } + if let Some(text) = value.as_str() { + if let Ok(parsed) = text.trim().parse::() { + return Some(parsed); + } + } + None +} + +fn push_gpu( + list: &mut Vec, + name: Option<&str>, + memory: Option, + vendor: Option<&str>, + driver: Option<&str>, +) { + if let Some(name) = name { + if name.trim().is_empty() { + return; + } + let mut obj = serde_json::Map::new(); + obj.insert("name".into(), json!(name.trim())); + if let Some(memory) = memory { + obj.insert("memoryBytes".into(), json!(memory)); + } + if let Some(vendor) = vendor { + if !vendor.trim().is_empty() { + obj.insert("vendor".into(), json!(vendor.trim())); + } + } + if let Some(driver) = driver { + if !driver.trim().is_empty() { + obj.insert("driver".into(), json!(driver.trim())); + } + } + list.push(serde_json::Value::Object(obj)); + } +} + +fn build_inventory_metadata(system: &System) -> serde_json::Value { + let cpu_brand = system + .cpus() + .first() + .map(|cpu| cpu.brand().to_string()) + .filter(|brand| !brand.trim().is_empty()); + // sysinfo 0.31 já retorna bytes em total_memory/used_memory + let mem_total_bytes = system.total_memory(); + let network = collect_network_addrs(); + let disks = collect_disks(system); + let mut inventory = json!({ + "cpu": { "brand": cpu_brand.clone() }, + "memory": { "totalBytes": mem_total_bytes }, + "network": network, + "disks": disks, + }); + + if let Some(obj) = inventory.as_object_mut() { + let mut hardware = serde_json::Map::new(); + if let Some(brand) = cpu_brand.clone() { + if !brand.trim().is_empty() { + hardware.insert("cpuType".into(), json!(brand.trim())); + } + } + if let Some(physical) = system.physical_core_count() { + hardware.insert("physicalCores".into(), json!(physical)); + } + hardware.insert("logicalCores".into(), json!(system.cpus().len())); + if mem_total_bytes > 0 { + hardware.insert("memoryBytes".into(), json!(mem_total_bytes)); + hardware.insert("memory".into(), json!(mem_total_bytes)); + } + if !hardware.is_empty() { + obj.insert("hardware".into(), serde_json::Value::Object(hardware)); + } + } + + #[cfg(target_os = "linux")] + { + // Softwares instalados (dpkg ou rpm) + let software = collect_software_linux(); + if let Some(obj) = inventory.as_object_mut() { + obj.insert("software".into(), software); + } + + // Serviços ativos (systemd) + let services = collect_services_linux(); + if let Some(obj) = inventory.as_object_mut() { + obj.insert("services".into(), services); + } + + // Informações estendidas (lsblk/lspci/lsusb/smartctl) + let extended = collect_linux_extended(); + if let Some(obj) = inventory.as_object_mut() { + obj.insert("extended".into(), extended); + } + } + + #[cfg(target_os = "windows")] + { + let mut extended = collect_windows_extended(); + // Fallback: se osInfo vier vazio, preenche com dados do sysinfo + if let Some(win) = extended.get_mut("windows").and_then(|v| v.as_object_mut()) { + let needs_os_info = match win.get("osInfo") { + Some(v) => v.as_object().map(|m| m.is_empty()).unwrap_or(true), + None => true, + }; + if needs_os_info { + let mut osmap = serde_json::Map::new(); + if let Some(name) = System::name() { + osmap.insert("ProductName".into(), json!(name)); + } + if let Some(ver) = System::os_version() { + osmap.insert("Version".into(), json!(ver)); + } + if let Some(build) = System::kernel_version() { + osmap.insert("BuildNumber".into(), json!(build)); + } + win.insert("osInfo".into(), serde_json::Value::Object(osmap)); + } + } + if let Some(obj) = inventory.as_object_mut() { + obj.insert("extended".into(), extended); + } + } + + #[cfg(target_os = "macos")] + { + let extended = collect_macos_extended(); + if let Some(obj) = inventory.as_object_mut() { + obj.insert("extended".into(), extended); + } + } + + // Normalização de software/serviços no topo do inventário + if let Some(obj) = inventory.as_object_mut() { + let extended_snapshot = obj.get("extended").and_then(|v| v.as_object()).cloned(); + // Merge software + let mut software: Vec = Vec::new(); + if let Some(existing) = obj.get("software").and_then(|v| v.as_array()) { + software.extend(existing.iter().cloned()); + } + if let Some(ext) = extended_snapshot.as_ref() { + // Windows normalize + if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) { + if let Some(ws) = win.get("software").and_then(|v| v.as_array()) { + for item in ws { + let name = item + .get("DisplayName") + .or_else(|| item.get("name")) + .cloned() + .unwrap_or(json!(null)); + let version = item + .get("DisplayVersion") + .or_else(|| item.get("version")) + .cloned() + .unwrap_or(json!(null)); + let publisher = item.get("Publisher").cloned().unwrap_or(json!(null)); + software + .push(json!({ "name": name, "version": version, "source": publisher })); + } + } + } + // macOS normalize + if let Some(macos) = ext.get("macos").and_then(|v| v.as_object()) { + if let Some(pkgs) = macos.get("packages").and_then(|v| v.as_array()) { + for p in pkgs { + software.push(json!({ "name": p, "version": null, "source": "pkgutil" })); + } + } + } + } + if !software.is_empty() { + obj.insert("software".into(), json!(software)); + } + + // Merge services + let mut services: Vec = Vec::new(); + if let Some(existing) = obj.get("services").and_then(|v| v.as_array()) { + services.extend(existing.iter().cloned()); + } + if let Some(ext) = extended_snapshot.as_ref() { + if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) { + if let Some(wsvc) = win.get("services").and_then(|v| v.as_array()) { + for s in wsvc { + let name = s.get("Name").cloned().unwrap_or(json!(null)); + let status = s.get("Status").cloned().unwrap_or(json!(null)); + let display = s.get("DisplayName").cloned().unwrap_or(json!(null)); + services.push( + json!({ "name": name, "status": status, "displayName": display }), + ); + } + } + } + } + if !services.is_empty() { + obj.insert("services".into(), json!(services)); + } + + let mut gpus: Vec = Vec::new(); + if let Some(ext) = extended_snapshot.as_ref() { + if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) { + if let Some(video) = win.get("videoControllers").and_then(|v| v.as_array()) { + for controller in video { + let name = controller.get("Name").and_then(|v| v.as_str()); + let memory = controller.get("AdapterRAM").and_then(parse_u64); + let driver = controller.get("DriverVersion").and_then(|v| v.as_str()); + push_gpu(&mut gpus, name, memory, None, driver); + } + } + if obj + .get("disks") + .and_then(|v| v.as_array()) + .map(|arr| arr.is_empty()) + .unwrap_or(true) + { + if let Some(raw) = win.get("disks").and_then(|v| v.as_array()) { + let mapped = raw + .iter() + .map(|disk| { + let model = disk.get("Model").and_then(|v| v.as_str()).unwrap_or_default(); + let serial = disk.get("SerialNumber").and_then(|v| v.as_str()).unwrap_or_default(); + let size = parse_u64(disk.get("Size").unwrap_or(&serde_json::Value::Null)).unwrap_or(0); + let interface = disk.get("InterfaceType").and_then(|v| v.as_str()).unwrap_or(""); + let media = disk.get("MediaType").and_then(|v| v.as_str()).unwrap_or(""); + json!({ + "name": if !model.is_empty() { model } else { serial }, + "mountPoint": "", + "fs": if !media.is_empty() { media } else { "—" }, + "interface": if !interface.is_empty() { serde_json::Value::String(interface.to_string()) } else { serde_json::Value::Null }, + "serial": if !serial.is_empty() { serde_json::Value::String(serial.to_string()) } else { serde_json::Value::Null }, + "totalBytes": size, + "availableBytes": serde_json::Value::Null, + }) + }) + .collect::>(); + if !mapped.is_empty() { + obj.insert("disks".into(), json!(mapped)); + } + } + } + } + if let Some(linux) = ext.get("linux").and_then(|v| v.as_object()) { + if let Some(pci) = linux.get("pciList").and_then(|v| v.as_array()) { + for entry in pci { + if let Some(text) = entry.get("text").and_then(|v| v.as_str()) { + let lower = text.to_lowercase(); + if lower.contains(" vga ") + || lower.contains(" 3d controller") + || lower.contains("display controller") + { + push_gpu(&mut gpus, Some(text), None, None, None); + } + } + } + } + } + if let Some(macos) = ext.get("macos").and_then(|v| v.as_object()) { + if let Some(profiler) = macos.get("systemProfiler").and_then(|v| v.as_object()) { + if let Some(displays) = profiler + .get("SPDisplaysDataType") + .and_then(|v| v.as_array()) + { + for display in displays { + if let Some(d) = display.as_object() { + let name = d.get("_name").and_then(|v| v.as_str()); + let vram = d + .get("spdisplays_vram") + .and_then(|v| v.as_str()) + .and_then(|s| { + let digits = s.split_whitespace().next().unwrap_or(""); + digits.parse::().ok().map(|n| { + if s.to_lowercase().contains("gb") { + n * 1024 * 1024 * 1024 + } else if s.to_lowercase().contains("mb") { + n * 1024 * 1024 + } else { + n + } + }) + }); + push_gpu(&mut gpus, name, vram, None, None); + } + } + } + } + } + } + + if !gpus.is_empty() { + let entry = obj.entry("hardware").or_insert_with(|| json!({})); + if let Some(hardware) = entry.as_object_mut() { + hardware.insert("gpus".into(), json!(gpus.clone())); + if let Some(primary) = gpus.first() { + hardware.insert("primaryGpu".into(), primary.clone()); + } + } + } + } + + json!({ "inventory": inventory }) +} + +pub fn collect_inventory_plain() -> serde_json::Value { + let system = collect_system(); + let meta = build_inventory_metadata(&system); + match meta.get("inventory") { + Some(value) => value.clone(), + None => json!({}), + } +} + +#[cfg(target_os = "linux")] +fn collect_software_linux() -> serde_json::Value { + use std::process::Command; + // Tenta dpkg-query primeiro + let dpkg = Command::new("sh") + .arg("-lc") + .arg("dpkg-query -W -f='${binary:Package}\t${Version}\n' 2>/dev/null || true") + .output(); + if let Ok(out) = dpkg { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + let mut items = Vec::new(); + for line in s.lines() { + let mut parts = line.split('\t'); + let name = parts.next().unwrap_or("").trim(); + let version = parts.next().unwrap_or("").trim(); + if !name.is_empty() { + items.push(json!({"name": name, "version": version, "source": "dpkg"})); + } + } + return json!(items); + } + } + + // Fallback rpm + let rpm = std::process::Command::new("sh") + .arg("-lc") + .arg("rpm -qa --qf '%{NAME}\t%{VERSION}-%{RELEASE}\n' 2>/dev/null || true") + .output(); + if let Ok(out) = rpm { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + let mut items = Vec::new(); + for line in s.lines() { + let mut parts = line.split('\t'); + let name = parts.next().unwrap_or("").trim(); + let version = parts.next().unwrap_or("").trim(); + if !name.is_empty() { + items.push(json!({"name": name, "version": version, "source": "rpm"})); + } + } + return json!(items); + } + } + json!([]) +} + +#[cfg(target_os = "linux")] +fn collect_services_linux() -> serde_json::Value { + use std::process::Command; + let out = Command::new("sh") + .arg("-lc") + .arg("systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null || true") + .output(); + if let Ok(out) = out { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + let mut items = Vec::new(); + for line in s.lines() { + // Typical format: UNIT LOAD ACTIVE SUB DESCRIPTION + // We take UNIT and ACTIVE + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.is_empty() { + continue; + } + let unit = cols.get(0).unwrap_or(&""); + let active = cols.get(2).copied().unwrap_or(""); + if !unit.is_empty() { + items.push(json!({"name": unit, "status": active})); + } + } + return json!(items); + } + } + json!([]) +} + +#[cfg(target_os = "linux")] +fn collect_linux_extended() -> serde_json::Value { + use std::process::Command; + // lsblk em JSON (block devices) + let block_json = Command::new("sh") + .arg("-lc") + .arg("lsblk -J -b 2>/dev/null || true") + .output() + .ok() + .and_then(|out| { + if out.status.success() { + Some(out.stdout) + } else { + Some(out.stdout) + } + }) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_else(|| json!({})); + + // lspci e lsusb — texto livre (depende de pacotes pciutils/usbutils) + let lspci = Command::new("sh") + .arg("-lc") + .arg("lspci 2>/dev/null || true") + .output() + .ok() + .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) + .unwrap_or_default(); + let lsusb = Command::new("sh") + .arg("-lc") + .arg("lsusb 2>/dev/null || true") + .output() + .ok() + .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) + .unwrap_or_default(); + // Parse básico de lspci/lsusb em listas + fn parse_lines_to_list(input: &str) -> Vec { + input + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .map(|l| json!({ "text": l })) + .collect::>() + } + let pci_list = parse_lines_to_list(&lspci); + let usb_list = parse_lines_to_list(&lsusb); + + // smartctl (se disponível) por disco + let mut smart: Vec = Vec::new(); + if let Ok(out) = std::process::Command::new("sh") + .arg("-lc") + .arg("command -v smartctl >/dev/null 2>&1 && echo yes || echo no") + .output() + { + if out.status.success() && String::from_utf8_lossy(&out.stdout).contains("yes") { + if let Some(devices) = block_json.get("blockdevices").and_then(|v| v.as_array()) { + for dev in devices { + let t = dev.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let name = dev.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if t == "disk" && !name.is_empty() { + let path = format!("/dev/{}", name); + if let Ok(out) = Command::new("sh") + .arg("-lc") + .arg(format!("smartctl -H -j {} 2>/dev/null || true", path)) + .output() + { + if out.status.success() || !out.stdout.is_empty() { + if let Ok(val) = + serde_json::from_slice::(&out.stdout) + { + smart.push(val); + } + } + } + } + } + } + } + } + + json!({ + "linux": { + "lsblk": block_json, + "lspci": lspci, + "lsusb": lsusb, + "pciList": pci_list, + "usbList": usb_list, + "smart": smart, + } + }) +} + +#[cfg(target_os = "windows")] +fn collect_windows_extended() -> serde_json::Value { + use base64::engine::general_purpose::STANDARD; + use base64::Engine as _; + use std::os::windows::process::CommandExt; + use std::process::Command; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + fn decode_powershell_text(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + if bytes.starts_with(&[0xFF, 0xFE]) { + return decode_utf16_le_to_string(&bytes[2..]); + } + if bytes.len() >= 2 && bytes[1] == 0 { + if let Some(s) = decode_utf16_le_to_string(bytes) { + return Some(s); + } + } + if bytes.contains(&0) { + if let Some(s) = decode_utf16_le_to_string(bytes) { + return Some(s); + } + } + let text = std::str::from_utf8(bytes).ok()?.trim().to_string(); + if text.is_empty() { + None + } else { + Some(text) + } + } + + fn decode_utf16_le_to_string(bytes: &[u8]) -> Option { + if bytes.len() % 2 != 0 { + return None; + } + let utf16: Vec = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + let text = String::from_utf16(&utf16).ok()?; + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + + fn preview_base64(bytes: &[u8], max_len: usize) -> String { + if bytes.is_empty() { + return "".to_string(); + } + let prefix = if bytes.len() > max_len { + &bytes[..max_len] + } else { + bytes + }; + format!("base64:{}...", STANDARD.encode(prefix)) + } + + fn encode_ps_script(script: &str) -> String { + let mut bytes = Vec::with_capacity(script.len() * 2); + for unit in script.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + STANDARD.encode(bytes) + } + + fn ps(cmd: &str) -> Option { + let script = format!( + "$ErrorActionPreference='SilentlyContinue';$ProgressPreference='SilentlyContinue';$result = & {{\n{}\n}};if ($null -eq $result) {{ return }};$json = $result | ConvertTo-Json -Depth 4 -Compress;if ([string]::IsNullOrWhiteSpace($json)) {{ return }};[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;$json;", + cmd + ); + let encoded = encode_ps_script(&script); + let out = Command::new("powershell") + .creation_flags(CREATE_NO_WINDOW) + .arg("-NoProfile") + .arg("-NoLogo") + .arg("-NonInteractive") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-EncodedCommand") + .arg(encoded) + .output() + .ok()?; + let stdout_text = decode_powershell_text(&out.stdout); + if cfg!(test) { + if let Some(ref txt) = stdout_text { + let preview = txt.chars().take(512).collect::(); + eprintln!("[collect_windows_extended] stdout `{cmd}` => {preview}"); + } else { + let preview = preview_base64(&out.stdout, 512); + eprintln!( + "[collect_windows_extended] stdout `{cmd}` => " + ); + } + if !out.stderr.is_empty() { + if let Some(err) = decode_powershell_text(&out.stderr) { + eprintln!("[collect_windows_extended] stderr `{cmd}` => {err}"); + } else { + let preview = preview_base64(&out.stderr, 512); + eprintln!( + "[collect_windows_extended] stderr `{cmd}` => " + ); + } + } + } + stdout_text.and_then(|text| serde_json::from_str::(&text).ok()) + } + + let software = ps(r#"@(Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'; Get-ItemProperty 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*') | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion, Publisher"#) + .unwrap_or_else(|| json!([])); + let services = + ps("@(Get-Service | Select-Object Name,Status,DisplayName)").unwrap_or_else(|| json!([])); + let defender = ps("Get-MpComputerStatus | Select-Object AMRunningMode,AntivirusEnabled,RealTimeProtectionEnabled,AntispywareEnabled").unwrap_or_else(|| json!({})); + let hotfix = ps("Get-HotFix | Select-Object HotFixID,InstalledOn").unwrap_or_else(|| json!([])); + let bitlocker = ps( + "@(if (Get-Command -Name Get-BitLockerVolume -ErrorAction SilentlyContinue) { Get-BitLockerVolume | Select-Object MountPoint,VolumeStatus,ProtectionStatus,LockStatus,EncryptionMethod,EncryptionPercentage,CapacityGB,KeyProtector } else { @() })", + ) + .unwrap_or_else(|| json!([])); + let tpm = ps( + "if (Get-Command -Name Get-Tpm -ErrorAction SilentlyContinue) { Get-Tpm | Select-Object TpmPresent,TpmReady,TpmEnabled,TpmActivated,ManagedAuthLevel,OwnerAuth,ManufacturerId,ManufacturerIdTxt,ManufacturerVersion,ManufacturerVersionFull20,SpecVersion } else { $null }", + ) + .unwrap_or_else(|| json!({})); + let secure_boot = ps( + r#" + if (-not (Get-Command -Name Confirm-SecureBootUEFI -ErrorAction SilentlyContinue)) { + [PSCustomObject]@{ Supported = $false; Enabled = $null; Error = 'Cmdlet Confirm-SecureBootUEFI indisponível' } + } else { + try { + $enabled = Confirm-SecureBootUEFI + [PSCustomObject]@{ Supported = $true; Enabled = [bool]$enabled; Error = $null } + } catch { + [PSCustomObject]@{ Supported = $true; Enabled = $null; Error = $_.Exception.Message } + } + } + "#, + ) + .unwrap_or_else(|| json!({})); + let device_guard = ps( + "@(Get-CimInstance -ClassName Win32_DeviceGuard | Select-Object SecurityServicesConfigured,SecurityServicesRunning,RequiredSecurityProperties,AvailableSecurityProperties,VirtualizationBasedSecurityStatus)", + ) + .unwrap_or_else(|| json!([])); + let firewall_profiles = ps( + "@(Get-NetFirewallProfile | Select-Object Name,Enabled,DefaultInboundAction,DefaultOutboundAction,NotifyOnListen)", + ) + .unwrap_or_else(|| json!([])); + let windows_update = ps( + r#" + $reg = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update' -ErrorAction SilentlyContinue + if ($null -eq $reg) { return $null } + $last = $null + if ($reg.PSObject.Properties.Name -contains 'LastSuccessTime') { + $raw = $reg.LastSuccessTime + if ($raw) { + try { + if ($raw -is [DateTime]) { + $last = ($raw.ToUniversalTime()).ToString('o') + } elseif ($raw -is [string]) { + $last = $raw + } else { + $last = [DateTime]::FromFileTimeUtc([long]$raw).ToString('o') + } + } catch { + $last = $raw + } + } + } + [PSCustomObject]@{ + AUOptions = $reg.AUOptions + NoAutoUpdate = $reg.NoAutoUpdate + ScheduledInstallDay = $reg.ScheduledInstallDay + ScheduledInstallTime = $reg.ScheduledInstallTime + DetectionFrequency = $reg.DetectionFrequencyEnabled + LastSuccessTime = $last + } + "#, + ) + .unwrap_or_else(|| json!({})); + let computer_system = ps( + "Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer,Model,Domain,DomainRole,PartOfDomain,Workgroup,TotalPhysicalMemory,HypervisorPresent,PCSystemType,PCSystemTypeEx", + ) + .unwrap_or_else(|| json!({})); + let device_join = ps( + r#" + $output = & dsregcmd.exe /status 2>$null + if (-not $output) { return $null } + $map = [ordered]@{} + $current = $null + foreach ($line in $output) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + if ($line -match '^\[(.+)\]$') { + $current = $matches[1].Trim() + if (-not $map.Contains($current)) { + $map[$current] = [ordered]@{} + } + continue + } + if (-not $current) { continue } + $parts = $line.Split(':', 2) + if ($parts.Length -ne 2) { continue } + $key = $parts[0].Trim() + $value = $parts[1].Trim() + if ($key) { + ($map[$current])[$key] = $value + } + } + if ($map.Count -eq 0) { return $null } + $obj = [ordered]@{} + foreach ($entry in $map.GetEnumerator()) { + $obj[$entry.Key] = [PSCustomObject]$entry.Value + } + [PSCustomObject]$obj + "#, + ) + .unwrap_or_else(|| json!({})); + + // Informações de build/edição e ativação + let os_info = ps(r#" + $cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'; + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue; + $lsItems = Get-CimInstance -Query "SELECT Name, LicenseStatus, PartialProductKey FROM SoftwareLicensingProduct WHERE PartialProductKey IS NOT NULL" | Where-Object { $_.Name -like 'Windows*' }; + $activatedItem = $lsItems | Where-Object { $_.LicenseStatus -eq 1 } | Select-Object -First 1; + $primaryItem = if ($activatedItem) { $activatedItem } else { $lsItems | Select-Object -First 1 }; + $lsCode = if ($primaryItem -and $primaryItem.LicenseStatus -ne $null) { [int]$primaryItem.LicenseStatus } else { 0 }; + [PSCustomObject]@{ + ProductName = $cv.ProductName + CurrentBuild = $cv.CurrentBuild + CurrentBuildNumber = $cv.CurrentBuildNumber + DisplayVersion = $cv.DisplayVersion + ReleaseId = $cv.ReleaseId + EditionID = $cv.EditionID + UBR = $cv.UBR + CompositionEditionID = $cv.CompositionEditionID + InstallationType = $cv.InstallationType + InstallDate = $cv.InstallDate + InstallationDate = $os.InstallDate + InstalledOn = $os.InstallDate + Version = $os.Version + BuildNumber = $os.BuildNumber + Caption = $os.Caption + FeatureExperiencePack = $cv.FeatureExperiencePack + LicenseStatus = $lsCode + IsActivated = ($activatedItem -ne $null) + } + "#).unwrap_or_else(|| json!({})); + + // Hardware detalhado (CPU/Board/BIOS/Memória/Vídeo/Discos) + let cpu = ps("Get-CimInstance Win32_Processor | Select-Object Name,Manufacturer,SocketDesignation,NumberOfCores,NumberOfLogicalProcessors,L2CacheSize,L3CacheSize,MaxClockSpeed").unwrap_or_else(|| json!({})); + let baseboard = ps( + "Get-CimInstance Win32_BaseBoard | Select-Object Product,Manufacturer,SerialNumber,Version", + ) + .unwrap_or_else(|| json!({})); + let bios = ps("Get-CimInstance Win32_BIOS | Select-Object Manufacturer,SMBIOSBIOSVersion,ReleaseDate,Version").unwrap_or_else(|| json!({})); + let memory = ps("@(Get-CimInstance Win32_PhysicalMemory | Select-Object BankLabel,Capacity,Manufacturer,PartNumber,SerialNumber,ConfiguredClockSpeed,Speed,ConfiguredVoltage)").unwrap_or_else(|| json!([])); + let video = ps("@(Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion,PNPDeviceID)").unwrap_or_else(|| json!([])); + let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([])); + + json!({ + "windows": { + "software": software, + "services": services, + "defender": defender, + "hotfix": hotfix, + "osInfo": os_info, + "cpu": cpu, + "baseboard": baseboard, + "bios": bios, + "memoryModules": memory, + "videoControllers": video, + "disks": disks, + "bitLocker": bitlocker, + "tpm": tpm, + "secureBoot": secure_boot, + "deviceGuard": device_guard, + "firewallProfiles": firewall_profiles, + "windowsUpdate": windows_update, + "computerSystem": computer_system, + "azureAdStatus": device_join, + } + }) +} + +#[cfg(target_os = "macos")] +fn collect_macos_extended() -> serde_json::Value { + use std::process::Command; + // system_profiler em JSON (pode ser pesado; limitar a alguns tipos) + let profiler = Command::new("sh") + .arg("-lc") + .arg("system_profiler -json SPHardwareDataType SPSoftwareDataType SPNetworkDataType SPStorageDataType SPDisplaysDataType 2>/dev/null || true") + .output() + .ok() + .and_then(|out| serde_json::from_slice::(&out.stdout).ok()) + .unwrap_or_else(|| json!({})); + let pkgs = Command::new("sh") + .arg("-lc") + .arg("pkgutil --pkgs 2>/dev/null || true") + .output() + .ok() + .map(|out| { + String::from_utf8_lossy(&out.stdout) + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + let services_text = Command::new("sh") + .arg("-lc") + .arg("launchctl list 2>/dev/null || true") + .output() + .ok() + .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) + .unwrap_or_default(); + + json!({ + "macos": { + "systemProfiler": profiler, + "packages": pkgs, + "launchctl": services_text, + } + }) +} + +fn collect_system() -> System { + let mut system = System::new_all(); + system.refresh_all(); + system +} + +fn collect_metrics(system: &System) -> MachineMetrics { + let collected_at = Utc::now(); + let total_memory = system.total_memory(); + let used_memory = system.used_memory(); + // sysinfo 0.31: valores já em bytes + let memory_total_bytes = total_memory; + let memory_used_bytes = used_memory; + let memory_used_percent = if total_memory > 0 { + (used_memory as f32 / total_memory as f32) * 100.0 + } else { + 0.0 + }; + + let load = System::load_average(); + let cpu_usage_percent = system.global_cpu_usage(); + let cpu_logical_cores = system.cpus().len(); + let cpu_physical_cores = system.physical_core_count(); + + MachineMetrics { + collected_at, + cpu_logical_cores, + cpu_physical_cores, + cpu_usage_percent, + load_average_one: Some(load.one), + load_average_five: Some(load.five), + load_average_fifteen: Some(load.fifteen), + memory_total_bytes, + memory_used_bytes, + memory_used_percent, + uptime_seconds: System::uptime(), + } +} + +pub fn collect_profile() -> Result { + let hostname = hostname::get() + .map_err(|_| AgentError::Hostname)? + .to_string_lossy() + .trim() + .to_string(); + + let system = collect_system(); + + let os_name = System::name() + .or_else(|| System::long_os_version()) + .unwrap_or_else(|| "desconhecido".to_string()); + let os_version = System::os_version(); + let architecture = std::env::consts::ARCH.to_string(); + + let mac_addresses = collect_mac_addresses(); + let serials: Vec = collect_serials(); + + if mac_addresses.is_empty() && serials.is_empty() { + return Err(AgentError::MissingIdentifiers); + } + + let metrics = collect_metrics(&system); + let cpu_brand = system + .cpus() + .first() + .map(|cpu| cpu.brand().to_string()) + .filter(|brand| !brand.trim().is_empty()); + + let inventory = MachineInventory { + cpu_brand, + host_identifier: serials.first().cloned(), + }; + + Ok(MachineProfile { + hostname, + os: MachineOs { + name: os_name, + version: os_version, + architecture: Some(architecture), + }, + mac_addresses, + serial_numbers: serials, + inventory, + metrics, + }) +} + +static HTTP_CLIENT: Lazy = Lazy::new(|| { + reqwest::Client::builder() + .user_agent("sistema-de-chamados-agent/1.0") + .timeout(Duration::from_secs(20)) + .use_rustls_tls() + .build() + .expect("failed to build http client") +}); + +async fn post_heartbeat( + base_url: &str, + token: &str, + status: Option, +) -> Result<(), AgentError> { + let system = collect_system(); + let metrics = collect_metrics(&system); + let hostname = hostname::get() + .map_err(|_| AgentError::Hostname)? + .to_string_lossy() + .into_owned(); + let os = MachineOs { + name: System::name() + .or_else(|| System::long_os_version()) + .unwrap_or_else(|| "desconhecido".to_string()), + version: System::os_version(), + architecture: Some(std::env::consts::ARCH.to_string()), + }; + + let payload = HeartbeatPayload { + machine_token: token.to_string(), + status, + hostname: Some(hostname), + os: Some(os), + metrics: Some(metrics), + metadata: Some(build_inventory_metadata(&system)), + }; + + let url = format!("{}/api/machines/heartbeat", base_url); + HTTP_CLIENT.post(url).json(&payload).send().await?; + Ok(()) +} + +struct HeartbeatHandle { + token: String, + base_url: String, + stop_signal: Arc, + join_handle: JoinHandle<()>, +} + +impl HeartbeatHandle { + fn stop(self) { + self.stop_signal.notify_waiters(); + self.join_handle.abort(); + } +} + +#[derive(Default)] +pub struct AgentRuntime { + inner: Mutex>, +} + +fn sanitize_base_url(input: &str) -> Result { + let trimmed = input.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Err(AgentError::InvalidApiUrl); + } + Ok(trimmed.to_string()) +} + +impl AgentRuntime { + pub fn new() -> Self { + Self { + inner: Mutex::new(None), + } + } + + pub fn start_heartbeat( + &self, + base_url: String, + token: String, + status: Option, + interval_seconds: Option, + ) -> Result<(), AgentError> { + let sanitized_base = sanitize_base_url(&base_url)?; + let interval = interval_seconds.unwrap_or(300).max(60); + + { + let mut guard = self.inner.lock(); + if let Some(handle) = guard.take() { + if handle.token == token && handle.base_url == sanitized_base { + // Reuse existing heartbeat; keep running. + *guard = Some(handle); + return Ok(()); + } + handle.stop(); + } + } + + let stop_signal = Arc::new(Notify::new()); + let stop_signal_clone = stop_signal.clone(); + let token_clone = token.clone(); + let base_clone = sanitized_base.clone(); + let status_clone = status.clone(); + + let join_handle = async_runtime::spawn(async move { + if let Err(error) = + post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await + { + eprintln!("[agent] Falha inicial ao enviar heartbeat: {error}"); + } + + let mut ticker = tokio::time::interval(Duration::from_secs(interval)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + // Wait interval + tokio::select! { + _ = stop_signal_clone.notified() => { + break; + } + _ = ticker.tick() => {} + } + + if let Err(error) = + post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await + { + eprintln!("[agent] Falha ao enviar heartbeat: {error}"); + } + } + }); + + let handle = HeartbeatHandle { + token, + base_url: sanitized_base, + stop_signal, + join_handle, + }; + + let mut guard = self.inner.lock(); + *guard = Some(handle); + + Ok(()) + } + + pub fn stop(&self) { + let mut guard = self.inner.lock(); + if let Some(handle) = guard.take() { + handle.stop(); + } + } +} + +#[cfg(all(test, target_os = "windows"))] +mod windows_tests { + use super::collect_windows_extended; + use serde_json::Value; + + fn expect_object<'a>(value: &'a Value, context: &str) -> &'a serde_json::Map { + value + .as_object() + .unwrap_or_else(|| panic!("{context} não é um objeto JSON: {value:?}")) + } + + #[test] + fn collects_activation_and_defender_status() { + let extended = collect_windows_extended(); + let windows = extended.get("windows").unwrap_or_else(|| { + panic!("payload windows ausente: {extended:?}"); + }); + let windows_obj = expect_object(windows, "windows"); + + let os_info = windows_obj + .get("osInfo") + .unwrap_or_else(|| panic!("windows.osInfo ausente: {windows_obj:?}")); + let os_info_obj = expect_object(os_info, "windows.osInfo"); + + let is_activated = os_info_obj.get("IsActivated").unwrap_or_else(|| { + panic!("campo IsActivated ausente em windows.osInfo: {os_info_obj:?}") + }); + assert!( + is_activated.as_bool().is_some(), + "esperava booleano em windows.osInfo.IsActivated, valor recebido: {is_activated:?}" + ); + + let license_status = os_info_obj.get("LicenseStatus").unwrap_or_else(|| { + panic!("campo LicenseStatus ausente em windows.osInfo: {os_info_obj:?}") + }); + assert!( + license_status.as_i64().is_some(), + "esperava número em windows.osInfo.LicenseStatus, valor recebido: {license_status:?}" + ); + + let defender = windows_obj.get("defender").unwrap_or_else(|| { + panic!("windows.defender ausente: {windows_obj:?}"); + }); + let defender_obj = expect_object(defender, "windows.defender"); + + let realtime = defender_obj + .get("RealTimeProtectionEnabled") + .unwrap_or_else(|| { + panic!( + "campo RealTimeProtectionEnabled ausente em windows.defender: {defender_obj:?}" + ) + }); + assert!( + realtime.as_bool().is_some(), + "esperava booleano em windows.defender.RealTimeProtectionEnabled, valor recebido: {realtime:?}" + ); + } +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/src/lib.rs b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..c20a616 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,58 @@ +mod agent; + +use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile}; +use tauri_plugin_store::Builder as StorePluginBuilder; + +#[tauri::command] +fn collect_machine_profile() -> Result { + collect_profile().map_err(|error| error.to_string()) +} + +#[tauri::command] +fn collect_machine_inventory() -> Result { + Ok(collect_inventory_plain()) +} + +#[tauri::command] +fn start_machine_agent( + state: tauri::State, + base_url: String, + token: String, + status: Option, + interval_seconds: Option, +) -> Result<(), String> { + state + .start_heartbeat(base_url, token, status, interval_seconds) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +fn stop_machine_agent(state: tauri::State) -> Result<(), String> { + state.stop(); + Ok(()) +} + +#[tauri::command] +fn open_devtools(window: tauri::WebviewWindow) -> Result<(), String> { + window.open_devtools(); + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .manage(AgentRuntime::new()) + .plugin(tauri_plugin_opener::init()) + .plugin(StorePluginBuilder::default().build()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) + .invoke_handler(tauri::generate_handler![ + collect_machine_profile, + collect_machine_inventory, + start_machine_agent, + stop_machine_agent, + open_devtools + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/src/main.rs b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..abafa26 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + appsdesktop_lib::run() +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/src-tauri/tauri.conf.json b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..d6a58d2 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Raven", + "version": "0.1.6", + "identifier": "br.com.esdrasrenan.sistemadechamados", + "build": { + "beforeDevCommand": "bun run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "bun run build", + "frontendDist": "../dist" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "Raven", + "width": 1100, + "height": 720, + "resizable": true, + "fullscreen": false, + "maximized": true + } + ], + "security": { + "csp": null + } + }, + "plugins": { + "updater": { + "endpoints": [ + "https://raw.githubusercontent.com/esdrasrenan/sistema-de-chamados/refs/heads/main/apps/desktop/public/latest.json" + ], + "dialog": true, + "active": true, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5MTMxRTQwODA1NEFCRjAKUldUd3ExU0FRQjRUR2VqcHBNdXhBMUV3WlM2cFA4dmNnNEhtMUJ2a3VVWVlTQnoxbEo5YUtlUTMK" + } + }, + "bundle": { + "active": true, + "createUpdaterArtifacts": true, + "targets": ["nsis", "deb", "rpm"], + "icon": [ + "icons/icon.ico", + "icons/icon.icns", + "icons/icon.png", + "icons/Raven.png" + ], + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper", + "silent": true + }, + "nsis": { + "displayLanguageSelector": true, + "installerIcon": "icons/icon.ico", + "headerImage": "icons/nsis-header.bmp", + "sidebarImage": "icons/nsis-sidebar.bmp", + "installMode": "perMachine", + "languages": ["PortugueseBR"] + } + } + } +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/src/assets/tauri.svg b/referência/sistema-de-chamados-main/apps/desktop/src/assets/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src/assets/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/referência/sistema-de-chamados-main/apps/desktop/src/assets/typescript.svg b/referência/sistema-de-chamados-main/apps/desktop/src/assets/typescript.svg new file mode 100644 index 0000000..30a5edd --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src/assets/typescript.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/referência/sistema-de-chamados-main/apps/desktop/src/assets/vite.svg b/referência/sistema-de-chamados-main/apps/desktop/src/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/referência/sistema-de-chamados-main/apps/desktop/src/components/DeactivationScreen.tsx b/referência/sistema-de-chamados-main/apps/desktop/src/components/DeactivationScreen.tsx new file mode 100644 index 0000000..972a0ea --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src/components/DeactivationScreen.tsx @@ -0,0 +1,42 @@ +import { ShieldAlert, Mail } from "lucide-react" + +export function DeactivationScreen({ companyName }: { companyName?: string | null }) { + return ( +
+
+
+ + Acesso bloqueado + +

Dispositivo desativada

+

+ Esta dispositivo foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o + envio de informações ficam indisponíveis. +

+ {companyName ? ( + + {companyName} + + ) : null} +
+ +
+
+

Como regularizar

+
    +
  • Entre em contato com o suporte da Rever e solicite a reativação.
  • +
  • Informe o nome do computador e seus dados de contato.
  • +
+
+ + + Falar com o suporte + +
+
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/src/components/ui/tabs.tsx b/referência/sistema-de-chamados-main/apps/desktop/src/components/ui/tabs.tsx new file mode 100644 index 0000000..9129cca --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src/components/ui/tabs.tsx @@ -0,0 +1,68 @@ +import * as React from "react" +import { + Root as TabsRootPrimitive, + List as TabsListPrimitive, + Trigger as TabsTriggerPrimitive, + Content as TabsContentPrimitive, + type TabsProps as TabsPrimitiveProps, + type TabsListProps as TabsPrimitiveListProps, + type TabsTriggerProps as TabsPrimitiveTriggerProps, + type TabsContentProps as TabsPrimitiveContentProps, +} from "@radix-ui/react-tabs" + +import { cn } from "../../lib/utils" + +type TabsProps = TabsPrimitiveProps & { className?: string } +type TabsListProps = TabsPrimitiveListProps & { className?: string } +type TabsTriggerProps = TabsPrimitiveTriggerProps & { className?: string } +type TabsContentProps = TabsPrimitiveContentProps & { className?: string } + +const TabsRoot = TabsRootPrimitive as unknown as React.ComponentType +const TabsListBase = TabsListPrimitive as unknown as React.ComponentType +const TabsTriggerBase = TabsTriggerPrimitive as unknown as React.ComponentType +const TabsContentBase = TabsContentPrimitive as unknown as React.ComponentType + +export function Tabs({ className, ...props }: TabsProps) { + return ( + + ) +} + +export function TabsList({ className, ...props }: TabsListProps) { + return ( + + ) +} + +export function TabsTrigger({ className, value, ...props }: TabsTriggerProps) { + return ( + + ) +} + +export function TabsContent({ className, value, ...props }: TabsContentProps) { + return ( + + ) +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/src/index.css b/referência/sistema-de-chamados-main/apps/desktop/src/index.css new file mode 100644 index 0000000..244ef4d --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src/index.css @@ -0,0 +1,61 @@ +@import "tailwindcss"; + +:root { + color-scheme: light; +} + +html, body, #root { + height: 100%; +} + +body { + @apply bg-slate-50 text-slate-900; +} + +.badge-status { + @apply inline-flex h-8 items-center gap-3 rounded-full border border-slate-200 px-3 text-sm font-semibold; +} + +.card { + @apply w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm; +} + +.btn { + @apply inline-flex items-center justify-center rounded-md border px-3 py-2 text-sm font-semibold transition; +} + +.btn-primary { + @apply border-black bg-black text-white hover:bg-black/90; +} + +.btn-outline { + @apply border-slate-300 bg-white text-slate-800 hover:bg-slate-50; +} + +.input { + @apply w-full rounded-lg border border-slate-300 px-3 py-2 text-sm; +} + +.label { + @apply text-sm font-medium; +} + +.tabs { + @apply mt-4 flex flex-col gap-3; +} + +.tab-list { + @apply flex flex-wrap gap-2 border-b border-slate-200 pb-2; +} + +.tab-btn { + @apply rounded-md border border-transparent bg-transparent px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100; +} + +.tab-btn.active { + @apply border-slate-300 bg-slate-100 text-slate-900; +} + +.stat-card { + @apply flex items-center gap-3 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-2; +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/src/lib/utils.ts b/referência/sistema-de-chamados-main/apps/desktop/src/lib/utils.ts new file mode 100644 index 0000000..24be1cd --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src/lib/utils.ts @@ -0,0 +1,4 @@ +export function cn(...classes: Array) { + return classes.filter(Boolean).join(" ") +} + diff --git a/referência/sistema-de-chamados-main/apps/desktop/src/main.tsx b/referência/sistema-de-chamados-main/apps/desktop/src/main.tsx new file mode 100644 index 0000000..e4bf0b4 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/src/main.tsx @@ -0,0 +1,1009 @@ +/* eslint-disable @next/next/no-img-element */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { createRoot } from "react-dom/client" +import { invoke } from "@tauri-apps/api/core" +import { Store } from "@tauri-apps/plugin-store" +import { appLocalDataDir, executableDir, join } from "@tauri-apps/api/path" +import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" +import { cn } from "./lib/utils" +import { DeactivationScreen } from "./components/DeactivationScreen" + +type MachineOs = { + name: string + version?: string | null + architecture?: string | null +} + +type MachineMetrics = { + collectedAt: string + cpuLogicalCores: number + cpuPhysicalCores?: number | null + cpuUsagePercent: number + memoryTotalBytes: number + memoryUsedBytes: number + memoryUsedPercent: number + uptimeSeconds: number +} + +type MachineInventory = { + cpuBrand?: string | null + hostIdentifier?: string | null +} + +type MachineProfile = { + hostname: string + os: MachineOs + macAddresses: string[] + serialNumbers: string[] + inventory: MachineInventory + metrics: MachineMetrics +} + +type MachineRegisterResponse = { + machineId: string + tenantId?: string | null + companyId?: string | null + companySlug?: string | null + machineToken: string + machineEmail?: string | null + expiresAt?: number | null + persona?: string | null + assignedUserId?: string | null + collaborator?: { + email: string + name?: string | null + } | null +} + +type AgentConfig = { + machineId: string + tenantId?: string | null + companySlug?: string | null + companyName?: string | null + machineEmail?: string | null + collaboratorEmail?: string | null + collaboratorName?: string | null + accessRole: "collaborator" | "manager" + assignedUserId?: string | null + assignedUserEmail?: string | null + assignedUserName?: string | null + apiBaseUrl: string + appUrl: string + createdAt: number + lastSyncedAt?: number | null + expiresAt?: number | null + heartbeatIntervalSec?: number | null +} + +declare global { + interface ImportMetaEnv { + readonly VITE_APP_URL?: string + readonly VITE_API_BASE_URL?: string + } + interface ImportMeta { readonly env: ImportMetaEnv } +} + +const STORE_FILENAME = "machine-agent.json" +const DEFAULT_APP_URL = import.meta.env.MODE === "production" ? "https://tickets.esdrasrenan.com.br" : "http://localhost:3000" + +function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) { + const trimmed = (value ?? fallback).trim() + if (!trimmed.startsWith("http")) return fallback + return trimmed.replace(/\/+$/, "") +} + +const appUrl = normalizeUrl(import.meta.env.VITE_APP_URL, DEFAULT_APP_URL) +const apiBaseUrl = normalizeUrl(import.meta.env.VITE_API_BASE_URL, appUrl) + +async function loadStore(): Promise { + // Tenta usar uma pasta "data" ao lado do executável (ex.: C:\Raven\data) + try { + const exeDir = await executableDir() + const storePath = await join(exeDir, "data", STORE_FILENAME) + return await Store.load(storePath) + } catch { + // Fallback: AppData local do usuário + const appData = await appLocalDataDir() + const storePath = await join(appData, STORE_FILENAME) + return await Store.load(storePath) + } +} + +async function readToken(store: Store): Promise { + return (await store.get("token")) ?? null +} + +async function writeToken(store: Store, token: string): Promise { + await store.set("token", token) + await store.save() +} + +async function readConfig(store: Store): Promise { + return (await store.get("config")) ?? null +} + +async function writeConfig(store: Store, cfg: AgentConfig): Promise { + await store.set("config", cfg) + await store.save() +} + +function bytes(n?: number) { + if (!n || !Number.isFinite(n)) return "—" + const u = ["B","KB","MB","GB","TB"] + let v = n; let i = 0 + while (v >= 1024 && i < u.length - 1) { v/=1024; i++ } + return `${v.toFixed(v>=10||i===0?0:1)} ${u[i]}` +} + +function pct(p?: number) { return !p && p !== 0 ? "—" : `${p.toFixed(0)}%` } + +type MachineStatePayload = { + isActive?: boolean | null + metadata?: Record | null +} + +function extractActiveFromMetadata(metadata: unknown): boolean { + if (!metadata || typeof metadata !== "object") return true + const record = metadata as Record + const direct = record["isActive"] + if (typeof direct === "boolean") return direct + const state = record["state"] + if (state && typeof state === "object") { + const nested = state as Record + const active = nested["isActive"] ?? nested["active"] ?? nested["enabled"] + if (typeof active === "boolean") return active + } + const flags = record["flags"] + if (flags && typeof flags === "object") { + const nested = flags as Record + const active = nested["isActive"] ?? nested["active"] + if (typeof active === "boolean") return active + } + const status = record["status"] + if (typeof status === "string") { + const normalized = status.trim().toLowerCase() + if (["deactivated", "desativada", "desativado", "inactive", "inativo", "disabled"].includes(normalized)) { + return false + } + } + return true +} + +function resolveMachineActive(machine?: MachineStatePayload | null): boolean { + if (!machine) return true + if (typeof machine.isActive === "boolean") return machine.isActive + return extractActiveFromMetadata(machine.metadata) +} + +function App() { + const [store, setStore] = useState(null) + const [token, setToken] = useState(null) + const [config, setConfig] = useState(null) + const [profile, setProfile] = useState(null) + const [logoSrc, setLogoSrc] = useState(() => `${appUrl}/logo-raven.png`) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [status, setStatus] = useState(null) + const [isMachineActive, setIsMachineActive] = useState(true) + const [showSecret, setShowSecret] = useState(false) + const [isLaunchingSystem, setIsLaunchingSystem] = useState(false) + const [tokenValidationTick, setTokenValidationTick] = useState(0) + const [, setIsValidatingToken] = useState(false) + const tokenVerifiedRef = useRef(false) + + const [provisioningCode, setProvisioningCode] = useState("") + const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null) + const [companyName, setCompanyName] = useState("") + const [isValidatingCode, setIsValidatingCode] = useState(false) + const [codeStatus, setCodeStatus] = useState<{ tone: "success" | "error"; message: string } | null>(null) + const [collabEmail, setCollabEmail] = useState("") + const [collabName, setCollabName] = useState("") + const [updating, setUpdating] = useState(false) + const [updateInfo, setUpdateInfo] = useState<{ message: string; tone: "info" | "success" | "error" } | null>({ + message: "Atualizações automáticas são verificadas a cada inicialização.", + tone: "info", + }) + const autoLaunchRef = useRef(false) + const autoUpdateRef = useRef(false) + const logoFallbackRef = useRef(false) + const emailRegex = useRef(/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i) + const isEmailValid = useMemo(() => emailRegex.current.test(collabEmail.trim()), [collabEmail]) + + useEffect(() => { + (async () => { + try { + const s = await loadStore() + setStore(s) + const t = await readToken(s) + setToken(t) + const cfg = await readConfig(s) + setConfig(cfg) + if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail) + if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName) + if (cfg?.companyName) setCompanyName(cfg.companyName) + if (!t) { + const p = await invoke("collect_machine_profile") + setProfile(p) + } + // Não assume online sem validar; valida abaixo em outro efeito + } catch { + setError("Falha ao carregar estado do agente.") + } + })() + }, []) + + // Valida token existente ao iniciar o app. Se inválido/expirado, limpa e volta ao onboarding. + useEffect(() => { + if (!store || !token) return + let cancelled = false + ;(async () => { + setIsValidatingToken(true) + try { + const res = await fetch(`${apiBaseUrl}/api/machines/heartbeat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineToken: token, status: "online" }), + }) + if (cancelled) return + if (res.ok) { + tokenVerifiedRef.current = true + setStatus("online") + setTokenValidationTick((tick) => tick + 1) + try { + await invoke("start_machine_agent", { + baseUrl: apiBaseUrl, + token, + status: "online", + intervalSeconds: 300, + }) + } catch (err) { + console.error("Falha ao iniciar heartbeat em segundo plano", err) + } + const payload = await res.clone().json().catch(() => null) + if (payload && typeof payload === "object" && "machine" in payload) { + const machineData = (payload as { machine?: MachineStatePayload }).machine + if (machineData) { + const currentActive = resolveMachineActive(machineData) + setIsMachineActive(currentActive) + } + } + return + } + const text = await res.text() + const msg = text.toLowerCase() + const isInvalid = + msg.includes("token de dispositivo inválido") || + msg.includes("token de dispositivo revogado") || + msg.includes("token de dispositivo expirado") + if (isInvalid) { + try { + await store.delete("token"); await store.delete("config"); await store.save() + } catch {} + autoLaunchRef.current = false + tokenVerifiedRef.current = false + setToken(null) + setConfig(null) + setStatus(null) + setIsMachineActive(true) + setError("Este dispositivo precisa ser reprovisionado. Informe o código de provisionamento.") + try { + const p = await invoke("collect_machine_profile") + if (!cancelled) setProfile(p) + } catch {} + } else { + // Não limpa token em falhas genéricas (ex.: rede); apenas informa + setError("Falha ao validar sessão da dispositivo. Tente novamente.") + tokenVerifiedRef.current = true + setTokenValidationTick((tick) => tick + 1) + } + } catch (err) { + if (!cancelled) { + console.error("Falha ao validar token (rede)", err) + tokenVerifiedRef.current = true + setTokenValidationTick((tick) => tick + 1) + } + } finally { + if (!cancelled) setIsValidatingToken(false) + } + })() + return () => { + cancelled = true + } + }, [store, token]) + + useEffect(() => { + if (!import.meta.env.DEV) return + + function onKeyDown(event: KeyboardEvent) { + const key = (event.key || "").toLowerCase() + if (key === "f12" || (event.ctrlKey && event.shiftKey && key === "i")) { + invoke("open_devtools").catch(() => {}) + event.preventDefault() + } + } + + function onContextMenu(event: MouseEvent) { + if (event.ctrlKey || event.shiftKey) { + invoke("open_devtools").catch(() => {}) + event.preventDefault() + } + } + + window.addEventListener("keydown", onKeyDown) + window.addEventListener("contextmenu", onContextMenu) + return () => { + window.removeEventListener("keydown", onKeyDown) + window.removeEventListener("contextmenu", onContextMenu) + } + }, []) + + useEffect(() => { + if (!store || !config) return + const email = collabEmail.trim() + const name = collabName.trim() + const normalizedEmail = email.length > 0 ? email : null + const normalizedName = name.length > 0 ? name : null + if ( + config.collaboratorEmail === normalizedEmail && + config.collaboratorName === normalizedName + ) { + return + } + const nextConfig: AgentConfig = { + ...config, + collaboratorEmail: normalizedEmail, + collaboratorName: normalizedName, + } + setConfig(nextConfig) + writeConfig(store, nextConfig).catch((err) => console.error("Falha ao atualizar colaborador", err)) + }, [store, config, config?.collaboratorEmail, config?.collaboratorName, collabEmail, collabName]) + + useEffect(() => { + if (!store || !config) return + const normalizedAppUrl = normalizeUrl(config.appUrl, appUrl) + const normalizedApiUrl = normalizeUrl(config.apiBaseUrl, apiBaseUrl) + const shouldForceRemote = import.meta.env.MODE === "production" + const nextAppUrl = shouldForceRemote && normalizedAppUrl.includes("localhost") ? appUrl : normalizedAppUrl + const nextApiUrl = shouldForceRemote && normalizedApiUrl.includes("localhost") ? apiBaseUrl : normalizedApiUrl + if (nextAppUrl !== config.appUrl || nextApiUrl !== config.apiBaseUrl) { + const updatedConfig = { ...config, appUrl: nextAppUrl, apiBaseUrl: nextApiUrl } + setConfig(updatedConfig) + writeConfig(store, updatedConfig).catch((err) => console.error("Falha ao atualizar configuração", err)) + } + }, [store, config]) + + useEffect(() => { + const trimmed = provisioningCode.trim() + if (trimmed.length < 32) { + setValidatedCompany(null) + setCodeStatus(null) + setCompanyName("") + return + } + let cancelled = false + const controller = new AbortController() + const timeout = setTimeout(async () => { + setIsValidatingCode(true) + try { + const res = await fetch(`${apiBaseUrl}/api/machines/provisioning`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provisioningCode: trimmed }), + signal: controller.signal, + }) + if (!res.ok) { + const message = res.status === 404 ? "Código não encontrado" : "Falha ao validar código" + if (!cancelled) { + setValidatedCompany(null) + setCompanyName("") + setCodeStatus({ tone: "error", message }) + } + return + } + const data = (await res.json()) as { company: { id: string; name: string; slug: string; tenantId: string } } + if (!cancelled) { + setValidatedCompany(data.company) + setCompanyName(data.company.name) + setCodeStatus({ tone: "success", message: `Empresa encontrada: ${data.company.name}` }) + } + } catch (error) { + if (!cancelled) { + console.error("Falha ao validar código de provisionamento", error) + setValidatedCompany(null) + setCompanyName("") + setCodeStatus({ tone: "error", message: "Não foi possível validar o código agora" }) + } + } finally { + if (!cancelled) setIsValidatingCode(false) + } + }, 400) + return () => { + cancelled = true + clearTimeout(timeout) + controller.abort() + setIsValidatingCode(false) + } + }, [provisioningCode]) + + const resolvedAppUrl = useMemo(() => { + if (!config?.appUrl) return appUrl + const normalized = normalizeUrl(config.appUrl, appUrl) + if (import.meta.env.MODE === "production" && normalized.includes("localhost")) { + return appUrl + } + return normalized + }, [config?.appUrl]) + + async function register() { + if (!profile) return + const trimmedCode = provisioningCode.trim().toLowerCase() + if (trimmedCode.length < 32) { + setError("Informe o código de provisionamento fornecido pela equipe.") + return + } + if (!validatedCompany) { + setError("Valide o código de provisionamento antes de registrar a dispositivo.") + return + } + const normalizedEmail = collabEmail.trim().toLowerCase() + if (!normalizedEmail) { + setError("Informe o e-mail do colaborador vinculado a esta dispositivo.") + return + } + if (!emailRegex.current.test(normalizedEmail)) { + setError("Informe um e-mail válido (ex.: nome@empresa.com)") + return + } + const normalizedName = collabName.trim() + if (!normalizedName) { + setError("Informe o nome completo do colaborador.") + return + } + + setBusy(true) + setError(null) + try { + const collaboratorPayload = { + email: normalizedEmail, + name: normalizedName, + } + const metadataPayload: Record = { + inventory: profile.inventory, + metrics: profile.metrics, + collaborator: { email: normalizedEmail, name: normalizedName, role: "collaborator" }, + } + + const payload = { + provisioningCode: trimmedCode, + hostname: profile.hostname, + os: profile.os, + macAddresses: profile.macAddresses, + serialNumbers: profile.serialNumbers, + metadata: metadataPayload, + collaborator: collaboratorPayload, + registeredBy: "desktop-agent", + } + + const res = await fetch(`${apiBaseUrl}/api/machines/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const text = await res.text() + throw new Error(`Falha no registro (${res.status}): ${text.slice(0, 300)}`) + } + + const data = (await res.json()) as MachineRegisterResponse + if (!store) throw new Error("Store ausente") + + await writeToken(store, data.machineToken) + + const cfg: AgentConfig = { + machineId: data.machineId, + tenantId: data.tenantId ?? validatedCompany.tenantId ?? null, + companySlug: data.companySlug ?? validatedCompany.slug ?? null, + companyName: validatedCompany.name, + machineEmail: data.machineEmail ?? null, + collaboratorEmail: collaboratorPayload.email, + collaboratorName: collaboratorPayload.name, + accessRole: "collaborator", + assignedUserId: data.assignedUserId ?? null, + assignedUserEmail: data.collaborator?.email ?? collaboratorPayload.email, + assignedUserName: data.collaborator?.name ?? collaboratorPayload.name, + apiBaseUrl, + appUrl, + createdAt: Date.now(), + lastSyncedAt: Date.now(), + expiresAt: data.expiresAt ?? null, + } + + await writeConfig(store, cfg) + setConfig(cfg) + setToken(data.machineToken) + setCompanyName(validatedCompany.name) + + await invoke("start_machine_agent", { + baseUrl: apiBaseUrl, + token: data.machineToken, + status: "online", + intervalSeconds: 300, + }) + setStatus("online") + tokenVerifiedRef.current = true + + // Abre o sistema imediatamente após registrar (evita ficar com token inválido no fluxo antigo) + try { + await fetch(`${apiBaseUrl}/api/machines/sessions`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineToken: data.machineToken, rememberMe: true }), + }) + } catch {} + const persona = (cfg.accessRole ?? "collaborator") === "manager" ? "manager" : "collaborator" + const redirectTarget = persona === "manager" ? "/dashboard" : "/portal/tickets" + const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(data.machineToken)}&redirect=${encodeURIComponent(redirectTarget)}` + window.location.href = url + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const openSystem = useCallback(async () => { + if (!token) return + setIsLaunchingSystem(true) + try { + // Tenta criar a sessão via API (evita dependência de redirecionamento + cookies em 3xx) + const res = await fetch(`${apiBaseUrl}/api/machines/sessions`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineToken: token, rememberMe: true }), + }) + if (res.ok) { + const payload = await res.clone().json().catch(() => null) + if (payload && typeof payload === "object" && "machine" in payload) { + const machineData = (payload as { machine?: MachineStatePayload }).machine + if (machineData) { + const currentActive = resolveMachineActive(machineData) + setIsMachineActive(currentActive) + if (currentActive) { + setError(null) + } + if (!currentActive) { + setError("Esta dispositivo está desativada. Entre em contato com o suporte da Rever para reativar o acesso.") + setIsLaunchingSystem(false) + return + } + } + } + } else { + if (res.status === 423) { + const payload = await res.clone().json().catch(() => null) + const message = + payload && typeof payload === "object" && typeof (payload as { error?: unknown }).error === "string" + ? ((payload as { error?: string }).error ?? "").trim() + : "" + setIsMachineActive(false) + setIsLaunchingSystem(false) + setError(message.length > 0 ? message : "Esta dispositivo está desativada. Entre em contato com o suporte da Rever.") + return + } + // Se sessão falhar, tenta identificar token inválido/expirado + try { + const hb = await fetch(`${apiBaseUrl}/api/machines/heartbeat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineToken: token }), + }) + if (!hb.ok) { + const text = await hb.text() + const low = text.toLowerCase() + const invalid = + low.includes("token de dispositivo inválido") || + low.includes("token de dispositivo revogado") || + low.includes("token de dispositivo expirado") + if (invalid) { + // Força onboarding + await store?.delete("token"); await store?.delete("config"); await store?.save() + autoLaunchRef.current = false + tokenVerifiedRef.current = false + setToken(null) + setConfig(null) + setStatus(null) + setIsMachineActive(true) + setError("Sessão expirada. Reprovisione a dispositivo para continuar.") + setIsLaunchingSystem(false) + const p = await invoke("collect_machine_profile") + setProfile(p) + return + } + } + } catch { + // ignora e segue para handshake + } + } + // Independente do resultado do POST, seguimos para o handshake em + // navegação de primeiro plano para garantir gravação de cookies. + } catch { + // ignoramos e seguimos para o handshake + } + const persona = (config?.accessRole ?? "collaborator") === "manager" ? "manager" : "collaborator" + // Envia para a página inicial apropriada após autenticar cookies/sessão + const redirectTarget = persona === "manager" ? "/dashboard" : "/portal/tickets" + const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}` + window.location.href = url + }, [token, config?.accessRole, resolvedAppUrl, store]) + + async function reprovision() { + if (!store) return + await store.delete("token"); await store.delete("config"); await store.save() + autoLaunchRef.current = false + setToken(null); setConfig(null); setStatus(null) + setProvisioningCode("") + setValidatedCompany(null) + setCodeStatus(null) + setCompanyName("") + setIsLaunchingSystem(false) + const p = await invoke("collect_machine_profile") + setProfile(p) + } + + async function sendInventoryNow() { + if (!token || !profile) return + setBusy(true); setError(null) + try { + const collaboratorPayload = collabEmail.trim() + ? { email: collabEmail.trim(), name: collabName.trim() || undefined } + : undefined + const collaboratorInventory = collaboratorPayload + ? { ...collaboratorPayload, role: "collaborator" as const } + : undefined + const inventoryPayload: Record = { ...profile.inventory } + if (collaboratorInventory) { + inventoryPayload.collaborator = collaboratorInventory + } + const payload = { + machineToken: token, + hostname: profile.hostname, + os: profile.os, + metrics: profile.metrics, + inventory: inventoryPayload, + } + const res = await fetch(`${apiBaseUrl}/api/machines/inventory`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const text = await res.text() + throw new Error(`Falha ao enviar inventário (${res.status}): ${text.slice(0, 200)}`) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + async function checkForUpdates(auto = false) { + try { + if (!auto) { + setUpdating(true) + setUpdateInfo({ tone: "info", message: "Procurando por atualizações..." }) + } + const { check } = await import("@tauri-apps/plugin-updater") + type UpdateResult = { + available?: boolean + version?: string + downloadAndInstall?: () => Promise + } + const update = (await check()) as UpdateResult | null + if (update?.available) { + setUpdateInfo({ + tone: "info", + message: `Atualização ${update.version} disponível. Baixando e aplicando...`, + }) + if (typeof update.downloadAndInstall === "function") { + await update.downloadAndInstall() + const { relaunch } = await import("@tauri-apps/plugin-process") + await relaunch() + } + } else if (!auto) { + setUpdateInfo({ tone: "info", message: "Nenhuma atualização disponível no momento." }) + } + } catch (error) { + console.error("Falha ao verificar atualizações", error) + if (!auto) { + setUpdateInfo({ + tone: "error", + message: "Falha ao verificar atualizações. Tente novamente mais tarde.", + }) + } + } finally { + if (!auto) setUpdating(false) + } + } + + useEffect(() => { + if (import.meta.env.DEV) return + if (autoUpdateRef.current) return + autoUpdateRef.current = true + checkForUpdates(true).catch((err: unknown) => { + console.error("Falha ao executar atualização automática", err) + }) + }, []) + + useEffect(() => { + if (!token) return + if (autoLaunchRef.current) return + if (!tokenVerifiedRef.current) return + autoLaunchRef.current = true + setIsLaunchingSystem(true) + openSystem() + }, [token, status, config?.accessRole, openSystem, tokenValidationTick]) + + if (isLaunchingSystem && token) { + return ( +
+
+ +

Abrindo plataforma da Rever…

+

Aguarde só um instante.

+
+
+ ) + } + + return ( +
+ {token && !isMachineActive ? ( + + ) : ( +
+
+ Logotipo Raven { + if (logoFallbackRef.current) return + logoFallbackRef.current = true + setLogoSrc(`${appUrl}/raven.png`) + }} + /> +
+ Raven + Sistema de chamados + +
+
+ {error ?

{error}

: null} + {!token ? ( +
+

Informe os dados para registrar esta dispositivo.

+
+ +
+ { + const value = e.target.value + setProvisioningCode(value) + setValidatedCompany(null) + setCodeStatus(null) + }} + /> + +
+ {isValidatingCode ? ( +

Validando código...

+ ) : codeStatus ? ( +

+ {codeStatus.message} +

+ ) : ( +

+ Informe o código único fornecido pela equipe para vincular esta dispositivo a uma empresa. +

+ )} +
+ {validatedCompany ? ( +
+
+ ) : null} +
+ + setCollabEmail(e.target.value)} + /> + {collabEmail && !isEmailValid ? ( +

Informe um e-mail válido (ex.: nome@empresa.com)

+ ) : null} +
+
+ + setCollabName(e.target.value)} + /> +
+ {profile ? ( +
+
+
Hostname
+
{profile.hostname}
+
+
+
Sistema
+
{profile.os.name}
+
+
+
CPU
+
{pct(profile.metrics.cpuUsagePercent)}
+
+
+
Memória
+
{bytes(profile.metrics.memoryUsedBytes)} / {bytes(profile.metrics.memoryTotalBytes)}
+
+
+ ) : null} +
+ +
+
+ ) : ( +
+ + + Resumo + Inventário + Configurações + + + {companyName ? ( +
+
{companyName}
+ {config?.collaboratorEmail ? ( +
+ Vinculado a {config.collaboratorEmail} +
+ ) : null} +
+ ) : null} +
+
+
CPU
+
{profile ? pct(profile.metrics.cpuUsagePercent) : "—"}
+
+
+
Memória
+
{profile ? `${bytes(profile.metrics.memoryUsedBytes)} / ${bytes(profile.metrics.memoryTotalBytes)}` : "—"}
+
+
+
+ + +
+
+ +

Inventário básico coletado localmente. Envie para sincronizar com o servidor.

+
+
+
Hostname
+
{profile?.hostname ?? "—"}
+
+
+
Sistema
+
{profile?.os?.name ?? "—"} {profile?.os?.version ?? ""}
+
+
+
+ + +
+
+ +
+ + setCollabEmail(e.target.value)} /> +
+
+ + setCollabName(e.target.value)} /> +
+
+ + + {updateInfo ? ( +
+ {updateInfo.message} +
+ ) : null} +
+
+
+
+ )} +
+ )} +
+ ) +} + +function StatusBadge({ status, className }: { status: string | null; className?: string }) { + const s = (status ?? "").toLowerCase() + const label = s === "online" ? "Online" : s === "offline" ? "Offline" : s === "maintenance" ? "Manutenção" : "Sem status" + const dot = s === "online" ? "bg-emerald-500" : s === "offline" ? "bg-rose-500" : s === "maintenance" ? "bg-amber-500" : "bg-slate-400" + const ring = s === "online" ? "bg-emerald-400/30" : s === "offline" ? "bg-rose-400/30" : s === "maintenance" ? "bg-amber-400/30" : "bg-slate-300/30" + const isOnline = s === "online" + return ( + + + + {isOnline ? : null} + + {label} + + ) +} + +const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })() +createRoot(root).render() diff --git a/referência/sistema-de-chamados-main/apps/desktop/tsconfig.json b/referência/sistema-de-chamados-main/apps/desktop/tsconfig.json new file mode 100644 index 0000000..0a3d9c7 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/referência/sistema-de-chamados-main/apps/desktop/vite.config.ts b/referência/sistema-de-chamados-main/apps/desktop/vite.config.ts new file mode 100644 index 0000000..9c1d6d2 --- /dev/null +++ b/referência/sistema-de-chamados-main/apps/desktop/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const host = process.env.TAURI_DEV_HOST; + +// https://vite.dev/config/ +export default defineConfig(async () => ({ + plugins: [react()], + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell Vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, + }, +})); diff --git a/referência/sistema-de-chamados-main/auth.ts b/referência/sistema-de-chamados-main/auth.ts new file mode 100644 index 0000000..51ec0cb --- /dev/null +++ b/referência/sistema-de-chamados-main/auth.ts @@ -0,0 +1 @@ +export { auth } from "./src/lib/auth" diff --git a/referência/sistema-de-chamados-main/build.log b/referência/sistema-de-chamados-main/build.log new file mode 100644 index 0000000..5a71460 --- /dev/null +++ b/referência/sistema-de-chamados-main/build.log @@ -0,0 +1,128 @@ + +> web@0.1.0 build C:\Users\monke\OneDrive\Documentos\Projetos\sistema-de-chamados\web +> next build + + Ôû▓ Next.js 15.5.3 + - Environments: .env + + Creating an optimized production build ... + Ô£ô Compiled successfully in 3.0s + Linting and checking validity of types ... + +./src/app/ConvexClientProvider.tsx +4:21 Warning: 'useMemo' is defined but never used. @typescript-eslint/no-unused-vars + +./src/app/tickets/new/page.tsx +16:9 Warning: The 'queues' logical expression could make the dependencies of useMemo Hook (at line 28) change on every render. To fix this, wrap the initialization of 'queues' in its own useMemo() Hook. react-hooks/exhaustive-deps +28:53 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +35:33 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +35:49 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +43:27 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +44:30 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +48:42 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +48:67 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/app/tickets/[id]/page.tsx +27:61 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/tickets/new-ticket-dialog.tsx +6:1 Warning: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. @typescript-eslint/ban-ts-comment +50:35 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +58:32 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +63:44 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +63:69 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +63:140 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +71:14 Warning: 'err' is defined but never used. @typescript-eslint/no-unused-vars +116:111 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +128:109 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +150:41 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/tickets/play-next-ticket-card.tsx +39:34 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +43:169 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +43:240 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +75:37 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +109:103 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +109:141 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/tickets/recent-tickets-panel.tsx +4:1 Warning: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. @typescript-eslint/ban-ts-comment +9:10 Warning: 'Spinner' is defined but never used. @typescript-eslint/no-unused-vars +27:58 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +30:41 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/tickets/ticket-comments.rich.tsx +31:9 Warning: 'generateUploadUrl' is assigned a value but never used. @typescript-eslint/no-unused-vars +52:100 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +61:49 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +61:74 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +64:14 Warning: 'err' is defined but never used. @typescript-eslint/no-unused-vars +113:34 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +151:83 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/tickets/ticket-detail-view.tsx +11:10 Warning: 'Separator' is defined but never used. @typescript-eslint/no-unused-vars +20:108 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +21:15 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +23:50 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +57:46 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +60:45 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +61:45 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +63:47 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/tickets/ticket-queue-summary.tsx +18:70 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/tickets/ticket-summary-header.tsx +50:50 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +59:63 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +59:85 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +59:109 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +61:26 Warning: 'e' is defined but never used. @typescript-eslint/no-unused-vars +98:63 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +98:89 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +98:113 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +107:31 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +121:42 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +125:60 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +125:82 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +125:106 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +134:31 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/tickets/ticket-timeline.tsx +12:10 Warning: 'cn' is defined but never used. @typescript-eslint/no-unused-vars +15:10 Warning: 'Separator' is defined but never used. @typescript-eslint/no-unused-vars +72:28 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/tickets/tickets-view.tsx +12:10 Warning: 'Spinner' is defined but never used. @typescript-eslint/no-unused-vars +27:80 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +31:31 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +36:76 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +49:51 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +./src/components/ui/dropzone.tsx +4:1 Warning: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. @typescript-eslint/ban-ts-comment + +./src/components/ui/field.tsx +11:52 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any +28:58 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any + +info - Need to disable some ESLint rules? Learn more here: https://nextjs.org/docs/app/api-reference/config/eslint#disabling-rules +Failed to compile. + +./src/components/tickets/play-next-ticket-card.tsx:43:9 +Type error: Type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: string; reference: number; tenantId: string; subject: string; status: "RESOLVED" | "CLOSED" | "PENDING" | "ON_HOLD" | "OPEN" | "NEW"; ... 14 more ...; lastTimelineEntry?: string | undefined; } | null; } | { ...' is not assignable to type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: string; reference: number; tenantId: string; subject: string; status: "RESOLVED" | "CLOSED" | "PENDING" | "ON_HOLD" | "OPEN" | "NEW"; ... 14 more ...; lastTimelineEntry?: string | undefined; } | null; } | null'. + Type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: any; reference: any; tenantId: any; subject: any; summary: any; status: any; priority: any; channel: any; ... 11 more ...; metrics: null; }; }' is not assignable to type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: string; reference: number; tenantId: string; subject: string; status: "RESOLVED" | "CLOSED" | "PENDING" | "ON_HOLD" | "OPEN" | "NEW"; ... 14 more ...; lastTimelineEntry?: string | undefined; } | null; }'. + The types of 'nextTicket.lastTimelineEntry' are incompatible between these types. + Type 'null' is not assignable to type 'string | undefined'. + + 41 | })?.[0] + 42 | +> 43 | const cardContext: TicketPlayContext | null = context ?? (nextTicketFromServer ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a: number, b: any) => a + b.pending, 0), waiting: queueSummary.reduce((a: number, b: any) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketFromServer } : null) + | ^ + 44 | + 45 | if (!cardContext || !cardContext.nextTicket) { + 46 | return ( +Next.js build worker exited with code: 1 and signal: null +ÔÇëELIFECYCLEÔÇë Command failed with exit code 1. diff --git a/referência/sistema-de-chamados-main/bun.lock b/referência/sistema-de-chamados-main/bun.lock new file mode 100644 index 0000000..802f6b0 --- /dev/null +++ b/referência/sistema-de-chamados-main/bun.lock @@ -0,0 +1,2389 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "web", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^3.10.0", + "@noble/hashes": "^1.5.0", + "@paper-design/shaders-react": "^0.0.55", + "@prisma/client": "^6.19.0", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-pdf/renderer": "^4.1.5", + "@react-three/fiber": "^9.3.0", + "@tabler/icons-react": "^3.35.0", + "@tanstack/react-table": "^8.21.3", + "@tiptap/extension-link": "^3.10.0", + "@tiptap/extension-mention": "^3.10.0", + "@tiptap/extension-placeholder": "^3.10.0", + "@tiptap/markdown": "^3.10.0", + "@tiptap/react": "^3.10.0", + "@tiptap/starter-kit": "^3.10.0", + "@tiptap/suggestion": "^3.10.0", + "better-auth": "^1.3.26", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "convex": "^1.28.0", + "date-fns": "^4.1.0", + "dotenv": "^16.4.5", + "lucide-react": "^0.544.0", + "next": "^16.0.1", + "next-themes": "^0.4.6", + "pdfkit": "^0.17.2", + "postcss": "^8.5.6", + "react": "19.2.0", + "react-day-picker": "^9.4.2", + "react-dom": "19.2.0", + "react-hook-form": "^7.64.0", + "recharts": "^2.15.4", + "sanitize-html": "^2.17.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "three": "^0.180.0", + "tippy.js": "^6.3.7", + "unicornstudio-react": "^1.4.31", + "vaul": "^1.1.2", + "zod": "^4.1.9", + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@tauri-apps/api": "^2.8.0", + "@tauri-apps/cli": "^2.8.4", + "@types/bun": "^1.1.10", + "@types/jsdom": "^21.1.7", + "@types/node": "^20", + "@types/pdfkit": "^0.17.3", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/sanitize-html": "^2.16.0", + "@types/three": "^0.180.0", + "@vitest/browser-playwright": "^4.0.1", + "better-sqlite3": "^12.4.1", + "cross-env": "^10.1.0", + "eslint": "^9", + "eslint-config-next": "^16.0.1", + "eslint-plugin-react-hooks": "^5.0.0", + "jsdom": "^27.0.1", + "playwright": "^1.56.1", + "prisma": "^6.19.0", + "tailwindcss": "^4", + "tsconfig-paths": "^4.2.0", + "tw-animate-css": "^1.3.8", + "typescript": "^5", + "typescript-eslint": "^8.46.2", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.0.1", + }, + }, + "apps/desktop": { + "name": "appsdesktop", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-tabs": "^1.1.13", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2", + "@tauri-apps/plugin-store": "^2", + "@tauri-apps/plugin-updater": "^2", + "lucide-react": "^0.544.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@vitejs/plugin-react": "^4.3.4", + "png-to-ico": "^3.0.1", + "typescript": "~5.6.2", + "vite": "^6.0.3", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.0.5", "", { "dependencies": { "@csstools/css-calc": "2.1.4", "@csstools/css-color-parser": "3.1.0", "@csstools/css-parser-algorithms": "3.0.5", "@csstools/css-tokenizer": "3.0.4", "lru-cache": "11.2.2" } }, "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.2", "", { "dependencies": { "@asamuzakjp/nwsapi": "2.3.9", "bidi-js": "1.0.3", "css-tree": "3.1.0", "is-potential-custom-element-name": "1.0.1", "lru-cache": "11.2.2" } }, "sha512-ccKogJI+0aiDhOahdjANIc9SDixSud1gbwdVrhn7kMopAtLXqsz9MKmQQtIl6Y5aC2IYq+j4dz/oedL2AVMmVQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "7.27.1", "js-tokens": "4.0.0", "picocolors": "1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="], + + "@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/generator": "7.28.3", "@babel/helper-compilation-targets": "7.27.2", "@babel/helper-module-transforms": "7.28.3", "@babel/helpers": "7.28.4", "@babel/parser": "7.28.4", "@babel/template": "7.27.2", "@babel/traverse": "7.28.4", "@babel/types": "7.28.4", "@jridgewell/remapping": "2.3.5", "convert-source-map": "2.0.0", "debug": "4.4.3", "gensync": "1.0.0-beta.2", "json5": "2.2.3", "semver": "6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="], + + "@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "7.28.4", "@babel/types": "7.28.4", "@jridgewell/gen-mapping": "0.3.13", "@jridgewell/trace-mapping": "0.3.31", "jsesc": "3.1.0" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "7.28.4", "@babel/helper-validator-option": "7.27.1", "browserslist": "4.26.3", "lru-cache": "5.1.1", "semver": "6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "7.28.4", "@babel/types": "7.28.4" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "7.27.1", "@babel/helper-validator-identifier": "7.27.1", "@babel/traverse": "7.28.4" }, "peerDependencies": { "@babel/core": "7.28.4" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "7.27.2", "@babel/types": "7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], + + "@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "7.27.1" }, "peerDependencies": { "@babel/core": "7.28.4" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "7.27.1" }, "peerDependencies": { "@babel/core": "7.28.4" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/parser": "7.28.4", "@babel/types": "7.28.4" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + + "@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/generator": "7.28.3", "@babel/helper-globals": "7.28.0", "@babel/parser": "7.28.4", "@babel/template": "7.27.2", "@babel/types": "7.28.4", "debug": "4.4.3" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="], + + "@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], + + "@better-auth/core": ["@better-auth/core@1.3.26", "", { "dependencies": { "better-call": "1.0.19", "zod": "4.1.11" } }, "sha512-S5ooXaOcn9eLV3/JayfbMsAB5PkfoTRaRrtpb5djwvI/UAJOgLyjqhd+rObsBycovQ/nPQvMKjzyM/G1oBKngA=="], + + "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "3.0.5", "@csstools/css-tokenizer": "3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "5.1.0", "@csstools/css-calc": "2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "3.0.5", "@csstools/css-tokenizer": "3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.14", "", { "peerDependencies": { "postcss": "8.5.6" } }, "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "3.1.1", "@dnd-kit/utilities": "3.2.2", "tslib": "2.8.1" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "3.2.2", "tslib": "2.8.1" }, "peerDependencies": { "@dnd-kit/core": "6.3.1", "react": "19.2.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "3.2.2", "tslib": "2.8.1" }, "peerDependencies": { "@dnd-kit/core": "6.3.1", "react": "19.2.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + + "@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "2.8.1" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "3.4.3" }, "peerDependencies": { "eslint": "9.37.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "2.1.6", "debug": "4.4.3", "minimatch": "3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.0", "", { "dependencies": { "@eslint/core": "0.16.0" } }, "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog=="], + + "@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "6.12.6", "debug": "4.4.3", "espree": "10.4.0", "globals": "14.0.0", "ignore": "5.3.2", "import-fresh": "3.3.1", "js-yaml": "4.1.0", "minimatch": "3.1.2", "strip-json-comments": "3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + + "@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "0.16.0", "levn": "0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "1.7.3", "@floating-ui/utils": "0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "1.7.4" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + + "@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "7.64.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "0.19.1", "@humanwhocodes/retry": "0.4.3" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.3" }, "os": "darwin", "cpu": "x64" }, "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.3" }, "os": "linux", "cpu": "arm" }, "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.3" }, "os": "linux", "cpu": "s390x" }, "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.4", "", { "dependencies": { "@emnapi/runtime": "1.5.0" }, "cpu": "none" }, "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "7.1.2" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "0.3.13", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.2", "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "1.5.0", "@emnapi/runtime": "1.5.0", "@tybys/wasm-util": "0.10.1" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + + "@next/env": ["@next/env@16.0.1", "", {}, "sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw=="], + + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.0.1", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-g4Cqmv/gyFEXNeVB2HkqDlYKfy+YrlM2k8AVIO/YQVEPfhVruH1VA99uT1zELLnPLIeOnx8IZ6Ddso0asfTIdw=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-XeEUJsE4JYtfrXe/LaJn3z1pD19fK0Q6Er8Qoufi+HqvdO4LEPyCxLUt4rxA+4RfYo6S9gMlmzCMU2F+AatFqQ=="], + + "@noble/ciphers": ["@noble/ciphers@2.0.1", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + + "@paper-design/shaders": ["@paper-design/shaders@0.0.55", "", {}, "sha512-9Qrt54v4bOvPsfC2o8s4dBDZJfhIsX3lCfsu/CkySbvLSTqV3x+POO51x5sEd4AFUj8DwhkF/Ai+z4hl4HGtQw=="], + + "@paper-design/shaders-react": ["@paper-design/shaders-react@0.0.55", "", { "dependencies": { "@paper-design/shaders": "0.0.55" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-bIxdbjg+R9Hote+xrp1Po1dFEFUsHtBKBdnU57ioWSpNxTjXP0DXQPStQkS3qmknuw8n2DErarVkDLSyJ0HzwQ=="], + + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "2.5.0", "asn1js": "3.0.6", "tslib": "2.8.1" } }, "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "2.5.0", "@peculiar/asn1-x509": "2.5.0", "@peculiar/asn1-x509-attr": "2.5.0", "asn1js": "3.0.6", "tslib": "2.8.1" } }, "sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "2.5.0", "@peculiar/asn1-x509": "2.5.0", "asn1js": "3.0.6", "tslib": "2.8.1" } }, "sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "2.5.0", "@peculiar/asn1-x509": "2.5.0", "asn1js": "3.0.6", "tslib": "2.8.1" } }, "sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.5.0", "", { "dependencies": { "@peculiar/asn1-cms": "2.5.0", "@peculiar/asn1-pkcs8": "2.5.0", "@peculiar/asn1-rsa": "2.5.0", "@peculiar/asn1-schema": "2.5.0", "asn1js": "3.0.6", "tslib": "2.8.1" } }, "sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "2.5.0", "@peculiar/asn1-x509": "2.5.0", "asn1js": "3.0.6", "tslib": "2.8.1" } }, "sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.5.0", "", { "dependencies": { "@peculiar/asn1-cms": "2.5.0", "@peculiar/asn1-pfx": "2.5.0", "@peculiar/asn1-pkcs8": "2.5.0", "@peculiar/asn1-schema": "2.5.0", "@peculiar/asn1-x509": "2.5.0", "@peculiar/asn1-x509-attr": "2.5.0", "asn1js": "3.0.6", "tslib": "2.8.1" } }, "sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "2.5.0", "@peculiar/asn1-x509": "2.5.0", "asn1js": "3.0.6", "tslib": "2.8.1" } }, "sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.5.0", "", { "dependencies": { "asn1js": "3.0.6", "pvtsutils": "1.3.6", "tslib": "2.8.1" } }, "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "2.5.0", "asn1js": "3.0.6", "pvtsutils": "1.3.6", "tslib": "2.8.1" } }, "sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "2.5.0", "@peculiar/asn1-x509": "2.5.0", "asn1js": "3.0.6", "tslib": "2.8.1" } }, "sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.0", "", { "dependencies": { "@peculiar/asn1-cms": "2.5.0", "@peculiar/asn1-csr": "2.5.0", "@peculiar/asn1-ecc": "2.5.0", "@peculiar/asn1-pkcs9": "2.5.0", "@peculiar/asn1-rsa": "2.5.0", "@peculiar/asn1-schema": "2.5.0", "@peculiar/asn1-x509": "2.5.0", "pvtsutils": "1.3.6", "reflect-metadata": "0.2.2", "tslib": "2.8.1", "tsyringe": "4.10.0" } }, "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + + "@prisma/client": ["@prisma/client@6.19.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g=="], + + "@prisma/config": ["@prisma/config@6.19.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg=="], + + "@prisma/debug": ["@prisma/debug@6.19.0", "", {}, "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA=="], + + "@prisma/engines": ["@prisma/engines@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/fetch-engine": "6.19.0", "@prisma/get-platform": "6.19.0" } }, "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw=="], + + "@prisma/engines-version": ["@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "", {}, "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/get-platform": "6.19.0" } }, "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ=="], + + "@prisma/get-platform": ["@prisma/get-platform@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0" } }, "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "1.2.6", "react-remove-scroll": "2.7.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "1.2.6", "react-remove-scroll": "2.7.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "1.2.6", "react-remove-scroll": "2.7.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "2.1.6", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "1.2.6", "react-remove-scroll": "2.7.1" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "1.6.0" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + + "@react-pdf/fns": ["@react-pdf/fns@3.1.2", "", {}, "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g=="], + + "@react-pdf/font": ["@react-pdf/font@4.0.3", "", { "dependencies": { "@react-pdf/pdfkit": "4.0.4", "@react-pdf/types": "2.9.1", "fontkit": "2.0.4", "is-url": "1.2.4" } }, "sha512-N1qQDZr6phXYQOp033Hvm2nkUkx2LkszjGPbmRavs9VOYzi4sp31MaccMKptL24ii6UhBh/z9yPUhnuNe/qHwA=="], + + "@react-pdf/image": ["@react-pdf/image@3.0.3", "", { "dependencies": { "@react-pdf/png-js": "3.0.0", "jay-peg": "1.1.1" } }, "sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ=="], + + "@react-pdf/layout": ["@react-pdf/layout@4.4.1", "", { "dependencies": { "@react-pdf/fns": "3.1.2", "@react-pdf/image": "3.0.3", "@react-pdf/primitives": "4.1.1", "@react-pdf/stylesheet": "6.1.1", "@react-pdf/textkit": "6.0.0", "@react-pdf/types": "2.9.1", "emoji-regex-xs": "1.0.0", "queue": "6.0.2", "yoga-layout": "3.2.1" } }, "sha512-GVzdlWoZWldRDzlWj3SttRXmVDxg7YfraAohwy+o9gb9hrbDJaaAV6jV3pc630Evd3K46OAzk8EFu8EgPDuVuA=="], + + "@react-pdf/pdfkit": ["@react-pdf/pdfkit@4.0.4", "", { "dependencies": { "@babel/runtime": "7.28.4", "@react-pdf/png-js": "3.0.0", "browserify-zlib": "0.2.0", "crypto-js": "4.2.0", "fontkit": "2.0.4", "jay-peg": "1.1.1", "linebreak": "1.1.0", "vite-compatible-readable-stream": "3.6.1" } }, "sha512-/nITLggsPlB66bVLnm0X7MNdKQxXelLGZG6zB5acF5cCgkFwmXHnLNyxYOUD4GMOMg1HOPShXDKWrwk2ZeHsvw=="], + + "@react-pdf/png-js": ["@react-pdf/png-js@3.0.0", "", { "dependencies": { "browserify-zlib": "0.2.0" } }, "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA=="], + + "@react-pdf/primitives": ["@react-pdf/primitives@4.1.1", "", {}, "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ=="], + + "@react-pdf/reconciler": ["@react-pdf/reconciler@1.1.4", "", { "dependencies": { "object-assign": "4.1.1", "scheduler": "0.25.0-rc-603e6108-20241029" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg=="], + + "@react-pdf/render": ["@react-pdf/render@4.3.1", "", { "dependencies": { "@babel/runtime": "7.28.4", "@react-pdf/fns": "3.1.2", "@react-pdf/primitives": "4.1.1", "@react-pdf/textkit": "6.0.0", "@react-pdf/types": "2.9.1", "abs-svg-path": "0.1.1", "color-string": "1.9.1", "normalize-svg-path": "1.1.0", "parse-svg-path": "0.1.2", "svg-arc-to-cubic-bezier": "3.2.0" } }, "sha512-v1WAaAhQShQZGcBxfjkEThGCHVH9CSuitrZ1bIOLvB5iBKM14abYK5D6djKhWCwF6FTzYeT2WRjRMVgze/ND2A=="], + + "@react-pdf/renderer": ["@react-pdf/renderer@4.3.1", "", { "dependencies": { "@babel/runtime": "7.28.4", "@react-pdf/fns": "3.1.2", "@react-pdf/font": "4.0.3", "@react-pdf/layout": "4.4.1", "@react-pdf/pdfkit": "4.0.4", "@react-pdf/primitives": "4.1.1", "@react-pdf/reconciler": "1.1.4", "@react-pdf/render": "4.3.1", "@react-pdf/types": "2.9.1", "events": "3.3.0", "object-assign": "4.1.1", "prop-types": "15.8.1", "queue": "6.0.2" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-dPKHiwGTaOsKqNWCHPYYrx8CDfAGsUnV4tvRsEu0VPGxuot1AOq/M+YgfN/Pb+MeXCTe2/lv6NvA8haUtj3tsA=="], + + "@react-pdf/stylesheet": ["@react-pdf/stylesheet@6.1.1", "", { "dependencies": { "@react-pdf/fns": "3.1.2", "@react-pdf/types": "2.9.1", "color-string": "1.9.1", "hsl-to-hex": "1.0.0", "media-engine": "1.0.3", "postcss-value-parser": "4.2.0" } }, "sha512-Iyw0A3wRIeQLN4EkaKf8yF9MvdMxiZ8JjoyzLzDHSxnKYoOA4UGu84veCb8dT9N8MxY5x7a0BUv/avTe586Plg=="], + + "@react-pdf/textkit": ["@react-pdf/textkit@6.0.0", "", { "dependencies": { "@react-pdf/fns": "3.1.2", "bidi-js": "1.0.3", "hyphen": "1.10.6", "unicode-properties": "1.4.1" } }, "sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw=="], + + "@react-pdf/types": ["@react-pdf/types@2.9.1", "", { "dependencies": { "@react-pdf/font": "4.0.3", "@react-pdf/primitives": "4.1.1", "@react-pdf/stylesheet": "6.1.1" } }, "sha512-5GoCgG0G5NMgpPuHbKG2xcVRQt7+E5pg3IyzVIIozKG3nLcnsXW4zy25vG1ZBQA0jmo39q34au/sOnL/0d1A4w=="], + + "@react-three/fiber": ["@react-three/fiber@9.3.0", "", { "dependencies": { "@babel/runtime": "7.28.4", "@types/react-reconciler": "0.32.1", "@types/webxr": "0.5.24", "base64-js": "1.5.1", "buffer": "6.0.3", "its-fine": "2.0.0", "react-reconciler": "0.31.0", "react-use-measure": "2.1.7", "scheduler": "0.25.0", "suspend-react": "0.1.3", "use-sync-external-store": "1.6.0", "zustand": "5.0.8" }, "optionalDependencies": { "react-dom": "19.2.0" }, "peerDependencies": { "react": "19.2.0", "three": "0.180.0" } }, "sha512-myPe3YL/C8+Eq939/4qIVEPBW/uxV0iiUbmjfwrs9sGKYDG8ib8Dz3Okq7BQt8P+0k4igedONbjXMQy84aDFmQ=="], + + "@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.4", "", { "os": "none", "cpu": "arm64" }, "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="], + + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="], + + "@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "1.1.28", "@levischuck/tiny-cbor": "0.2.11", "@peculiar/asn1-android": "2.5.0", "@peculiar/asn1-ecc": "2.5.0", "@peculiar/asn1-rsa": "2.5.0", "@peculiar/asn1-schema": "2.5.0", "@peculiar/asn1-x509": "2.5.0", "@peculiar/x509": "1.14.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@tabler/icons": ["@tabler/icons@3.35.0", "", {}, "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ=="], + + "@tabler/icons-react": ["@tabler/icons-react@3.35.0", "", { "dependencies": { "@tabler/icons": "3.35.0" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "enhanced-resolve": "5.18.3", "jiti": "2.6.1", "lightningcss": "1.30.1", "magic-string": "0.30.19", "source-map-js": "1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "2.1.1", "tar": "7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.14", "", { "dependencies": { "@alloc/quick-lru": "5.2.0", "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "postcss": "8.5.6", "tailwindcss": "4.1.14" } }, "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg=="], + + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + + "@tauri-apps/cli": ["@tauri-apps/cli@2.8.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.8.4", "@tauri-apps/cli-darwin-x64": "2.8.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.8.4", "@tauri-apps/cli-linux-arm64-gnu": "2.8.4", "@tauri-apps/cli-linux-arm64-musl": "2.8.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.8.4", "@tauri-apps/cli-linux-x64-gnu": "2.8.4", "@tauri-apps/cli-linux-x64-musl": "2.8.4", "@tauri-apps/cli-win32-arm64-msvc": "2.8.4", "@tauri-apps/cli-win32-ia32-msvc": "2.8.4", "@tauri-apps/cli-win32-x64-msvc": "2.8.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-ejUZBzuQRcjFV+v/gdj/DcbyX/6T4unZQjMSBZwLzP/CymEjKcc2+Fc8xTORThebHDUvqoXMdsCZt8r+hyN15g=="], + + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.8.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA=="], + + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.8.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g=="], + + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.8.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw=="], + + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw=="], + + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw=="], + + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.8.4", "", { "os": "linux", "cpu": "none" }, "sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ=="], + + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw=="], + + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA=="], + + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.8.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w=="], + + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.8.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA=="], + + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.8.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA=="], + + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "2.8.0" } }, "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA=="], + + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.0", "", { "dependencies": { "@tauri-apps/api": "2.8.0" } }, "sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ=="], + + "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.0", "", { "dependencies": { "@tauri-apps/api": "2.8.0" } }, "sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A=="], + + "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.9.0", "", { "dependencies": { "@tauri-apps/api": "2.8.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="], + + "@tiptap/core": ["@tiptap/core@3.10.1", "", { "peerDependencies": { "@tiptap/pm": "3.10.1" } }, "sha512-YY/u+RsjLVhcUaIn+wv6vjMx8kldO7SzFFnRu0iuC+QW57VrlqUzqz5PR6CenphwJHuqGM5b3SCr4K2ZPjN8jQ=="], + + "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-swBtOW1g6LMwA1LTZN2GBpdgwOD6pL/SX1GrfZJ46uQF8uBuErsUc+Iop7SX3pVPGLmQg40k0qW5k9QjEC8dGw=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-8TE9oFEonoAs0k3Vd1RGW1FiDBayJiBWyd+1eoH6EEmk1DD7quHcP1mBNZwPpjhONMITaSmizs2FjweWYibFwA=="], + + "@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.10.1", "", { "dependencies": { "@floating-ui/dom": "1.7.4" }, "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-oNRXAupEeDCeI4nkIhCYSUuT9eZeHDWXcC5fQeWDzCPv3hOcm7W4jqUGJhWWD6qhcbmUSKmsGDTLkBfNk4NT4Q=="], + + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.10.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.10.1" } }, "sha512-SzE8u9QrpzculNmtxKJZAvNG2hGLwishk4oUocK8aAYGUhesKd4pLHE1emA54TgWP0t1aXstg49QIhmHcUND0A=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-jeStJuFR5jpwHw/jdnqc1sVNe73dJcqDhcjmNV8cxy86BBadSGynUL1O1/vIyGbF1BFkU69UDBAOLptPH/M2Xg=="], + + "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-Yy7XREi27aUE3S1NMihq0j4vM9rNLa3AQVHWFx1Ze2Jec2MUK7ef8WUkMs28cX76M+yB4P63Q2z8meH6HUAzyA=="], + + "@tiptap/extension-document": ["@tiptap/extension-document@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-HM9lmPGKX1s9NJYQh1BD6oLqwh0gWylNmgkT6hEI7lm7DANxaYyMZue9anCDae+K6tln22BauXGAfbRb6Bs0Lw=="], + + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.10.1", "", { "peerDependencies": { "@tiptap/extensions": "3.10.1" } }, "sha512-fF3h2Oac8vr21uJh+tiUEz/XUoEzXqx5JpoyWj6BmrTulaMY5uw+SUbh1MxN2EeZ+dUvoc8wPATvn0TTq/3GpA=="], + + "@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.10.1", "", { "peerDependencies": { "@floating-ui/dom": "1.7.4", "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-D5ociNnOI3OP4NxS8eKiiqjUdO7geOguK4ZhJo1CFiIXXoLyV20wqqu4fe8Hq9+4gbEyyJ55Tz/AzLiaXw/GPQ=="], + + "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.10.1", "", { "peerDependencies": { "@tiptap/extensions": "3.10.1" } }, "sha512-Tg43PHL21ZgVXiQZrXmMWCx8jZGEfxB7xxamEkl0CdRFGkcXRmARXuNKT72NtCI3t7/QSlKbpyD/2/9RFGvyeA=="], + + "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-kCz/ILEVr3jd4/adOfl9d62dEe9PQrHXAB5rBy1ZFoNC+C7Trq8cgpyqUYFAK7Z500nKmUgQh1GtqGN2vy338Q=="], + + "@tiptap/extension-heading": ["@tiptap/extension-heading@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-udG4cG1pmumECEb6WDW/qYtuHcHscTMPCR6mG8hz0WpYk1S+LQWGPaQPdvHK6qYrMo/3YwQcYZv5vuQiB3dpjg=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-P9dJrVnVlYTESmXWMDmAMHw1TLHZwKQV8Yfz1f8mCuuIHTR++hoWVgjZ70MYZzdAMCug3FWsmDjo+sxGCWOTpg=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-/VbABhC20z/KWhKjcFUk7jJuOgD8Hp2V5lr6fOLFJaRpptoJhmbCRrPJzEZhs/Z55nv6aF7ZxVxtjzO0FpKneQ=="], + + "@tiptap/extension-link": ["@tiptap/extension-link@3.10.1", "", { "dependencies": { "linkifyjs": "4.3.2" }, "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-87OBwlU/ylPCDNhNyKPQaM0KiT0FscyAqh8/oErmI7gKVdrUNfO4zcqIOKHql32lEu9KsmpSum/jSeeUJMR4pA=="], + + "@tiptap/extension-list": ["@tiptap/extension-list@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-v1TqDqNq3RXwKXyCoObv+42qrxAEtpac3BRZKWwwUcxM55oP5HxeaiEo2usheLs3+fEFkKtWKof2I9gUW0HLvA=="], + + "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.10.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.10.1" } }, "sha512-YCK2N2RJGnvMTolwMD3kutnN4x1duBhUH14SdigJuPQLhDi02ck6jjTCNTjQRgDfpL9qfSLpPdn0ou7+NbFu3g=="], + + "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.10.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.10.1" } }, "sha512-EPFZtv4yzuCRXqyIQ6v7xvDFGb9L4O+r6NpQ/Aim6fgQmElxHKs75iDet0dFWGQ/Re/o1Q7zgW3mhBcl1MLszw=="], + + "@tiptap/extension-mention": ["@tiptap/extension-mention@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1", "@tiptap/suggestion": "3.10.1" } }, "sha512-s7zC3gBQQL99vH37/WdagfLFIDmSJz1uV6fsouckIag0nHBxKTPsZy4LR8CRZZ6RECIyj2WGm71GoVqKUUSEBw=="], + + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.10.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.10.1" } }, "sha512-dpKNFFF8QqfwSuXYoTktb3Woeqqjc3pZ4Vx4F4JSyzIlgBPLim0Wkn18ClJFIC2But/FcLm6NQrlpnimExfFlQ=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-ocxyg947q5yOSyripEingN7SnsJ/4cYuxOg8BdNlSao8HzUTw5298/81Almf2pT0FNAJHMp8R4Xsii2oMlJ/yQ=="], + + "@tiptap/extension-placeholder": ["@tiptap/extension-placeholder@3.10.1", "", { "peerDependencies": { "@tiptap/extensions": "3.10.1" } }, "sha512-7R31ytEtyYKNrj3g610sHiUvseRnNyzxMlYtwXEQIZ8w3St5QduwJm+AMOygS4Nmdg88C1zsu1VIiRZCKFutbw=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-NYnQOQM/HRvOcCRdetZthMMOZFpxpJ2PBuYg6u6ysotFJPWVVaegtNfZ4se0UdxDNPYInTW3gAgF05Tq/XszRQ=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-Af0WBQJvjiTnEArutOZENCVNGuK7Ln3BwUH3jXsk4OUHxOyt5JK9qsDePsO46Dj9OlXHbnBi5hAnhJGI8zGLzw=="], + + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1" } }, "sha512-U56hHqCSjwP8wAq28n6A+l+aNW/DxJXiaNwXs7YlC4IjRDkbsl5q53UcOlRCoVnYVY2mxj1L6Zmu2u6dhjeuSQ=="], + + "@tiptap/extensions": ["@tiptap/extensions@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-tZZ1IGIcch4ezuoid3iPSirh0s2GQuSKY6ceWRJCVeZ2gT2LsN3i10tqfidcYrsmyQRMuM7QUfRmH5HOKJZ73Q=="], + + "@tiptap/markdown": ["@tiptap/markdown@3.10.1", "", { "dependencies": { "marked": "16.4.1" }, "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-uWXFAO34u36PYU95kjIl3k1FeNXoZ4Vv4wMR2tZDU1B5xyTgYddLCBpr4Brl2FPAW97VNsqkFabZA1pHXC9OEQ=="], + + "@tiptap/pm": ["@tiptap/pm@3.10.1", "", { "dependencies": { "prosemirror-changeset": "2.3.1", "prosemirror-collab": "1.3.1", "prosemirror-commands": "1.7.1", "prosemirror-dropcursor": "1.8.2", "prosemirror-gapcursor": "1.3.2", "prosemirror-history": "1.4.1", "prosemirror-inputrules": "1.5.0", "prosemirror-keymap": "1.2.3", "prosemirror-markdown": "1.13.2", "prosemirror-menu": "1.2.5", "prosemirror-model": "1.25.3", "prosemirror-schema-basic": "1.2.4", "prosemirror-schema-list": "1.5.1", "prosemirror-state": "1.4.3", "prosemirror-tables": "1.8.1", "prosemirror-trailing-node": "3.0.0", "prosemirror-transform": "1.10.4", "prosemirror-view": "1.41.2" } }, "sha512-LhTRI+bECLFqitWN821A7faVFVw5OitFGWn45LIIRc/1Jg3lkqsaqx3LcLN1sjXd+f/vfoeXLKSD6VJvv/B/nQ=="], + + "@tiptap/react": ["@tiptap/react@3.10.1", "", { "dependencies": { "@types/use-sync-external-store": "0.0.6", "fast-deep-equal": "3.1.3", "use-sync-external-store": "1.6.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "3.10.1", "@tiptap/extension-floating-menu": "3.10.1" }, "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1", "@types/react": "18.3.26", "@types/react-dom": "18.3.7", "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-skL1a+WorLKv+m0bkbPKIbavN2CSBueWqEWxYs1AxI0qk2v49oRj/cyvv7lLUC2sdzds9GqXHcSBDqsw8Th+hw=="], + + "@tiptap/starter-kit": ["@tiptap/starter-kit@3.10.1", "", { "dependencies": { "@tiptap/core": "3.10.1", "@tiptap/extension-blockquote": "3.10.1", "@tiptap/extension-bold": "3.10.1", "@tiptap/extension-bullet-list": "3.10.1", "@tiptap/extension-code": "3.10.1", "@tiptap/extension-code-block": "3.10.1", "@tiptap/extension-document": "3.10.1", "@tiptap/extension-dropcursor": "3.10.1", "@tiptap/extension-gapcursor": "3.10.1", "@tiptap/extension-hard-break": "3.10.1", "@tiptap/extension-heading": "3.10.1", "@tiptap/extension-horizontal-rule": "3.10.1", "@tiptap/extension-italic": "3.10.1", "@tiptap/extension-link": "3.10.1", "@tiptap/extension-list": "3.10.1", "@tiptap/extension-list-item": "3.10.1", "@tiptap/extension-list-keymap": "3.10.1", "@tiptap/extension-ordered-list": "3.10.1", "@tiptap/extension-paragraph": "3.10.1", "@tiptap/extension-strike": "3.10.1", "@tiptap/extension-text": "3.10.1", "@tiptap/extension-underline": "3.10.1", "@tiptap/extensions": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-7IRZqLbvb6VWTS1nIRClQiE54I37aXFejdViTBRvxWb2TiWW8wpsfSdNAMklfwmFbg7RmOO9vaOHlilVu+donw=="], + + "@tiptap/suggestion": ["@tiptap/suggestion@3.10.1", "", { "peerDependencies": { "@tiptap/core": "3.10.1", "@tiptap/pm": "3.10.1" } }, "sha512-QpSMsMtpsBSapCDytjdKXLcuPunnd00fGSrYp23C4BDI2Ph7JOYHsgv/wIKgpAYg2fpbJT6DIIbpSHhWluEFyw=="], + + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "7.28.4", "@babel/types": "7.28.4", "@types/babel__generator": "7.27.0", "@types/babel__template": "7.4.4", "@types/babel__traverse": "7.28.0" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "7.28.4" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "7.28.4", "@babel/types": "7.28.4" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "7.28.4" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "4.0.2", "assertion-error": "2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "3.1.3" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "3.0.4" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "3.1.1" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + + "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "5.0.0", "@types/mdurl": "2.0.0" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], + + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + + "@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="], + + "@types/pdfkit": ["@types/pdfkit@0.17.3", "", { "dependencies": { "@types/node": "20.19.19" } }, "sha512-E4tp2qFaghqfS4K5TR4Gn1uTIkg0UAkhUgvVIszr5cS6ZmbioPWEkvhNDy3GtR9qdKC8DLQAnaaMlTcf346VsA=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.26", "", { "dependencies": { "@types/prop-types": "15.7.15", "csstype": "3.1.3" } }, "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "18.3.26" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@types/react-reconciler": ["@types/react-reconciler@0.32.1", "", { "peerDependencies": { "@types/react": "18.3.26" } }, "sha512-RsqPttsBQ+6af0nATFXJJpemYQH7kL9+xLNm1z+0MjQFDKBZDM2R6SBrjdvRmHu9i9fM6povACj57Ft+pKRNOA=="], + + "@types/sanitize-html": ["@types/sanitize-html@2.16.0", "", { "dependencies": { "htmlparser2": "8.0.2" } }, "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw=="], + + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.180.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "0.12.0", "@tweenjs/tween.js": "23.1.3", "@types/stats.js": "0.17.4", "@types/webxr": "0.5.24", "@webgpu/types": "0.1.65", "fflate": "0.8.2", "meshoptimizer": "0.22.0" } }, "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg=="], + + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.2", "", { "dependencies": { "@eslint-community/regexpp": "4.12.1", "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/type-utils": "8.46.2", "@typescript-eslint/utils": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "1.4.0", "ignore": "7.0.5", "natural-compare": "1.4.0", "ts-api-utils": "2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "8.46.2", "eslint": "9.37.0", "typescript": "5.9.3" } }, "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", "@typescript-eslint/typescript-estree": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "debug": "4.4.3" }, "peerDependencies": { "eslint": "9.37.0", "typescript": "5.9.3" } }, "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.46.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "8.46.2", "@typescript-eslint/types": "8.46.2", "debug": "4.4.3" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.2", "", { "dependencies": { "@typescript-eslint/types": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2" } }, "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.46.2", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.46.2", "", { "dependencies": { "@typescript-eslint/types": "8.46.2", "@typescript-eslint/typescript-estree": "8.46.2", "@typescript-eslint/utils": "8.46.2", "debug": "4.4.3", "ts-api-utils": "2.1.0" }, "peerDependencies": { "eslint": "9.37.0", "typescript": "5.9.3" } }, "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.46.2", "", {}, "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.2", "@typescript-eslint/tsconfig-utils": "8.46.2", "@typescript-eslint/types": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "debug": "4.4.3", "fast-glob": "3.3.3", "is-glob": "4.0.3", "minimatch": "9.0.5", "semver": "7.7.2", "ts-api-utils": "2.1.0" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.2", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.0", "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", "@typescript-eslint/typescript-estree": "8.46.2" }, "peerDependencies": { "eslint": "9.37.0", "typescript": "5.9.3" } }, "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.2", "", { "dependencies": { "@typescript-eslint/types": "8.46.2", "eslint-visitor-keys": "4.2.1" } }, "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "0.2.12" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "7.28.4", "@babel/plugin-transform-react-jsx-self": "7.27.1", "@babel/plugin-transform-react-jsx-source": "7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "7.20.5", "react-refresh": "0.17.0" }, "peerDependencies": { "vite": "6.3.6" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@vitest/browser": ["@vitest/browser@4.0.1", "", { "dependencies": { "@vitest/mocker": "4.0.1", "@vitest/utils": "4.0.1", "magic-string": "0.30.19", "pixelmatch": "7.1.0", "pngjs": "7.0.0", "sirv": "3.0.2", "tinyrainbow": "3.0.3", "ws": "8.18.3" }, "peerDependencies": { "vitest": "4.0.1" } }, "sha512-nXESrLVnYEaFwqPAYa8cCrpTQMAatO1OepV/i27RVz9uawKAQSsSH7T8wjxouKXZAr8dBITYJmcIZNBbDNFn/A=="], + + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.0.1", "", { "dependencies": { "@vitest/browser": "4.0.1", "@vitest/mocker": "4.0.1", "tinyrainbow": "3.0.3" }, "peerDependencies": { "playwright": "1.56.1", "vitest": "4.0.1" } }, "sha512-hp1z7yfubB6saK0NCUiRUjvVWoomCeUIUcnvdzr8tRTe6NjFjgeJqc6bt+iG59OqcIx+acneR+OJuB3P8ajiFg=="], + + "@vitest/expect": ["@vitest/expect@4.0.1", "", { "dependencies": { "@standard-schema/spec": "1.0.0", "@types/chai": "5.2.3", "@vitest/spy": "4.0.1", "@vitest/utils": "4.0.1", "chai": "6.2.0", "tinyrainbow": "3.0.3" } }, "sha512-KtvGLN/IWoZfg68JF2q/zbDEo+UJTWnc7suYJ8RF+ZTBeBcBz4NIOJDxO4Q3bEY9GsOYhgy5cOevcVPFh4+V7g=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.1", "", { "dependencies": { "@vitest/spy": "4.0.1", "estree-walker": "3.0.3", "magic-string": "0.30.19" }, "optionalDependencies": { "vite": "7.1.11" } }, "sha512-fwmvg8YvwSAE41Hyhul7dL4UzPhG+k2VaZCcL+aHagLx4qlNQgKYTw7coF4YdjAxSBBt0b408gQFYMX1Qeqweg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.1", "", { "dependencies": { "tinyrainbow": "3.0.3" } }, "sha512-6nq3JY/zQ91+oX1vd4fajiVNyA/HMhaF9cOw5P9cQi6ML7PRi7ilVaQ77PulF+4kvUKr9bcLm9GoAtwlVFbGzw=="], + + "@vitest/runner": ["@vitest/runner@4.0.1", "", { "dependencies": { "@vitest/utils": "4.0.1", "pathe": "2.0.3" } }, "sha512-nxUoWmw7ZX2OiSNwolJeSOOzrrR/o79wRTwP7HhiW/lDFwQHtWMj9snMhrdvccFqanvI8897E81eXjgDbrRvqA=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.1", "", { "dependencies": { "@vitest/pretty-format": "4.0.1", "magic-string": "0.30.19", "pathe": "2.0.3" } }, "sha512-CvfsEWutEIN/Z9ScXYup7YwlPeK9JICrV7FN9p3pVytsyh+aCHAH0PUi//YlTiQ7T8qYxJYpUrAwZL9XqmZ5ZA=="], + + "@vitest/spy": ["@vitest/spy@4.0.1", "", {}, "sha512-Hj0/TBQ2EN72wDpfKiUf63mRCkE0ZiSGXGeDDvW9T3LBKVVApItd0GyQLDBIe03kWbyK9gOTEbJVVWthcLFzCg=="], + + "@vitest/utils": ["@vitest/utils@4.0.1", "", { "dependencies": { "@vitest/pretty-format": "4.0.1", "tinyrainbow": "3.0.3" } }, "sha512-uRrACgpIz5sxuT87ml7xhh7EdKtW8k0N9oSFVBPl8gHB/JfLObLe9dXO6ZrsNN55FzciGIRqIEILgTQvg1eNHw=="], + + "@webgpu/types": ["@webgpu/types@0.1.65", "", {}, "sha512-cYrHab4d6wuVvDW5tdsfI6/o6vcLMDe6w2Citd1oS51Xxu2ycLCnVo4fqwujfKWijrZMInTJIKcXxteoy21nVA=="], + + "abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "appsdesktop": ["appsdesktop@workspace:apps/desktop"], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "is-array-buffer": "3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-object-atoms": "1.1.1", "get-intrinsic": "1.3.0", "is-string": "1.1.1", "math-intrinsics": "1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "es-shim-unscopables": "1.1.0" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "es-shim-unscopables": "1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-shim-unscopables": "1.1.0" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-shim-unscopables": "1.1.0" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-errors": "1.3.0", "es-shim-unscopables": "1.1.0" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "1.0.2", "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "is-array-buffer": "3.0.5" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "1.3.6", "pvutils": "1.1.3", "tslib": "2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "1.1.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.8.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw=="], + + "better-auth": ["better-auth@1.3.26", "", { "dependencies": { "@better-auth/core": "1.3.26", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "2.0.1", "@noble/hashes": "2.0.1", "@simplewebauthn/browser": "13.2.2", "@simplewebauthn/server": "13.2.2", "better-call": "1.0.19", "defu": "6.1.4", "jose": "6.1.0", "kysely": "0.28.7", "nanostores": "1.0.1", "zod": "4.1.11" }, "optionalDependencies": { "next": "16.0.1", "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-umaOGmv29yF4sD6o2zlW6B0Oayko5yD/A8mKJOFDDEIoVP/pR7nJ/2KsqKy+xvBpnDsKdrZseqA8fmczPviUHw=="], + + "better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "rou3": "0.5.1", "set-cookie-parser": "2.7.1", "uncrypto": "0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="], + + "better-sqlite3": ["better-sqlite3@12.4.1", "", { "dependencies": { "bindings": "1.5.0", "prebuild-install": "7.1.3" } }, "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "5.7.1", "inherits": "2.0.4", "readable-stream": "3.6.2" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "1.5.1" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + + "browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "1.0.11" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="], + + "browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "2.8.16", "caniuse-lite": "1.0.30001747", "electron-to-chromium": "1.5.234", "node-releases": "2.0.23", "update-browserslist-db": "1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "1.5.1", "ieee754": "1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "4.0.3", "confbox": "0.2.2", "defu": "6.1.4", "dotenv": "16.6.1", "exsolve": "1.0.7", "giget": "2.0.0", "jiti": "2.6.1", "ohash": "2.0.11", "pathe": "2.0.3", "perfect-debounce": "1.0.0", "pkg-types": "2.3.0", "rc9": "2.1.2" } }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "get-intrinsic": "1.3.0", "set-function-length": "1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "get-intrinsic": "1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001747", "", {}, "sha512-mzFa2DGIhuc5490Nd/G31xN1pnBnYMadtkyTjefPI7wzypqgCEpeWu9bJr0OnDsyKrW75zA9ZAt7pbQFmwLsQg=="], + + "chai": ["chai@6.2.0", "", {}, "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "4.1.2" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "3.4.2" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "1.1.4", "simple-swizzle": "0.2.4" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "convex": ["convex@1.28.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "3.6.2" }, "optionalDependencies": { "react": "19.2.0" }, "bin": { "convex": "bin/main.js" } }, "sha512-40FgeJ/LxP9TxnkDDztU/A5gcGTdq1klcTT5mM0Ak+kSlQiDktMpjNX1TfkWLxXaE3lI4qvawKH95v2RiYgFxA=="], + + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + + "cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "1.0.0", "cross-spawn": "7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "3.1.1", "shebang-command": "2.0.0", "which": "2.0.2" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "1.2.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + + "cssstyle": ["cssstyle@5.3.1", "", { "dependencies": { "@asamuzakjp/css-color": "4.0.5", "@csstools/css-syntax-patches-for-csstree": "1.0.14", "css-tree": "3.1.0" } }, "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "2.0.3" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "3.1.0" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "3.2.4", "d3-format": "3.1.0", "d3-interpolate": "3.0.1", "d3-time": "3.1.0", "d3-time-format": "4.1.0" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "3.2.4" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "3.1.0" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "4.0.0", "whatwg-url": "15.1.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-data-view": "1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-data-view": "1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-data-view": "1.0.2" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "1.0.1", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "1.1.4", "has-property-descriptors": "1.0.2", "object-keys": "1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.1", "", {}, "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "2.0.3" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "7.28.4", "csstype": "3.1.3" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "entities": "4.5.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "2.0.0", "domelementtype": "2.3.0", "domhandler": "5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.234", "", {}, "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "4.2.11", "tapable": "2.3.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "1.0.2", "arraybuffer.prototype.slice": "1.0.4", "available-typed-arrays": "1.0.7", "call-bind": "1.0.8", "call-bound": "1.0.4", "data-view-buffer": "1.0.2", "data-view-byte-length": "1.0.2", "data-view-byte-offset": "1.0.1", "es-define-property": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "es-set-tostringtag": "2.1.0", "es-to-primitive": "1.3.0", "function.prototype.name": "1.1.8", "get-intrinsic": "1.3.0", "get-proto": "1.0.1", "get-symbol-description": "1.1.0", "globalthis": "1.0.4", "gopd": "1.2.0", "has-property-descriptors": "1.0.2", "has-proto": "1.2.0", "has-symbols": "1.1.0", "hasown": "2.0.2", "internal-slot": "1.1.0", "is-array-buffer": "3.0.5", "is-callable": "1.2.7", "is-data-view": "1.0.2", "is-negative-zero": "2.0.3", "is-regex": "1.2.1", "is-set": "2.0.3", "is-shared-array-buffer": "1.0.4", "is-string": "1.1.1", "is-typed-array": "1.1.15", "is-weakref": "1.1.1", "math-intrinsics": "1.1.0", "object-inspect": "1.13.4", "object-keys": "1.1.1", "object.assign": "4.1.7", "own-keys": "1.0.1", "regexp.prototype.flags": "1.5.4", "safe-array-concat": "1.1.3", "safe-push-apply": "1.0.0", "safe-regex-test": "1.1.0", "set-proto": "1.0.0", "stop-iteration-iterator": "1.1.0", "string.prototype.trim": "1.2.10", "string.prototype.trimend": "1.0.9", "string.prototype.trimstart": "1.0.8", "typed-array-buffer": "1.0.3", "typed-array-byte-length": "1.0.3", "typed-array-byte-offset": "1.0.4", "typed-array-length": "1.0.7", "unbox-primitive": "1.1.0", "which-typed-array": "1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.2.1", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-errors": "1.3.0", "es-set-tostringtag": "2.1.0", "function-bind": "1.1.2", "get-intrinsic": "1.3.0", "globalthis": "1.0.4", "gopd": "1.2.0", "has-property-descriptors": "1.0.2", "has-proto": "1.2.0", "has-symbols": "1.1.0", "internal-slot": "1.1.0", "iterator.prototype": "1.1.5", "safe-array-concat": "1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "has-tostringtag": "1.0.2", "hasown": "2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "1.2.7", "is-date-object": "1.1.0", "is-symbol": "1.1.1" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.0", "@eslint-community/regexpp": "4.12.1", "@eslint/config-array": "0.21.0", "@eslint/config-helpers": "0.4.0", "@eslint/core": "0.16.0", "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "0.4.0", "@humanfs/node": "0.16.7", "@humanwhocodes/module-importer": "1.0.1", "@humanwhocodes/retry": "0.4.3", "@types/estree": "1.0.8", "@types/json-schema": "7.0.15", "ajv": "6.12.6", "chalk": "4.1.2", "cross-spawn": "7.0.6", "debug": "4.4.3", "escape-string-regexp": "4.0.0", "eslint-scope": "8.4.0", "eslint-visitor-keys": "4.2.1", "espree": "10.4.0", "esquery": "1.6.0", "esutils": "2.0.3", "fast-deep-equal": "3.1.3", "file-entry-cache": "8.0.0", "find-up": "5.0.0", "glob-parent": "6.0.2", "ignore": "5.3.2", "imurmurhash": "0.1.4", "is-glob": "4.0.3", "json-stable-stringify-without-jsonify": "1.0.1", "lodash.merge": "4.6.2", "minimatch": "3.1.2", "natural-compare": "1.4.0", "optionator": "0.9.4" }, "optionalDependencies": { "jiti": "2.6.1" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="], + + "eslint-config-next": ["eslint-config-next@16.0.1", "", { "dependencies": { "@next/eslint-plugin-next": "16.0.1", "eslint-import-resolver-node": "0.3.9", "eslint-import-resolver-typescript": "3.10.1", "eslint-plugin-import": "2.32.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.0.0", "globals": "16.4.0", "typescript-eslint": "8.46.2" }, "optionalDependencies": { "typescript": "5.9.3" }, "peerDependencies": { "eslint": "9.37.0" } }, "sha512-wNuHw5gNOxwLUvpg0cu6IL0crrVC9hAwdS/7UwleNkwyaMiWIOAwf8yzXVqBBzL3c9A7jVRngJxjoSpPP1aEhg=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "3.2.7", "is-core-module": "2.16.1", "resolve": "1.22.10" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], + + "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "4.4.3", "get-tsconfig": "4.10.1", "is-bun-module": "2.0.0", "stable-hash": "0.0.5", "tinyglobby": "0.2.15", "unrs-resolver": "1.11.1" }, "optionalDependencies": { "eslint-plugin-import": "2.32.0" }, "peerDependencies": { "eslint": "9.37.0" } }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], + + "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "3.2.7" }, "optionalDependencies": { "@typescript-eslint/parser": "8.46.2", "eslint": "9.37.0", "eslint-import-resolver-node": "0.3.9", "eslint-import-resolver-typescript": "3.10.1" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "1.1.0", "array-includes": "3.1.9", "array.prototype.findlastindex": "1.2.6", "array.prototype.flat": "1.3.3", "array.prototype.flatmap": "1.3.3", "debug": "3.2.7", "doctrine": "2.1.0", "eslint-import-resolver-node": "0.3.9", "eslint-module-utils": "2.12.1", "hasown": "2.0.2", "is-core-module": "2.16.1", "is-glob": "4.0.3", "minimatch": "3.1.2", "object.fromentries": "2.0.8", "object.groupby": "1.0.3", "object.values": "1.2.1", "semver": "6.3.1", "string.prototype.trimend": "1.0.9", "tsconfig-paths": "3.15.0" }, "optionalDependencies": { "@typescript-eslint/parser": "8.46.2" }, "peerDependencies": { "eslint": "9.37.0" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "5.3.2", "array-includes": "3.1.9", "array.prototype.flatmap": "1.3.3", "ast-types-flow": "0.0.8", "axe-core": "4.10.3", "axobject-query": "4.1.0", "damerau-levenshtein": "1.0.8", "emoji-regex": "9.2.2", "hasown": "2.0.2", "jsx-ast-utils": "3.3.5", "language-tags": "1.0.9", "minimatch": "3.1.2", "object.fromentries": "2.0.8", "safe-regex-test": "1.1.0", "string.prototype.includes": "2.0.1" }, "peerDependencies": { "eslint": "9.37.0" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "3.1.9", "array.prototype.findlast": "1.2.5", "array.prototype.flatmap": "1.3.3", "array.prototype.tosorted": "1.1.4", "doctrine": "2.1.0", "es-iterator-helpers": "1.2.1", "estraverse": "5.3.0", "hasown": "2.0.2", "jsx-ast-utils": "3.3.5", "minimatch": "3.1.2", "object.entries": "1.1.9", "object.fromentries": "2.0.8", "object.values": "1.2.1", "prop-types": "15.8.1", "resolve": "2.0.0-next.5", "semver": "6.3.1", "string.prototype.matchall": "4.0.12", "string.prototype.repeat": "1.0.0" }, "peerDependencies": { "eslint": "9.37.0" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "9.37.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "4.3.0", "estraverse": "5.3.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "8.15.0", "acorn-jsx": "5.3.2", "eslint-visitor-keys": "4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "1.0.8" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], + + "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-equals": ["fast-equals@5.3.2", "", {}, "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ=="], + + "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "@nodelib/fs.walk": "1.2.8", "glob-parent": "5.1.2", "merge2": "1.4.1", "micromatch": "4.0.8" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "1.1.0" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.5.0", "", { "optionalDependencies": { "picomatch": "4.0.3" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "4.0.1" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "6.0.0", "path-exists": "4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "3.3.3", "keyv": "4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "0.5.15", "brotli": "1.3.3", "clone": "2.1.2", "dfa": "1.2.0", "fast-deep-equal": "3.1.3", "restructure": "3.0.2", "tiny-inflate": "1.0.3", "unicode-properties": "1.4.1", "unicode-trie": "2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-properties": "1.2.1", "functions-have-names": "1.2.3", "hasown": "2.0.2", "is-callable": "1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "function-bind": "1.1.2", "get-proto": "1.0.1", "gopd": "1.2.0", "has-symbols": "1.1.0", "hasown": "2.0.2", "math-intrinsics": "1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "1.0.1", "es-object-atoms": "1.1.1" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "0.1.6", "consola": "3.4.2", "defu": "6.1.4", "node-fetch-native": "1.6.7", "nypm": "0.6.2", "pathe": "2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "1.2.1", "gopd": "1.2.0" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "1.0.1" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "1.0.1" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "1.1.0" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "hsl-to-hex": ["hsl-to-hex@1.0.0", "", { "dependencies": { "hsl-to-rgb-for-reals": "1.1.1" } }, "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA=="], + + "hsl-to-rgb-for-reals": ["hsl-to-rgb-for-reals@1.1.1", "", {}, "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "domutils": "3.2.2", "entities": "4.5.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "hyphen": ["hyphen@1.10.6", "", {}, "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "1.0.1", "resolve-from": "4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "hasown": "2.0.2", "side-channel": "1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "get-intrinsic": "1.3.0" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "1.0.0", "call-bound": "1.0.4", "get-proto": "1.0.1", "has-tostringtag": "1.0.2", "safe-regex-test": "1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "1.1.0" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "7.7.2" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "get-intrinsic": "1.3.0", "is-typed-array": "1.1.15" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "1.0.4" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "1.0.4", "generator-function": "2.0.1", "get-proto": "1.0.1", "has-tostringtag": "1.0.2", "safe-regex-test": "1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "1.0.4", "gopd": "1.2.0", "has-tostringtag": "1.0.2", "hasown": "2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "1.0.4" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "1.0.4", "has-symbols": "1.1.0", "safe-regex-test": "1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "1.1.19" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-url": ["is-url@1.2.4", "", {}, "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "1.0.4" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "1.0.4", "get-intrinsic": "1.3.0" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "1.1.4", "es-object-atoms": "1.1.1", "get-intrinsic": "1.3.0", "get-proto": "1.0.1", "has-symbols": "1.1.0", "set-function-name": "2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "its-fine": ["its-fine@2.0.0", "", { "dependencies": { "@types/react-reconciler": "0.28.9" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng=="], + + "jay-peg": ["jay-peg@1.1.1", "", { "dependencies": { "restructure": "3.0.2" } }, "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="], + + "jpeg-exif": ["jpeg-exif@1.1.4", "", {}, "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "jsdom": ["jsdom@27.0.1", "", { "dependencies": { "@asamuzakjp/dom-selector": "6.7.2", "cssstyle": "5.3.1", "data-urls": "6.0.0", "decimal.js": "10.6.0", "html-encoding-sniffer": "4.0.0", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "is-potential-custom-element-name": "1.0.1", "parse5": "8.0.0", "rrweb-cssom": "0.8.0", "saxes": "6.0.0", "symbol-tree": "3.2.4", "tough-cookie": "6.0.0", "w3c-xmlserializer": "5.0.0", "webidl-conversions": "8.0.0", "whatwg-encoding": "3.1.1", "whatwg-mimetype": "4.0.0", "whatwg-url": "15.1.0", "ws": "8.18.3", "xml-name-validator": "5.0.0" } }, "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "3.1.9", "array.prototype.flat": "1.3.3", "object.assign": "4.1.7", "object.values": "1.2.1" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kysely": ["kysely@0.28.7", "", {}, "sha512-u/cAuTL4DRIiO2/g4vNGRgklEKNIj5Q3CG7RoUB5DV5SfEC2hMvPxKi0GWPmnzwL2ryIeud2VTcEEmqzTzEPNw=="], + + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "0.3.23" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "2.1.1" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "2.1.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + + "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], + + "lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "19.2.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="], + + "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + + "markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "2.0.1", "entities": "4.5.0", "linkify-it": "5.0.0", "mdurl": "2.0.0", "punycode.js": "2.3.1", "uc.micro": "2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="], + + "marked": ["marked@16.4.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + + "media-engine": ["media-engine@1.0.3", "", {}, "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "3.0.3", "picomatch": "2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "1.1.12" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "nanostores": ["nanostores@1.0.1", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "napi-postinstall": ["napi-postinstall@0.3.3", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "next": ["next@16.0.1", "", { "dependencies": { "@next/env": "16.0.1", "@swc/helpers": "0.5.15", "caniuse-lite": "1.0.30001747", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.1", "@next/swc-darwin-x64": "16.0.1", "@next/swc-linux-arm64-gnu": "16.0.1", "@next/swc-linux-arm64-musl": "16.0.1", "@next/swc-linux-x64-gnu": "16.0.1", "@next/swc-linux-x64-musl": "16.0.1", "@next/swc-win32-arm64-msvc": "16.0.1", "@next/swc-win32-x64-msvc": "16.0.1", "sharp": "0.34.4" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" }, "bin": { "next": "dist/bin/next" } }, "sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw=="], + + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + + "node-abi": ["node-abi@3.78.0", "", { "dependencies": { "semver": "7.7.2" } }, "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "node-releases": ["node-releases@2.0.23", "", {}, "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg=="], + + "normalize-svg-path": ["normalize-svg-path@1.1.0", "", { "dependencies": { "svg-arc-to-cubic-bezier": "3.2.0" } }, "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg=="], + + "nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "0.1.6", "consola": "3.4.2", "pathe": "2.0.3", "pkg-types": "2.3.0", "tinyexec": "1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-object-atoms": "1.1.1", "has-symbols": "1.1.0", "object-keys": "1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-object-atoms": "1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-object-atoms": "1.1.1" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.24.0" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-object-atoms": "1.1.1" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "0.1.4", "fast-levenshtein": "2.0.6", "levn": "0.4.1", "prelude-ls": "1.2.1", "type-check": "0.4.0", "word-wrap": "1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "1.3.0", "object-keys": "1.1.1", "safe-push-apply": "1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "3.1.0" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="], + + "parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pdfkit": ["pdfkit@0.17.2", "", { "dependencies": { "crypto-js": "4.2.0", "fontkit": "2.0.4", "jpeg-exif": "1.1.4", "linebreak": "1.1.0", "png-js": "1.0.0" } }, "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="], + + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "0.2.2", "exsolve": "1.0.7", "pathe": "2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + + "playwright": ["playwright@1.56.1", "", { "dependencies": { "playwright-core": "1.56.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw=="], + + "playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="], + + "png-js": ["png-js@1.0.0", "", {}, "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="], + + "png-to-ico": ["png-to-ico@3.0.1", "", { "dependencies": { "@types/node": "22.18.11", "minimist": "1.2.8", "pngjs": "7.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-S8BOAoaGd9gT5uaemQ62arIY3Jzco7Uc7LwUTqRyqJDTsKqOAiyfyN4dSdT0D+Zf8XvgztgpRbM5wnQd7EgYwg=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "2.1.1", "expand-template": "2.0.3", "github-from-package": "0.0.0", "minimist": "1.2.8", "mkdirp-classic": "0.5.3", "napi-build-utils": "2.0.0", "node-abi": "3.78.0", "pump": "3.0.3", "rc": "1.2.8", "simple-get": "4.0.1", "tar-fs": "2.1.4", "tunnel-agent": "0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + + "prisma": ["prisma@6.19.0", "", { "dependencies": { "@prisma/config": "6.19.0", "@prisma/engines": "6.19.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "1.4.0", "object-assign": "4.1.1", "react-is": "16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "prosemirror-changeset": ["prosemirror-changeset@2.3.1", "", { "dependencies": { "prosemirror-transform": "1.10.4" } }, "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ=="], + + "prosemirror-collab": ["prosemirror-collab@1.3.1", "", { "dependencies": { "prosemirror-state": "1.4.3" } }, "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ=="], + + "prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "1.25.3", "prosemirror-state": "1.4.3", "prosemirror-transform": "1.10.4" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="], + + "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "1.4.3", "prosemirror-transform": "1.10.4", "prosemirror-view": "1.41.2" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="], + + "prosemirror-gapcursor": ["prosemirror-gapcursor@1.3.2", "", { "dependencies": { "prosemirror-keymap": "1.2.3", "prosemirror-model": "1.25.3", "prosemirror-state": "1.4.3", "prosemirror-view": "1.41.2" } }, "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ=="], + + "prosemirror-history": ["prosemirror-history@1.4.1", "", { "dependencies": { "prosemirror-state": "1.4.3", "prosemirror-transform": "1.10.4", "prosemirror-view": "1.41.2", "rope-sequence": "1.3.4" } }, "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ=="], + + "prosemirror-inputrules": ["prosemirror-inputrules@1.5.0", "", { "dependencies": { "prosemirror-state": "1.4.3", "prosemirror-transform": "1.10.4" } }, "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA=="], + + "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "1.4.3", "w3c-keyname": "2.2.8" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], + + "prosemirror-markdown": ["prosemirror-markdown@1.13.2", "", { "dependencies": { "@types/markdown-it": "14.1.2", "markdown-it": "14.1.0", "prosemirror-model": "1.25.3" } }, "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g=="], + + "prosemirror-menu": ["prosemirror-menu@1.2.5", "", { "dependencies": { "crelt": "1.0.6", "prosemirror-commands": "1.7.1", "prosemirror-history": "1.4.1", "prosemirror-state": "1.4.3" } }, "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ=="], + + "prosemirror-model": ["prosemirror-model@1.25.3", "", { "dependencies": { "orderedmap": "2.1.1" } }, "sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA=="], + + "prosemirror-schema-basic": ["prosemirror-schema-basic@1.2.4", "", { "dependencies": { "prosemirror-model": "1.25.3" } }, "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ=="], + + "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "1.25.3", "prosemirror-state": "1.4.3", "prosemirror-transform": "1.10.4" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], + + "prosemirror-state": ["prosemirror-state@1.4.3", "", { "dependencies": { "prosemirror-model": "1.25.3", "prosemirror-transform": "1.10.4", "prosemirror-view": "1.41.2" } }, "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q=="], + + "prosemirror-tables": ["prosemirror-tables@1.8.1", "", { "dependencies": { "prosemirror-keymap": "1.2.3", "prosemirror-model": "1.25.3", "prosemirror-state": "1.4.3", "prosemirror-transform": "1.10.4", "prosemirror-view": "1.41.2" } }, "sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug=="], + + "prosemirror-trailing-node": ["prosemirror-trailing-node@3.0.0", "", { "dependencies": { "@remirror/core-constants": "3.0.0", "escape-string-regexp": "4.0.0" }, "peerDependencies": { "prosemirror-model": "1.25.3", "prosemirror-state": "1.4.3", "prosemirror-view": "1.41.2" } }, "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ=="], + + "prosemirror-transform": ["prosemirror-transform@1.10.4", "", { "dependencies": { "prosemirror-model": "1.25.3" } }, "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw=="], + + "prosemirror-view": ["prosemirror-view@1.41.2", "", { "dependencies": { "prosemirror-model": "1.25.3", "prosemirror-state": "1.4.3", "prosemirror-transform": "1.10.4" } }, "sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.3", "", {}, "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ=="], + + "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "2.0.4" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "0.6.0", "ini": "1.3.8", "minimist": "1.2.8", "strip-json-comments": "2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "6.1.4", "destr": "2.0.5" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + + "react-day-picker": ["react-day-picker@9.11.1", "", { "dependencies": { "@date-fns/tz": "1.4.1", "date-fns": "4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw=="], + + "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + + "react-hook-form": ["react-hook-form@7.64.0", "", { "peerDependencies": { "react": "19.2.0" } }, "sha512-fnN+vvTiMLnRqKNTVhDysdrUay0kUUAymQnFIznmgDvapjveUWOOPqMNzPg+A+0yf9DuE2h6xzBjN1s+Qx8wcg=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-reconciler": ["react-reconciler@0.31.0", "", { "dependencies": { "scheduler": "0.25.0" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "2.3.8", "react-style-singleton": "2.2.3", "tslib": "2.8.1", "use-callback-ref": "1.3.3", "use-sidecar": "1.1.3" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "2.2.3", "tslib": "2.8.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "5.3.2", "prop-types": "15.8.1", "react-transition-group": "4.4.5" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "1.0.1", "tslib": "2.8.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "7.28.4", "dom-helpers": "5.2.1", "loose-envify": "1.4.0", "prop-types": "15.8.1" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "react-use-measure": ["react-use-measure@2.1.7", "", { "optionalDependencies": { "react-dom": "19.2.0" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "2.0.4", "string_decoder": "1.3.0", "util-deprecate": "1.0.2" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "2.1.1", "eventemitter3": "4.0.7", "lodash": "4.17.21", "react-is": "18.3.1", "react-smooth": "4.0.4", "recharts-scale": "0.4.5", "tiny-invariant": "1.3.3", "victory-vendor": "36.9.2" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "2.5.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "get-intrinsic": "1.3.0", "get-proto": "1.0.1", "which-builtin-type": "1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-errors": "1.3.0", "get-proto": "1.0.1", "gopd": "1.2.0", "set-function-name": "2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="], + + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + + "rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="], + + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "1.2.3" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "get-intrinsic": "1.3.0", "has-symbols": "1.1.0", "isarray": "2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "1.3.0", "isarray": "2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-regex": "1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sanitize-html": ["sanitize-html@2.17.0", "", { "dependencies": { "deepmerge": "4.3.1", "escape-string-regexp": "4.0.0", "htmlparser2": "8.0.2", "is-plain-object": "5.0.0", "parse-srcset": "1.0.2", "postcss": "8.5.6" } }, "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "1.1.4", "es-errors": "1.3.0", "function-bind": "1.1.2", "get-intrinsic": "1.3.0", "gopd": "1.2.0", "has-property-descriptors": "1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "1.1.4", "es-errors": "1.3.0", "functions-have-names": "1.2.3", "has-property-descriptors": "1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.1" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "1.0.0", "detect-libc": "2.1.1", "semver": "7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4", "side-channel-list": "1.0.0", "side-channel-map": "1.0.1", "side-channel-weakmap": "1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4", "side-channel-map": "1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "6.0.0", "once": "1.4.0", "simple-concat": "1.0.1" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "0.3.4" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "1.0.0-next.29", "mrmime": "2.0.1", "totalist": "3.0.1" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "internal-slot": "1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.24.0" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "get-intrinsic": "1.3.0", "gopd": "1.2.0", "has-symbols": "1.1.0", "internal-slot": "1.1.0", "regexp.prototype.flags": "1.5.4", "set-function-name": "2.0.2", "side-channel": "1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "1.2.1", "es-abstract": "1.24.0" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-data-property": "1.1.4", "define-properties": "1.2.1", "es-abstract": "1.24.0", "es-object-atoms": "1.1.1", "has-property-descriptors": "1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-object-atoms": "1.1.1" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-object-atoms": "1.1.1" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": "19.2.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], + + "svg-arc-to-cubic-bezier": ["svg-arc-to-cubic-bezier@3.2.0", "", {}, "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], + + "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "4.0.1", "chownr": "3.0.0", "minipass": "7.1.2", "minizlib": "3.1.0", "yallist": "5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "1.1.4", "mkdirp-classic": "0.5.3", "pump": "3.0.3", "tar-stream": "2.2.0" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "4.1.0", "end-of-stream": "1.4.5", "fs-constants": "1.0.0", "inherits": "2.0.4", "readable-stream": "3.6.2" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "three": ["three@0.180.0", "", {}, "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w=="], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + + "tippy.js": ["tippy.js@6.3.7", "", { "dependencies": { "@popperjs/core": "2.11.8" } }, "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ=="], + + "tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="], + + "tldts-core": ["tldts-core@7.0.17", "", {}, "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "7.0.17" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "optionalDependencies": { "typescript": "5.9.3" }, "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "2.2.3", "minimist": "1.2.8", "strip-bom": "3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-typed-array": "1.1.15" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "1.0.8", "for-each": "0.3.5", "gopd": "1.2.0", "has-proto": "1.2.0", "is-typed-array": "1.1.15" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "1.0.7", "call-bind": "1.0.8", "for-each": "0.3.5", "gopd": "1.2.0", "has-proto": "1.2.0", "is-typed-array": "1.1.15", "reflect.getprototypeof": "1.0.10" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "1.0.8", "for-each": "0.3.5", "gopd": "1.2.0", "is-typed-array": "1.1.15", "possible-typed-array-names": "1.1.0", "reflect.getprototypeof": "1.0.10" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.46.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.2", "@typescript-eslint/parser": "8.46.2", "@typescript-eslint/typescript-estree": "8.46.2", "@typescript-eslint/utils": "8.46.2" }, "peerDependencies": { "eslint": "9.37.0", "typescript": "5.9.3" } }, "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg=="], + + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "has-bigints": "1.1.0", "has-symbols": "1.1.0", "which-boxed-primitive": "1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "1.5.1", "unicode-trie": "2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "0.2.9", "tiny-inflate": "1.0.3" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + + "unicornstudio-react": ["unicornstudio-react@1.4.31", "", { "optionalDependencies": { "next": "16.0.1" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-EYPeBPyOXiL6ltLMQRJFbBktnai+RQee4UZk5OcFWbVXii//E8pRF9p4++5ByEiBvDIX4jyj5Mgtxi76Kr12kQ=="], + + "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "0.3.3" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], + + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "3.2.0", "picocolors": "1.1.1" }, "peerDependencies": { "browserslist": "4.26.3" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "2.8.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "1.1.0", "tslib": "2.8.1" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "19.2.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "1.1.15" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "3.2.2", "@types/d3-ease": "3.0.2", "@types/d3-interpolate": "3.0.4", "@types/d3-scale": "4.0.9", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.4", "@types/d3-timer": "3.0.2", "d3-array": "3.2.4", "d3-ease": "3.0.1", "d3-interpolate": "3.0.1", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-timer": "3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + + "vite": ["vite@6.3.6", "", { "dependencies": { "esbuild": "0.25.4", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.52.4", "tinyglobby": "0.2.15" }, "optionalDependencies": { "@types/node": "22.18.11", "fsevents": "2.3.3", "jiti": "2.6.1", "lightningcss": "1.30.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA=="], + + "vite-compatible-readable-stream": ["vite-compatible-readable-stream@3.6.1", "", { "dependencies": { "inherits": "2.0.4", "string_decoder": "1.3.0", "util-deprecate": "1.0.2" } }, "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ=="], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "4.4.3", "globrex": "0.1.2", "tsconfck": "3.1.6" }, "optionalDependencies": { "vite": "7.1.11" } }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + + "vitest": ["vitest@4.0.1", "", { "dependencies": { "@vitest/expect": "4.0.1", "@vitest/mocker": "4.0.1", "@vitest/pretty-format": "4.0.1", "@vitest/runner": "4.0.1", "@vitest/snapshot": "4.0.1", "@vitest/spy": "4.0.1", "@vitest/utils": "4.0.1", "debug": "4.4.3", "es-module-lexer": "1.7.0", "expect-type": "1.2.2", "magic-string": "0.30.19", "pathe": "2.0.3", "picomatch": "4.0.3", "std-env": "3.10.0", "tinybench": "2.9.0", "tinyexec": "0.3.2", "tinyglobby": "0.2.15", "tinyrainbow": "3.0.3", "vite": "7.1.11", "why-is-node-running": "2.3.0" }, "optionalDependencies": { "@types/node": "20.19.19", "@vitest/browser-playwright": "4.0.1", "jsdom": "27.0.1" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-4rwTfUNF0MExMZBiNirkzZpeyUZGOs3JD76N2qHNP9i6w6/bff7MRv2I9yFJKd1ICxzn2igpra+E4t9o2EfQhw=="], + + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "6.0.0", "webidl-conversions": "8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "1.1.0", "is-boolean-object": "1.2.2", "is-number-object": "1.1.1", "is-string": "1.1.1", "is-symbol": "1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "1.0.4", "function.prototype.name": "1.1.8", "has-tostringtag": "1.0.2", "is-async-function": "2.1.1", "is-date-object": "1.1.0", "is-finalizationregistry": "1.1.1", "is-generator-function": "1.1.2", "is-regex": "1.2.1", "is-weakref": "1.1.1", "isarray": "2.0.5", "which-boxed-primitive": "1.1.1", "which-collection": "1.0.2", "which-typed-array": "1.1.19" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "2.0.3", "is-set": "2.0.3", "is-weakmap": "2.0.2", "is-weakset": "2.0.4" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "1.0.7", "call-bind": "1.0.8", "call-bound": "1.0.4", "for-each": "0.3.5", "get-proto": "1.0.1", "gopd": "1.2.0", "has-tostringtag": "1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.18.3", "", {}, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "4.1.11" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "zustand": ["zustand@5.0.8", "", { "optionalDependencies": { "@types/react": "18.3.26", "react": "19.2.0", "use-sync-external-store": "1.6.0" } }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "3.1.1" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@react-pdf/reconciler/scheduler": ["scheduler@0.25.0-rc-603e6108-20241029", "", {}, "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="], + + "@types/jsdom/@types/node": ["@types/node@22.18.11", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "@nodelib/fs.walk": "1.2.8", "glob-parent": "5.1.2", "merge2": "1.4.1", "micromatch": "4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "2.0.2" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "@vitest/mocker/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "0.25.11", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.52.5", "tinyglobby": "0.2.15" }, "optionalDependencies": { "@types/node": "20.19.19", "fsevents": "2.3.3", "jiti": "2.6.1", "lightningcss": "1.30.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="], + + "appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + + "better-auth/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "1.5.1", "ieee754": "1.2.1" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "bun-types/@types/node": ["@types/node@22.18.11", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ=="], + + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "eslint-config-next/eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.0", "", { "dependencies": { "@babel/core": "7.28.4", "@babel/parser": "7.28.4", "hermes-parser": "0.25.1", "zod": "4.1.11", "zod-validation-error": "4.0.2" }, "peerDependencies": { "eslint": "9.37.0" } }, "sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw=="], + + "eslint-config-next/globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], + + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "0.0.29", "json5": "1.0.2", "minimist": "1.2.8", "strip-bom": "3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "its-fine/@types/react-reconciler": ["@types/react-reconciler@0.28.9", "", { "peerDependencies": { "@types/react": "18.3.26" } }, "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg=="], + + "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "6.0.1" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + + "linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + + "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "node-abi/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "nypm/tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], + + "png-to-ico/@types/node": ["@types/node@22.18.11", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + + "vite/@types/node": ["@types/node@22.18.11", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vite-tsconfig-paths/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "0.25.11", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.52.5", "tinyglobby": "0.2.15" }, "optionalDependencies": { "@types/node": "20.19.19", "fsevents": "2.3.3", "jiti": "2.6.1", "lightningcss": "1.30.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="], + + "vitest/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "0.25.11", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.52.5", "tinyglobby": "0.2.15" }, "optionalDependencies": { "@types/node": "20.19.19", "fsevents": "2.3.3", "jiti": "2.6.1", "lightningcss": "1.30.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="], + + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "@vitest/mocker/vite/esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="], + + "@vitest/mocker/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "@vitest/mocker/vite/rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], + + "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "1.2.8" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + + "vite-tsconfig-paths/vite/esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="], + + "vite-tsconfig-paths/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vite-tsconfig-paths/vite/rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], + + "vitest/vite/esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="], + + "vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vitest/vite/rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], + + "@vitest/mocker/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="], + + "@vitest/mocker/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="], + + "@vitest/mocker/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="], + + "@vitest/mocker/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="], + + "@vitest/mocker/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="], + + "@vitest/mocker/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="], + + "@vitest/mocker/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="], + + "@vitest/mocker/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="], + + "@vitest/mocker/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="], + + "@vitest/mocker/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="], + + "@vitest/mocker/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="], + + "@vitest/mocker/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="], + + "@vitest/mocker/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="], + + "@vitest/mocker/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="], + + "@vitest/mocker/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="], + + "@vitest/mocker/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="], + + "@vitest/mocker/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="], + + "@vitest/mocker/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="], + + "@vitest/mocker/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="], + + "@vitest/mocker/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="], + + "@vitest/mocker/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="], + + "@vitest/mocker/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="], + + "@vitest/mocker/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="], + + "@vitest/mocker/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="], + + "@vitest/mocker/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.5", "", { "os": "none", "cpu": "arm64" }, "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ=="], + + "@vitest/mocker/vite/rollup/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="], + + "vite-tsconfig-paths/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.5", "", { "os": "none", "cpu": "arm64" }, "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ=="], + + "vite-tsconfig-paths/vite/rollup/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], + + "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="], + + "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="], + + "vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="], + + "vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="], + + "vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="], + + "vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="], + + "vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="], + + "vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="], + + "vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="], + + "vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="], + + "vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="], + + "vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="], + + "vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="], + + "vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="], + + "vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="], + + "vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="], + + "vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="], + + "vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="], + + "vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="], + + "vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="], + + "vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="], + + "vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="], + + "vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="], + + "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="], + + "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="], + + "vitest/vite/rollup/@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], + + "vitest/vite/rollup/@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="], + + "vitest/vite/rollup/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA=="], + + "vitest/vite/rollup/@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA=="], + + "vitest/vite/rollup/@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA=="], + + "vitest/vite/rollup/@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ=="], + + "vitest/vite/rollup/@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ=="], + + "vitest/vite/rollup/@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ=="], + + "vitest/vite/rollup/@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg=="], + + "vitest/vite/rollup/@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q=="], + + "vitest/vite/rollup/@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA=="], + + "vitest/vite/rollup/@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw=="], + + "vitest/vite/rollup/@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw=="], + + "vitest/vite/rollup/@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg=="], + + "vitest/vite/rollup/@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ=="], + + "vitest/vite/rollup/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q=="], + + "vitest/vite/rollup/@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg=="], + + "vitest/vite/rollup/@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.5", "", { "os": "none", "cpu": "arm64" }, "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw=="], + + "vitest/vite/rollup/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w=="], + + "vitest/vite/rollup/@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg=="], + + "vitest/vite/rollup/@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ=="], + + "vitest/vite/rollup/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], + } +} diff --git a/referência/sistema-de-chamados-main/bunfig.toml b/referência/sistema-de-chamados-main/bunfig.toml new file mode 100644 index 0000000..1e03899 --- /dev/null +++ b/referência/sistema-de-chamados-main/bunfig.toml @@ -0,0 +1,3 @@ +[test] +preload = ["./tests/setup/bun-test-env.ts"] +timeout = 15000 diff --git a/referência/sistema-de-chamados-main/codex_ed25519 b/referência/sistema-de-chamados-main/codex_ed25519 new file mode 100644 index 0000000..92a0683 --- /dev/null +++ b/referência/sistema-de-chamados-main/codex_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACALomD1WTMgTtF+ZE/6d1QF73cY9W2W/5U9iQIEceaIogAAAJCCFZZTghWW +UwAAAAtzc2gtZWQyNTUxOQAAACALomD1WTMgTtF+ZE/6d1QF73cY9W2W/5U9iQIEceaIog +AAAED2WbX9/mtNwqBlVJIoWNJg1lTO7M1vOLXgP+h8q/CWBQuiYPVZMyBO0X5kT/p3VAXv +dxj1bZb/lT2JAgRx5oiiAAAACWNvZGV4LWNsaQECAwQ= +-----END OPENSSH PRIVATE KEY----- diff --git a/referência/sistema-de-chamados-main/codex_ed25519.pub b/referência/sistema-de-chamados-main/codex_ed25519.pub new file mode 100644 index 0000000..adcde5d --- /dev/null +++ b/referência/sistema-de-chamados-main/codex_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAuiYPVZMyBO0X5kT/p3VAXvdxj1bZb/lT2JAgRx5oii codex-cli diff --git a/referência/sistema-de-chamados-main/components.json b/referência/sistema-de-chamados-main/components.json new file mode 100644 index 0000000..a574e35 --- /dev/null +++ b/referência/sistema-de-chamados-main/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} \ No newline at end of file diff --git a/referência/sistema-de-chamados-main/components/shadcn-studio/input/input-41.tsx b/referência/sistema-de-chamados-main/components/shadcn-studio/input/input-41.tsx new file mode 100644 index 0000000..f3ffddd --- /dev/null +++ b/referência/sistema-de-chamados-main/components/shadcn-studio/input/input-41.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { MinusIcon, PlusIcon } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +const clamp = (value: number, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => + Math.min(Math.max(value, min), max) + +const InputWithEndButtonsDemo = () => { + const [value, setValue] = useState(1024) + const minValue = 0 + + const formattedValue = useMemo(() => (Number.isFinite(value) ? value.toString() : ''), [value]) + + const handleManualChange = useCallback( + (event: React.ChangeEvent) => { + const digitsOnly = event.target.value.replace(/\D/g, '') + if (digitsOnly.length === 0) { + setValue(minValue) + return + } + const next = Number.parseInt(digitsOnly, 10) + setValue(clamp(next, minValue)) + }, + [minValue], + ) + + const handleIncrement = useCallback(() => setValue((current) => clamp(current + 1, minValue)), [minValue]) + const handleDecrement = useCallback(() => setValue((current) => clamp(current - 1, minValue)), [minValue]) + + return ( +
+ +
+ + + +
+

Demonstração simples de input numérico com botões de incremento.

+
+ ) +} + +export default InputWithEndButtonsDemo diff --git a/referência/sistema-de-chamados-main/components/shadcn-studio/input/input-end-text-addon.tsx b/referência/sistema-de-chamados-main/components/shadcn-studio/input/input-end-text-addon.tsx new file mode 100644 index 0000000..c96081b --- /dev/null +++ b/referência/sistema-de-chamados-main/components/shadcn-studio/input/input-end-text-addon.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useId } from 'react' + +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +const InputEndTextAddOnDemo = () => { + const id = useId() + + return ( +
+ +
+ + + .com + +
+
+ ) +} + +export default InputEndTextAddOnDemo diff --git a/referência/sistema-de-chamados-main/convex/README.md b/referência/sistema-de-chamados-main/convex/README.md new file mode 100644 index 0000000..8aca79d --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/README.md @@ -0,0 +1,92 @@ +# Welcome to your Convex functions directory! + +CI note: touching a file under `convex/**` intentionally triggers the Convex deploy job. + +Write your Convex functions here. +See https://docs.convex.dev/functions for more. + +A query function that takes two arguments looks like: + +```ts +// convex/myFunctions.ts +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const myQueryFunction = query({ + // Validators for arguments. + args: { + first: v.number(), + second: v.string(), + }, + + // Function implementation. + handler: async (ctx, args) => { + // Read the database as many times as you need here. + // See https://docs.convex.dev/database/reading-data. + const documents = await ctx.db.query("tablename").collect(); + + // Arguments passed from the client are properties of the args object. + console.log(args.first, args.second); + + // Write arbitrary JavaScript here: filter, aggregate, build derived data, + // remove non-public properties, or create new objects. + return documents; + }, +}); +``` + +Using this query function in a React component looks like: + +```ts +const data = useQuery(api.myFunctions.myQueryFunction, { + first: 10, + second: "hello", +}); +``` + +A mutation function looks like: + +```ts +// convex/myFunctions.ts +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const myMutationFunction = mutation({ + // Validators for arguments. + args: { + first: v.string(), + second: v.string(), + }, + + // Function implementation. + handler: async (ctx, args) => { + // Insert or modify documents in the database here. + // Mutations can also read from the database like queries. + // See https://docs.convex.dev/database/writing-data. + const message = { body: args.first, author: args.second }; + const id = await ctx.db.insert("messages", message); + + // Optionally, return a value from your mutation. + return await ctx.db.get(id); + }, +}); +``` + +Using this mutation function in a React component looks like: + +```ts +const mutation = useMutation(api.myFunctions.myMutationFunction); +function handleButtonPress() { + // fire and forget, the most common way to use mutations + mutation({ first: "Hello!", second: "me" }); + // OR + // use the result once the mutation has completed + mutation({ first: "Hello!", second: "me" }).then((result) => + console.log(result), + ); +} +``` + +Use the Convex CLI to push your functions to a deployment. See everything +the Convex CLI can do by running `npx convex -h` in your project root +directory. To learn more, launch the docs with `npx convex docs`. diff --git a/referência/sistema-de-chamados-main/convex/_generated/api.d.ts b/referência/sistema-de-chamados-main/convex/_generated/api.d.ts new file mode 100644 index 0000000..9495ab3 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/_generated/api.d.ts @@ -0,0 +1,101 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as alerts from "../alerts.js"; +import type * as alerts_actions from "../alerts_actions.js"; +import type * as bootstrap from "../bootstrap.js"; +import type * as categories from "../categories.js"; +import type * as categorySlas from "../categorySlas.js"; +import type * as commentTemplates from "../commentTemplates.js"; +import type * as companies from "../companies.js"; +import type * as crons from "../crons.js"; +import type * as dashboards from "../dashboards.js"; +import type * as deviceExportTemplates from "../deviceExportTemplates.js"; +import type * as deviceFieldDefaults from "../deviceFieldDefaults.js"; +import type * as deviceFields from "../deviceFields.js"; +import type * as devices from "../devices.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 machines from "../machines.js"; +import type * as metrics from "../metrics.js"; +import type * as migrations from "../migrations.js"; +import type * as queues from "../queues.js"; +import type * as rbac from "../rbac.js"; +import type * as reports from "../reports.js"; +import type * as revision from "../revision.js"; +import type * as seed from "../seed.js"; +import type * as slas from "../slas.js"; +import type * as teams from "../teams.js"; +import type * as ticketFormSettings from "../ticketFormSettings.js"; +import type * as ticketFormTemplates from "../ticketFormTemplates.js"; +import type * as ticketNotifications from "../ticketNotifications.js"; +import type * as tickets from "../tickets.js"; +import type * as users from "../users.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +declare const fullApi: ApiFromModules<{ + alerts: typeof alerts; + alerts_actions: typeof alerts_actions; + bootstrap: typeof bootstrap; + categories: typeof categories; + categorySlas: typeof categorySlas; + commentTemplates: typeof commentTemplates; + companies: typeof companies; + crons: typeof crons; + dashboards: typeof dashboards; + deviceExportTemplates: typeof deviceExportTemplates; + deviceFieldDefaults: typeof deviceFieldDefaults; + deviceFields: typeof deviceFields; + devices: typeof devices; + fields: typeof fields; + files: typeof files; + invites: typeof invites; + machines: typeof machines; + metrics: typeof metrics; + migrations: typeof migrations; + queues: typeof queues; + rbac: typeof rbac; + reports: typeof reports; + revision: typeof revision; + seed: typeof seed; + slas: typeof slas; + teams: typeof teams; + ticketFormSettings: typeof ticketFormSettings; + ticketFormTemplates: typeof ticketFormTemplates; + ticketNotifications: typeof ticketNotifications; + tickets: typeof tickets; + users: typeof users; +}>; +declare const fullApiWithMounts: typeof fullApi; + +export declare const api: FilterApi< + typeof fullApiWithMounts, + FunctionReference +>; +export declare const internal: FilterApi< + typeof fullApiWithMounts, + FunctionReference +>; + +export declare const components: {}; diff --git a/referência/sistema-de-chamados-main/convex/_generated/api.js b/referência/sistema-de-chamados-main/convex/_generated/api.js new file mode 100644 index 0000000..e9ac2c6 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/_generated/api.js @@ -0,0 +1,22 @@ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/referência/sistema-de-chamados-main/convex/_generated/dataModel.d.ts b/referência/sistema-de-chamados-main/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..1016591 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/_generated/dataModel.d.ts @@ -0,0 +1,59 @@ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/referência/sistema-de-chamados-main/convex/_generated/server.d.ts b/referência/sistema-de-chamados-main/convex/_generated/server.d.ts new file mode 100644 index 0000000..2057d97 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/_generated/server.d.ts @@ -0,0 +1,148 @@ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + AnyComponents, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, + FunctionReference, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +type GenericCtx = + | GenericActionCtx + | GenericMutationCtx + | GenericQueryCtx; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * This function will be used to respond to HTTP requests received by a Convex + * deployment if the requests matches the path and method where this action + * is routed. Be sure to route your action in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/referência/sistema-de-chamados-main/convex/_generated/server.js b/referência/sistema-de-chamados-main/convex/_generated/server.js new file mode 100644 index 0000000..d05f21b --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/_generated/server.js @@ -0,0 +1,89 @@ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, + componentsGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define a Convex HTTP action. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object + * as its second. + * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. + */ +export const httpAction = httpActionGeneric; diff --git a/referência/sistema-de-chamados-main/convex/alerts.ts b/referência/sistema-de-chamados-main/convex/alerts.ts new file mode 100644 index 0000000..eeb44d3 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/alerts.ts @@ -0,0 +1,136 @@ +import { mutation, query } from "./_generated/server" +import { v } from "convex/values" + +export const log = mutation({ + args: { + tenantId: v.string(), + companyId: v.optional(v.id("companies")), + companyName: v.string(), + usagePct: v.number(), + threshold: v.number(), + range: v.string(), + recipients: v.array(v.string()), + deliveredCount: v.number(), + }, + handler: async (ctx, args) => { + const now = Date.now() + await ctx.db.insert("alerts", { + tenantId: args.tenantId, + companyId: args.companyId, + companyName: args.companyName, + usagePct: args.usagePct, + threshold: args.threshold, + range: args.range, + recipients: args.recipients, + deliveredCount: args.deliveredCount, + createdAt: now, + }) + }, +}) + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + limit: v.optional(v.number()), + companyId: v.optional(v.id("companies")), + start: v.optional(v.number()), + end: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, viewerId, limit, companyId, start, end }) => { + // Only admins can see the full alerts log + const user = await ctx.db.get(viewerId) + if (!user || user.tenantId !== tenantId || (user.role ?? "").toUpperCase() !== "ADMIN") { + return [] + } + let items = await ctx.db + .query("alerts") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + if (companyId) items = items.filter((a) => a.companyId === companyId) + if (typeof start === "number") items = items.filter((a) => a.createdAt >= start) + if (typeof end === "number") items = items.filter((a) => a.createdAt < end) + return items + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, Math.max(1, Math.min(limit ?? 200, 500))) + }, +}) + +export const managersForCompany = query({ + args: { tenantId: v.string(), companyId: v.id("companies") }, + handler: async (ctx, { tenantId, companyId }) => { + const users = await ctx.db + .query("users") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect() + return users.filter((u) => (u.role ?? "").toUpperCase() === "MANAGER") + }, +}) + +export const lastForCompanyBySlug = query({ + args: { tenantId: v.string(), slug: v.string() }, + handler: async (ctx, { tenantId, slug }) => { + const company = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first() + if (!company) return null + const items = await ctx.db + .query("alerts") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + const matches = items.filter((a) => a.companyId === company._id) + if (matches.length === 0) return null + const last = matches.sort((a, b) => b.createdAt - a.createdAt)[0] + return { createdAt: last.createdAt, usagePct: last.usagePct, threshold: last.threshold } + }, +}) + +export const lastForCompaniesBySlugs = query({ + args: { tenantId: v.string(), slugs: v.array(v.string()) }, + handler: async (ctx, { tenantId, slugs }) => { + const result: Record = {} + // Fetch all alerts once for the tenant + const alerts = await ctx.db + .query("alerts") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + for (const slug of slugs) { + const company = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first() + if (!company) { + result[slug] = null + continue + } + const matches = alerts.filter((a) => a.companyId === company._id) + if (matches.length === 0) { + result[slug] = null + continue + } + const last = matches.sort((a, b) => b.createdAt - a.createdAt)[0] + result[slug] = { createdAt: last.createdAt, usagePct: last.usagePct, threshold: last.threshold } + } + return result + }, +}) + +export const tenantIds = query({ + args: {}, + handler: async (ctx) => { + const companies = await ctx.db.query("companies").collect() + return Array.from(new Set(companies.map((c) => c.tenantId))) + }, +}) + +export const existsForCompanyRange = query({ + args: { tenantId: v.string(), companyId: v.id("companies"), start: v.number(), end: v.number() }, + handler: async (ctx, { tenantId, companyId, start, end }) => { + const items = await ctx.db + .query("alerts") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + return items.some((a) => a.companyId === companyId && a.createdAt >= start && a.createdAt < end) + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/alerts_actions.ts b/referência/sistema-de-chamados-main/convex/alerts_actions.ts new file mode 100644 index 0000000..6d40b14 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/alerts_actions.ts @@ -0,0 +1,160 @@ +"use node" + +import tls from "tls" + +import { action } from "./_generated/server" +import { api } from "./_generated/api" +import { v } from "convex/values" +import type { Id } from "./_generated/dataModel" + +function b64(input: string) { + return Buffer.from(input, "utf8").toString("base64") +} + +async function sendSmtpMail(cfg: { host: string; port: number; username: string; password: string; from: string }, to: string, subject: string, html: string) { + return new Promise((resolve, reject) => { + const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => { + let buffer = "" + const send = (line: string) => socket.write(line + "\r\n") + const wait = (expected: string | RegExp) => + new Promise((res) => { + const onData = (data: Buffer) => { + buffer += data.toString() + const lines = buffer.split(/\r?\n/) + const last = lines.filter(Boolean).slice(-1)[0] ?? "" + if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) { + socket.removeListener("data", onData) + res() + } + } + socket.on("data", onData) + socket.on("error", reject) + }) + + ;(async () => { + await wait(/^220 /) + send(`EHLO ${cfg.host}`) + await wait(/^250-/) + await wait(/^250 /) + send("AUTH LOGIN") + await wait(/^334 /) + send(b64(cfg.username)) + await wait(/^334 /) + send(b64(cfg.password)) + await wait(/^235 /) + send(`MAIL FROM:<${cfg.from.match(/<(.+)>/)?.[1] ?? cfg.from}>`) + await wait(/^250 /) + send(`RCPT TO:<${to}>`) + await wait(/^250 /) + send("DATA") + await wait(/^354 /) + const headers = [ + `From: ${cfg.from}`, + `To: ${to}`, + `Subject: ${subject}`, + "MIME-Version: 1.0", + "Content-Type: text/html; charset=UTF-8", + ].join("\r\n") + send(headers + "\r\n\r\n" + html + "\r\n.") + await wait(/^250 /) + send("QUIT") + socket.end() + resolve() + })().catch(reject) + }) + socket.on("error", reject) + }) +} + +export const sendHoursUsageAlerts = action({ + args: { range: v.optional(v.string()), threshold: v.optional(v.number()) }, + handler: async (ctx, { range, threshold }) => { + const R = (range ?? "30d") as string + const T = typeof threshold === "number" ? threshold : 90 + + const smtp = { + host: process.env.SMTP_ADDRESS!, + port: Number(process.env.SMTP_PORT ?? 465), + username: process.env.SMTP_USERNAME!, + password: process.env.SMTP_PASSWORD!, + from: process.env.MAILER_SENDER_EMAIL || "no-reply@example.com", + } + if (!smtp.host || !smtp.username || !smtp.password) { + console.warn("SMTP not configured; skipping alerts send") + return { sent: 0 } + } + + const targetHour = Number(process.env.ALERTS_LOCAL_HOUR ?? 8) + const now = new Date() + const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Sao_Paulo", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) + const parts = Object.fromEntries(fmt.formatToParts(now).map((p) => [p.type, p.value])) as Record + const hourSP = Number(parts.hour) + if (hourSP !== targetHour) { + return { skipped: true, reason: "hour_guard" } + } + + const dayKey = `${parts.year}-${parts.month}-${parts.day}` + const startSP = new Date(`${dayKey}T00:00:00-03:00`).getTime() + const endSP = startSP + 24 * 60 * 60 * 1000 + + const tenants = await ctx.runQuery(api.alerts.tenantIds, {}) + let totalSent = 0 + + for (const tenantId of tenants) { + const report = await ctx.runQuery(api.reports.hoursByClientInternal, { tenantId, range: R }) + type Item = { + companyId: Id<"companies"> + name: string + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth: number | null + } + const items = (report.items ?? []) as Item[] + const candidates = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= T) + + for (const item of candidates) { + const already = await ctx.runQuery(api.alerts.existsForCompanyRange, { tenantId, companyId: item.companyId, start: startSP, end: endSP }) + if (already) continue + const managers = await ctx.runQuery(api.alerts.managersForCompany, { tenantId, companyId: item.companyId }) + if (managers.length === 0) continue + const usagePct = (((item.totalMs / 3600000) / (item.contractedHoursPerMonth || 1)) * 100) + const subject = `Alerta: uso de horas em ${item.name} acima de ${T}%` + const body = ` +

Olá,

+

O uso de horas contratadas para ${item.name} atingiu ${usagePct.toFixed(1)}%.

+
    +
  • Horas internas: ${(item.internalMs/3600000).toFixed(2)}
  • +
  • Horas externas: ${(item.externalMs/3600000).toFixed(2)}
  • +
  • Total: ${(item.totalMs/3600000).toFixed(2)}
  • +
  • Contratadas/mês: ${item.contractedHoursPerMonth}
  • +
+

Reveja a alocação da equipe e, se necessário, ajuste o atendimento.

+ ` + let delivered = 0 + for (const m of managers) { + try { + await sendSmtpMail(smtp, m.email, subject, body) + delivered += 1 + } catch (error) { + console.error("Failed to send alert to", m.email, error) + } + } + totalSent += delivered + await ctx.runMutation(api.alerts.log, { + tenantId, + companyId: item.companyId, + companyName: item.name, + usagePct, + threshold: T, + range: R, + recipients: managers.map((m) => m.email), + deliveredCount: delivered, + }) + } + } + + return { sent: totalSent } + }, +}) + diff --git a/referência/sistema-de-chamados-main/convex/bootstrap.ts b/referência/sistema-de-chamados-main/convex/bootstrap.ts new file mode 100644 index 0000000..e6031bf --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/bootstrap.ts @@ -0,0 +1,39 @@ +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const ensureDefaults = mutation({ + args: { tenantId: v.string() }, + handler: async (ctx, { tenantId }) => { + let existing = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + existing = await Promise.all( + existing.map(async (queue) => { + if (queue.name === "Suporte N1" || queue.slug === "suporte-n1") { + await ctx.db.patch(queue._id, { name: "Chamados", slug: "chamados" }); + return (await ctx.db.get(queue._id)) ?? queue; + } + if (queue.name === "Suporte N2" || queue.slug === "suporte-n2") { + await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" }); + return (await ctx.db.get(queue._id)) ?? queue; + } + if (queue.name === "Field Services" || queue.slug === "field-services") { + await ctx.db.patch(queue._id, { name: "Visitas", slug: "visitas" }); + return (await ctx.db.get(queue._id)) ?? queue; + } + return queue; + }) + ); + if (existing.length === 0) { + const queues = [ + { name: "Chamados", slug: "chamados" }, + { name: "Laboratório", slug: "laboratorio" }, + { name: "Visitas", slug: "visitas" }, + ]; + for (const q of queues) { + await ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined }); + } + } + }, +}); diff --git a/referência/sistema-de-chamados-main/convex/categories.ts b/referência/sistema-de-chamados-main/convex/categories.ts new file mode 100644 index 0000000..e7adb2b --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/categories.ts @@ -0,0 +1,546 @@ +import { mutation, query } from "./_generated/server" +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 + secondary: string[] +} + +const DEFAULT_CATEGORY_SEED: CategorySeed[] = [ + { + name: "Backup", + secondary: ["Instalação, configuração ou agendamento", "Restauração"], + }, + { + name: "Certificado digital", + secondary: ["Instalação", "Reparo"], + }, + { + name: "E-mail", + secondary: [ + "Cota excedida", + "Criação, remoção ou configuração", + "Mensagem de caixa cheia", + "Migração", + "Não envia ou recebe", + "Política Office 365", + "Problemas com anexo", + "Problemas com spam", + "Problemas na base de dados", + "Relay de servidores ou aplicação", + "Resetar ou alterar senha", + ], + }, + { + name: "Estações", + secondary: [ + "Formatação ou clonagem", + "Instalação de SSD ou memória", + "Lentidão ou travamento", + "Problemas com o sistema operacional", + ], + }, + { + name: "Firewall / Roteador", + secondary: ["Configuração de VPN", "Instalação, restrição ou reparo", "Liberação ou restrição de sites"], + }, + { + name: "Hardware", + secondary: [ + "Bateria de lítio", + "Fonte de alimentação", + "HD", + "Limpeza de PC", + "Memória", + "Monitor", + "Nobreak", + "Placa de rede", + "Placa de vídeo", + "Placa-mãe", + "Troca de peça", + ], + }, + { + name: "Implantação", + secondary: ["Implantação Rever"], + }, + { + name: "Implantação de serviços", + secondary: ["Antivírus", "E-mail", "Firewall", "Office", "Sistemas"], + }, + { + name: "Impressora", + secondary: [ + "Configuração", + "Instalação de impressora", + "Instalação de scanner", + "Problemas de impressão", + "Problemas de scanner", + ], + }, + { + name: "Internet / Rede", + secondary: [ + "Lentidão", + "Mapear unidade de rede", + "Problemas de acesso ao Wi-Fi", + "Problemas de cabeamento", + "Problemas no switch", + "Sem acesso à internet", + "Sem acesso à rede", + "Sem acesso a um site específico", + ], + }, + { + name: "Kernel Panic Full", + secondary: ["Firewall", "Internet", "Provedor de e-mail", "Servidor", "Wi-Fi"], + }, + { + name: "Orçamento", + secondary: ["Computadores", "Periféricos", "Serviços", "Softwares", "Servidores"], + }, + { + name: "Procedimento de admissão/desligamento", + secondary: ["Admissão", "Desligamento"], + }, + { + name: "Projetos", + secondary: ["Projeto de infraestrutura", "Projeto de Wi-Fi", "Projeto de servidor"], + }, + { + name: "Relatório / Licenciamento", + secondary: [ + "Levantamento de NFs de softwares", + "Licenças", + "Preencher auditoria Microsoft", + "Relatório de estações", + ], + }, + { + name: "Servidor", + secondary: [ + "Adicionar ou trocar HD", + "Configuração de AD/Pastas/GPO", + "Configuração de SO", + "Configuração ou reparo de TS", + "Criação ou remoção de usuário", + "Lentidão ou travamento", + "Problemas de login", + ], + }, + { + name: "Sistema de produção (ERP)", + secondary: [ + "Instalação, atualização, configuração ou reparo", + "Lentidão ou travamento", + "Mensagem de erro", + "Phoenix atualização ou configuração", + "SCI ÚNICO atualização ou configuração", + "SCI ÚNICO lentidão", + "SCI VISUAL atualização ou configuração", + "SCI VISUAL lentidão ou travamento", + ], + }, + { + name: "Software APP", + secondary: [ + "Ativação do Office", + "Ativação do Windows", + "Instalação, atualização, configuração ou reparo", + ], + }, + { + name: "Telefonia", + secondary: ["Instalação, atualização, configuração ou reparo"], + }, + { + name: "Visita de rotina", + secondary: ["Serviços agendados"], + }, +] + +function slugify(value: string) { + return value + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .replace(/[\u0300-\u036f]/g, "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") +} + +async function ensureUniqueSlug( + ctx: Pick, + table: "ticketCategories" | "ticketSubcategories", + tenantId: string, + base: string, + scope: { categoryId?: Id<"ticketCategories"> } +) { + let slug = base || "categoria" + let counter = 1 + while (true) { + const existing = + table === "ticketCategories" + ? await ctx.db + .query("ticketCategories") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first() + : await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_slug", (q) => q.eq("categoryId", scope.categoryId!).eq("slug", slug)) + .first() + if (!existing) return slug + slug = `${base}-${counter}` + counter += 1 + } +} + +export const list = query({ + args: { tenantId: v.string() }, + handler: async (ctx, { tenantId }) => { + const categories = await ctx.db + .query("ticketCategories") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect() + + if (categories.length === 0) { + return [] + } + + const subcategories = await ctx.db + .query("ticketSubcategories") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId)) + .collect() + + return categories.map((category) => ({ + id: category._id, + name: category.name, + slug: category.slug, + description: category.description, + order: category.order, + secondary: subcategories + .filter((item) => item.categoryId === category._id) + .sort((a, b) => a.order - b.order) + .map((item) => ({ + id: item._id, + name: item.name, + slug: item.slug, + order: item.order, + })), + })) + }, +}) + +export const ensureDefaults = mutation({ + args: { tenantId: v.string() }, + handler: async (ctx, { tenantId }) => { + const existingCount = await ctx.db + .query("ticketCategories") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + if (existingCount.length > 0) { + return { created: 0 } + } + + const now = Date.now() + let created = 0 + let order = 0 + + for (const seed of DEFAULT_CATEGORY_SEED) { + const baseSlug = slugify(seed.name) + const slug = await ensureUniqueSlug(ctx, "ticketCategories", tenantId, baseSlug, {}) + const categoryId = await ctx.db.insert("ticketCategories", { + tenantId, + name: seed.name, + slug, + description: seed.description, + order, + createdAt: now, + updatedAt: now, + }) + created += 1 + let subOrder = 0 + for (const secondary of seed.secondary) { + const subSlug = await ensureUniqueSlug( + ctx, + "ticketSubcategories", + tenantId, + slugify(secondary), + { categoryId } + ) + await ctx.db.insert("ticketSubcategories", { + tenantId, + categoryId, + name: secondary, + slug: subSlug, + order: subOrder, + createdAt: now, + updatedAt: now, + }) + subOrder += 1 + } + order += 1 + } + + return { created } + }, +}) + +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, 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") + } + const baseSlug = slugify(trimmed) + const slug = await ensureUniqueSlug(ctx, "ticketCategories", tenantId, baseSlug, {}) + const now = Date.now() + const last = await ctx.db + .query("ticketCategories") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .order("desc") + .first() + const order = (last?.order ?? -1) + 1 + const id = await ctx.db.insert("ticketCategories", { + tenantId, + name: trimmed, + slug, + description, + order, + 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 + }, +}) + +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, 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") + } + const trimmed = name.trim() + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a categoria") + } + const now = Date.now() + await ctx.db.patch(categoryId, { + name: trimmed, + description, + updatedAt: now, + }) + }, +}) + +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, 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") + } + if (transferTo) { + const target = await ctx.db.get(transferTo) + if (!target || target.tenantId !== tenantId) { + throw new ConvexError("Categoria de destino inválida") + } + const subs = await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_order", (q) => q.eq("categoryId", categoryId)) + .collect() + for (const sub of subs) { + await ctx.db.patch(sub._id, { + categoryId: transferTo, + updatedAt: Date.now(), + }) + } + const ticketsToMove = await ctx.db + .query("tickets") + .withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId)) + .collect() + for (const ticket of ticketsToMove) { + await ctx.db.patch(ticket._id, { + categoryId: transferTo, + subcategoryId: undefined, + updatedAt: Date.now(), + }) + } + } else { + const ticketsLinked = await ctx.db + .query("tickets") + .withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId)) + .first() + if (ticketsLinked) { + throw new ConvexError("Não é possível remover uma categoria vinculada a tickets sem informar destino") + } + const subs = await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_order", (q) => q.eq("categoryId", categoryId)) + .collect() + for (const sub of subs) { + await ctx.db.delete(sub._id) + } + } + await ctx.db.delete(categoryId) + }, +}) + +export const createSubcategory = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + categoryId: v.id("ticketCategories"), + name: v.string(), + }, + 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") + } + const trimmed = name.trim() + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a subcategoria") + } + const baseSlug = slugify(trimmed) + const slug = await ensureUniqueSlug(ctx, "ticketSubcategories", tenantId, baseSlug, { categoryId }) + const now = Date.now() + const last = await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_order", (q) => q.eq("categoryId", categoryId)) + .order("desc") + .first() + const order = (last?.order ?? -1) + 1 + const id = await ctx.db.insert("ticketSubcategories", { + tenantId, + categoryId, + name: trimmed, + slug, + order, + createdAt: now, + updatedAt: now, + }) + return id + }, +}) + +export const updateSubcategory = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + subcategoryId: v.id("ticketSubcategories"), + name: v.string(), + }, + 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") + } + const trimmed = name.trim() + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a subcategoria") + } + await ctx.db.patch(subcategoryId, { + name: trimmed, + updatedAt: Date.now(), + }) + }, +}) + +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, 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") + } + if (transferTo) { + const target = await ctx.db.get(transferTo) + if (!target || target.tenantId !== tenantId) { + throw new ConvexError("Subcategoria destino inválida") + } + const tickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_subcategory", (q) => q.eq("tenantId", tenantId).eq("subcategoryId", subcategoryId)) + .collect() + for (const ticket of tickets) { + await ctx.db.patch(ticket._id, { + subcategoryId: transferTo, + updatedAt: Date.now(), + }) + } + } else { + const linked = await ctx.db + .query("tickets") + .withIndex("by_tenant_subcategory", (q) => q.eq("tenantId", tenantId).eq("subcategoryId", subcategoryId)) + .first() + if (linked) { + throw new ConvexError("Não é possível remover uma subcategoria vinculada a tickets sem informar destino") + } + } + await ctx.db.delete(subcategoryId) + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/categorySlas.ts b/referência/sistema-de-chamados-main/convex/categorySlas.ts new file mode 100644 index 0000000..09086a5 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/categorySlas.ts @@ -0,0 +1,169 @@ +import { mutation, query } from "./_generated/server" +import { ConvexError, v } from "convex/values" + +import { requireAdmin } from "./rbac" + +const PRIORITY_VALUES = ["URGENT", "HIGH", "MEDIUM", "LOW", "DEFAULT"] as const +const VALID_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"] as const +const VALID_TIME_MODES = ["business", "calendar"] as const + +type CategorySlaRuleInput = { + priority: string + responseTargetMinutes?: number | null + responseMode?: string | null + solutionTargetMinutes?: number | null + solutionMode?: string | null + alertThreshold?: number | null + pauseStatuses?: string[] | null + calendarType?: string | null +} + +const ruleInput = v.object({ + priority: v.string(), + responseTargetMinutes: v.optional(v.number()), + responseMode: v.optional(v.string()), + solutionTargetMinutes: v.optional(v.number()), + solutionMode: v.optional(v.string()), + alertThreshold: v.optional(v.number()), + pauseStatuses: v.optional(v.array(v.string())), + calendarType: v.optional(v.string()), +}) + +function normalizePriority(value: string) { + const upper = value.trim().toUpperCase() + return PRIORITY_VALUES.includes(upper as (typeof PRIORITY_VALUES)[number]) ? upper : "DEFAULT" +} + +function sanitizeTime(value?: number | null) { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined + return Math.round(value) +} + +function normalizeMode(value?: string | null) { + if (!value) return "calendar" + const normalized = value.toLowerCase() + return VALID_TIME_MODES.includes(normalized as (typeof VALID_TIME_MODES)[number]) ? normalized : "calendar" +} + +function normalizeThreshold(value?: number | null) { + if (typeof value !== "number" || Number.isNaN(value)) { + return 0.8 + } + const clamped = Math.min(Math.max(value, 0.1), 0.95) + return Math.round(clamped * 100) / 100 +} + +function normalizePauseStatuses(value?: string[] | null) { + if (!Array.isArray(value)) return ["PAUSED"] + const normalized = new Set() + for (const status of value) { + if (typeof status !== "string") continue + const upper = status.trim().toUpperCase() + if (VALID_STATUSES.includes(upper as (typeof VALID_STATUSES)[number])) { + normalized.add(upper) + } + } + if (normalized.size === 0) { + normalized.add("PAUSED") + } + return Array.from(normalized) +} + +export const get = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + categoryId: v.id("ticketCategories"), + }, + handler: async (ctx, { tenantId, viewerId, categoryId }) => { + await requireAdmin(ctx, viewerId, tenantId) + const category = await ctx.db.get(categoryId) + if (!category || category.tenantId !== tenantId) { + throw new ConvexError("Categoria não encontrada") + } + const records = await ctx.db + .query("categorySlaSettings") + .withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId)) + .collect() + + return { + categoryId, + categoryName: category.name, + rules: records.map((record) => ({ + priority: record.priority, + responseTargetMinutes: record.responseTargetMinutes ?? null, + responseMode: record.responseMode ?? "calendar", + solutionTargetMinutes: record.solutionTargetMinutes ?? null, + solutionMode: record.solutionMode ?? "calendar", + alertThreshold: record.alertThreshold ?? 0.8, + pauseStatuses: record.pauseStatuses ?? ["PAUSED"], + })), + } + }, +}) + +export const save = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + categoryId: v.id("ticketCategories"), + rules: v.array(ruleInput), + }, + handler: async (ctx, { tenantId, actorId, categoryId, rules }) => { + await requireAdmin(ctx, actorId, tenantId) + const category = await ctx.db.get(categoryId) + if (!category || category.tenantId !== tenantId) { + throw new ConvexError("Categoria não encontrada") + } + const sanitized = sanitizeRules(rules) + const existing = await ctx.db + .query("categorySlaSettings") + .withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId)) + .collect() + await Promise.all(existing.map((record) => ctx.db.delete(record._id))) + + const now = Date.now() + for (const rule of sanitized) { + await ctx.db.insert("categorySlaSettings", { + tenantId, + categoryId, + priority: rule.priority, + responseTargetMinutes: rule.responseTargetMinutes, + responseMode: rule.responseMode, + solutionTargetMinutes: rule.solutionTargetMinutes, + solutionMode: rule.solutionMode, + alertThreshold: rule.alertThreshold, + pauseStatuses: rule.pauseStatuses, + calendarType: rule.calendarType ?? undefined, + createdAt: now, + updatedAt: now, + actorId, + }) + } + }, +}) + +function sanitizeRules(rules: CategorySlaRuleInput[]) { + const normalized: Record> = {} + for (const rule of rules) { + const built = buildRule(rule) + normalized[built.priority] = built + } + return Object.values(normalized) +} + +function buildRule(rule: CategorySlaRuleInput) { + const priority = normalizePriority(rule.priority) + const responseTargetMinutes = sanitizeTime(rule.responseTargetMinutes) + const solutionTargetMinutes = sanitizeTime(rule.solutionTargetMinutes) + return { + priority, + responseTargetMinutes, + responseMode: normalizeMode(rule.responseMode), + solutionTargetMinutes, + solutionMode: normalizeMode(rule.solutionMode), + alertThreshold: normalizeThreshold(rule.alertThreshold), + pauseStatuses: normalizePauseStatuses(rule.pauseStatuses), + calendarType: rule.calendarType ?? null, + } +} diff --git a/referência/sistema-de-chamados-main/convex/commentTemplates.ts b/referência/sistema-de-chamados-main/convex/commentTemplates.ts new file mode 100644 index 0000000..713f5a0 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/commentTemplates.ts @@ -0,0 +1,183 @@ +import sanitizeHtml from "sanitize-html" +import { ConvexError, v } from "convex/values" + +import { mutation, query } from "./_generated/server" +import { requireStaff } from "./rbac" + +const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { + allowedTags: [ + "p", + "br", + "a", + "strong", + "em", + "u", + "s", + "blockquote", + "ul", + "ol", + "li", + "code", + "pre", + "span", + "h1", + "h2", + "h3", + ], + allowedAttributes: { + a: ["href", "name", "target", "rel"], + span: ["style"], + code: ["class"], + pre: ["class"], + }, + allowedSchemes: ["http", "https", "mailto"], + transformTags: { + a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true), + }, + allowVulnerableTags: false, +} + +function sanitizeTemplateBody(body: string) { + const sanitized = sanitizeHtml(body || "", SANITIZE_OPTIONS).trim() + return sanitized +} + +function normalizeTitle(title: string) { + return title?.trim() +} + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + kind: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, viewerId, kind }) => { + await requireStaff(ctx, viewerId, tenantId) + const normalizedKind = (kind ?? "comment").toLowerCase() + const templates = await ctx.db + .query("commentTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + return templates + .filter((template) => (template.kind ?? "comment") === normalizedKind) + .sort((a, b) => a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" })) + .map((template) => ({ + id: template._id, + title: template.title, + body: template.body, + kind: template.kind ?? "comment", + createdAt: template.createdAt, + updatedAt: template.updatedAt, + createdBy: template.createdBy, + updatedBy: template.updatedBy ?? null, + })) + }, +}) + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + title: v.string(), + body: v.string(), + kind: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, actorId, title, body, kind }) => { + await requireStaff(ctx, actorId, tenantId) + const normalizedTitle = normalizeTitle(title) + if (!normalizedTitle || normalizedTitle.length < 3) { + throw new ConvexError("Informe um título válido para o template") + } + const sanitizedBody = sanitizeTemplateBody(body) + if (!sanitizedBody) { + throw new ConvexError("Informe o conteúdo do template") + } + const normalizedKind = (kind ?? "comment").toLowerCase() + + const existing = await ctx.db + .query("commentTemplates") + .withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle)) + .first() + + if (existing && (existing.kind ?? "comment") === normalizedKind) { + throw new ConvexError("Já existe um template com este título") + } + + const now = Date.now() + const id = await ctx.db.insert("commentTemplates", { + tenantId, + kind: normalizedKind, + title: normalizedTitle, + body: sanitizedBody, + createdBy: actorId, + updatedBy: actorId, + createdAt: now, + updatedAt: now, + }) + + return id + }, +}) + +export const update = mutation({ + args: { + templateId: v.id("commentTemplates"), + tenantId: v.string(), + actorId: v.id("users"), + title: v.string(), + body: v.string(), + kind: v.optional(v.string()), + }, + handler: async (ctx, { templateId, tenantId, actorId, title, body, kind }) => { + await requireStaff(ctx, actorId, tenantId) + const template = await ctx.db.get(templateId) + if (!template || template.tenantId !== tenantId) { + throw new ConvexError("Template não encontrado") + } + + const normalizedTitle = normalizeTitle(title) + if (!normalizedTitle || normalizedTitle.length < 3) { + throw new ConvexError("Informe um título válido para o template") + } + const sanitizedBody = sanitizeTemplateBody(body) + if (!sanitizedBody) { + throw new ConvexError("Informe o conteúdo do template") + } + const normalizedKind = (kind ?? "comment").toLowerCase() + + const duplicate = await ctx.db + .query("commentTemplates") + .withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle)) + .first() + if (duplicate && duplicate._id !== templateId && (duplicate.kind ?? "comment") === normalizedKind) { + throw new ConvexError("Já existe um template com este título") + } + + const now = Date.now() + await ctx.db.patch(templateId, { + kind: normalizedKind, + title: normalizedTitle, + body: sanitizedBody, + updatedBy: actorId, + updatedAt: now, + }) + }, +}) + +export const remove = mutation({ + args: { + templateId: v.id("commentTemplates"), + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { templateId, tenantId, actorId }) => { + await requireStaff(ctx, actorId, tenantId) + const template = await ctx.db.get(templateId) + if (!template || template.tenantId !== tenantId) { + throw new ConvexError("Template não encontrado") + } + await ctx.db.delete(templateId) + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/companies.ts b/referência/sistema-de-chamados-main/convex/companies.ts new file mode 100644 index 0000000..16266e4 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/companies.ts @@ -0,0 +1,155 @@ +import { ConvexError, v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { requireStaff } from "./rbac"; + +function normalizeSlug(input?: string | null): string | undefined { + if (!input) return undefined + const trimmed = input.trim() + if (!trimmed) return undefined + const ascii = trimmed + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[\u2013\u2014]/g, "-") + const sanitized = ascii.replace(/[^\w\s-]/g, "").replace(/[_\s]+/g, "-") + const collapsed = sanitized.replace(/-+/g, "-").toLowerCase() + const normalized = collapsed.replace(/^-+|-+$/g, "") + return normalized || undefined +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId) + const companies = await ctx.db + .query("companies") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + return companies.map((c) => ({ id: c._id, name: c.name, slug: c.slug })) + }, +}) + +export const ensureProvisioned = mutation({ + args: { + tenantId: v.string(), + slug: v.string(), + name: v.string(), + provisioningCode: v.string(), + }, + handler: async (ctx, { tenantId, slug, name, provisioningCode }) => { + const normalizedSlug = normalizeSlug(slug) + if (!normalizedSlug) { + throw new ConvexError("Slug inválido") + } + const trimmedName = name.trim() + if (!trimmedName) { + throw new ConvexError("Nome inválido") + } + + const existing = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", normalizedSlug)) + .unique() + + if (existing) { + if (existing.provisioningCode !== provisioningCode) { + await ctx.db.patch(existing._id, { provisioningCode }) + } + return { + id: existing._id, + slug: existing.slug, + name: existing.name, + } + } + + const now = Date.now() + const id = await ctx.db.insert("companies", { + tenantId, + name: trimmedName, + slug: normalizedSlug, + provisioningCode, + isAvulso: false, + contractedHoursPerMonth: undefined, + cnpj: undefined, + domain: undefined, + phone: undefined, + description: undefined, + address: undefined, + legalName: undefined, + tradeName: undefined, + stateRegistration: undefined, + stateRegistrationType: undefined, + primaryCnae: undefined, + timezone: undefined, + businessHours: undefined, + supportEmail: undefined, + billingEmail: undefined, + contactPreferences: undefined, + clientDomains: undefined, + communicationChannels: undefined, + fiscalAddress: undefined, + hasBranches: false, + regulatedEnvironments: undefined, + privacyPolicyAccepted: false, + privacyPolicyReference: undefined, + privacyPolicyMetadata: undefined, + contracts: undefined, + contacts: undefined, + locations: undefined, + sla: undefined, + tags: undefined, + customFields: undefined, + notes: undefined, + createdAt: now, + updatedAt: now, + }) + + return { + id, + slug: normalizedSlug, + name: trimmedName, + } + }, +}) + +export const removeBySlug = mutation({ + args: { + tenantId: v.string(), + slug: v.string(), + }, + handler: async (ctx, { tenantId, slug }) => { + const normalizedSlug = normalizeSlug(slug) ?? slug + const existing = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", normalizedSlug)) + .unique() + + if (!existing) { + return { removed: false } + } + + // Preserve company snapshot on related tickets before deletion + const relatedTickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", existing._id)) + .collect() + if (relatedTickets.length > 0) { + const companySnapshot = { + name: existing.name, + slug: existing.slug, + isAvulso: existing.isAvulso ?? undefined, + } + for (const t of relatedTickets) { + const needsPatch = !t.companySnapshot || + t.companySnapshot.name !== companySnapshot.name || + t.companySnapshot.slug !== companySnapshot.slug || + Boolean(t.companySnapshot.isAvulso ?? false) !== Boolean(companySnapshot.isAvulso ?? false) + if (needsPatch) { + await ctx.db.patch(t._id, { companySnapshot }) + } + } + } + + await ctx.db.delete(existing._id) + return { removed: true } + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/convex.config.ts b/referência/sistema-de-chamados-main/convex/convex.config.ts new file mode 100644 index 0000000..5d4c781 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/convex.config.ts @@ -0,0 +1,9 @@ +import { defineApp } from "convex/server"; + +const app = defineApp(); + +// You can install Convex Components here in the future, e.g. rate limiter, workflows, etc. +// app.use(componentConfig) + +// Chore: touchpoint to trigger Convex build re-execution. +export default app; diff --git a/referência/sistema-de-chamados-main/convex/crons.ts b/referência/sistema-de-chamados-main/convex/crons.ts new file mode 100644 index 0000000..74c7fb2 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/crons.ts @@ -0,0 +1,5 @@ +import { cronJobs } from "convex/server" + +const crons = cronJobs() + +export default crons diff --git a/referência/sistema-de-chamados-main/convex/dashboards.ts b/referência/sistema-de-chamados-main/convex/dashboards.ts new file mode 100644 index 0000000..9569e5e --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/dashboards.ts @@ -0,0 +1,937 @@ +import { ConvexError, v } from "convex/values" + +import type { Doc, Id } from "./_generated/dataModel" +import { mutation, query } from "./_generated/server" +import { requireStaff } from "./rbac" + +const WIDGET_TYPES = [ + "kpi", + "bar", + "line", + "area", + "pie", + "radar", + "gauge", + "table", + "queue-summary", + "text", +] as const + +type WidgetType = (typeof WIDGET_TYPES)[number] + +const gridItemValidator = v.object({ + i: v.string(), + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + minW: v.optional(v.number()), + minH: v.optional(v.number()), + static: v.optional(v.boolean()), +}) + +const widgetLayoutValidator = v.object({ + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + minW: v.optional(v.number()), + minH: v.optional(v.number()), + static: v.optional(v.boolean()), +}) + +function assertWidgetType(type: string): asserts type is WidgetType { + if (!WIDGET_TYPES.includes(type as WidgetType)) { + throw new ConvexError(`Tipo de widget inválido: ${type}`) + } +} + +function sanitizeTitle(input?: string | null): string | undefined { + if (!input) return undefined + const trimmed = input.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function generateWidgetKey(dashboardId: Id<"dashboards">) { + const rand = Math.random().toString(36).slice(2, 8) + return `${dashboardId.toString().slice(-6)}-${rand}` +} + +function normalizeQueueSummaryConfig(config: unknown) { + return { + type: "queue-summary", + title: "Resumo das filas", + dataSource: { metricKey: "queues.summary_cards" }, + ...(typeof config === "object" && config ? (config as Record) : {}), + } +} + +function queueSummaryLayout(widgetKey: string) { + return { + i: widgetKey, + x: 0, + y: 0, + w: 12, + h: 8, + minW: 8, + minH: 7, + static: false, + } +} + +function normalizeWidgetConfig(type: WidgetType, config: unknown) { + if (config && typeof config === "object") { + return config + } + switch (type) { + case "kpi": + return { + type: "kpi", + title: "Novo KPI", + dataSource: { metricKey: "tickets.waiting_action_now" }, + options: { trend: "tickets.waiting_action_last_7d" }, + } + case "bar": + return { + type: "bar", + title: "Abertos x Resolvidos", + dataSource: { metricKey: "tickets.opened_resolved_by_day", params: { range: "30d" } }, + encoding: { + x: "date", + y: [ + { field: "opened", label: "Abertos" }, + { field: "resolved", label: "Resolvidos" }, + ], + }, + options: { legend: true }, + } + case "line": + return { + type: "line", + title: "Resoluções por dia", + dataSource: { metricKey: "tickets.opened_resolved_by_day", params: { range: "30d" } }, + encoding: { + x: "date", + y: [{ field: "resolved", label: "Resolvidos" }], + }, + options: { legend: false }, + } + case "area": + return { + type: "area", + title: "Volume acumulado", + dataSource: { metricKey: "tickets.opened_resolved_by_day", params: { range: "30d" } }, + encoding: { + x: "date", + y: [ + { field: "opened", label: "Abertos" }, + { field: "resolved", label: "Resolvidos" }, + ], + stacked: true, + }, + options: { legend: true }, + } + case "pie": + return { + type: "pie", + title: "Backlog por prioridade", + dataSource: { metricKey: "tickets.open_by_priority", params: { range: "30d" } }, + encoding: { category: "priority", value: "total" }, + options: { legend: true }, + } + case "radar": + return { + type: "radar", + title: "SLA por fila", + dataSource: { metricKey: "tickets.sla_compliance_by_queue", params: { range: "30d" } }, + encoding: { angle: "queue", radius: "compliance" }, + options: {}, + } + case "gauge": + return { + type: "gauge", + title: "Cumprimento de SLA", + dataSource: { metricKey: "tickets.sla_rate", params: { range: "7d" } }, + options: { min: 0, max: 1, thresholds: [0.5, 0.8] }, + } + case "table": + return { + type: "table", + title: "Tickets recentes", + dataSource: { metricKey: "tickets.awaiting_table", params: { limit: 20 } }, + columns: [ + { field: "reference", label: "Referência" }, + { field: "subject", label: "Assunto" }, + { field: "status", label: "Status" }, + { field: "updatedAt", label: "Atualizado em" }, + ], + options: { downloadCSV: true }, + } + case "queue-summary": + return { + type: "queue-summary", + title: "Resumo por fila", + dataSource: { metricKey: "queues.summary_cards" }, + } + case "text": + default: + return { + type: "text", + title: "Notas", + content: "Use este espaço para destacar insights ou orientações.", + } + } +} + +function sanitizeDashboard(dashboard: Doc<"dashboards">) { + return { + id: dashboard._id, + tenantId: dashboard.tenantId, + name: dashboard.name, + description: dashboard.description ?? null, + aspectRatio: dashboard.aspectRatio ?? "16:9", + theme: dashboard.theme ?? "system", + filters: dashboard.filters ?? {}, + layout: dashboard.layout ?? [], + sections: dashboard.sections ?? [], + tvIntervalSeconds: dashboard.tvIntervalSeconds ?? 30, + readySelector: dashboard.readySelector ?? null, + createdBy: dashboard.createdBy, + updatedBy: dashboard.updatedBy ?? null, + createdAt: dashboard.createdAt, + updatedAt: dashboard.updatedAt, + isArchived: dashboard.isArchived ?? false, + } +} + +function normalizeOrder(index: number) { + return index >= 0 ? index : 0 +} + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + includeArchived: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, viewerId, includeArchived }) => { + await requireStaff(ctx, viewerId, tenantId) + const dashboards = await ctx.db + .query("dashboards") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + const filtered = (includeArchived ? dashboards : dashboards.filter((d) => !(d.isArchived ?? false))).sort( + (a, b) => b.updatedAt - a.updatedAt, + ) + + return Promise.all( + filtered.map(async (dashboard) => { + const widgets = await ctx.db + .query("dashboardWidgets") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboard._id)) + .collect() + return { + ...sanitizeDashboard(dashboard), + widgetsCount: widgets.length, + } + }), + ) + }, +}) + +export const get = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + dashboardId: v.id("dashboards"), + }, + handler: async (ctx, { tenantId, viewerId, dashboardId }) => { + await requireStaff(ctx, viewerId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + const widgets = await ctx.db + .query("dashboardWidgets") + .withIndex("by_dashboard_order", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + widgets.sort((a, b) => a.order - b.order || a.createdAt - b.createdAt) + + const shares = await ctx.db + .query("dashboardShares") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + return { + dashboard: sanitizeDashboard(dashboard), + widgets: widgets.map((widget) => ({ + id: widget._id, + dashboardId: widget.dashboardId, + widgetKey: widget.widgetKey, + title: widget.title ?? null, + type: widget.type, + config: widget.config, + layout: widget.layout ?? null, + order: widget.order, + isHidden: widget.isHidden ?? false, + createdAt: widget.createdAt, + updatedAt: widget.updatedAt, + })), + shares: shares.map((share) => ({ + id: share._id, + audience: share.audience, + token: share.token ?? null, + expiresAt: share.expiresAt ?? null, + canEdit: share.canEdit, + createdBy: share.createdBy, + createdAt: share.createdAt, + lastAccessAt: share.lastAccessAt ?? null, + })), + } + }, +}) + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + aspectRatio: v.optional(v.string()), + theme: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, actorId, name, description, aspectRatio, theme }) => { + await requireStaff(ctx, actorId, tenantId) + const trimmedName = name.trim() + if (trimmedName.length === 0) { + throw new ConvexError("Nome do dashboard inválido") + } + + const now = Date.now() + const dashboardId = await ctx.db.insert("dashboards", { + tenantId, + name: trimmedName, + description: sanitizeTitle(description), + aspectRatio: aspectRatio?.trim() || "16:9", + theme: theme?.trim() || "system", + filters: {}, + layout: [], + sections: [], + tvIntervalSeconds: 30, + readySelector: "[data-dashboard-ready=true]", + createdBy: actorId, + updatedBy: actorId, + createdAt: now, + updatedAt: now, + isArchived: false, + }) + + return { id: dashboardId } + }, +}) + +export const updateMetadata = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + name: v.optional(v.string()), + description: v.optional(v.string()), + aspectRatio: v.optional(v.string()), + theme: v.optional(v.string()), + readySelector: v.optional(v.string()), + tvIntervalSeconds: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, name, description, aspectRatio, theme, readySelector, tvIntervalSeconds }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + const patch: Partial> = {} + if (typeof name === "string") { + const trimmed = name.trim() + if (!trimmed) throw new ConvexError("Nome do dashboard inválido") + patch.name = trimmed + } + if (typeof description !== "undefined") { + patch.description = sanitizeTitle(description) + } + if (typeof aspectRatio === "string") { + patch.aspectRatio = aspectRatio.trim() || "16:9" + } + if (typeof theme === "string") { + patch.theme = theme.trim() || "system" + } + if (typeof readySelector === "string") { + patch.readySelector = readySelector.trim() || "[data-dashboard-ready=true]" + } + if (typeof tvIntervalSeconds === "number" && Number.isFinite(tvIntervalSeconds) && tvIntervalSeconds > 0) { + patch.tvIntervalSeconds = Math.max(5, Math.round(tvIntervalSeconds)) + } + patch.updatedAt = Date.now() + patch.updatedBy = actorId + await ctx.db.patch(dashboardId, patch) + return { ok: true } + }, +}) + +export const updateFilters = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + filters: v.any(), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, filters }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + await ctx.db.patch(dashboardId, { + filters, + updatedAt: Date.now(), + updatedBy: actorId, + }) + return { ok: true } + }, +}) + +export const updateSections = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + sections: v.array( + v.object({ + id: v.string(), + title: v.optional(v.string()), + description: v.optional(v.string()), + widgetKeys: v.array(v.string()), + durationSeconds: v.optional(v.number()), + }), + ), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, sections }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + const normalized = sections.map((section) => ({ + ...section, + title: sanitizeTitle(section.title), + description: sanitizeTitle(section.description), + durationSeconds: + typeof section.durationSeconds === "number" && Number.isFinite(section.durationSeconds) + ? Math.max(5, Math.round(section.durationSeconds)) + : undefined, + })) + await ctx.db.patch(dashboardId, { + sections: normalized, + updatedAt: Date.now(), + updatedBy: actorId, + }) + return { ok: true } + }, +}) + +export const updateLayout = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + layout: v.array(gridItemValidator), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, layout }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + const widgets = await ctx.db + .query("dashboardWidgets") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + const byKey = new Map>() + widgets.forEach((widget) => byKey.set(widget.widgetKey, widget)) + + const now = Date.now() + await ctx.db.patch(dashboardId, { + layout, + updatedAt: now, + updatedBy: actorId, + }) + + for (let index = 0; index < layout.length; index++) { + const item = layout[index] + const widget = byKey.get(item.i) + if (!widget) { + throw new ConvexError(`Widget ${item.i} não encontrado neste dashboard`) + } + await ctx.db.patch(widget._id, { + layout: { + x: item.x, + y: item.y, + w: item.w, + h: item.h, + minW: item.minW, + minH: item.minH, + static: item.static, + }, + order: normalizeOrder(index), + updatedAt: now, + updatedBy: actorId, + }) + } + + return { ok: true } + }, +}) + +export const addWidget = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + type: v.string(), + title: v.optional(v.string()), + config: v.optional(v.any()), + layout: v.optional(widgetLayoutValidator), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, type, title, config, layout }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + assertWidgetType(type) + const widgetKey = generateWidgetKey(dashboardId) + const now = Date.now() + const existingWidgets = await ctx.db + .query("dashboardWidgets") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + const widgetId = await ctx.db.insert("dashboardWidgets", { + tenantId, + dashboardId, + widgetKey, + title: sanitizeTitle(title), + type, + config: normalizeWidgetConfig(type, config), + layout: layout ?? undefined, + order: existingWidgets.length, + createdBy: actorId, + updatedBy: actorId, + createdAt: now, + updatedAt: now, + isHidden: false, + }) + + const nextLayout = [...(dashboard.layout ?? [])] + if (layout) { + nextLayout.push({ i: widgetKey, ...layout }) + } else { + const baseY = Math.max(0, nextLayout.length * 4) + nextLayout.push({ i: widgetKey, x: 0, y: baseY, w: 6, h: 6 }) + } + + await ctx.db.patch(dashboardId, { + layout: nextLayout, + updatedAt: now, + updatedBy: actorId, + }) + + return { id: widgetId, widgetKey } + }, +}) + +export const updateWidget = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + widgetId: v.id("dashboardWidgets"), + title: v.optional(v.string()), + type: v.optional(v.string()), + config: v.optional(v.any()), + layout: v.optional(widgetLayoutValidator), + hidden: v.optional(v.boolean()), + order: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, actorId, widgetId, title, type, config, layout, hidden, order }) => { + await requireStaff(ctx, actorId, tenantId) + const widget = await ctx.db.get(widgetId) + if (!widget || widget.tenantId !== tenantId) { + throw new ConvexError("Widget não encontrado") + } + + const patch: Partial> = {} + if (typeof title !== "undefined") { + patch.title = sanitizeTitle(title) + } + if (typeof type === "string") { + assertWidgetType(type) + patch.type = type + patch.config = normalizeWidgetConfig(type, config ?? widget.config) + } else if (typeof config !== "undefined") { + patch.config = config + } + if (typeof layout !== "undefined") { + patch.layout = layout + } + if (typeof hidden === "boolean") { + patch.isHidden = hidden + } + if (typeof order === "number" && Number.isFinite(order)) { + patch.order = Math.max(0, Math.round(order)) + } + patch.updatedAt = Date.now() + patch.updatedBy = actorId + + await ctx.db.patch(widgetId, patch) + return { ok: true } + }, +}) + +export const ensureQueueSummaryWidget = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + }, + handler: async (ctx, { tenantId, actorId, dashboardId }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + const widgets = await ctx.db + .query("dashboardWidgets") + .withIndex("by_dashboard_order", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + widgets.sort((a, b) => a.order - b.order || a.createdAt - b.createdAt) + + const now = Date.now() + let queueWidget = widgets.find((widget) => widget.type === "queue-summary") + let changed = false + + if (!queueWidget) { + // Shift existing widgets to make room at the top. + await Promise.all( + widgets.map((widget) => + ctx.db.patch(widget._id, { + order: widget.order + 1, + updatedAt: now, + updatedBy: actorId, + }), + ), + ) + const widgetKey = generateWidgetKey(dashboardId) + const config = normalizeQueueSummaryConfig(undefined) + const layoutWithKey = queueSummaryLayout(widgetKey) + const widgetLayout = { ...layoutWithKey } + delete (widgetLayout as { i?: string }).i + const widgetId = await ctx.db.insert("dashboardWidgets", { + tenantId, + dashboardId, + widgetKey, + title: config.title, + type: "queue-summary", + config, + layout: widgetLayout, + order: 0, + createdBy: actorId, + updatedBy: actorId, + createdAt: now, + updatedAt: now, + isHidden: false, + }) + const createdWidget = await ctx.db.get(widgetId) + if (!createdWidget) { + throw new ConvexError("Falha ao criar widget de resumo por fila.") + } + queueWidget = createdWidget + widgets.unshift(queueWidget) + changed = true + } else { + // Ensure the existing widget is first and has the expected config. + const desiredConfig = normalizeQueueSummaryConfig(queueWidget.config) + if (JSON.stringify(queueWidget.config) !== JSON.stringify(desiredConfig)) { + await ctx.db.patch(queueWidget._id, { config: desiredConfig, updatedAt: now, updatedBy: actorId }) + queueWidget = { ...queueWidget, config: desiredConfig } + changed = true + } + if (queueWidget.order !== 0) { + let nextOrder = 1 + for (const widget of widgets) { + if (widget._id === queueWidget._id) continue + if (widget.order !== nextOrder) { + await ctx.db.patch(widget._id, { order: nextOrder, updatedAt: now, updatedBy: actorId }) + } + nextOrder += 1 + } + await ctx.db.patch(queueWidget._id, { order: 0, updatedAt: now, updatedBy: actorId }) + changed = true + } + } + + if (!queueWidget) { + throw new ConvexError("Não foi possível garantir o widget de resumo por fila.") + } + + const widgetKey = queueWidget.widgetKey + + // Normalize dashboard layout (queue summary first). + const currentLayout = Array.isArray(dashboard.layout) ? dashboard.layout : [] + const filteredLayout = currentLayout.filter((item) => item.i !== widgetKey) + const normalizedLayout = [queueSummaryLayout(widgetKey), ...filteredLayout] + + let dashboardPatch: Partial> = {} + if (JSON.stringify(currentLayout) !== JSON.stringify(normalizedLayout)) { + dashboardPatch.layout = normalizedLayout + changed = true + } + + // Ensure sections used in TV playlists include the widget on the first slide. + if (Array.isArray(dashboard.sections) && dashboard.sections.length > 0) { + const updatedSections = dashboard.sections.map((section, index) => { + const keys = Array.isArray(section.widgetKeys) ? section.widgetKeys : [] + const without = keys.filter((key) => key !== widgetKey) + if (index === 0) { + const nextKeys = [widgetKey, ...without] + if (JSON.stringify(nextKeys) !== JSON.stringify(keys)) { + changed = true + return { ...section, widgetKeys: nextKeys } + } + return section + } + if (without.length !== keys.length) { + changed = true + return { ...section, widgetKeys: without } + } + return section + }) + if (changed && JSON.stringify(updatedSections) !== JSON.stringify(dashboard.sections)) { + dashboardPatch.sections = updatedSections + } + } + + if (Object.keys(dashboardPatch).length > 0) { + dashboardPatch.updatedAt = now + dashboardPatch.updatedBy = actorId + await ctx.db.patch(dashboardId, dashboardPatch) + } else if (changed) { + await ctx.db.patch(dashboardId, { updatedAt: now, updatedBy: actorId }) + } + + if (!changed) { + // Nothing changed; still return success. + return { ensured: false, widgetKey } + } + + return { ensured: true, widgetKey } + }, +}) + +export const duplicateWidget = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + widgetId: v.id("dashboardWidgets"), + }, + handler: async (ctx, { tenantId, actorId, widgetId }) => { + await requireStaff(ctx, actorId, tenantId) + const widget = await ctx.db.get(widgetId) + if (!widget || widget.tenantId !== tenantId) { + throw new ConvexError("Widget não encontrado") + } + const dashboard = await ctx.db.get(widget.dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + assertWidgetType(widget.type) + const now = Date.now() + const widgetKey = generateWidgetKey(widget.dashboardId) + const newWidgetId = await ctx.db.insert("dashboardWidgets", { + tenantId, + dashboardId: widget.dashboardId, + widgetKey, + title: sanitizeTitle(widget.title), + type: widget.type, + config: widget.config, + layout: widget.layout ?? undefined, + order: widget.order + 1, + createdBy: actorId, + updatedBy: actorId, + createdAt: now, + updatedAt: now, + isHidden: widget.isHidden ?? false, + }) + + const duplicateLayout = widget.layout + ? { + x: widget.layout.x, + y: (widget.layout.y ?? 0) + (widget.layout.h ?? 6) + 1, + w: widget.layout.w, + h: widget.layout.h ?? 6, + minW: widget.layout.minW, + minH: widget.layout.minH, + static: widget.layout.static, + } + : { x: 0, y: Math.max(0, (dashboard.layout?.length ?? 0) * 4), w: 6, h: 6 } + const nextLayout = [...(dashboard.layout ?? []), { i: widgetKey, ...duplicateLayout }] + await ctx.db.patch(dashboard._id, { + layout: nextLayout, + updatedAt: now, + updatedBy: actorId, + }) + return { id: newWidgetId, widgetKey } + }, +}) + +export const removeWidget = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + widgetId: v.id("dashboardWidgets"), + }, + handler: async (ctx, { tenantId, actorId, widgetId }) => { + await requireStaff(ctx, actorId, tenantId) + const widget = await ctx.db.get(widgetId) + if (!widget || widget.tenantId !== tenantId) { + throw new ConvexError("Widget não encontrado") + } + const dashboard = await ctx.db.get(widget.dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + await ctx.db.delete(widgetId) + const filteredLayout = (dashboard.layout ?? []).filter((item) => item.i !== widget.widgetKey) + await ctx.db.patch(dashboard._id, { + layout: filteredLayout, + updatedAt: Date.now(), + updatedBy: actorId, + }) + + return { ok: true } + }, +}) + +export const archive = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + archived: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, archived }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + await ctx.db.patch(dashboardId, { + isArchived: archived ?? !(dashboard.isArchived ?? false), + updatedAt: Date.now(), + updatedBy: actorId, + }) + return { ok: true } + }, +}) + +export const upsertShare = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + audience: v.union(v.literal("private"), v.literal("tenant"), v.literal("public-link")), + canEdit: v.boolean(), + expiresAt: v.optional(v.union(v.number(), v.null())), + token: v.optional(v.union(v.string(), v.null())), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, audience, canEdit, expiresAt, token }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + const existingShares = await ctx.db + .query("dashboardShares") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + const now = Date.now() + let shareDoc = existingShares.find((share) => share.audience === audience) + const normalizedExpiresAt = + typeof expiresAt === "number" && Number.isFinite(expiresAt) ? Math.max(now, Math.round(expiresAt)) : undefined + const normalizedToken = typeof token === "string" && token.trim().length > 0 ? token.trim() : undefined + + if (!shareDoc) { + const generatedToken = audience === "public-link" ? normalizedToken ?? cryptoToken() : undefined + await ctx.db.insert("dashboardShares", { + tenantId, + dashboardId, + audience, + token: generatedToken, + canEdit, + expiresAt: normalizedExpiresAt, + createdBy: actorId, + createdAt: now, + lastAccessAt: undefined, + }) + } else { + await ctx.db.patch(shareDoc._id, { + canEdit, + token: audience === "public-link" ? normalizedToken ?? shareDoc.token ?? cryptoToken() : undefined, + expiresAt: normalizedExpiresAt, + lastAccessAt: shareDoc.lastAccessAt, + }) + } + + await ctx.db.patch(dashboardId, { updatedAt: now, updatedBy: actorId }) + return { ok: true } + }, +}) + +export const revokeShareToken = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + }, + handler: async (ctx, { tenantId, actorId, dashboardId }) => { + await requireStaff(ctx, actorId, tenantId) + const shares = await ctx.db + .query("dashboardShares") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + for (const share of shares) { + if (share.audience === "public-link") { + await ctx.db.patch(share._id, { + token: cryptoToken(), + lastAccessAt: undefined, + }) + } + } + await ctx.db.patch(dashboardId, { updatedAt: Date.now(), updatedBy: actorId }) + return { ok: true } + }, +}) + +function cryptoToken() { + return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6) +} diff --git a/referência/sistema-de-chamados-main/convex/deviceExportTemplates.ts b/referência/sistema-de-chamados-main/convex/deviceExportTemplates.ts new file mode 100644 index 0000000..c3e9327 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/deviceExportTemplates.ts @@ -0,0 +1,372 @@ +import { mutation, query } from "./_generated/server" +import type { MutationCtx, QueryCtx } from "./_generated/server" +import { ConvexError, v } from "convex/values" +import type { Id } from "./_generated/dataModel" + +import { requireAdmin, requireUser } from "./rbac" + +type AnyCtx = MutationCtx | QueryCtx + +function normalizeSlug(input: string) { + return input + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") +} + +async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, excludeId?: Id<"deviceExportTemplates">) { + const existing = await ctx.db + .query("deviceExportTemplates") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first() + + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe um template com este identificador") + } +} + +async function unsetDefaults( + ctx: MutationCtx, + tenantId: string, + companyId: Id<"companies"> | undefined | null, + excludeId?: Id<"deviceExportTemplates"> +) { + const templates = await ctx.db + .query("deviceExportTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + await Promise.all( + templates + .filter((tpl) => tpl._id !== excludeId) + .filter((tpl) => { + if (companyId) { + return tpl.companyId === companyId + } + return !tpl.companyId + }) + .map((tpl) => ctx.db.patch(tpl._id, { isDefault: false })) + ) +} + +function normalizeColumns(columns: { key: string; label?: string | null }[]) { + return columns + .map((col) => ({ + key: col.key.trim(), + label: col.label?.trim() || undefined, + })) + .filter((col) => col.key.length > 0) +} + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + companyId: v.optional(v.id("companies")), + includeInactive: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, viewerId, companyId, includeInactive }) => { + await requireAdmin(ctx, viewerId, tenantId) + const templates = await ctx.db + .query("deviceExportTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + return templates + .filter((tpl) => { + if (!includeInactive && tpl.isActive === false) return false + if (!companyId) return true + if (!tpl.companyId) return true + return tpl.companyId === companyId + }) + .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) + .map((tpl) => ({ + id: tpl._id, + slug: tpl.slug, + name: tpl.name, + description: tpl.description ?? "", + columns: tpl.columns ?? [], + filters: tpl.filters ?? null, + companyId: tpl.companyId ?? null, + isDefault: Boolean(tpl.isDefault), + isActive: tpl.isActive ?? true, + createdAt: tpl.createdAt, + updatedAt: tpl.updatedAt, + createdBy: tpl.createdBy ?? null, + updatedBy: tpl.updatedBy ?? null, + })) + }, +}) + +export const listForTenant = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + companyId: v.optional(v.id("companies")), + }, + handler: async (ctx, { tenantId, viewerId, companyId }) => { + await requireUser(ctx, viewerId, tenantId) + const templates = await ctx.db + .query("deviceExportTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + return templates + .filter((tpl) => tpl.isActive !== false) + .filter((tpl) => { + if (!companyId) return !tpl.companyId + return !tpl.companyId || tpl.companyId === companyId + }) + .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) + .map((tpl) => ({ + id: tpl._id, + slug: tpl.slug, + name: tpl.name, + description: tpl.description ?? "", + columns: tpl.columns ?? [], + filters: tpl.filters ?? null, + companyId: tpl.companyId ?? null, + isDefault: Boolean(tpl.isDefault), + isActive: tpl.isActive ?? true, + })) + }, +}) + +export const getDefault = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + companyId: v.optional(v.id("companies")), + }, + handler: async (ctx, { tenantId, viewerId, companyId }) => { + await requireUser(ctx, viewerId, tenantId) + const indexQuery = companyId + ? ctx.db + .query("deviceExportTemplates") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + : ctx.db.query("deviceExportTemplates").withIndex("by_tenant_default", (q) => q.eq("tenantId", tenantId).eq("isDefault", true)) + + const templates = await indexQuery.collect() + const candidate = templates.find((tpl) => tpl.isDefault) ?? null + if (candidate) { + return { + id: candidate._id, + slug: candidate.slug, + name: candidate.name, + description: candidate.description ?? "", + columns: candidate.columns ?? [], + filters: candidate.filters ?? null, + companyId: candidate.companyId ?? null, + isDefault: Boolean(candidate.isDefault), + isActive: candidate.isActive ?? true, + } + } + + if (companyId) { + const globalDefault = await ctx.db + .query("deviceExportTemplates") + .withIndex("by_tenant_default", (q) => q.eq("tenantId", tenantId).eq("isDefault", true)) + .first() + + if (globalDefault) { + return { + id: globalDefault._id, + slug: globalDefault.slug, + name: globalDefault.name, + description: globalDefault.description ?? "", + columns: globalDefault.columns ?? [], + filters: globalDefault.filters ?? null, + companyId: globalDefault.companyId ?? null, + isDefault: Boolean(globalDefault.isDefault), + isActive: globalDefault.isActive ?? true, + } + } + } + + return null + }, +}) + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + columns: v.array( + v.object({ + key: v.string(), + label: v.optional(v.string()), + }) + ), + filters: v.optional(v.any()), + companyId: v.optional(v.id("companies")), + isDefault: v.optional(v.boolean()), + isActive: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + const normalizedName = args.name.trim() + if (normalizedName.length < 3) { + throw new ConvexError("Informe um nome para o template") + } + const slug = normalizeSlug(normalizedName) + if (!slug) { + throw new ConvexError("Não foi possível gerar um identificador para o template") + } + await ensureUniqueSlug(ctx, args.tenantId, slug) + + const columns = normalizeColumns(args.columns) + if (columns.length === 0) { + throw new ConvexError("Selecione ao menos uma coluna") + } + + const now = Date.now() + const templateId = await ctx.db.insert("deviceExportTemplates", { + tenantId: args.tenantId, + name: normalizedName, + slug, + description: args.description ?? undefined, + columns, + filters: args.filters ?? undefined, + companyId: args.companyId ?? undefined, + isDefault: Boolean(args.isDefault), + isActive: args.isActive ?? true, + createdBy: args.actorId, + updatedBy: args.actorId, + createdAt: now, + updatedAt: now, + }) + + if (args.isDefault) { + await unsetDefaults(ctx, args.tenantId, args.companyId ?? null, templateId) + } + + return templateId + }, +}) + +export const update = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + templateId: v.id("deviceExportTemplates"), + name: v.string(), + description: v.optional(v.string()), + columns: v.array( + v.object({ + key: v.string(), + label: v.optional(v.string()), + }) + ), + filters: v.optional(v.any()), + companyId: v.optional(v.id("companies")), + isDefault: v.optional(v.boolean()), + isActive: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + const template = await ctx.db.get(args.templateId) + if (!template || template.tenantId !== args.tenantId) { + throw new ConvexError("Template não encontrado") + } + const normalizedName = args.name.trim() + if (normalizedName.length < 3) { + throw new ConvexError("Informe um nome para o template") + } + let slug = template.slug + if (template.name !== normalizedName) { + slug = normalizeSlug(normalizedName) + if (!slug) throw new ConvexError("Não foi possível gerar um identificador para o template") + await ensureUniqueSlug(ctx, args.tenantId, slug, args.templateId) + } + + const columns = normalizeColumns(args.columns) + if (columns.length === 0) { + throw new ConvexError("Selecione ao menos uma coluna") + } + + const isDefault = Boolean(args.isDefault) + await ctx.db.patch(args.templateId, { + name: normalizedName, + slug, + description: args.description ?? undefined, + columns, + filters: args.filters ?? undefined, + companyId: args.companyId ?? undefined, + isDefault, + isActive: args.isActive ?? true, + updatedAt: Date.now(), + updatedBy: args.actorId, + }) + + if (isDefault) { + await unsetDefaults(ctx, args.tenantId, args.companyId ?? null, args.templateId) + } + }, +}) + +export const remove = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + templateId: v.id("deviceExportTemplates"), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + const template = await ctx.db.get(args.templateId) + if (!template || template.tenantId !== args.tenantId) { + throw new ConvexError("Template não encontrado") + } + await ctx.db.delete(args.templateId) + }, +}) + +export const setDefault = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + templateId: v.id("deviceExportTemplates"), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + const template = await ctx.db.get(args.templateId) + if (!template || template.tenantId !== args.tenantId) { + throw new ConvexError("Template não encontrado") + } + await unsetDefaults(ctx, args.tenantId, template.companyId ?? null, args.templateId) + await ctx.db.patch(args.templateId, { + isDefault: true, + updatedAt: Date.now(), + updatedBy: args.actorId, + }) + }, +}) + +export const clearCompanyDefault = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + companyId: v.id("companies"), + }, + handler: async (ctx, { tenantId, actorId, companyId }) => { + await requireAdmin(ctx, actorId, tenantId) + const templates = await ctx.db + .query("deviceExportTemplates") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect() + const now = Date.now() + await Promise.all( + templates.map((tpl) => + ctx.db.patch(tpl._id, { + isDefault: false, + updatedAt: now, + updatedBy: actorId, + }) + ) + ) + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/deviceFieldDefaults.ts b/referência/sistema-de-chamados-main/convex/deviceFieldDefaults.ts new file mode 100644 index 0000000..97c09da --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/deviceFieldDefaults.ts @@ -0,0 +1,131 @@ +"use server"; + +import type { MutationCtx } from "./_generated/server"; +import type { Doc } from "./_generated/dataModel"; + +const DEFAULT_MOBILE_DEVICE_FIELDS: Array<{ + key: string; + label: string; + type: "text" | "select"; + description?: string; + options?: Array<{ value: string; label: string }>; +}> = [ + { + key: "mobile_identificacao", + label: "Identificação interna", + type: "text", + description: "Como o time reconhece este dispositivo (ex.: iPhone da Ana).", + }, + { + key: "mobile_ram", + label: "Memória RAM", + type: "text", + }, + { + key: "mobile_storage", + label: "Armazenamento (HD/SSD)", + type: "text", + }, + { + key: "mobile_cpu", + label: "Processador", + type: "text", + }, + { + key: "mobile_hostname", + label: "Hostname", + type: "text", + }, + { + key: "mobile_patrimonio", + label: "Patrimônio", + type: "text", + }, + { + key: "mobile_observacoes", + label: "Observações", + type: "text", + }, + { + key: "mobile_situacao", + label: "Situação do equipamento", + type: "select", + options: [ + { value: "em_uso", label: "Em uso" }, + { value: "reserva", label: "Reserva" }, + { value: "manutencao", label: "Em manutenção" }, + { value: "inativo", label: "Inativo" }, + ], + }, + { + key: "mobile_cargo", + label: "Cargo", + type: "text", + }, + { + key: "mobile_setor", + label: "Setor", + type: "text", + }, +]; + +export async function ensureMobileDeviceFields(ctx: MutationCtx, tenantId: string) { + const existingMobileFields = await ctx.db + .query("deviceFields") + .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", "mobile")) + .collect(); + const allFields = await ctx.db + .query("deviceFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const existingByKey = new Map>(); + existingMobileFields.forEach((field) => existingByKey.set(field.key, field)); + + let order = allFields.reduce((max, field) => Math.max(max, field.order ?? 0), 0); + const now = Date.now(); + + for (const definition of DEFAULT_MOBILE_DEVICE_FIELDS) { + const current = existingByKey.get(definition.key); + if (current) { + const updates: Partial> = {}; + if ((current.label ?? "").trim() !== definition.label) { + updates.label = definition.label; + } + if ((current.description ?? "") !== (definition.description ?? "")) { + updates.description = definition.description ?? undefined; + } + const existingOptions = JSON.stringify(current.options ?? null); + const desiredOptions = JSON.stringify(definition.options ?? null); + if (existingOptions !== desiredOptions) { + updates.options = definition.options ?? undefined; + } + if (current.type !== definition.type) { + updates.type = definition.type; + } + if (Object.keys(updates).length) { + await ctx.db.patch(current._id, { + ...updates, + updatedAt: now, + }); + } + continue; + } + + order += 1; + await ctx.db.insert("deviceFields", { + tenantId, + key: definition.key, + label: definition.label, + description: definition.description ?? undefined, + type: definition.type, + required: false, + options: definition.options ?? undefined, + scope: "mobile", + companyId: undefined, + order, + createdAt: now, + updatedAt: now, + }); + } +} diff --git a/referência/sistema-de-chamados-main/convex/deviceFields.ts b/referência/sistema-de-chamados-main/convex/deviceFields.ts new file mode 100644 index 0000000..61d591f --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/deviceFields.ts @@ -0,0 +1,284 @@ +import { mutation, query } from "./_generated/server" +import type { MutationCtx, QueryCtx } from "./_generated/server" +import { ConvexError, v } from "convex/values" +import type { Id } from "./_generated/dataModel" + +import { requireAdmin, requireUser } from "./rbac" +import { ensureMobileDeviceFields } from "./deviceFieldDefaults" + +const FIELD_TYPES = ["text", "number", "select", "multiselect", "date", "boolean"] as const +type FieldType = (typeof FIELD_TYPES)[number] + +type AnyCtx = MutationCtx | QueryCtx + +function normalizeKey(label: string) { + return label + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "_") + .replace(/_+/g, "_") +} + +async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"deviceFields">) { + const existing = await ctx.db + .query("deviceFields") + .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) + .first() + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe um campo com este identificador") + } +} + +function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) { + if ((type === "select" || type === "multiselect") && (!options || options.length === 0)) { + throw new ConvexError("Campos de seleção precisam de pelo menos uma opção") + } +} + +function matchesScope(fieldScope: string | undefined, scope: string | undefined) { + if (!scope || scope === "all") return true + if (!fieldScope || fieldScope === "all") return true + return fieldScope === scope +} + +function matchesCompany(fieldCompanyId: Id<"companies"> | undefined, companyId: Id<"companies"> | undefined, includeScoped?: boolean) { + if (!companyId) { + if (includeScoped) return true + return fieldCompanyId ? false : true + } + return !fieldCompanyId || fieldCompanyId === companyId +} + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + companyId: v.optional(v.id("companies")), + scope: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, viewerId, companyId, scope }) => { + await requireAdmin(ctx, viewerId, tenantId) + const fieldsQuery = ctx.db + .query("deviceFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + + const fields = await fieldsQuery.collect() + return fields + .filter((field) => matchesCompany(field.companyId, companyId, true)) + .filter((field) => matchesScope(field.scope, scope)) + .sort((a, b) => a.order - b.order) + .map((field) => ({ + id: field._id, + key: field.key, + label: field.label, + description: field.description ?? "", + type: field.type as FieldType, + required: Boolean(field.required), + options: field.options ?? [], + order: field.order, + scope: field.scope ?? "all", + companyId: field.companyId ?? null, + })) + }, +}) + +export const listForTenant = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + companyId: v.optional(v.id("companies")), + scope: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, viewerId, companyId, scope }) => { + await requireUser(ctx, viewerId, tenantId) + const fields = await ctx.db + .query("deviceFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect() + + return fields + .filter((field) => matchesCompany(field.companyId, companyId, false)) + .filter((field) => matchesScope(field.scope, scope)) + .sort((a, b) => a.order - b.order) + .map((field) => ({ + id: field._id, + key: field.key, + label: field.label, + description: field.description ?? "", + type: field.type as FieldType, + required: Boolean(field.required), + options: field.options ?? [], + order: field.order, + scope: field.scope ?? "all", + companyId: field.companyId ?? null, + })) + }, +}) + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + label: v.string(), + description: v.optional(v.string()), + type: v.string(), + required: v.optional(v.boolean()), + options: v.optional( + v.array( + v.object({ + value: v.string(), + label: v.string(), + }) + ) + ), + scope: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + const normalizedLabel = args.label.trim() + if (normalizedLabel.length < 2) { + throw new ConvexError("Informe um rótulo para o campo") + } + if (!FIELD_TYPES.includes(args.type as FieldType)) { + throw new ConvexError("Tipo de campo inválido") + } + validateOptions(args.type as FieldType, args.options ?? undefined) + + const key = normalizeKey(normalizedLabel) + await ensureUniqueKey(ctx, args.tenantId, key) + + const existing = await ctx.db + .query("deviceFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", args.tenantId)) + .collect() + const maxOrder = existing.reduce((acc, item) => Math.max(acc, item.order ?? 0), 0) + const now = Date.now() + + const id = await ctx.db.insert("deviceFields", { + tenantId: args.tenantId, + key, + label: normalizedLabel, + description: args.description ?? undefined, + type: args.type, + required: Boolean(args.required), + options: args.options ?? undefined, + scope: args.scope ?? "all", + companyId: args.companyId ?? undefined, + order: maxOrder + 1, + createdAt: now, + updatedAt: now, + createdBy: args.actorId, + updatedBy: args.actorId, + }) + + return id + }, +}) + +export const update = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + fieldId: v.id("deviceFields"), + label: v.string(), + description: v.optional(v.string()), + type: v.string(), + required: v.optional(v.boolean()), + options: v.optional( + v.array( + v.object({ + value: v.string(), + label: v.string(), + }) + ) + ), + scope: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + const field = await ctx.db.get(args.fieldId) + if (!field || field.tenantId !== args.tenantId) { + throw new ConvexError("Campo não encontrado") + } + if (!FIELD_TYPES.includes(args.type as FieldType)) { + throw new ConvexError("Tipo de campo inválido") + } + const normalizedLabel = args.label.trim() + if (normalizedLabel.length < 2) { + throw new ConvexError("Informe um rótulo para o campo") + } + validateOptions(args.type as FieldType, args.options ?? undefined) + + let key = field.key + if (field.label !== normalizedLabel) { + key = normalizeKey(normalizedLabel) + await ensureUniqueKey(ctx, args.tenantId, key, args.fieldId) + } + + await ctx.db.patch(args.fieldId, { + key, + label: normalizedLabel, + description: args.description ?? undefined, + type: args.type, + required: Boolean(args.required), + options: args.options ?? undefined, + scope: args.scope ?? "all", + companyId: args.companyId ?? undefined, + updatedAt: Date.now(), + updatedBy: args.actorId, + }) + }, +}) + +export const remove = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + fieldId: v.id("deviceFields"), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + const field = await ctx.db.get(args.fieldId) + if (!field || field.tenantId !== args.tenantId) { + throw new ConvexError("Campo não encontrado") + } + await ctx.db.delete(args.fieldId) + }, +}) + +export const reorder = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + orderedIds: v.array(v.id("deviceFields")), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + const now = Date.now() + await Promise.all( + args.orderedIds.map((fieldId, index) => + ctx.db.patch(fieldId, { + order: index + 1, + updatedAt: now, + updatedBy: args.actorId, + }) + ) + ) + }, +}) + +export const ensureDefaults = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { tenantId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId) + await ensureMobileDeviceFields(ctx, tenantId) + return { ok: true } + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/devices.ts b/referência/sistema-de-chamados-main/convex/devices.ts new file mode 100644 index 0000000..d01664f --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/devices.ts @@ -0,0 +1 @@ +export * from "./machines" diff --git a/referência/sistema-de-chamados-main/convex/fields.ts b/referência/sistema-de-chamados-main/convex/fields.ts new file mode 100644 index 0000000..38d6863 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/fields.ts @@ -0,0 +1,287 @@ +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; + +import { requireAdmin, requireUser } from "./rbac"; + +const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const; + +type FieldType = (typeof FIELD_TYPES)[number]; + +function normalizeKey(label: string) { + return label + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "_") + .replace(/_+/g, "_"); +} + +type AnyCtx = QueryCtx | MutationCtx; + +async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"ticketFields">) { + const existing = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe um campo com este identificador"); + } +} + +function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) { + if (type === "select" && (!options || options.length === 0)) { + throw new ConvexError("Campos de seleção precisam de pelo menos uma opção"); + } +} + +async function validateCompanyScope(ctx: AnyCtx, tenantId: string, companyId?: Id<"companies"> | null) { + if (!companyId) return undefined; + const company = await ctx.db.get(companyId); + if (!company || company.tenantId !== tenantId) { + throw new ConvexError("Empresa inválida para o campo"); + } + return companyId; +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) }, + handler: async (ctx, { tenantId, viewerId, scope }) => { + await requireAdmin(ctx, viewerId, tenantId); + const fields = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return fields + .filter((field) => { + if (!scope) return true; + const fieldScope = (field.scope ?? "all").trim(); + if (fieldScope === "all" || fieldScope.length === 0) return true; + return fieldScope === scope; + }) + .sort((a, b) => a.order - b.order) + .map((field) => ({ + id: field._id, + key: field.key, + label: field.label, + description: field.description ?? "", + type: field.type as FieldType, + required: field.required, + options: field.options ?? [], + order: field.order, + scope: field.scope ?? "all", + companyId: field.companyId ?? null, + createdAt: field.createdAt, + updatedAt: field.updatedAt, + })); + }, +}); + +export const listForTenant = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) }, + handler: async (ctx, { tenantId, viewerId, scope }) => { + await requireUser(ctx, viewerId, tenantId); + const fields = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return fields + .filter((field) => { + if (!scope) return true; + const fieldScope = (field.scope ?? "all").trim(); + if (fieldScope === "all" || fieldScope.length === 0) return true; + return fieldScope === scope; + }) + .sort((a, b) => a.order - b.order) + .map((field) => ({ + id: field._id, + key: field.key, + label: field.label, + description: field.description ?? "", + type: field.type as FieldType, + required: field.required, + options: field.options ?? [], + order: field.order, + scope: field.scope ?? "all", + companyId: field.companyId ?? null, + })); + }, +}); + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + label: v.string(), + description: v.optional(v.string()), + type: v.string(), + required: v.boolean(), + options: v.optional( + v.array( + v.object({ + value: v.string(), + label: v.string(), + }) + ) + ), + scope: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + }, + handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope, companyId }) => { + await requireAdmin(ctx, actorId, tenantId); + const normalizedLabel = label.trim(); + if (normalizedLabel.length < 2) { + throw new ConvexError("Informe um rótulo para o campo"); + } + if (!FIELD_TYPES.includes(type as FieldType)) { + throw new ConvexError("Tipo de campo inválido"); + } + validateOptions(type as FieldType, options ?? undefined); + const key = normalizeKey(normalizedLabel); + await ensureUniqueKey(ctx, tenantId, key); + const normalizedScope = (() => { + const raw = scope?.trim(); + if (!raw || raw.length === 0) return "all"; + const safe = raw.toLowerCase(); + if (!/^[a-z0-9_\-]+$/i.test(safe)) { + throw new ConvexError("Escopo inválido para o campo"); + } + return safe; + })(); + const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined); + + const existing = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + const maxOrder = existing.reduce((acc: number, item: Doc<"ticketFields">) => Math.max(acc, item.order ?? 0), 0); + + const now = Date.now(); + const id = await ctx.db.insert("ticketFields", { + tenantId, + key, + label: normalizedLabel, + description, + type, + required, + options, + order: maxOrder + 1, + scope: normalizedScope, + companyId: companyRef, + createdAt: now, + updatedAt: now, + }); + return id; + }, +}); + +export const update = mutation({ + args: { + tenantId: v.string(), + fieldId: v.id("ticketFields"), + actorId: v.id("users"), + label: v.string(), + description: v.optional(v.string()), + type: v.string(), + required: v.boolean(), + options: v.optional( + v.array( + v.object({ + value: v.string(), + label: v.string(), + }) + ) + ), + scope: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + }, + handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope, companyId }) => { + await requireAdmin(ctx, actorId, tenantId); + const field = await ctx.db.get(fieldId); + if (!field || field.tenantId !== tenantId) { + throw new ConvexError("Campo não encontrado"); + } + if (!FIELD_TYPES.includes(type as FieldType)) { + throw new ConvexError("Tipo de campo inválido"); + } + const normalizedLabel = label.trim(); + if (normalizedLabel.length < 2) { + throw new ConvexError("Informe um rótulo para o campo"); + } + validateOptions(type as FieldType, options ?? undefined); + + const normalizedScope = (() => { + const raw = scope?.trim(); + if (!raw || raw.length === 0) return "all"; + const safe = raw.toLowerCase(); + if (!/^[a-z0-9_\-]+$/i.test(safe)) { + throw new ConvexError("Escopo inválido para o campo"); + } + return safe; + })(); + const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined); + + let key = field.key; + if (field.label !== normalizedLabel) { + key = normalizeKey(normalizedLabel); + await ensureUniqueKey(ctx, tenantId, key, fieldId); + } + + await ctx.db.patch(fieldId, { + key, + label: normalizedLabel, + description, + type, + required, + options, + scope: normalizedScope, + companyId: companyRef, + updatedAt: Date.now(), + }); + }, +}); + +export const remove = mutation({ + args: { + tenantId: v.string(), + fieldId: v.id("ticketFields"), + actorId: v.id("users"), + }, + handler: async (ctx, { tenantId, fieldId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId); + const field = await ctx.db.get(fieldId); + if (!field || field.tenantId !== tenantId) { + throw new ConvexError("Campo não encontrado"); + } + await ctx.db.delete(fieldId); + }, +}); +export const reorder = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + orderedIds: v.array(v.id("ticketFields")), + }, + handler: async (ctx, { tenantId, actorId, orderedIds }) => { + await requireAdmin(ctx, actorId, tenantId); + const fields = await Promise.all(orderedIds.map((id) => ctx.db.get(id))); + fields.forEach((field) => { + if (!field || field.tenantId !== tenantId) { + throw new ConvexError("Campo inválido para reordenação"); + } + }); + const now = Date.now(); + await Promise.all( + orderedIds.map((fieldId, index) => + ctx.db.patch(fieldId, { + order: index + 1, + updatedAt: now, + }) + ) + ); + }, +}); diff --git a/referência/sistema-de-chamados-main/convex/files.ts b/referência/sistema-de-chamados-main/convex/files.ts new file mode 100644 index 0000000..9e7ee74 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/files.ts @@ -0,0 +1,18 @@ +import { action } from "./_generated/server"; +import { v } from "convex/values"; + +export const generateUploadUrl = action({ + args: {}, + handler: async (ctx) => { + return await ctx.storage.generateUploadUrl(); + }, +}); + +export const getUrl = action({ + args: { storageId: v.id("_storage") }, + handler: async (ctx, { storageId }) => { + const url = await ctx.storage.getUrl(storageId); + return url; + }, +}); + diff --git a/referência/sistema-de-chamados-main/convex/invites.ts b/referência/sistema-de-chamados-main/convex/invites.ts new file mode 100644 index 0000000..54aebc4 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/invites.ts @@ -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); + } + }, +}); diff --git a/referência/sistema-de-chamados-main/convex/machines.ts b/referência/sistema-de-chamados-main/convex/machines.ts new file mode 100644 index 0000000..4f0a460 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/machines.ts @@ -0,0 +1,2294 @@ +// ci: trigger convex functions deploy (no-op) +import { mutation, query } from "./_generated/server" +import { api } from "./_generated/api" +import { paginationOptsValidator } from "convex/server" +import { ConvexError, v, Infer } from "convex/values" +import { sha256 } from "@noble/hashes/sha256" +import { randomBytes } from "@noble/hashes/utils" +import type { Doc, Id } from "./_generated/dataModel" +import type { MutationCtx, QueryCtx } from "./_generated/server" +import { normalizeStatus } from "./tickets" +import { requireAdmin } from "./rbac" +import { ensureMobileDeviceFields } from "./deviceFieldDefaults" + +const DEFAULT_TENANT_ID = "tenant-atlas" +const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias +const ALLOWED_MACHINE_PERSONAS = new Set(["collaborator", "manager"]) +const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000 +const OPEN_TICKET_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]) +const MACHINE_TICKETS_STATS_PAGE_SIZE = 200 + +type NormalizedIdentifiers = { + macs: string[] + serials: string[] +} + +function getTokenTtlMs(): number { + const raw = process.env["MACHINE_TOKEN_TTL_MS"] + if (!raw) return DEFAULT_TOKEN_TTL_MS + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed < 60_000) { + return DEFAULT_TOKEN_TTL_MS + } + return parsed +} + +export function getOfflineThresholdMs(): number { + const raw = process.env["MACHINE_OFFLINE_THRESHOLD_MS"] + if (!raw) return DEFAULT_OFFLINE_THRESHOLD_MS + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_OFFLINE_THRESHOLD_MS + } + return parsed +} + +export function getStaleThresholdMs(offlineMs: number): number { + const raw = process.env["MACHINE_STALE_THRESHOLD_MS"] + if (!raw) return offlineMs * 12 + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= offlineMs) { + return offlineMs * 12 + } + return parsed +} + +function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]): NormalizedIdentifiers { + const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase() + const normalizeSerial = (value: string) => value.trim().toLowerCase() + + const macs = Array.from(new Set(macAddresses.map(normalizeMac).filter(Boolean))).sort() + const serials = Array.from(new Set(serialNumbers.map(normalizeSerial).filter(Boolean))).sort() + + if (macs.length === 0 && serials.length === 0) { + throw new ConvexError("Informe ao menos um identificador (MAC ou serial)") + } + + return { macs, serials } +} + +function normalizeOptionalIdentifiers(macAddresses?: string[] | null, serialNumbers?: string[] | null): NormalizedIdentifiers { + const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase() + const normalizeSerial = (value: string) => value.trim().toLowerCase() + const macs = Array.from(new Set((macAddresses ?? []).map(normalizeMac).filter(Boolean))).sort() + const serials = Array.from(new Set((serialNumbers ?? []).map(normalizeSerial).filter(Boolean))).sort() + return { macs, serials } +} + +async function findActiveMachineToken(ctx: QueryCtx, machineId: Id<"machines">, now: number) { + const tokens = await ctx.db + .query("machineTokens") + .withIndex("by_machine_revoked_expires", (q) => + q.eq("machineId", machineId).eq("revoked", false).gt("expiresAt", now), + ) + .collect() + + return tokens.length > 0 ? tokens[0]! : null +} + +function toHex(input: Uint8Array) { + return Array.from(input) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") +} + +function computeFingerprint(tenantId: string, companySlug: string | undefined, hostname: string, ids: NormalizedIdentifiers) { + const payload = JSON.stringify({ + tenantId, + companySlug: companySlug ?? null, + hostname: hostname.trim().toLowerCase(), + macs: ids.macs, + serials: ids.serials, + }) + return toHex(sha256(payload)) +} + +function generateManualFingerprint(tenantId: string, displayName: string) { + const payload = JSON.stringify({ + tenantId, + displayName: displayName.trim().toLowerCase(), + nonce: toHex(randomBytes(16)), + createdAt: Date.now(), + }) + return toHex(sha256(payload)) +} + +function formatDeviceCustomFieldDisplay( + type: string, + value: unknown, + options?: Array<{ value: string; label: string }> +): string | null { + if (value === null || value === undefined) return null + switch (type) { + case "text": + return String(value).trim() + case "number": { + const num = typeof value === "number" ? value : Number(value) + if (!Number.isFinite(num)) return null + return String(num) + } + case "boolean": + return value ? "Sim" : "Não" + case "date": { + const date = value instanceof Date ? value : new Date(String(value)) + if (Number.isNaN(date.getTime())) return null + return date.toISOString().slice(0, 10) + } + case "select": { + const raw = String(value) + const option = options?.find((opt) => opt.value === raw || opt.label === raw) + return option?.label ?? raw + } + case "multiselect": { + const arr = Array.isArray(value) + ? value + : typeof value === "string" + ? value.split(",").map((s) => s.trim()).filter(Boolean) + : [] + if (arr.length === 0) return null + const labels = arr.map((raw) => { + const opt = options?.find((o) => o.value === raw || o.label === raw) + return opt?.label ?? String(raw) + }) + return labels.join(", ") + } + default: + try { + return JSON.stringify(value) + } catch { + return String(value) + } + } +} + +function extractCollaboratorEmail(metadata: unknown): string | null { + if (!metadata || typeof metadata !== "object") return null + const record = metadata as Record + const collaborator = record["collaborator"] + if (!collaborator || typeof collaborator !== "object") return null + const email = (collaborator as { email?: unknown }).email + if (typeof email !== "string") return null + const trimmed = email.trim().toLowerCase() + return trimmed || null +} + +function matchesExistingHardware(existing: Doc<"machines">, identifiers: NormalizedIdentifiers, hostname: string): boolean { + const intersectsMac = existing.macAddresses.some((mac) => identifiers.macs.includes(mac)) + const intersectsSerial = existing.serialNumbers.some((serial) => identifiers.serials.includes(serial)) + const sameHostname = existing.hostname.trim().toLowerCase() === hostname.trim().toLowerCase() + return intersectsMac || intersectsSerial || sameHostname +} + +function hashToken(token: string) { + return toHex(sha256(token)) +} + +async function getActiveToken( + ctx: MutationCtx, + tokenValue: string +): Promise<{ token: Doc<"machineTokens">; machine: Doc<"machines"> }> { + const tokenHash = hashToken(tokenValue) + const token = await ctx.db + .query("machineTokens") + .withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash)) + .unique() + + if (!token) { + throw new ConvexError("Token de dispositivo inválido") + } + if (token.revoked) { + throw new ConvexError("Token de dispositivo revogado") + } + if (token.expiresAt < Date.now()) { + throw new ConvexError("Token de dispositivo expirado") + } + + const machine = await ctx.db.get(token.machineId) + if (!machine) { + throw new ConvexError("Dispositivo não encontrada para o token fornecido") + } + + return { token, machine } +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function mergeInventory(current: unknown, patch: unknown): unknown { + if (!isObject(patch)) { + return patch + } + const base: Record = isObject(current) ? { ...(current as Record) } : {} + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue + if (isObject(value) && isObject(base[key])) { + base[key] = mergeInventory(base[key], value) + } else { + base[key] = value + } + } + return base +} + +function mergeMetadata(current: unknown, patch: Record) { + const base: Record = isObject(current) ? { ...(current as Record) } : {} + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue + if (key === "inventory") { + base[key] = mergeInventory(base[key], value) + } else if (isObject(value) && isObject(base[key])) { + base[key] = mergeInventory(base[key], value) + } else { + base[key] = value + } + } + return base +} + +type JsonRecord = Record + +function ensureRecord(value: unknown): JsonRecord | null { + return isObject(value) ? (value as JsonRecord) : null +} + +function ensureRecordArray(value: unknown): JsonRecord[] { + if (!Array.isArray(value)) return [] + return value.filter(isObject) as JsonRecord[] +} + +function ensureFiniteNumber(value: unknown): number | null { + const num = typeof value === "number" ? value : Number(value) + return Number.isFinite(num) ? num : null +} + +function ensureString(value: unknown): string | null { + return typeof value === "string" ? value : null +} + +function getNestedRecord(root: JsonRecord | null, ...keys: string[]): JsonRecord | null { + let current: JsonRecord | null = root + for (const key of keys) { + if (!current) return null + current = ensureRecord(current[key]) + } + return current +} + +function getNestedRecordArray(root: JsonRecord | null, ...keys: string[]): JsonRecord[] { + if (keys.length === 0) return [] + const parent = getNestedRecord(root, ...keys.slice(0, -1)) + if (!parent) return [] + return ensureRecordArray(parent[keys[keys.length - 1]]) +} + +type PostureFinding = { + kind: "CPU_HIGH" | "SERVICE_DOWN" | "SMART_FAIL" + message: string + severity: "warning" | "critical" +} + +async function createTicketForAlert( + ctx: MutationCtx, + tenantId: string, + companyId: Id<"companies"> | undefined, + subject: string, + summary: string +) { + const actorEmail = process.env["MACHINE_ALERTS_TICKET_REQUESTER_EMAIL"] ?? "admin@sistema.dev" + const actor = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", actorEmail)) + .unique() + if (!actor) return null + + // pick first category/subcategory if not configured + const category = await ctx.db.query("ticketCategories").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).first() + if (!category) return null + const subcategory = await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_order", (q) => q.eq("categoryId", category._id)) + .first() + if (!subcategory) return null + + try { + const id = await ctx.runMutation(api.tickets.create, { + actorId: actor._id, + tenantId, + subject, + summary, + priority: "Alta", + channel: "Automação", + queueId: undefined, + requesterId: actor._id, + assigneeId: undefined, + categoryId: category._id, + subcategoryId: subcategory._id, + customFields: undefined, + }) + return id + } catch (error) { + console.error("[machines.alerts] Falha ao criar ticket:", error) + return null + } +} + +async function evaluatePostureAndMaybeRaise( + ctx: MutationCtx, + machine: Doc<"machines">, + args: { + metrics?: JsonRecord | null + inventory?: JsonRecord | null + metadata?: JsonRecord | null + } +) { + const findings: PostureFinding[] = [] + + // Janela temporal de CPU (5 minutos) + const now = Date.now() + const metadataPatch = ensureRecord(args.metadata) + const metrics = ensureRecord(args.metrics) ?? ensureRecord(metadataPatch?.["metrics"]) + const metaObj: JsonRecord = ensureRecord(machine.metadata) ?? {} + const prevWindowRecords = ensureRecordArray(metaObj["cpuWindow"]) + const prevWindow: Array<{ ts: number; usage: number }> = prevWindowRecords + .map((entry) => { + const ts = ensureFiniteNumber(entry["ts"]) + const usage = ensureFiniteNumber(entry["usage"]) + if (ts === null || usage === null) return null + return { ts, usage } + }) + .filter((entry): entry is { ts: number; usage: number } => entry !== null) + const window = prevWindow.filter((p) => now - p.ts <= 5 * 60 * 1000) + const usage = + ensureFiniteNumber(metrics?.["cpuUsagePercent"]) ?? ensureFiniteNumber(metrics?.["cpu_usage_percent"]) + if (usage !== null) { + window.push({ ts: now, usage }) + } + if (window.length > 0) { + const avg = window.reduce((acc, p) => acc + p.usage, 0) / window.length + if (avg >= 90) { + findings.push({ kind: "CPU_HIGH", message: `CPU média ${avg.toFixed(0)}% em 5 min`, severity: "warning" }) + } + } + + const inventory = ensureRecord(args.inventory) ?? ensureRecord(metadataPatch?.["inventory"]) + if (inventory) { + const services = ensureRecordArray(inventory["services"]) + if (services.length > 0) { + const criticalList = (process.env["MACHINE_CRITICAL_SERVICES"] ?? "") + .split(/[\s,]+/) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + const criticalSet = new Set(criticalList) + const firstDown = services.find((service) => { + const status = ensureString(service["status"]) ?? ensureString(service["Status"]) ?? "" + const name = ensureString(service["name"]) ?? ensureString(service["Name"]) ?? "" + return Boolean(name) && status.toLowerCase() !== "running" + }) + if (firstDown) { + const name = ensureString(firstDown["name"]) ?? ensureString(firstDown["Name"]) ?? "serviço" + const sev: "warning" | "critical" = criticalSet.has(name.toLowerCase()) ? "critical" : "warning" + findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${name}`, severity: sev }) + } + } + const smartEntries = getNestedRecordArray(inventory, "extended", "linux", "smart") + if (smartEntries.length > 0) { + const firstFail = smartEntries.find((disk) => { + const status = ensureString(disk["smart_status"]) ?? ensureString(disk["status"]) ?? "" + return status.toLowerCase() !== "ok" + }) + if (firstFail) { + const model = + ensureString(firstFail["model_name"]) ?? + ensureString(firstFail["model_family"]) ?? + ensureString(firstFail["model"]) ?? + "Disco" + const deviceRecord = getNestedRecord(firstFail, "device") + const serial = + ensureString(firstFail["serial_number"]) ?? + ensureString(deviceRecord?.["name"]) ?? + "—" + const temperatureRecord = getNestedRecord(firstFail, "temperature") + const temp = + ensureFiniteNumber(temperatureRecord?.["current"]) ?? + ensureFiniteNumber(temperatureRecord?.["value"]) + const details = temp !== null ? `${model} (${serial}) · ${temp}ºC` : `${model} (${serial})` + findings.push({ kind: "SMART_FAIL", message: `SMART em falha: ${details}`, severity: "critical" }) + } + } + } + + // Persistir janela de CPU (limite de 120 amostras) + const cpuWindowCapped = window.slice(-120) + await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, { cpuWindow: cpuWindowCapped }) }) + + if (!findings.length) return + + const record = { + postureAlerts: findings, + lastPostureAt: now, + } + const prevMeta = ensureRecord(machine.metadata) + const lastAtPrev = ensureFiniteNumber(prevMeta?.["lastPostureAt"]) ?? 0 + await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now }) + + await Promise.all( + findings.map((finding) => + ctx.db.insert("machineAlerts", { + tenantId: machine.tenantId, + machineId: machine._id, + companyId: machine.companyId ?? undefined, + kind: finding.kind, + message: finding.message, + severity: finding.severity, + createdAt: now, + }) + ) + ) + + if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return + if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return + + const subject = `Alerta de dispositivo: ${machine.hostname}` + const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ") + await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary) +} + +export const register = mutation({ + args: { + provisioningCode: v.string(), + hostname: v.string(), + os: v.object({ + name: v.string(), + version: v.optional(v.string()), + architecture: v.optional(v.string()), + }), + macAddresses: v.array(v.string()), + serialNumbers: v.array(v.string()), + metadata: v.optional(v.any()), + registeredBy: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const normalizedCode = args.provisioningCode.trim().toLowerCase() + const companyRecord = await ctx.db + .query("companies") + .withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode)) + .unique() + + if (!companyRecord) { + throw new ConvexError("Código de provisionamento inválido") + } + + const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID + const companyId = companyRecord._id + const companySlug = companyRecord.slug + const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers) + const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers) + const now = Date.now() + const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record) : undefined + + let existing = await ctx.db + .query("machines") + .withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint)) + .first() + + if (!existing) { + const collaboratorEmail = extractCollaboratorEmail(metadataPatch ?? args.metadata) + if (collaboratorEmail) { + const candidate = await ctx.db + .query("machines") + .withIndex("by_tenant_assigned_email", (q) => q.eq("tenantId", tenantId).eq("assignedUserEmail", collaboratorEmail)) + .first() + if (candidate && matchesExistingHardware(candidate, identifiers, args.hostname)) { + existing = candidate + } + } + } + + let machineId: Id<"machines"> + + if (existing) { + await ctx.db.patch(existing._id, { + tenantId, + companyId: companyId ?? existing.companyId, + companySlug: companySlug ?? existing.companySlug, + hostname: args.hostname, + displayName: existing.displayName ?? args.hostname, + osName: args.os.name, + osVersion: args.os.version, + architecture: args.os.architecture, + macAddresses: identifiers.macs, + serialNumbers: identifiers.serials, + fingerprint, + metadata: metadataPatch ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata, + lastHeartbeatAt: now, + updatedAt: now, + status: "online", + isActive: true, + registeredBy: args.registeredBy ?? existing.registeredBy, + deviceType: existing.deviceType ?? "desktop", + devicePlatform: args.os.name ?? existing.devicePlatform, + managementMode: existing.managementMode ?? "agent", + persona: existing.persona, + assignedUserId: existing.assignedUserId, + assignedUserEmail: existing.assignedUserEmail, + assignedUserName: existing.assignedUserName, + assignedUserRole: existing.assignedUserRole, + }) + machineId = existing._id + } else { + machineId = await ctx.db.insert("machines", { + tenantId, + companyId, + companySlug, + hostname: args.hostname, + displayName: args.hostname, + osName: args.os.name, + osVersion: args.os.version, + architecture: args.os.architecture, + macAddresses: identifiers.macs, + serialNumbers: identifiers.serials, + fingerprint, + metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined, + lastHeartbeatAt: now, + status: "online", + isActive: true, + createdAt: now, + updatedAt: now, + registeredBy: args.registeredBy, + deviceType: "desktop", + devicePlatform: args.os.name, + managementMode: "agent", + persona: undefined, + assignedUserId: undefined, + assignedUserEmail: undefined, + assignedUserName: undefined, + assignedUserRole: undefined, + }) + } + + const previousTokens = await ctx.db + .query("machineTokens") + .withIndex("by_machine", (q) => q.eq("machineId", machineId)) + .collect() + + for (const token of previousTokens) { + if (!token.revoked) { + await ctx.db.patch(token._id, { revoked: true, lastUsedAt: now }) + } + } + + const tokenPlain = toHex(randomBytes(32)) + const tokenHash = hashToken(tokenPlain) + const expiresAt = now + getTokenTtlMs() + + await ctx.db.insert("machineTokens", { + tenantId, + machineId, + tokenHash, + expiresAt, + revoked: false, + createdAt: now, + usageCount: 0, + type: "machine", + }) + + return { + machineId, + tenantId, + companyId, + companySlug, + machineToken: tokenPlain, + expiresAt, + } + }, +}) + +export const upsertInventory = mutation({ + args: { + provisioningCode: v.string(), + hostname: v.string(), + os: v.object({ + name: v.string(), + version: v.optional(v.string()), + architecture: v.optional(v.string()), + }), + macAddresses: v.array(v.string()), + serialNumbers: v.array(v.string()), + inventory: v.optional(v.any()), + metrics: v.optional(v.any()), + registeredBy: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const normalizedCode = args.provisioningCode.trim().toLowerCase() + const companyRecord = await ctx.db + .query("companies") + .withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode)) + .unique() + + if (!companyRecord) { + throw new ConvexError("Código de provisionamento inválido") + } + + const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID + const companyId = companyRecord._id + const companySlug = companyRecord.slug + const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers) + const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers) + const now = Date.now() + + const metadataPatch: Record = {} + if (args.inventory && typeof args.inventory === "object") { + metadataPatch.inventory = args.inventory as Record + } + if (args.metrics && typeof args.metrics === "object") { + metadataPatch.metrics = args.metrics as Record + } + + const existing = await ctx.db + .query("machines") + .withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint)) + .first() + + let machineId: Id<"machines"> + + if (existing) { + await ctx.db.patch(existing._id, { + tenantId, + companyId: companyId ?? existing.companyId, + companySlug: companySlug ?? existing.companySlug, + hostname: args.hostname, + displayName: existing.displayName ?? args.hostname, + osName: args.os.name, + osVersion: args.os.version, + architecture: args.os.architecture, + macAddresses: identifiers.macs, + serialNumbers: identifiers.serials, + metadata: Object.keys(metadataPatch).length ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata, + lastHeartbeatAt: now, + updatedAt: now, + status: args.metrics ? "online" : existing.status ?? "unknown", + registeredBy: args.registeredBy ?? existing.registeredBy, + deviceType: existing.deviceType ?? "desktop", + devicePlatform: args.os.name ?? existing.devicePlatform, + managementMode: existing.managementMode ?? "agent", + persona: existing.persona, + assignedUserId: existing.assignedUserId, + assignedUserEmail: existing.assignedUserEmail, + assignedUserName: existing.assignedUserName, + assignedUserRole: existing.assignedUserRole, + }) + machineId = existing._id + } else { + machineId = await ctx.db.insert("machines", { + tenantId, + companyId, + companySlug, + hostname: args.hostname, + displayName: args.hostname, + osName: args.os.name, + osVersion: args.os.version, + architecture: args.os.architecture, + macAddresses: identifiers.macs, + serialNumbers: identifiers.serials, + fingerprint, + metadata: Object.keys(metadataPatch).length ? mergeMetadata(undefined, metadataPatch) : undefined, + lastHeartbeatAt: now, + status: args.metrics ? "online" : "unknown", + createdAt: now, + updatedAt: now, + registeredBy: args.registeredBy, + deviceType: "desktop", + devicePlatform: args.os.name, + managementMode: "agent", + persona: undefined, + assignedUserId: undefined, + assignedUserEmail: undefined, + assignedUserName: undefined, + assignedUserRole: undefined, + }) + } + + // Evaluate posture/alerts based on provided metrics/inventory + const machine = (await ctx.db.get(machineId)) as Doc<"machines"> + await evaluatePostureAndMaybeRaise(ctx, machine, { metrics: args.metrics, inventory: args.inventory }) + + return { + machineId, + tenantId, + companyId, + companySlug, + status: args.metrics ? "online" : "unknown", + } + }, +}) + +export const heartbeat = mutation({ + args: { + machineToken: v.string(), + status: v.optional(v.string()), + hostname: v.optional(v.string()), + os: v.optional( + v.object({ + name: v.string(), + version: v.optional(v.string()), + architecture: v.optional(v.string()), + }) + ), + metrics: v.optional(v.any()), + inventory: v.optional(v.any()), + metadata: v.optional(v.any()), + }, + handler: async (ctx, args) => { + const { machine, token } = await getActiveToken(ctx, args.machineToken) + const now = Date.now() + + const metadataPatch: Record = {} + if (args.metadata && typeof args.metadata === "object") { + Object.assign(metadataPatch, args.metadata as Record) + } + if (args.inventory && typeof args.inventory === "object") { + metadataPatch.inventory = mergeInventory(metadataPatch.inventory, args.inventory as Record) + } + if (args.metrics && typeof args.metrics === "object") { + metadataPatch.metrics = args.metrics as Record + } + const mergedMetadata = Object.keys(metadataPatch).length ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata + + await ctx.db.patch(machine._id, { + hostname: args.hostname ?? machine.hostname, + displayName: machine.displayName ?? args.hostname ?? machine.hostname, + osName: args.os?.name ?? machine.osName, + osVersion: args.os?.version ?? machine.osVersion, + architecture: args.os?.architecture ?? machine.architecture, + devicePlatform: args.os?.name ?? machine.devicePlatform, + deviceType: machine.deviceType ?? "desktop", + managementMode: machine.managementMode ?? "agent", + lastHeartbeatAt: now, + updatedAt: now, + status: args.status ?? "online", + metadata: mergedMetadata, + }) + + await ctx.db.patch(token._id, { + lastUsedAt: now, + usageCount: (token.usageCount ?? 0) + 1, + expiresAt: now + getTokenTtlMs(), + }) + + // Evaluate posture/alerts & optionally create ticket + const fresh = (await ctx.db.get(machine._id)) as Doc<"machines"> + await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata }) + + return { + ok: true, + machineId: machine._id, + expiresAt: now + getTokenTtlMs(), + } + }, +}) + +export const resolveToken = mutation({ + args: { + machineToken: v.string(), + }, + handler: async (ctx, args) => { + const { machine, token } = await getActiveToken(ctx, args.machineToken) + const now = Date.now() + + await ctx.db.patch(token._id, { + lastUsedAt: now, + usageCount: (token.usageCount ?? 0) + 1, + }) + + return { + machine: { + _id: machine._id, + tenantId: machine.tenantId, + companyId: machine.companyId, + companySlug: machine.companySlug, + hostname: machine.hostname, + osName: machine.osName, + osVersion: machine.osVersion, + architecture: machine.architecture, + authUserId: machine.authUserId, + authEmail: machine.authEmail, + persona: machine.persona ?? null, + assignedUserId: machine.assignedUserId ?? null, + assignedUserEmail: machine.assignedUserEmail ?? null, + assignedUserName: machine.assignedUserName ?? null, + assignedUserRole: machine.assignedUserRole ?? null, + linkedUserIds: machine.linkedUserIds ?? [], + status: machine.status, + lastHeartbeatAt: machine.lastHeartbeatAt, + metadata: machine.metadata, + isActive: machine.isActive ?? true, + }, + token: { + expiresAt: token.expiresAt, + lastUsedAt: token.lastUsedAt ?? null, + usageCount: token.usageCount ?? 0, + }, + } + }, +}) + +export const listByTenant = query({ + args: { + tenantId: v.optional(v.string()), + includeMetadata: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const tenantId = args.tenantId ?? DEFAULT_TENANT_ID + const includeMetadata = Boolean(args.includeMetadata) + const now = Date.now() + + const tenantCompanies = await ctx.db + .query("companies") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + const companyById = new Map() + const companyBySlug = new Map() + for (const company of tenantCompanies) { + companyById.set(company._id, company) + if (company.slug) { + companyBySlug.set(company.slug, company) + } + } + + const machines = await ctx.db + .query("machines") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + return Promise.all( + machines.map(async (machine) => { + const activeToken = await findActiveMachineToken(ctx, machine._id, now) + const offlineThresholdMs = getOfflineThresholdMs() + const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs) + const manualStatus = (machine.status ?? "").toLowerCase() + let derivedStatus: string + if (machine.isActive === false) { + derivedStatus = "deactivated" + } else if (["maintenance", "blocked"].includes(manualStatus)) { + derivedStatus = manualStatus + } else if (machine.lastHeartbeatAt) { + const age = now - machine.lastHeartbeatAt + if (age <= offlineThresholdMs) { + derivedStatus = "online" + } else if (age <= staleThresholdMs) { + derivedStatus = "offline" + } else { + derivedStatus = "stale" + } + } else { + derivedStatus = machine.status ?? "unknown" + } + + const metadata = includeMetadata ? (machine.metadata ?? null) : null + + let metrics: Record | null = null + let inventory: Record | null = null + let postureAlerts: Array> | null = null + let lastPostureAt: number | null = null + + if (metadata && typeof metadata === "object") { + const metaRecord = metadata as Record + if (metaRecord.metrics && typeof metaRecord.metrics === "object") { + metrics = metaRecord.metrics as Record + } + if (metaRecord.inventory && typeof metaRecord.inventory === "object") { + inventory = metaRecord.inventory as Record + } + if (Array.isArray(metaRecord.postureAlerts)) { + postureAlerts = metaRecord.postureAlerts as Array> + } + if (typeof metaRecord.lastPostureAt === "number") { + lastPostureAt = metaRecord.lastPostureAt as number + } + } + + // linked users summary + const linkedUserIds = machine.linkedUserIds ?? [] + const linkedUsers = await Promise.all( + linkedUserIds.map(async (id) => { + const u = await ctx.db.get(id) + if (!u) return null + return { id: u._id, email: u.email, name: u.name } + }) + ).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>) + + const companyFromId = machine.companyId ? companyById.get(machine.companyId) ?? null : null + const companyFromSlug = machine.companySlug ? companyBySlug.get(machine.companySlug) ?? null : null + const resolvedCompany = companyFromId ?? companyFromSlug + + return { + id: machine._id, + tenantId: machine.tenantId, + hostname: machine.hostname, + displayName: machine.displayName ?? null, + deviceType: machine.deviceType ?? "desktop", + devicePlatform: machine.devicePlatform ?? null, + deviceProfile: machine.deviceProfile ?? null, + managementMode: machine.managementMode ?? "agent", + companyId: machine.companyId ?? null, + companySlug: machine.companySlug ?? companyFromId?.slug ?? companyFromSlug?.slug ?? null, + companyName: resolvedCompany?.name ?? null, + osName: machine.osName, + osVersion: machine.osVersion ?? null, + architecture: machine.architecture ?? null, + macAddresses: machine.macAddresses, + serialNumbers: machine.serialNumbers, + authUserId: machine.authUserId ?? null, + authEmail: machine.authEmail ?? null, + persona: machine.persona ?? null, + assignedUserId: machine.assignedUserId ?? null, + assignedUserEmail: machine.assignedUserEmail ?? null, + assignedUserName: machine.assignedUserName ?? null, + assignedUserRole: machine.assignedUserRole ?? null, + linkedUsers, + status: derivedStatus, + isActive: machine.isActive ?? true, + lastHeartbeatAt: machine.lastHeartbeatAt ?? null, + heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null, + registeredBy: machine.registeredBy ?? null, + createdAt: machine.createdAt, + updatedAt: machine.updatedAt, + token: activeToken + ? { + expiresAt: activeToken.expiresAt, + lastUsedAt: activeToken.lastUsedAt ?? null, + usageCount: activeToken.usageCount ?? 0, + } + : null, + metrics, + inventory, + postureAlerts, + lastPostureAt, + customFields: machine.customFields ?? [], + } + }) + ) + }, +}) + +export async function getByIdHandler( + ctx: QueryCtx, + args: { id: Id<"machines">; includeMetadata?: boolean } +) { + const includeMetadata = Boolean(args.includeMetadata) + const now = Date.now() + + const machine = await ctx.db.get(args.id) + if (!machine) return null + + const companyFromId = machine.companyId ? await ctx.db.get(machine.companyId) : null + const machineSlug = machine.companySlug ?? null + let companyFromSlug: typeof companyFromId | null = null + if (!companyFromId && machineSlug) { + companyFromSlug = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", machine.tenantId).eq("slug", machineSlug)) + .unique() + } + const resolvedCompany = companyFromId ?? companyFromSlug + + const activeToken = await findActiveMachineToken(ctx, machine._id, now) + + const offlineThresholdMs = getOfflineThresholdMs() + const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs) + const manualStatus = (machine.status ?? "").toLowerCase() + let derivedStatus: string + if (machine.isActive === false) { + derivedStatus = "deactivated" + } else if (["maintenance", "blocked"].includes(manualStatus)) { + derivedStatus = manualStatus + } else if (machine.lastHeartbeatAt) { + const age = now - machine.lastHeartbeatAt + if (age <= offlineThresholdMs) { + derivedStatus = "online" + } else if (age <= staleThresholdMs) { + derivedStatus = "offline" + } else { + derivedStatus = "stale" + } + } else { + derivedStatus = machine.status ?? "unknown" + } + + const meta = includeMetadata ? (machine.metadata ?? null) : null + let metrics: Record | null = null + let inventory: Record | null = null + let postureAlerts: Array> | null = null + let lastPostureAt: number | null = null + if (meta && typeof meta === "object") { + const metaRecord = meta as Record + if (metaRecord.metrics && typeof metaRecord.metrics === "object") { + metrics = metaRecord.metrics as Record + } + if (metaRecord.inventory && typeof metaRecord.inventory === "object") { + inventory = metaRecord.inventory as Record + } + if (Array.isArray(metaRecord.postureAlerts)) { + postureAlerts = metaRecord.postureAlerts as Array> + } + if (typeof metaRecord.lastPostureAt === "number") { + lastPostureAt = metaRecord.lastPostureAt as number + } + } + + const linkedUserIds = machine.linkedUserIds ?? [] + const linkedUsers = await Promise.all( + linkedUserIds.map(async (id) => { + const u = await ctx.db.get(id) + if (!u) return null + return { id: u._id, email: u.email, name: u.name } + }) + ).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>) + + return { + id: machine._id, + tenantId: machine.tenantId, + hostname: machine.hostname, + displayName: machine.displayName ?? null, + deviceType: machine.deviceType ?? "desktop", + devicePlatform: machine.devicePlatform ?? null, + deviceProfile: machine.deviceProfile ?? null, + managementMode: machine.managementMode ?? "agent", + companyId: machine.companyId ?? null, + companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null, + companyName: resolvedCompany?.name ?? null, + osName: machine.osName, + osVersion: machine.osVersion ?? null, + architecture: machine.architecture ?? null, + macAddresses: machine.macAddresses, + serialNumbers: machine.serialNumbers, + authUserId: machine.authUserId ?? null, + authEmail: machine.authEmail ?? null, + persona: machine.persona ?? null, + assignedUserId: machine.assignedUserId ?? null, + assignedUserEmail: machine.assignedUserEmail ?? null, + assignedUserName: machine.assignedUserName ?? null, + assignedUserRole: machine.assignedUserRole ?? null, + linkedUsers, + status: derivedStatus, + isActive: machine.isActive ?? true, + lastHeartbeatAt: machine.lastHeartbeatAt ?? null, + heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null, + registeredBy: machine.registeredBy ?? null, + createdAt: machine.createdAt, + updatedAt: machine.updatedAt, + token: activeToken + ? { + expiresAt: activeToken.expiresAt, + lastUsedAt: activeToken.lastUsedAt ?? null, + usageCount: activeToken.usageCount ?? 0, + } + : null, + metrics, + inventory, + postureAlerts, + lastPostureAt, + remoteAccess: machine.remoteAccess ?? null, + customFields: machine.customFields ?? [], + } +} + +export const getById = query({ + args: { + id: v.id("machines"), + includeMetadata: v.optional(v.boolean()), + }, + handler: getByIdHandler, +}) + +export const listAlerts = query({ + args: { + machineId: v.optional(v.id("machines")), + deviceId: v.optional(v.id("machines")), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const machineId = args.machineId ?? args.deviceId + if (!machineId) { + throw new ConvexError("Identificador do dispositivo não informado") + } + const limit = Math.max(1, Math.min(args.limit ?? 50, 200)) + const alerts = await ctx.db + .query("machineAlerts") + .withIndex("by_machine_created", (q) => q.eq("machineId", machineId)) + .order("desc") + .take(limit) + + return alerts.map((alert) => ({ + id: alert._id, + machineId: alert.machineId, + tenantId: alert.tenantId, + companyId: alert.companyId ?? null, + kind: alert.kind, + message: alert.message, + severity: alert.severity, + createdAt: alert.createdAt, + })) + }, +}) + +export const listOpenTickets = query({ + args: { + machineId: v.optional(v.id("machines")), + deviceId: v.optional(v.id("machines")), + limit: v.optional(v.number()), + }, + handler: async (ctx, { machineId: providedMachineId, deviceId, limit }) => { + const machineId = providedMachineId ?? deviceId + if (!machineId) { + throw new ConvexError("Identificador do dispositivo não informado") + } + const machine = await ctx.db.get(machineId) + if (!machine) { + return { totalOpen: 0, hasMore: false, tickets: [] } + } + const takeLimit = Math.max(1, Math.min(limit ?? 10, 50)) + const candidates = await ctx.db + .query("tickets") + .withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId)) + .order("desc") + .take(200) + + const openTickets = candidates + .filter((ticket) => normalizeStatus(ticket.status) !== "RESOLVED") + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + const totalOpen = openTickets.length + const limited = openTickets.slice(0, takeLimit) + + return { + totalOpen, + hasMore: totalOpen > takeLimit, + tickets: limited.map((ticket) => ({ + id: ticket._id, + reference: ticket.reference, + subject: ticket.subject, + status: normalizeStatus(ticket.status), + priority: ticket.priority ?? "MEDIUM", + updatedAt: ticket.updatedAt, + createdAt: ticket.createdAt, + assignee: ticket.assigneeSnapshot + ? { + name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null, + email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null, + } + : null, + machine: { + id: String(ticket.machineId ?? machineId), + hostname: + ((ticket.machineSnapshot as { hostname?: string } | undefined)?.hostname ?? machine.hostname ?? null), + }, + })), + } + }, +}) + +type MachineTicketsHistoryFilter = { + statusFilter: "all" | "open" | "resolved" + priorityFilter: string | null + from: number | null + to: number | null +} + +type ListTicketsHistoryArgs = { + machineId: Id<"machines"> + status?: "all" | "open" | "resolved" + priority?: string + search?: string + from?: number + to?: number + paginationOpts: Infer +} + +type GetTicketsHistoryStatsArgs = { + machineId: Id<"machines"> + status?: "all" | "open" | "resolved" + priority?: string + search?: string + from?: number + to?: number +} + +function createMachineTicketsQuery( + ctx: QueryCtx, + machine: Doc<"machines">, + machineId: Id<"machines">, + filters: MachineTicketsHistoryFilter +) { + let working = ctx.db + .query("tickets") + .withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId)) + .order("desc") + + if (filters.statusFilter === "open") { + working = working.filter((q) => + q.or( + q.eq(q.field("status"), "PENDING"), + q.eq(q.field("status"), "AWAITING_ATTENDANCE"), + q.eq(q.field("status"), "PAUSED") + ) + ) + } else if (filters.statusFilter === "resolved") { + working = working.filter((q) => q.eq(q.field("status"), "RESOLVED")) + } + + if (filters.priorityFilter) { + working = working.filter((q) => q.eq(q.field("priority"), filters.priorityFilter)) + } + + if (typeof filters.from === "number") { + working = working.filter((q) => q.gte(q.field("updatedAt"), filters.from!)) + } + + if (typeof filters.to === "number") { + working = working.filter((q) => q.lte(q.field("updatedAt"), filters.to!)) + } + + return working +} + +function matchesTicketSearch(ticket: Doc<"tickets">, searchTerm: string): boolean { + const normalized = searchTerm.trim().toLowerCase() + if (!normalized) return true + + const subject = ticket.subject.toLowerCase() + if (subject.includes(normalized)) return true + + const summary = typeof ticket.summary === "string" ? ticket.summary.toLowerCase() : "" + if (summary.includes(normalized)) return true + + const reference = `#${ticket.reference}`.toLowerCase() + if (reference.includes(normalized)) return true + + const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined + if (requesterSnapshot) { + if (requesterSnapshot.name?.toLowerCase().includes(normalized)) return true + if (requesterSnapshot.email?.toLowerCase().includes(normalized)) return true + } + + const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined + if (assigneeSnapshot) { + if (assigneeSnapshot.name?.toLowerCase().includes(normalized)) return true + if (assigneeSnapshot.email?.toLowerCase().includes(normalized)) return true + } + + return false +} + +export async function listTicketsHistoryHandler(ctx: QueryCtx, args: ListTicketsHistoryArgs) { + const machine = await ctx.db.get(args.machineId) + if (!machine) { + return { + page: [], + isDone: true, + continueCursor: args.paginationOpts.cursor ?? "", + } + } + + const normalizedStatusFilter = args.status ?? "all" + const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null + const searchTerm = args.search?.trim().toLowerCase() ?? null + const from = typeof args.from === "number" ? args.from : null + const to = typeof args.to === "number" ? args.to : null + const filters: MachineTicketsHistoryFilter = { + statusFilter: normalizedStatusFilter, + priorityFilter: normalizedPriorityFilter, + from, + to, + } + + const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate(args.paginationOpts) + + const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page + const queueCache = new Map | null>() + const items = await Promise.all( + page.map(async (ticket) => { + let queueName: string | null = null + if (ticket.queueId) { + const key = String(ticket.queueId) + if (!queueCache.has(key)) { + queueCache.set(key, (await ctx.db.get(ticket.queueId)) as Doc<"queues"> | null) + } + queueName = queueCache.get(key)?.name ?? null + } + const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined + const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined + return { + id: ticket._id, + reference: ticket.reference, + subject: ticket.subject, + status: normalizeStatus(ticket.status), + priority: (ticket.priority ?? "MEDIUM").toString().toUpperCase(), + updatedAt: ticket.updatedAt ?? ticket.createdAt ?? 0, + createdAt: ticket.createdAt ?? 0, + queue: queueName, + requester: requesterSnapshot + ? { + name: requesterSnapshot.name ?? null, + email: requesterSnapshot.email ?? null, + } + : null, + assignee: assigneeSnapshot + ? { + name: assigneeSnapshot.name ?? null, + email: assigneeSnapshot.email ?? null, + } + : null, + } + }) + ) + + return { + page: items, + isDone: pageResult.isDone, + continueCursor: pageResult.continueCursor, + splitCursor: pageResult.splitCursor ?? undefined, + pageStatus: pageResult.pageStatus ?? undefined, + } +} + +export const listTicketsHistory = query({ + args: { + machineId: v.id("machines"), + status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))), + priority: v.optional(v.string()), + search: v.optional(v.string()), + from: v.optional(v.number()), + to: v.optional(v.number()), + paginationOpts: paginationOptsValidator, + }, + handler: listTicketsHistoryHandler, +}) + +export async function getTicketsHistoryStatsHandler( + ctx: QueryCtx, + args: GetTicketsHistoryStatsArgs +) { + const machine = await ctx.db.get(args.machineId) + if (!machine) { + return { total: 0, openCount: 0, resolvedCount: 0 } + } + + const normalizedStatusFilter = args.status ?? "all" + const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null + const searchTerm = args.search?.trim().toLowerCase() ?? "" + const from = typeof args.from === "number" ? args.from : null + const to = typeof args.to === "number" ? args.to : null + const filters: MachineTicketsHistoryFilter = { + statusFilter: normalizedStatusFilter, + priorityFilter: normalizedPriorityFilter, + from, + to, + } + + let cursor: string | null = null + let total = 0 + let openCount = 0 + let done = false + + while (!done) { + const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate({ + numItems: MACHINE_TICKETS_STATS_PAGE_SIZE, + cursor, + }) + const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page + total += page.length + for (const ticket of page) { + if (OPEN_TICKET_STATUSES.has(normalizeStatus(ticket.status))) { + openCount += 1 + } + } + done = pageResult.isDone + cursor = pageResult.continueCursor ?? null + if (!cursor) { + done = true + } + } + + return { + total, + openCount, + resolvedCount: total - openCount, + } +} + +export const getTicketsHistoryStats = query({ + args: { + machineId: v.id("machines"), + status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))), + priority: v.optional(v.string()), + search: v.optional(v.string()), + from: v.optional(v.number()), + to: v.optional(v.number()), + }, + handler: getTicketsHistoryStatsHandler, +}) + +export async function updatePersonaHandler( + ctx: MutationCtx, + args: { + machineId: Id<"machines"> + persona?: string | null + assignedUserId?: Id<"users"> + assignedUserEmail?: string | null + assignedUserName?: string | null + assignedUserRole?: string | null + } +) { + const machine = await ctx.db.get(args.machineId) + if (!machine) { + throw new ConvexError("Dispositivo não encontrada") + } + + let nextPersona = machine.persona ?? undefined + const personaProvided = args.persona !== undefined + if (args.persona !== undefined) { + const trimmed = (args.persona ?? "").trim().toLowerCase() + if (!trimmed) { + nextPersona = undefined + } else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) { + throw new ConvexError("Perfil inválido para a dispositivo") + } else { + nextPersona = trimmed + } + } + + let nextAssignedUserId = machine.assignedUserId ?? undefined + if (args.assignedUserId !== undefined) { + nextAssignedUserId = args.assignedUserId + } + + let nextAssignedEmail = machine.assignedUserEmail ?? undefined + if (args.assignedUserEmail !== undefined) { + const trimmedEmail = (args.assignedUserEmail ?? "").trim().toLowerCase() + nextAssignedEmail = trimmedEmail || undefined + } + + let nextAssignedName = machine.assignedUserName ?? undefined + if (args.assignedUserName !== undefined) { + const trimmedName = (args.assignedUserName ?? "").trim() + nextAssignedName = trimmedName || undefined + } + + let nextAssignedRole = machine.assignedUserRole ?? undefined + if (args.assignedUserRole !== undefined) { + const trimmedRole = (args.assignedUserRole ?? "").trim().toUpperCase() + nextAssignedRole = trimmedRole || undefined + } + + if (personaProvided && !nextPersona) { + nextAssignedUserId = undefined + nextAssignedEmail = undefined + nextAssignedName = undefined + nextAssignedRole = undefined + } + + if (nextPersona && !nextAssignedUserId) { + throw new ConvexError("Associe um usuário ao definir a persona da dispositivo") + } + + if (nextPersona && nextAssignedUserId) { + const assignedUser = await ctx.db.get(nextAssignedUserId) + if (!assignedUser) { + throw new ConvexError("Usuário vinculado não encontrado") + } + if (assignedUser.tenantId !== machine.tenantId) { + throw new ConvexError("Usuário vinculado pertence a outro tenant") + } + } + + let nextMetadata = machine.metadata + if (nextPersona) { + const collaboratorMeta = { + email: nextAssignedEmail ?? null, + name: nextAssignedName ?? null, + role: nextPersona, + } + nextMetadata = mergeMetadata(machine.metadata, { collaborator: collaboratorMeta }) + } + + const patch: Record = { + persona: nextPersona, + assignedUserId: nextPersona ? nextAssignedUserId : undefined, + assignedUserEmail: nextPersona ? nextAssignedEmail : undefined, + assignedUserName: nextPersona ? nextAssignedName : undefined, + assignedUserRole: nextPersona ? nextAssignedRole : undefined, + updatedAt: Date.now(), + } + if (nextMetadata !== machine.metadata) { + patch.metadata = nextMetadata + } + + if (personaProvided) { + patch.persona = nextPersona + } + + if (nextPersona) { + patch.assignedUserId = nextAssignedUserId + patch.assignedUserEmail = nextAssignedEmail + patch.assignedUserName = nextAssignedName + patch.assignedUserRole = nextAssignedRole + } else if (personaProvided) { + patch.assignedUserId = undefined + patch.assignedUserEmail = undefined + patch.assignedUserName = undefined + patch.assignedUserRole = undefined + } + + await ctx.db.patch(machine._id, patch) + return { ok: true, persona: nextPersona ?? null } +} + +export const saveDeviceCustomFields = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + machineId: v.id("machines"), + fields: v.array( + v.object({ + fieldId: v.id("deviceFields"), + value: v.any(), + }) + ), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + const machine = await ctx.db.get(args.machineId) + if (!machine || machine.tenantId !== args.tenantId) { + throw new ConvexError("Dispositivo não encontrado") + } + + const companyId = machine.companyId ?? null + const deviceType = (machine.deviceType ?? "desktop").toLowerCase() + + const entries = await Promise.all( + args.fields.map(async ({ fieldId, value }) => { + const definition = await ctx.db.get(fieldId) + if (!definition || definition.tenantId !== args.tenantId) { + return null + } + if (companyId && definition.companyId && definition.companyId !== companyId) { + return null + } + if (!companyId && definition.companyId) { + return null + } + const scope = (definition.scope ?? "all").toLowerCase() + if (scope !== "all" && scope !== deviceType) { + return null + } + const displayValue = + value === null || value === undefined + ? null + : formatDeviceCustomFieldDisplay(definition.type, value, definition.options ?? undefined) + return { + fieldId: definition._id, + fieldKey: definition.key, + label: definition.label, + type: definition.type, + value: value ?? null, + displayValue: displayValue ?? undefined, + } + }) + ) + + const customFields = entries.filter(Boolean) as Array<{ + fieldId: Id<"deviceFields"> + fieldKey: string + label: string + type: string + value: unknown + displayValue?: string + }> + + await ctx.db.patch(args.machineId, { + customFields, + updatedAt: Date.now(), + }) + }, +}) + +export const saveDeviceProfile = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + machineId: v.optional(v.id("machines")), + companyId: v.optional(v.id("companies")), + companySlug: v.optional(v.string()), + displayName: v.string(), + hostname: v.optional(v.string()), + deviceType: v.string(), + devicePlatform: v.optional(v.string()), + osName: v.optional(v.string()), + osVersion: v.optional(v.string()), + macAddresses: v.optional(v.array(v.string())), + serialNumbers: v.optional(v.array(v.string())), + profile: v.optional(v.any()), + status: v.optional(v.string()), + isActive: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + await requireAdmin(ctx, args.actorId, args.tenantId) + await ensureMobileDeviceFields(ctx, args.tenantId) + const displayName = args.displayName.trim() + if (!displayName) { + throw new ConvexError("Informe o nome do dispositivo") + } + const hostname = (args.hostname ?? displayName).trim() + if (!hostname) { + throw new ConvexError("Informe o identificador do dispositivo") + } + + const normalizedType = (() => { + const candidate = args.deviceType.trim().toLowerCase() + if (["desktop", "mobile", "tablet"].includes(candidate)) return candidate + return "desktop" + })() + const normalizedPlatform = args.devicePlatform?.trim() || args.osName?.trim() || null + const normalizedStatus = (args.status ?? "unknown").trim() || "unknown" + const normalizedSlug = args.companySlug?.trim() || undefined + const osNameInput = args.osName === undefined ? undefined : args.osName.trim() + const osVersionInput = args.osVersion === undefined ? undefined : args.osVersion.trim() + const now = Date.now() + + if (args.machineId) { + const machine = await ctx.db.get(args.machineId) + if (!machine || machine.tenantId !== args.tenantId) { + throw new ConvexError("Dispositivo não encontrado para atualização") + } + if (machine.managementMode && machine.managementMode !== "manual") { + throw new ConvexError("Somente dispositivos manuais podem ser editados por esta ação") + } + + const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers) + const macAddresses = + args.macAddresses === undefined ? machine.macAddresses : normalizedIdentifiers.macs + const serialNumbers = + args.serialNumbers === undefined ? machine.serialNumbers : normalizedIdentifiers.serials + const deviceProfilePatch = args.profile === undefined ? undefined : args.profile ?? null + + const osNameValue = osNameInput === undefined ? machine.osName : osNameInput || machine.osName + const osVersionValue = + osVersionInput === undefined ? machine.osVersion ?? undefined : osVersionInput || undefined + + await ctx.db.patch(args.machineId, { + companyId: args.companyId ?? machine.companyId ?? undefined, + companySlug: normalizedSlug ?? machine.companySlug ?? undefined, + hostname, + displayName, + osName: osNameValue, + osVersion: osVersionValue, + macAddresses, + serialNumbers, + deviceType: normalizedType, + devicePlatform: normalizedPlatform ?? machine.devicePlatform ?? undefined, + deviceProfile: deviceProfilePatch, + managementMode: "manual", + status: normalizedStatus, + isActive: args.isActive ?? machine.isActive ?? true, + updatedAt: now, + }) + + return { machineId: args.machineId } + } + + const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers) + const fingerprint = generateManualFingerprint(args.tenantId, displayName) + const deviceProfile = args.profile ?? undefined + const osNameValue = osNameInput || normalizedPlatform || "Desconhecido" + const osVersionValue = osVersionInput || undefined + + const machineId = await ctx.db.insert("machines", { + tenantId: args.tenantId, + companyId: args.companyId ?? undefined, + companySlug: normalizedSlug ?? undefined, + hostname, + displayName, + osName: osNameValue, + osVersion: osVersionValue, + macAddresses: normalizedIdentifiers.macs, + serialNumbers: normalizedIdentifiers.serials, + fingerprint, + metadata: undefined, + deviceType: normalizedType, + devicePlatform: normalizedPlatform ?? undefined, + deviceProfile, + managementMode: "manual", + status: normalizedStatus, + isActive: args.isActive ?? true, + createdAt: now, + updatedAt: now, + registeredBy: "manual", + persona: undefined, + assignedUserId: undefined, + assignedUserEmail: undefined, + assignedUserName: undefined, + assignedUserRole: undefined, + }) + + return { machineId } + }, +}) + +export const updatePersona = mutation({ + args: { + machineId: v.id("machines"), + persona: v.optional(v.string()), + assignedUserId: v.optional(v.id("users")), + assignedUserEmail: v.optional(v.string()), + assignedUserName: v.optional(v.string()), + assignedUserRole: v.optional(v.string()), + }, + handler: updatePersonaHandler, +}) + +export const getContext = query({ + args: { + machineId: v.id("machines"), + }, + handler: async (ctx, args) => { + const machine = await ctx.db.get(args.machineId) + if (!machine) { + throw new ConvexError("Dispositivo não encontrada") + } + + const linkedUserIds = machine.linkedUserIds ?? [] + const linkedUsers = await Promise.all( + linkedUserIds.map(async (id) => { + const u = await ctx.db.get(id) + if (!u) return null + return { id: u._id, email: u.email, name: u.name } + }) + ).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>) + + return { + id: machine._id, + tenantId: machine.tenantId, + companyId: machine.companyId ?? null, + companySlug: machine.companySlug ?? null, + persona: machine.persona ?? null, + assignedUserId: machine.assignedUserId ?? null, + assignedUserEmail: machine.assignedUserEmail ?? null, + assignedUserName: machine.assignedUserName ?? null, + assignedUserRole: machine.assignedUserRole ?? null, + metadata: machine.metadata ?? null, + authEmail: machine.authEmail ?? null, + isActive: machine.isActive ?? true, + linkedUsers, + } + }, +}) + +export const findByAuthEmail = query({ + args: { + authEmail: v.string(), + }, + handler: async (ctx, args) => { + const normalizedEmail = args.authEmail.trim().toLowerCase() + + const machine = await ctx.db + .query("machines") + .withIndex("by_auth_email", (q) => q.eq("authEmail", normalizedEmail)) + .first() + + if (!machine) { + return null + } + + return { + id: machine._id, + } + }, +}) + +export const linkAuthAccount = mutation({ + args: { + machineId: v.id("machines"), + authUserId: v.string(), + authEmail: v.string(), + }, + handler: async (ctx, args) => { + const machine = await ctx.db.get(args.machineId) + if (!machine) { + throw new ConvexError("Dispositivo não encontrada") + } + + await ctx.db.patch(machine._id, { + authUserId: args.authUserId, + authEmail: args.authEmail, + updatedAt: Date.now(), + }) + + return { ok: true } + }, +}) + +export const linkUser = mutation({ + args: { + machineId: v.id("machines"), + email: v.string(), + }, + handler: async (ctx, { machineId, email }) => { + const machine = await ctx.db.get(machineId) + if (!machine) throw new ConvexError("Dispositivo não encontrada") + const tenantId = machine.tenantId + const normalized = email.trim().toLowerCase() + + const user = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalized)) + .first() + if (!user) throw new ConvexError("Usuário não encontrado") + const role = (user.role ?? "").toUpperCase() + if (role === 'ADMIN' || role === 'AGENT') { + throw new ConvexError('Usuários administrativos não podem ser vinculados a dispositivos') + } + + const current = new Set>(machine.linkedUserIds ?? []) + current.add(user._id) + await ctx.db.patch(machine._id, { linkedUserIds: Array.from(current), updatedAt: Date.now() }) + return { ok: true } + }, +}) + +export const unlinkUser = mutation({ + args: { + machineId: v.id("machines"), + userId: v.id("users"), + }, + handler: async (ctx, { machineId, userId }) => { + const machine = await ctx.db.get(machineId) + if (!machine) throw new ConvexError("Dispositivo não encontrada") + const next = (machine.linkedUserIds ?? []).filter((id) => id !== userId) + await ctx.db.patch(machine._id, { linkedUserIds: next, updatedAt: Date.now() }) + return { ok: true } + }, +}) + +export const rename = mutation({ + args: { + machineId: v.id("machines"), + actorId: v.id("users"), + hostname: v.string(), + }, + handler: async (ctx, { machineId, actorId, hostname }) => { + // Reutiliza requireStaff através de tickets.ts helpers + const machine = await ctx.db.get(machineId) + if (!machine) { + throw new ConvexError("Dispositivo não encontrada") + } + // Verifica permissão no tenant da dispositivo + const viewer = await ctx.db.get(actorId) + if (!viewer || viewer.tenantId !== machine.tenantId) { + throw new ConvexError("Acesso negado ao tenant da dispositivo") + } + const normalizedRole = (viewer.role ?? "AGENT").toUpperCase() + const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"]) + if (!STAFF.has(normalizedRole)) { + throw new ConvexError("Apenas equipe interna pode renomear dispositivos") + } + + const nextName = hostname.trim() + if (nextName.length < 2) { + throw new ConvexError("Informe um nome válido para a dispositivo") + } + + await ctx.db.patch(machineId, { + hostname: nextName, + displayName: nextName, + updatedAt: Date.now(), + }) + return { ok: true } + }, +}) + +export const toggleActive = mutation({ + args: { + machineId: v.id("machines"), + actorId: v.id("users"), + active: v.boolean(), + }, + handler: async (ctx, { machineId, actorId, active }) => { + const machine = await ctx.db.get(machineId) + if (!machine) { + throw new ConvexError("Dispositivo não encontrada") + } + + const actor = await ctx.db.get(actorId) + if (!actor || actor.tenantId !== machine.tenantId) { + throw new ConvexError("Acesso negado ao tenant da dispositivo") + } + const normalizedRole = (actor.role ?? "AGENT").toUpperCase() + const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"]) + if (!STAFF.has(normalizedRole)) { + throw new ConvexError("Apenas equipe interna pode atualizar o status da dispositivo") + } + + await ctx.db.patch(machineId, { + isActive: active, + updatedAt: Date.now(), + }) + + return { ok: true } + }, +}) + +export const resetAgent = mutation({ + args: { + machineId: v.id("machines"), + actorId: v.id("users"), + }, + handler: async (ctx, { machineId, actorId }) => { + const machine = await ctx.db.get(machineId) + if (!machine) { + throw new ConvexError("Dispositivo não encontrada") + } + + const actor = await ctx.db.get(actorId) + if (!actor || actor.tenantId !== machine.tenantId) { + throw new ConvexError("Acesso negado ao tenant da dispositivo") + } + const normalizedRole = (actor.role ?? "AGENT").toUpperCase() + const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"]) + if (!STAFF.has(normalizedRole)) { + throw new ConvexError("Apenas equipe interna pode resetar o agente da dispositivo") + } + + const tokens = await ctx.db + .query("machineTokens") + .withIndex("by_machine", (q) => q.eq("machineId", machineId)) + .collect() + + const now = Date.now() + let revokedCount = 0 + for (const token of tokens) { + if (!token.revoked) { + await ctx.db.patch(token._id, { + revoked: true, + expiresAt: now, + }) + revokedCount += 1 + } + } + + await ctx.db.patch(machineId, { + status: "unknown", + updatedAt: now, + }) + + return { machineId, revoked: revokedCount } + }, +}) + +type RemoteAccessEntry = { + id: string + provider: string + identifier: string + url: string | null + username: string | null + password: string | null + notes: string | null + lastVerifiedAt: number | null + metadata: Record | null +} + +function createRemoteAccessId() { + return `ra_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36)}` +} + +function coerceString(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null + } + return null +} + +function coerceNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string") { + const trimmed = value.trim() + if (!trimmed) return null + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +function normalizeRemoteAccessEntry(raw: unknown): RemoteAccessEntry | null { + if (!raw) return null + if (typeof raw === "string") { + const trimmed = raw.trim() + if (!trimmed) return null + const isUrl = /^https?:\/\//i.test(trimmed) + return { + id: createRemoteAccessId(), + provider: "Remoto", + identifier: isUrl ? trimmed : trimmed, + url: isUrl ? trimmed : null, + username: null, + password: null, + notes: null, + lastVerifiedAt: null, + metadata: null, + } + } + if (typeof raw !== "object") return null + const record = raw as Record + const provider = + coerceString(record.provider) ?? + coerceString(record.tool) ?? + coerceString(record.vendor) ?? + coerceString(record.name) ?? + "Remoto" + const identifier = + coerceString(record.identifier) ?? + coerceString(record.code) ?? + coerceString(record.id) ?? + coerceString(record.accessId) + const url = + coerceString(record.url) ?? + coerceString(record.link) ?? + coerceString(record.remoteUrl) ?? + coerceString(record.console) ?? + coerceString(record.viewer) ?? + null + const resolvedIdentifier = identifier ?? url ?? "Acesso remoto" + const notes = coerceString(record.notes) ?? coerceString(record.note) ?? coerceString(record.description) ?? coerceString(record.obs) ?? null + const username = + coerceString((record as Record).username) ?? + coerceString((record as Record).user) ?? + coerceString((record as Record).login) ?? + coerceString((record as Record).email) ?? + coerceString((record as Record).account) ?? + null + const password = + coerceString((record as Record).password) ?? + coerceString((record as Record).pass) ?? + coerceString((record as Record).secret) ?? + coerceString((record as Record).pin) ?? + null + const timestamp = + coerceNumber(record.lastVerifiedAt) ?? + coerceNumber(record.verifiedAt) ?? + coerceNumber(record.checkedAt) ?? + coerceNumber(record.updatedAt) ?? + null + const id = coerceString(record.id) ?? createRemoteAccessId() + const metadata = + record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) + ? (record.metadata as Record) + : null + return { + id, + provider, + identifier: resolvedIdentifier, + url, + username, + password, + notes, + lastVerifiedAt: timestamp, + metadata, + } +} + +function normalizeRemoteAccessList(raw: unknown): RemoteAccessEntry[] { + const source = Array.isArray(raw) ? raw : raw ? [raw] : [] + const seen = new Set() + const entries: RemoteAccessEntry[] = [] + for (const item of source) { + const entry = normalizeRemoteAccessEntry(item) + if (!entry) continue + let nextId = entry.id + while (seen.has(nextId)) { + nextId = createRemoteAccessId() + } + seen.add(nextId) + entries.push(nextId === entry.id ? entry : { ...entry, id: nextId }) + } + return entries +} + +export const updateRemoteAccess = mutation({ + args: { + machineId: v.id("machines"), + actorId: v.id("users"), + provider: v.optional(v.string()), + identifier: v.optional(v.string()), + url: v.optional(v.string()), + username: v.optional(v.string()), + password: v.optional(v.string()), + notes: v.optional(v.string()), + action: v.optional(v.string()), + entryId: v.optional(v.string()), + clear: v.optional(v.boolean()), + }, + handler: async (ctx, { machineId, actorId, provider, identifier, url, username, password, notes, action, entryId, clear }) => { + const machine = await ctx.db.get(machineId) + if (!machine) { + throw new ConvexError("Dispositivo não encontrada") + } + + const actor = await ctx.db.get(actorId) + if (!actor || actor.tenantId !== machine.tenantId) { + throw new ConvexError("Acesso negado ao tenant da dispositivo") + } + + const normalizedRole = (actor.role ?? "AGENT").toUpperCase() + if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { + throw new ConvexError("Somente administradores e agentes podem ajustar o acesso remoto.") + } + + const actionMode = (() => { + if (clear) return "clear" as const + const normalized = (action ?? "").toLowerCase() + if (normalized === "clear") return "clear" as const + if (normalized === "delete" || normalized === "remove") return "delete" as const + return "upsert" as const + })() + + const existingEntries = normalizeRemoteAccessList(machine.remoteAccess) + + if (actionMode === "clear") { + await ctx.db.patch(machineId, { remoteAccess: null, updatedAt: Date.now() }) + return { remoteAccess: null } + } + + if (actionMode === "delete") { + const trimmedEntryId = coerceString(entryId) + const trimmedProvider = coerceString(provider) + const trimmedIdentifier = coerceString(identifier) + let target: RemoteAccessEntry | undefined + if (trimmedEntryId) { + target = existingEntries.find((entry) => entry.id === trimmedEntryId) + } + if (!target && trimmedProvider && trimmedIdentifier) { + target = existingEntries.find( + (entry) => entry.provider === trimmedProvider && entry.identifier === trimmedIdentifier + ) + } + if (!target && trimmedIdentifier) { + target = existingEntries.find((entry) => entry.identifier === trimmedIdentifier) + } + if (!target && trimmedProvider) { + target = existingEntries.find((entry) => entry.provider === trimmedProvider) + } + if (!target) { + throw new ConvexError("Entrada de acesso remoto não encontrada.") + } + const nextEntries = existingEntries.filter((entry) => entry.id !== target!.id) + const nextValue = nextEntries.length > 0 ? nextEntries : null + await ctx.db.patch(machineId, { remoteAccess: nextValue, updatedAt: Date.now() }) + return { remoteAccess: nextValue } + } + + const trimmedProvider = (provider ?? "").trim() + const trimmedIdentifier = (identifier ?? "").trim() + if (!trimmedProvider || !trimmedIdentifier) { + throw new ConvexError("Informe provedor e identificador do acesso remoto.") + } + + let normalizedUrl: string | null = null + if (url) { + const trimmedUrl = url.trim() + if (trimmedUrl) { + if (!/^https?:\/\//i.test(trimmedUrl)) { + throw new ConvexError("Informe uma URL válida iniciando com http:// ou https://.") + } + try { + new URL(trimmedUrl) + } catch { + throw new ConvexError("Informe uma URL válida para o acesso remoto.") + } + normalizedUrl = trimmedUrl + } + } + + const cleanedNotes = notes?.trim() ? notes.trim() : null + const cleanedUsername = username?.trim() ? username.trim() : null + const cleanedPassword = password?.trim() ? password.trim() : null + const lastVerifiedAt = Date.now() + const targetEntryId = + coerceString(entryId) ?? + existingEntries.find( + (entry) => entry.provider === trimmedProvider && entry.identifier === trimmedIdentifier + )?.id ?? + createRemoteAccessId() + + const updatedEntry: RemoteAccessEntry = { + id: targetEntryId, + provider: trimmedProvider, + identifier: trimmedIdentifier, + url: normalizedUrl, + username: cleanedUsername, + password: cleanedPassword, + notes: cleanedNotes, + lastVerifiedAt, + metadata: { + provider: trimmedProvider, + identifier: trimmedIdentifier, + url: normalizedUrl, + username: cleanedUsername, + password: cleanedPassword, + notes: cleanedNotes, + lastVerifiedAt, + }, + } + + const existingIndex = existingEntries.findIndex((entry) => entry.id === targetEntryId) + let nextEntries: RemoteAccessEntry[] + if (existingIndex >= 0) { + nextEntries = [...existingEntries] + nextEntries[existingIndex] = updatedEntry + } else { + nextEntries = [...existingEntries, updatedEntry] + } + + await ctx.db.patch(machineId, { + remoteAccess: nextEntries, + updatedAt: Date.now(), + }) + + return { remoteAccess: nextEntries } + }, +}) + +export const remove = mutation({ + args: { + machineId: v.id("machines"), + actorId: v.id("users"), + }, + handler: async (ctx, { machineId, actorId }) => { + const machine = await ctx.db.get(machineId) + if (!machine) { + throw new ConvexError("Dispositivo não encontrada") + } + + const actor = await ctx.db.get(actorId) + if (!actor || actor.tenantId !== machine.tenantId) { + throw new ConvexError("Acesso negado ao tenant da dispositivo") + } + const role = (actor.role ?? "AGENT").toUpperCase() + const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"]) + if (!STAFF.has(role)) { + throw new ConvexError("Apenas equipe interna pode excluir dispositivos") + } + + const tokens = await ctx.db + .query("machineTokens") + .withIndex("by_machine", (q) => q.eq("machineId", machineId)) + .collect() + + await Promise.all(tokens.map((token) => ctx.db.delete(token._id))) + await ctx.db.delete(machineId) + return { ok: true } + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/metrics.ts b/referência/sistema-de-chamados-main/convex/metrics.ts new file mode 100644 index 0000000..f4db8dd --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/metrics.ts @@ -0,0 +1,967 @@ +import { ConvexError, v } from "convex/values" + +import type { Doc, Id } from "./_generated/dataModel" +import { query } from "./_generated/server" +import type { QueryCtx } from "./_generated/server" +import { + OPEN_STATUSES, + ONE_DAY_MS, + fetchScopedTickets, + fetchScopedTicketsByCreatedRange, + fetchScopedTicketsByResolvedRange, + normalizeStatus, +} from "./reports" +import { requireStaff } from "./rbac" + +type Viewer = Awaited> + +type MetricResolverInput = { + tenantId: string + viewer: Viewer + viewerId: Id<"users"> + params?: Record | undefined +} + +type MetricRunPayload = { + meta: { kind: string; key: string } & Record + data: unknown +} + +type MetricResolver = (ctx: QueryCtx, input: MetricResolverInput) => Promise + +function parseRange(params?: Record): number { + const value = params?.range + if (typeof value === "string") { + const normalized = value.toLowerCase() + if (normalized === "7d") return 7 + if (normalized === "90d") return 90 + } + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.min(365, Math.max(1, Math.round(value))) + } + return 30 +} + +function parseLimit(params?: Record, fallback = 20) { + const value = params?.limit + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.min(200, Math.round(value)) + } + return fallback +} + +function parseCompanyId(params?: Record): Id<"companies"> | undefined { + const value = params?.companyId + if (typeof value === "string" && value.length > 0) { + return value as Id<"companies"> + } + return undefined +} + +function parseQueueIds(params?: Record): string[] | undefined { + const value = params?.queueIds ?? params?.queueId + if (Array.isArray(value)) { + const clean = value + .map((entry) => (typeof entry === "string" ? entry.trim() : null)) + .filter((entry): entry is string => Boolean(entry && entry.length > 0)) + return clean.length > 0 ? clean : undefined + } + if (typeof value === "string") { + const trimmed = value.trim() + return trimmed.length > 0 ? [trimmed] : undefined + } + return undefined +} + +function filterTicketsByQueue | null }>( + tickets: T[], + queueIds?: string[], +): T[] { + if (!queueIds || queueIds.length === 0) { + return tickets + } + const normalized = new Set(queueIds.map((id) => id.trim())) + const includesNull = normalized.has("sem-fila") || normalized.has("null") + return tickets.filter((ticket) => { + if (!ticket.queueId) { + return includesNull + } + return normalized.has(String(ticket.queueId)) + }) +} + +const QUEUE_RENAME_LOOKUP: Record = { + "Suporte N1": "Chamados", + "suporte-n1": "Chamados", + chamados: "Chamados", + "Suporte N2": "Laboratório", + "suporte-n2": "Laboratório", + laboratorio: "Laboratório", + Laboratorio: "Laboratório", + visitas: "Visitas", +} + +function renameQueueName(value: string) { + const direct = QUEUE_RENAME_LOOKUP[value] + if (direct) return direct + const normalizedKey = value.replace(/\s+/g, "-").toLowerCase() + return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value +} + +type AgentStatsRaw = { + agentId: Id<"users"> + name: string | null + email: string | null + open: number + paused: number + resolved: number + totalSla: number + compliantSla: number + resolutionMinutes: number[] + firstResponseMinutes: number[] +} + +type AgentStatsComputed = { + agentId: string + name: string | null + email: string | null + open: number + paused: number + resolved: number + slaRate: number | null + avgResolutionMinutes: number | null + avgFirstResponseMinutes: number | null + totalSla: number + compliantSla: number +} + +function average(values: number[]): number | null { + if (!values || values.length === 0) return null + const sum = values.reduce((acc, value) => acc + value, 0) + return sum / values.length +} + +function isTicketCompliant(ticket: Doc<"tickets">, now: number) { + const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null + const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null + if (dueAt) { + if (resolvedAt) { + return resolvedAt <= dueAt + } + return dueAt >= now + } + return resolvedAt !== null +} + +function ensureAgentStats(map: Map, ticket: Doc<"tickets">): AgentStatsRaw | null { + const assigneeId = ticket.assigneeId + if (!assigneeId) return null + const key = String(assigneeId) + let stats = map.get(key) + const snapshot = ticket.assigneeSnapshot as { name?: string | null; email?: string | null } | undefined + const snapshotName = snapshot?.name ?? null + const snapshotEmail = snapshot?.email ?? null + if (!stats) { + stats = { + agentId: assigneeId, + name: snapshotName, + email: snapshotEmail, + open: 0, + paused: 0, + resolved: 0, + totalSla: 0, + compliantSla: 0, + resolutionMinutes: [], + firstResponseMinutes: [], + } + map.set(key, stats) + } else { + if (!stats.name && snapshotName) stats.name = snapshotName + if (!stats.email && snapshotEmail) stats.email = snapshotEmail + } + return stats +} + +async function computeAgentStats( + ctx: QueryCtx, + tenantId: string, + viewer: Viewer, + rangeDays: number, + agentFilter?: Id<"users">, +) { + const scopedTickets = await fetchScopedTickets(ctx, tenantId, viewer) + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - rangeDays * ONE_DAY_MS + + const statsMap = new Map() + + const matchesFilter = (ticket: Doc<"tickets">) => { + if (!ticket.assigneeId) return false + if (agentFilter && ticket.assigneeId !== agentFilter) return false + return true + } + + for (const ticket of scopedTickets) { + if (!matchesFilter(ticket)) continue + const stats = ensureAgentStats(statsMap, ticket) + if (!stats) continue + const status = normalizeStatus(ticket.status) + if (status === "PAUSED") { + stats.paused += 1 + } else if (OPEN_STATUSES.has(status)) { + stats.open += 1 + } + } + + const inRange = scopedTickets.filter( + (ticket) => matchesFilter(ticket) && ticket.createdAt >= startMs && ticket.createdAt < endMs, + ) + const now = Date.now() + for (const ticket of inRange) { + const stats = ensureAgentStats(statsMap, ticket) + if (!stats) continue + stats.totalSla += 1 + if (isTicketCompliant(ticket, now)) { + stats.compliantSla += 1 + } + const status = normalizeStatus(ticket.status) + if ( + status === "RESOLVED" && + typeof ticket.resolvedAt === "number" && + ticket.resolvedAt >= startMs && + ticket.resolvedAt < endMs + ) { + stats.resolved += 1 + stats.resolutionMinutes.push((ticket.resolvedAt - ticket.createdAt) / 60000) + } + if ( + typeof ticket.firstResponseAt === "number" && + ticket.firstResponseAt >= startMs && + ticket.firstResponseAt < endMs + ) { + stats.firstResponseMinutes.push((ticket.firstResponseAt - ticket.createdAt) / 60000) + } + } + + const agentIds = Array.from(statsMap.keys()) as string[] + if (agentIds.length > 0) { + const docs = await Promise.all(agentIds.map((id) => ctx.db.get(id as Id<"users">))) + docs.forEach((doc, index) => { + const stats = statsMap.get(agentIds[index]) + if (!stats || !doc) return + if (!stats.name && doc.name) stats.name = doc.name + if (!stats.email && doc.email) stats.email = doc.email + }) + } + + const computed = new Map() + for (const [key, raw] of statsMap.entries()) { + const avgResolution = average(raw.resolutionMinutes) + const avgFirstResponse = average(raw.firstResponseMinutes) + const slaRate = + raw.totalSla > 0 ? Math.min(1, Math.max(0, raw.compliantSla / raw.totalSla)) : null + computed.set(key, { + agentId: key, + name: raw.name ?? raw.email ?? null, + email: raw.email ?? null, + open: raw.open, + paused: raw.paused, + resolved: raw.resolved, + slaRate, + avgResolutionMinutes: avgResolution, + avgFirstResponseMinutes: avgFirstResponse, + totalSla: raw.totalSla, + compliantSla: raw.compliantSla, + }) + } + + return computed +} + +const metricResolvers: Record = { + "tickets.opened_resolved_by_day": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + const queueIds = parseQueueIds(params) + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - rangeDays * ONE_DAY_MS + + const openedTickets = filterTicketsByQueue( + await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId), + queueIds, + ) + const resolvedTickets = filterTicketsByQueue( + await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId), + queueIds, + ) + + const opened: Record = {} + const resolved: Record = {} + + for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { + const d = new Date(endMs - (offset + 1) * ONE_DAY_MS) + const key = formatDateKey(d.getTime()) + opened[key] = 0 + resolved[key] = 0 + } + + for (const ticket of openedTickets) { + if (ticket.createdAt >= startMs && ticket.createdAt < endMs) { + const key = formatDateKey(ticket.createdAt) + opened[key] = (opened[key] ?? 0) + 1 + } + } + + for (const ticket of resolvedTickets) { + if (typeof ticket.resolvedAt !== "number") continue + if (ticket.resolvedAt < startMs || ticket.resolvedAt >= endMs) continue + const key = formatDateKey(ticket.resolvedAt) + resolved[key] = (resolved[key] ?? 0) + 1 + } + + const series = [] + for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { + const d = new Date(endMs - (offset + 1) * ONE_DAY_MS) + const key = formatDateKey(d.getTime()) + series.push({ date: key, opened: opened[key] ?? 0, resolved: resolved[key] ?? 0 }) + } + + return { + meta: { kind: "series", key: "tickets.opened_resolved_by_day", rangeDays }, + data: series, + } + }, + "tickets.waiting_action_now": async (ctx, { tenantId, viewer, params }) => { + const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params)) + const now = Date.now() + let total = 0 + let atRisk = 0 + + for (const ticket of tickets) { + const status = normalizeStatus(ticket.status) + if (!OPEN_STATUSES.has(status)) continue + total += 1 + if (ticket.dueAt && ticket.dueAt < now) { + atRisk += 1 + } + } + + return { + meta: { kind: "single", key: "tickets.waiting_action_now", unit: "tickets" }, + data: { value: total, atRisk }, + } + }, + "tickets.waiting_action_last_7d": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = 7 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - rangeDays * ONE_DAY_MS + const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params)) + + const daily: Record = {} + for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { + const d = new Date(endMs - (offset + 1) * ONE_DAY_MS) + const key = formatDateKey(d.getTime()) + daily[key] = { total: 0, atRisk: 0 } + } + + for (const ticket of tickets) { + if (ticket.createdAt < startMs) continue + const key = formatDateKey(ticket.createdAt) + const bucket = daily[key] + if (!bucket) continue + if (OPEN_STATUSES.has(normalizeStatus(ticket.status))) { + bucket.total += 1 + if (ticket.dueAt && ticket.dueAt < Date.now()) { + bucket.atRisk += 1 + } + } + } + + const values = Object.values(daily) + const total = values.reduce((sum, item) => sum + item.total, 0) + const atRisk = values.reduce((sum, item) => sum + item.atRisk, 0) + + return { + meta: { kind: "single", key: "tickets.waiting_action_last_7d", aggregation: "sum", rangeDays }, + data: { value: total, atRisk }, + } + }, + "tickets.open_by_priority": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - rangeDays * ONE_DAY_MS + const tickets = filterTicketsByQueue( + await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId), + parseQueueIds(params), + ) + + const counts: Record = {} + for (const ticket of tickets) { + if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue + const key = (ticket.priority ?? "MEDIUM").toUpperCase() + counts[key] = (counts[key] ?? 0) + 1 + } + + const data = Object.entries(counts).map(([priority, total]) => ({ priority, total })) + data.sort((a, b) => b.total - a.total) + + return { + meta: { kind: "collection", key: "tickets.open_by_priority", rangeDays }, + data, + } + }, + "tickets.open_by_queue": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - rangeDays * ONE_DAY_MS + const queueFilter = parseQueueIds(params) + const tickets = filterTicketsByQueue( + await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId), + parseQueueIds(params), + ) + + const queueCounts = new Map() + for (const ticket of tickets) { + if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue + const queueKey = ticket.queueId ? String(ticket.queueId) : "sem-fila" + if (queueFilter && queueFilter.length > 0 && !queueFilter.includes(queueKey)) { + continue + } + queueCounts.set(queueKey, (queueCounts.get(queueKey) ?? 0) + 1) + } + + const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() + const data = Array.from(queueCounts.entries()).map(([queueId, total]) => { + const queue = queues.find((q) => String(q._id) === queueId) + return { + queueId, + name: queue ? queue.name : queueId === "sem-fila" ? "Sem fila" : "Fila desconhecida", + total, + } + }) + + data.sort((a, b) => b.total - a.total) + + return { + meta: { kind: "collection", key: "tickets.open_by_queue", rangeDays }, + data, + } + }, + "queues.summary_cards": async (ctx, { tenantId, viewer, params }) => { + const queueFilter = parseQueueIds(params) + const filterHas = queueFilter && queueFilter.length > 0 + const normalizeKey = (id: Id<"queues"> | null) => (id ? String(id) : "sem-fila") + + const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() + const queueNameMap = new Map() + queues.forEach((queue) => { + const key = String(queue._id) + queueNameMap.set(key, renameQueueName(queue.name)) + }) + + const now = Date.now() + const stats = new Map< + string, + { id: string; name: string; pending: number; inProgress: number; paused: number; breached: number } + >() + + const ensureEntry = (key: string, fallbackName?: string) => { + if (!stats.has(key)) { + const resolvedName = + queueNameMap.get(key) ?? + (key === "sem-fila" ? "Sem fila" : fallbackName ?? "Fila desconhecida") + stats.set(key, { + id: key, + name: resolvedName, + pending: 0, + inProgress: 0, + paused: 0, + breached: 0, + }) + } + return stats.get(key)! + } + + for (const queue of queues) { + const key = String(queue._id) + if (filterHas && queueFilter && !queueFilter.includes(key)) continue + ensureEntry(key) + } + + const scopedTickets = await fetchScopedTickets(ctx, tenantId, viewer) + for (const ticket of scopedTickets) { + const key = normalizeKey(ticket.queueId ?? null) + if (filterHas && queueFilter && !queueFilter.includes(key)) continue + const entry = ensureEntry(key) + const status = normalizeStatus(ticket.status) + if (status === "PENDING") { + entry.pending += 1 + } else if (status === "AWAITING_ATTENDANCE") { + entry.inProgress += 1 + } else if (status === "PAUSED") { + entry.paused += 1 + } + if (status !== "RESOLVED") { + const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null + if (dueAt && dueAt < now) { + entry.breached += 1 + } + } + } + + if (!(filterHas && queueFilter && !queueFilter.includes("sem-fila"))) { + ensureEntry("sem-fila", "Sem fila") + } else if (filterHas) { + stats.delete("sem-fila") + } + + const data = Array.from(stats.values()).map((item) => ({ + id: item.id, + name: item.name, + pending: item.pending, + inProgress: item.inProgress, + paused: item.paused, + breached: item.breached, + })) + + data.sort((a, b) => { + const totalA = a.pending + a.inProgress + a.paused + const totalB = b.pending + b.inProgress + b.paused + if (totalA === totalB) { + return a.name.localeCompare(b.name, "pt-BR") + } + return totalB - totalA + }) + + return { + meta: { kind: "collection", key: "queues.summary_cards" }, + data, + } + }, + "tickets.sla_compliance_by_queue": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - rangeDays * ONE_DAY_MS + const queueFilter = parseQueueIds(params) + const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) + const now = Date.now() + const stats = new Map() + + for (const ticket of tickets) { + const queueKey = ticket.queueId ? String(ticket.queueId) : "sem-fila" + if (queueFilter && queueFilter.length > 0 && !queueFilter.includes(queueKey)) { + continue + } + const current = stats.get(queueKey) ?? { total: 0, compliant: 0 } + current.total += 1 + const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null + const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null + let compliant = false + if (dueAt) { + if (resolvedAt) { + compliant = resolvedAt <= dueAt + } else { + compliant = dueAt >= now + } + } else { + compliant = resolvedAt !== null + } + if (compliant) { + current.compliant += 1 + } + stats.set(queueKey, current) + } + + const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() + const data = Array.from(stats.entries()).map(([queueId, value]) => { + const queue = queues.find((q) => String(q._id) === queueId) + const compliance = value.total > 0 ? value.compliant / value.total : 0 + return { + queueId, + name: queue ? queue.name : queueId === "sem-fila" ? "Sem fila" : "Fila desconhecida", + total: value.total, + compliance, + } + }) + + data.sort((a, b) => (b.compliance ?? 0) - (a.compliance ?? 0)) + + return { + meta: { kind: "collection", key: "tickets.sla_compliance_by_queue", rangeDays }, + data, + } + }, + "tickets.sla_rate": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - rangeDays * ONE_DAY_MS + const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) + + const total = tickets.length + const resolved = tickets.filter((t) => normalizeStatus(t.status) === "RESOLVED").length + const rate = total > 0 ? resolved / total : 0 + + return { + meta: { kind: "single", key: "tickets.sla_rate", rangeDays, unit: "ratio" }, + data: { value: rate, total, resolved }, + } + }, + "tickets.awaiting_table": async (ctx, { tenantId, viewer, params }) => { + const limit = parseLimit(params, 20) + const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params)) + const awaiting = tickets + .filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))) + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, limit) + .map((ticket) => ({ + id: ticket._id, + reference: ticket.reference ?? null, + subject: ticket.subject, + status: normalizeStatus(ticket.status), + priority: ticket.priority, + updatedAt: ticket.updatedAt, + createdAt: ticket.createdAt, + assignee: ticket.assigneeSnapshot + ? { + name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null, + email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null, + } + : null, + queueId: ticket.queueId ?? null, + })) + + return { + meta: { kind: "table", key: "tickets.awaiting_table", limit }, + data: awaiting, + } + }, + "devices.health_summary": async (ctx, { tenantId, params }) => { + const limit = parseLimit(params, 10) + const machines = await ctx.db.query("machines").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() + const now = Date.now() + const summary = machines + .map((machine) => { + const lastHeartbeatAt = machine.lastHeartbeatAt ?? null + const minutesSinceHeartbeat = lastHeartbeatAt ? Math.round((now - lastHeartbeatAt) / 60000) : null + const status = deriveMachineStatus(machine, now) + const cpu = clampPercent(pickMachineMetric(machine, ["cpuUsagePercent", "cpu_usage_percent"])) + const memory = clampPercent(pickMachineMetric(machine, ["memoryUsedPercent", "memory_usage_percent"])) + const disk = clampPercent(pickMachineMetric(machine, ["diskUsedPercent", "diskUsagePercent", "storageUsedPercent"])) + const alerts = readMachineAlertsCount(machine) + const fallbackHostname = readString((machine as unknown as Record)["computerName"]) + const hostname = machine.hostname ?? fallbackHostname ?? "Dispositivo sem nome" + const attention = + (cpu ?? 0) > 85 || + (memory ?? 0) > 90 || + (disk ?? 0) > 90 || + (minutesSinceHeartbeat ?? Infinity) > 120 || + alerts > 0 + return { + id: machine._id, + hostname, + status, + cpuUsagePercent: cpu, + memoryUsedPercent: memory, + diskUsedPercent: disk, + lastHeartbeatAt, + minutesSinceHeartbeat, + alerts, + attention, + } + }) + .sort((a, b) => { + if (a.attention === b.attention) { + return (b.cpuUsagePercent ?? 0) - (a.cpuUsagePercent ?? 0) + } + return a.attention ? -1 : 1 + }) + .slice(0, limit) + + return { + meta: { kind: "collection", key: "devices.health_summary", limit }, + data: summary, + } + }, + "agents.self_ticket_status": async (ctx, { tenantId, viewer, viewerId, params }) => { + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) + const stats = statsMap.get(String(viewerId)) + const data = [ + { status: "Abertos", total: stats?.open ?? 0 }, + { status: "Pausados", total: stats?.paused ?? 0 }, + { status: "Resolvidos", total: stats?.resolved ?? 0 }, + ] + return { + meta: { kind: "collection", key: "agents.self_ticket_status", rangeDays }, + data, + } + }, + "agents.self_open_total": async (ctx, { tenantId, viewer, viewerId, params }) => { + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) + const stats = statsMap.get(String(viewerId)) + return { + meta: { kind: "single", key: "agents.self_open_total", unit: "tickets", rangeDays }, + data: { value: stats?.open ?? 0 }, + } + }, + "agents.self_paused_total": async (ctx, { tenantId, viewer, viewerId, params }) => { + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) + const stats = statsMap.get(String(viewerId)) + return { + meta: { kind: "single", key: "agents.self_paused_total", unit: "tickets", rangeDays }, + data: { value: stats?.paused ?? 0 }, + } + }, + "agents.self_resolved_total": async (ctx, { tenantId, viewer, viewerId, params }) => { + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) + const stats = statsMap.get(String(viewerId)) + return { + meta: { kind: "single", key: "agents.self_resolved_total", unit: "tickets", rangeDays }, + data: { value: stats?.resolved ?? 0 }, + } + }, + "agents.self_sla_rate": async (ctx, { tenantId, viewer, viewerId, params }) => { + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) + const stats = statsMap.get(String(viewerId)) + return { + meta: { kind: "single", key: "agents.self_sla_rate", rangeDays }, + data: { + value: stats?.slaRate ?? 0, + total: stats?.totalSla ?? 0, + compliant: stats?.compliantSla ?? 0, + }, + } + }, + "agents.self_avg_resolution_minutes": async (ctx, { tenantId, viewer, viewerId, params }) => { + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) + const stats = statsMap.get(String(viewerId)) + const raw = stats?.avgResolutionMinutes ?? null + const value = raw !== null ? Math.round(raw * 10) / 10 : 0 + return { + meta: { kind: "single", key: "agents.self_avg_resolution_minutes", unit: "minutes", rangeDays }, + data: { value }, + } + }, + "agents.team_overview": async (ctx, { tenantId, viewer, params }) => { + if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") { + throw new ConvexError("Apenas administradores podem acessar esta métrica.") + } + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays) + const data = Array.from(statsMap.values()) + .map((stats) => ({ + agentId: stats.agentId, + agentName: stats.name ?? stats.email ?? "Agente", + open: stats.open, + paused: stats.paused, + resolved: stats.resolved, + slaRate: stats.slaRate !== null ? Math.round(stats.slaRate * 1000) / 10 : null, + avgResolutionMinutes: + stats.avgResolutionMinutes !== null ? Math.round(stats.avgResolutionMinutes * 10) / 10 : null, + })) + .sort((a, b) => b.resolved - a.resolved) + return { + meta: { kind: "collection", key: "agents.team_overview", rangeDays }, + data, + } + }, + "agents.team_resolved_total": async (ctx, { tenantId, viewer, params }) => { + if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") { + throw new ConvexError("Apenas administradores podem acessar esta métrica.") + } + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays) + const data = Array.from(statsMap.values()) + .map((stats) => ({ + agentId: stats.agentId, + agentName: stats.name ?? stats.email ?? "Agente", + resolved: stats.resolved, + })) + .sort((a, b) => b.resolved - a.resolved) + return { + meta: { kind: "collection", key: "agents.team_resolved_total", rangeDays }, + data, + } + }, + "agents.team_sla_rate": async (ctx, { tenantId, viewer, params }) => { + if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") { + throw new ConvexError("Apenas administradores podem acessar esta métrica.") + } + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays) + const data = Array.from(statsMap.values()) + .map((stats) => ({ + agentId: stats.agentId, + agentName: stats.name ?? stats.email ?? "Agente", + compliance: stats.slaRate ?? 0, + total: stats.totalSla, + compliant: stats.compliantSla, + })) + .sort((a, b) => (b.compliance ?? 0) - (a.compliance ?? 0)) + return { + meta: { kind: "collection", key: "agents.team_sla_rate", rangeDays }, + data, + } + }, + "agents.team_avg_resolution_minutes": async (ctx, { tenantId, viewer, params }) => { + if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") { + throw new ConvexError("Apenas administradores podem acessar esta métrica.") + } + const rangeDays = parseRange(params) + const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays) + const data = Array.from(statsMap.values()) + .map((stats) => ({ + agentId: stats.agentId, + agentName: stats.name ?? stats.email ?? "Agente", + minutes: + stats.avgResolutionMinutes !== null ? Math.round(stats.avgResolutionMinutes * 10) / 10 : 0, + })) + .sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0)) + return { + meta: { kind: "collection", key: "agents.team_avg_resolution_minutes", rangeDays }, + data, + } + }, +} + +export const run = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + metricKey: v.string(), + params: v.optional(v.any()), + }, + handler: async (ctx, { tenantId, viewerId, metricKey, params }) => { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const resolver = metricResolvers[metricKey] + if (!resolver) { + return { + meta: { kind: "error", key: metricKey, message: "Métrica não suportada" }, + data: null, + } + } + const payload = await resolver(ctx, { + tenantId, + viewer, + viewerId, + params: params && typeof params === "object" ? (params as Record) : undefined, + }) + return payload + }, +}) + +function formatDateKey(timestamp: number) { + const d = new Date(timestamp) + const year = d.getUTCFullYear() + const month = `${d.getUTCMonth() + 1}`.padStart(2, "0") + const day = `${d.getUTCDate()}`.padStart(2, "0") + return `${year}-${month}-${day}` +} + +function deriveMachineStatus(machine: Record, now: number) { + const lastHeartbeatAt = typeof machine.lastHeartbeatAt === "number" ? machine.lastHeartbeatAt : null + if (!lastHeartbeatAt) return "unknown" + const diffMinutes = (now - lastHeartbeatAt) / 60000 + if (diffMinutes <= 10) return "online" + if (diffMinutes <= 120) return "stale" + return "offline" +} + +function clampPercent(value: unknown) { + if (typeof value !== "number" || !Number.isFinite(value)) return null + if (value < 0) return 0 + if (value > 100) return 100 + return Math.round(value * 10) / 10 +} + +function readNumeric(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string") { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + return null +} + +function pickMachineMetric(machine: Doc<"machines">, keys: string[]): number | null { + const record = machine as unknown as Record + for (const key of keys) { + const direct = readNumeric(record[key]) + if (direct !== null) { + return direct + } + } + const metadata = record["metadata"] + if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { + const metrics = (metadata as Record)["metrics"] + if (metrics && typeof metrics === "object" && !Array.isArray(metrics)) { + const metricsRecord = metrics as Record + for (const key of keys) { + const value = readNumeric(metricsRecord[key]) + if (value !== null) { + return value + } + } + } + } + return null +} + +function readMachineAlertsCount(machine: Doc<"machines">): number { + const record = machine as unknown as Record + const directCount = readNumeric(record["postureAlertsCount"]) + if (directCount !== null) { + return directCount + } + const directArray = record["postureAlerts"] + if (Array.isArray(directArray)) { + return directArray.length + } + const metadata = record["metadata"] + if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { + const metadataRecord = metadata as Record + const metaCount = readNumeric(metadataRecord["postureAlertsCount"]) + if (metaCount !== null) { + return metaCount + } + const metaAlerts = metadataRecord["postureAlerts"] + if (Array.isArray(metaAlerts)) { + return metaAlerts.length + } + } + return 0 +} + +function readString(value: unknown): string | null { + if (typeof value === "string" && value.trim().length > 0) { + return value + } + return null +} diff --git a/referência/sistema-de-chamados-main/convex/migrations.ts b/referência/sistema-de-chamados-main/convex/migrations.ts new file mode 100644 index 0000000..ab1adca --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/migrations.ts @@ -0,0 +1,924 @@ +import { randomBytes } from "@noble/hashes/utils" +import { ConvexError, v } from "convex/values" + +import { mutation, query } from "./_generated/server" +import type { Id } from "./_generated/dataModel" +import type { MutationCtx, QueryCtx } from "./_generated/server" + +const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret" + +const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]) +const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]) + +function normalizeEmail(value: string) { + return value.trim().toLowerCase() +} + +function generateProvisioningCode() { + return Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("") +} + +type ImportedUser = { + email: string + name: string + role?: string | null + avatarUrl?: string | null + teams?: string[] | null + companySlug?: string | null +} + +type ImportedQueue = { + slug?: string | null + name: string +} + +type ImportedCompany = { + slug: string + name: string + provisioningCode?: string | null + isAvulso?: boolean | null + cnpj?: string | null + domain?: string | null + phone?: string | null + description?: string | null + address?: string | null + createdAt?: number | null + updatedAt?: number | null +} + +function normalizeRole(role: string | null | undefined) { + if (!role) return "AGENT" + const normalized = role.toUpperCase() + if (VALID_ROLES.has(normalized)) return normalized + // map legacy CUSTOMER to MANAGER + if (normalized === "CUSTOMER") return "MANAGER" + return "AGENT" +} + +function slugify(value: string) { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .toLowerCase() +} + +function pruneUndefined>(input: T): T { + for (const key of Object.keys(input)) { + if (input[key] === undefined) { + delete input[key] + } + } + return input +} + +type TicketSlaSnapshotRecord = { + categoryId?: Id<"ticketCategories"> + categoryName?: string + priority?: string + responseTargetMinutes?: number + responseMode?: string + solutionTargetMinutes?: number + solutionMode?: string + alertThreshold?: number + pauseStatuses?: string[] +} + +type ExportedSlaSnapshot = { + categoryId?: string + categoryName?: string + priority?: string + responseTargetMinutes?: number + responseMode?: string + solutionTargetMinutes?: number + solutionMode?: string + alertThreshold?: number + pauseStatuses?: string[] +} + +function serializeSlaSnapshot(snapshot?: TicketSlaSnapshotRecord | null): ExportedSlaSnapshot | undefined { + if (!snapshot) return undefined + const exported = pruneUndefined({ + categoryId: snapshot.categoryId ? String(snapshot.categoryId) : undefined, + categoryName: snapshot.categoryName, + priority: snapshot.priority, + responseTargetMinutes: snapshot.responseTargetMinutes, + responseMode: snapshot.responseMode, + solutionTargetMinutes: snapshot.solutionTargetMinutes, + solutionMode: snapshot.solutionMode, + alertThreshold: snapshot.alertThreshold, + pauseStatuses: snapshot.pauseStatuses && snapshot.pauseStatuses.length > 0 ? snapshot.pauseStatuses : undefined, + }) + return Object.keys(exported).length > 0 ? exported : undefined +} + +function normalizeImportedSlaSnapshot(snapshot: unknown): TicketSlaSnapshotRecord | undefined { + if (!snapshot || typeof snapshot !== "object") return undefined + const record = snapshot as Record + const pauseStatuses = Array.isArray(record.pauseStatuses) + ? record.pauseStatuses.filter((value): value is string => typeof value === "string") + : undefined + + const normalized = pruneUndefined({ + categoryName: typeof record.categoryName === "string" ? record.categoryName : undefined, + priority: typeof record.priority === "string" ? record.priority : undefined, + responseTargetMinutes: typeof record.responseTargetMinutes === "number" ? record.responseTargetMinutes : undefined, + responseMode: typeof record.responseMode === "string" ? record.responseMode : undefined, + solutionTargetMinutes: typeof record.solutionTargetMinutes === "number" ? record.solutionTargetMinutes : undefined, + solutionMode: typeof record.solutionMode === "string" ? record.solutionMode : undefined, + alertThreshold: typeof record.alertThreshold === "number" ? record.alertThreshold : undefined, + pauseStatuses: pauseStatuses && pauseStatuses.length > 0 ? pauseStatuses : undefined, + }) + + return Object.keys(normalized).length > 0 ? normalized : undefined +} + +async function ensureUser( + ctx: MutationCtx, + tenantId: string, + data: ImportedUser, + cache: Map>, + companyCache: Map> +) { + if (cache.has(data.email)) { + return cache.get(data.email)! + } + const existing = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", data.email)) + .first() + + const role = normalizeRole(data.role) + const companyId = data.companySlug ? companyCache.get(data.companySlug) : undefined + const record = existing + ? (() => { + const needsPatch = + existing.name !== data.name || + existing.role !== role || + existing.avatarUrl !== (data.avatarUrl ?? undefined) || + JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? []) || + (existing.companyId ?? undefined) !== companyId + if (needsPatch) { + return ctx.db.patch(existing._id, { + name: data.name, + role, + avatarUrl: data.avatarUrl ?? undefined, + teams: data.teams ?? undefined, + tenantId, + companyId, + }) + } + return Promise.resolve() + })() + : ctx.db.insert("users", { + tenantId, + email: data.email, + name: data.name, + role, + avatarUrl: data.avatarUrl ?? undefined, + teams: data.teams ?? undefined, + companyId, + }) + + const id = existing ? existing._id : ((await record) as Id<"users">) + cache.set(data.email, id) + return id +} + +async function ensureQueue( + ctx: MutationCtx, + tenantId: string, + data: ImportedQueue, + cache: Map> +) { + const slug = data.slug && data.slug.trim().length > 0 ? data.slug : slugify(data.name) + if (cache.has(slug)) return cache.get(slug)! + + const bySlug = await ctx.db + .query("queues") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first() + if (bySlug) { + if (bySlug.name !== data.name) { + await ctx.db.patch(bySlug._id, { name: data.name }) + } + cache.set(slug, bySlug._id) + return bySlug._id + } + + const byName = await ctx.db + .query("queues") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", data.name)) + .first() + if (byName) { + if (byName.slug !== slug) { + await ctx.db.patch(byName._id, { slug }) + } + cache.set(slug, byName._id) + return byName._id + } + + const id = await ctx.db.insert("queues", { + tenantId, + name: data.name, + slug, + teamId: undefined, + }) + cache.set(slug, id) + return id +} + +async function ensureCompany( + ctx: MutationCtx, + tenantId: string, + data: ImportedCompany, + cache: Map> +) { + const slug = data.slug || slugify(data.name) + if (cache.has(slug)) { + return cache.get(slug)! + } + + const existing = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first() + + const payload = pruneUndefined({ + tenantId, + name: data.name, + slug, + provisioningCode: data.provisioningCode ?? existing?.provisioningCode ?? generateProvisioningCode(), + isAvulso: data.isAvulso ?? undefined, + cnpj: data.cnpj ?? undefined, + domain: data.domain ?? undefined, + phone: data.phone ?? undefined, + description: data.description ?? undefined, + address: data.address ?? undefined, + createdAt: data.createdAt ?? Date.now(), + updatedAt: data.updatedAt ?? Date.now(), + }) + + let id: Id<"companies"> + if (existing) { + const existingIsAvulso = existing.isAvulso ?? undefined + const targetIsAvulso = payload.isAvulso ?? existingIsAvulso + const targetCnpj = payload.cnpj ?? undefined + const targetDomain = payload.domain ?? undefined + const targetPhone = payload.phone ?? undefined + const targetDescription = payload.description ?? undefined + const targetAddress = payload.address ?? undefined + + const needsPatch = + existing.name !== payload.name || + existingIsAvulso !== targetIsAvulso || + (existing.cnpj ?? undefined) !== targetCnpj || + (existing.domain ?? undefined) !== targetDomain || + (existing.phone ?? undefined) !== targetPhone || + (existing.description ?? undefined) !== targetDescription || + (existing.address ?? undefined) !== targetAddress || + existing.provisioningCode !== payload.provisioningCode + if (needsPatch) { + await ctx.db.patch(existing._id, { + name: payload.name, + isAvulso: targetIsAvulso, + cnpj: targetCnpj, + domain: targetDomain, + phone: targetPhone, + description: targetDescription, + address: targetAddress, + provisioningCode: payload.provisioningCode, + updatedAt: Date.now(), + }) + } + id = existing._id + } else { + id = await ctx.db.insert("companies", payload) + } + + cache.set(slug, id) + return id +} + +async function getTenantUsers(ctx: QueryCtx, tenantId: string) { + return ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() +} + +async function getTenantQueues(ctx: QueryCtx, tenantId: string) { + return ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() +} + +async function getTenantCompanies(ctx: QueryCtx, tenantId: string) { + return ctx.db + .query("companies") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() +} + +export const exportTenantSnapshot = query({ + args: { + secret: v.string(), + tenantId: v.string(), + }, + handler: async (ctx, { secret, tenantId }) => { + if (secret !== SECRET) { + throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend") + } + + const [users, queues, companies] = await Promise.all([ + getTenantUsers(ctx, tenantId), + getTenantQueues(ctx, tenantId), + getTenantCompanies(ctx, tenantId), + ]) + + const userMap = new Map(users.map((user) => [user._id, user])) + const queueMap = new Map(queues.map((queue) => [queue._id, queue])) + const companyMap = new Map(companies.map((company) => [company._id, company])) + + const tickets = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + const ticketsWithRelations = [] + + for (const ticket of tickets) { + const comments = await ctx.db + .query("ticketComments") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) + .collect() + + const events = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) + .collect() + + const requester = userMap.get(ticket.requesterId) + const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined + const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined + const company = ticket.companyId + ? companyMap.get(ticket.companyId) + : requester?.companyId + ? companyMap.get(requester.companyId) + : undefined + + if (!requester) { + continue + } + + ticketsWithRelations.push({ + reference: ticket.reference, + subject: ticket.subject, + summary: ticket.summary ?? null, + status: ticket.status, + priority: ticket.priority, + channel: ticket.channel, + queueSlug: queue?.slug ?? undefined, + requesterEmail: requester.email, + assigneeEmail: assignee?.email ?? undefined, + companySlug: company?.slug ?? undefined, + dueAt: ticket.dueAt ?? undefined, + firstResponseAt: ticket.firstResponseAt ?? undefined, + resolvedAt: ticket.resolvedAt ?? undefined, + closedAt: ticket.closedAt ?? undefined, + createdAt: ticket.createdAt, + updatedAt: ticket.updatedAt, + tags: ticket.tags ?? [], + slaSnapshot: serializeSlaSnapshot(ticket.slaSnapshot as TicketSlaSnapshotRecord | null), + slaResponseDueAt: ticket.slaResponseDueAt ?? undefined, + slaSolutionDueAt: ticket.slaSolutionDueAt ?? undefined, + slaResponseStatus: ticket.slaResponseStatus ?? undefined, + slaSolutionStatus: ticket.slaSolutionStatus ?? undefined, + slaPausedAt: ticket.slaPausedAt ?? undefined, + slaPausedBy: ticket.slaPausedBy ?? undefined, + slaPausedMs: ticket.slaPausedMs ?? undefined, + comments: comments + .map((comment) => { + const author = userMap.get(comment.authorId) + if (!author) { + return null + } + return { + authorEmail: author.email, + visibility: comment.visibility, + body: comment.body, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + } + }) + .filter((value): value is { + authorEmail: string + visibility: string + body: string + createdAt: number + updatedAt: number + } => value !== null), + events: events.map((event) => ({ + type: event.type, + payload: event.payload ?? {}, + createdAt: event.createdAt, + })), + }) + } + + return { + tenantId, + companies: companies.map((company) => ({ + slug: company.slug, + name: company.name, + isAvulso: company.isAvulso ?? false, + cnpj: company.cnpj ?? null, + domain: company.domain ?? null, + phone: company.phone ?? null, + description: company.description ?? null, + address: company.address ?? null, + createdAt: company.createdAt, + updatedAt: company.updatedAt, + })), + users: users.map((user) => ({ + email: user.email, + name: user.name, + role: user.role ?? null, + avatarUrl: user.avatarUrl ?? null, + teams: user.teams ?? [], + companySlug: user.companyId ? companyMap.get(user.companyId)?.slug ?? null : null, + })), + queues: queues.map((queue) => ({ + name: queue.name, + slug: queue.slug, + })), + tickets: ticketsWithRelations, + } + }, +}) + +export const importPrismaSnapshot = mutation({ + args: { + secret: v.string(), + snapshot: v.object({ + tenantId: v.string(), + companies: v.array( + v.object({ + slug: v.string(), + name: v.string(), + cnpj: v.optional(v.string()), + domain: v.optional(v.string()), + phone: v.optional(v.string()), + description: v.optional(v.string()), + address: v.optional(v.string()), + createdAt: v.optional(v.number()), + updatedAt: v.optional(v.number()), + }) + ), + users: v.array( + v.object({ + email: v.string(), + name: v.string(), + role: v.optional(v.string()), + avatarUrl: v.optional(v.string()), + teams: v.optional(v.array(v.string())), + companySlug: v.optional(v.string()), + }) + ), + queues: v.array( + v.object({ + name: v.string(), + slug: v.optional(v.string()), + }) + ), + tickets: v.array( + v.object({ + reference: v.number(), + subject: v.string(), + summary: v.optional(v.string()), + status: v.string(), + priority: v.string(), + channel: v.string(), + queueSlug: v.optional(v.string()), + requesterEmail: v.string(), + assigneeEmail: v.optional(v.string()), + companySlug: v.optional(v.string()), + dueAt: v.optional(v.number()), + firstResponseAt: v.optional(v.number()), + resolvedAt: v.optional(v.number()), + closedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + tags: v.optional(v.array(v.string())), + slaSnapshot: v.optional(v.any()), + slaResponseDueAt: v.optional(v.number()), + slaSolutionDueAt: v.optional(v.number()), + slaResponseStatus: v.optional(v.string()), + slaSolutionStatus: v.optional(v.string()), + slaPausedAt: v.optional(v.number()), + slaPausedBy: v.optional(v.string()), + slaPausedMs: v.optional(v.number()), + comments: v.array( + v.object({ + authorEmail: v.string(), + visibility: v.string(), + body: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + }) + ), + events: v.array( + v.object({ + type: v.string(), + payload: v.optional(v.any()), + createdAt: v.number(), + }) + ), + }) + ), + }), + }, + handler: async (ctx, { secret, snapshot }) => { + if (!SECRET) { + throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend") + } + if (secret !== SECRET) { + throw new ConvexError("Segredo inválido para sincronização") + } + + const companyCache = new Map>() + const userCache = new Map>() + const queueCache = new Map>() + + for (const company of snapshot.companies) { + await ensureCompany(ctx, snapshot.tenantId, company, companyCache) + } + + for (const user of snapshot.users) { + await ensureUser(ctx, snapshot.tenantId, user, userCache, companyCache) + } + + for (const queue of snapshot.queues) { + await ensureQueue(ctx, snapshot.tenantId, queue, queueCache) + } + + const snapshotStaffEmails = new Set( + snapshot.users + .filter((user) => INTERNAL_STAFF_ROLES.has(normalizeRole(user.role ?? null))) + .map((user) => normalizeEmail(user.email)) + ) + + const existingTenantUsers = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", snapshot.tenantId)) + .collect() + + for (const user of existingTenantUsers) { + const role = normalizeRole(user.role ?? null) + if (INTERNAL_STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) { + await ctx.db.delete(user._id) + } + } + + let ticketsUpserted = 0 + let commentsInserted = 0 + let eventsInserted = 0 + + for (const ticket of snapshot.tickets) { + const normalizedSnapshot = normalizeImportedSlaSnapshot(ticket.slaSnapshot) + const slaPausedMs = typeof ticket.slaPausedMs === "number" ? ticket.slaPausedMs : undefined + + const requesterId = await ensureUser( + ctx, + snapshot.tenantId, + { + email: ticket.requesterEmail, + name: ticket.requesterEmail, + }, + userCache, + companyCache + ) + const assigneeId = ticket.assigneeEmail + ? await ensureUser( + ctx, + snapshot.tenantId, + { + email: ticket.assigneeEmail, + name: ticket.assigneeEmail, + }, + userCache, + companyCache + ) + : undefined + + const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined + const companyId = ticket.companySlug ? companyCache.get(ticket.companySlug) ?? (await ensureCompany(ctx, snapshot.tenantId, { slug: ticket.companySlug, name: ticket.companySlug }, companyCache)) : undefined + + const existing = await ctx.db + .query("tickets") + .withIndex("by_tenant_reference", (q) => q.eq("tenantId", snapshot.tenantId).eq("reference", ticket.reference)) + .first() + + const payload = pruneUndefined({ + tenantId: snapshot.tenantId, + reference: ticket.reference, + subject: ticket.subject, + summary: ticket.summary ?? undefined, + status: ticket.status, + priority: ticket.priority, + channel: ticket.channel, + queueId: queueId as Id<"queues"> | undefined, + categoryId: undefined, + subcategoryId: undefined, + requesterId, + assigneeId: assigneeId as Id<"users"> | undefined, + working: false, + slaPolicyId: undefined, + companyId: companyId as Id<"companies"> | undefined, + dueAt: ticket.dueAt ?? undefined, + firstResponseAt: ticket.firstResponseAt ?? undefined, + resolvedAt: ticket.resolvedAt ?? undefined, + closedAt: ticket.closedAt ?? undefined, + updatedAt: ticket.updatedAt, + createdAt: ticket.createdAt, + tags: ticket.tags && ticket.tags.length > 0 ? ticket.tags : undefined, + slaSnapshot: normalizedSnapshot, + slaResponseDueAt: ticket.slaResponseDueAt ?? undefined, + slaSolutionDueAt: ticket.slaSolutionDueAt ?? undefined, + slaResponseStatus: ticket.slaResponseStatus ?? undefined, + slaSolutionStatus: ticket.slaSolutionStatus ?? undefined, + slaPausedAt: ticket.slaPausedAt ?? undefined, + slaPausedBy: ticket.slaPausedBy ?? undefined, + slaPausedMs, + customFields: undefined, + totalWorkedMs: undefined, + activeSessionId: undefined, + }) + + let ticketId: Id<"tickets"> + if (existing) { + await ctx.db.patch(existing._id, payload) + ticketId = existing._id + } else { + ticketId = await ctx.db.insert("tickets", payload) + } + + ticketsUpserted += 1 + + const existingComments = await ctx.db + .query("ticketComments") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect() + for (const comment of existingComments) { + await ctx.db.delete(comment._id) + } + + const existingEvents = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect() + for (const event of existingEvents) { + await ctx.db.delete(event._id) + } + + for (const comment of ticket.comments) { + const authorId = await ensureUser( + ctx, + snapshot.tenantId, + { + email: comment.authorEmail, + name: comment.authorEmail, + }, + userCache, + companyCache + ) + await ctx.db.insert("ticketComments", { + ticketId, + authorId, + visibility: comment.visibility, + body: comment.body, + attachments: [], + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }) + commentsInserted += 1 + } + + for (const event of ticket.events) { + await ctx.db.insert("ticketEvents", { + ticketId, + type: event.type, + payload: event.payload ?? {}, + createdAt: event.createdAt, + }) + eventsInserted += 1 + } + } + + return { + usersProcessed: userCache.size, + queuesProcessed: queueCache.size, + ticketsUpserted, + commentsInserted, + eventsInserted, + } + }, +}) + +export const backfillTicketCommentAuthorSnapshots = mutation({ + args: { + limit: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + }, + handler: async (ctx, { limit, dryRun }) => { + const effectiveDryRun = Boolean(dryRun) + const maxUpdates = limit && limit > 0 ? limit : null + const comments = await ctx.db.query("ticketComments").collect() + + let updated = 0 + let skippedExisting = 0 + let missingAuthors = 0 + + for (const comment of comments) { + if (comment.authorSnapshot) { + skippedExisting += 1 + continue + } + if (maxUpdates !== null && updated >= maxUpdates) { + break + } + + const author = await ctx.db.get(comment.authorId) + let name: string | null = author?.name ?? null + const email: string | null = author?.email ?? null + let avatarUrl: string | null = author?.avatarUrl ?? null + const teams: string[] | undefined = (author?.teams ?? undefined) as string[] | undefined + + if (!author) { + missingAuthors += 1 + const events = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", comment.ticketId)) + .collect() + const matchingEvent = events.find( + (event) => event.type === "COMMENT_ADDED" && event.createdAt === comment.createdAt, + ) + if (matchingEvent && matchingEvent.payload && typeof matchingEvent.payload === "object") { + const payload = matchingEvent.payload as { authorName?: string; authorAvatar?: string } + if (typeof payload.authorName === "string" && payload.authorName.trim().length > 0) { + name = payload.authorName.trim() + } + if (typeof payload.authorAvatar === "string" && payload.authorAvatar.trim().length > 0) { + avatarUrl = payload.authorAvatar + } + } + } + + const snapshot = pruneUndefined({ + name: name && name.trim().length > 0 ? name : "Usuário removido", + email: email ?? undefined, + avatarUrl: avatarUrl ?? undefined, + teams: teams && teams.length > 0 ? teams : undefined, + }) + + if (!effectiveDryRun) { + await ctx.db.patch(comment._id, { authorSnapshot: snapshot }) + } + updated += 1 + } + + return { + dryRun: effectiveDryRun, + totalComments: comments.length, + updated, + skippedExisting, + missingAuthors, + limit: maxUpdates, + } + }, +}) + +export const syncMachineCompanyReferences = mutation({ + args: { + tenantId: v.optional(v.string()), + dryRun: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, dryRun }) => { + const effectiveDryRun = Boolean(dryRun) + + const machines = tenantId && tenantId.trim().length > 0 + ? await ctx.db + .query("machines") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + : await ctx.db.query("machines").collect() + + const slugCache = new Map | null>() + const summary = { + total: machines.length, + updated: 0, + skippedMissingSlug: 0, + skippedMissingCompany: 0, + alreadyLinked: 0, + } + + for (const machine of machines) { + const slug = machine.companySlug ?? null + if (!slug) { + summary.skippedMissingSlug += 1 + continue + } + + const cacheKey = `${machine.tenantId}::${slug}` + let companyId = slugCache.get(cacheKey) + if (companyId === undefined) { + const company = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", machine.tenantId).eq("slug", slug)) + .unique() + companyId = company?._id ?? null + slugCache.set(cacheKey, companyId) + } + + if (!companyId) { + summary.skippedMissingCompany += 1 + continue + } + + if (machine.companyId === companyId) { + summary.alreadyLinked += 1 + continue + } + + if (!effectiveDryRun) { + await ctx.db.patch(machine._id, { companyId }) + } + summary.updated += 1 + } + + return { + dryRun: effectiveDryRun, + ...summary, + } + }, +}) + +export const backfillTicketSnapshots = mutation({ + args: { tenantId: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, { tenantId, limit }) => { + const tickets = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + let processed = 0 + for (const t of tickets) { + if (limit && processed >= limit) break + const patch: Record = {} + if (!t.requesterSnapshot) { + const requester = await ctx.db.get(t.requesterId) + if (requester) { + patch.requesterSnapshot = { + name: requester.name, + email: requester.email, + avatarUrl: requester.avatarUrl ?? undefined, + teams: requester.teams ?? undefined, + } + } + } + if (t.assigneeId && !t.assigneeSnapshot) { + const assignee = await ctx.db.get(t.assigneeId) + if (assignee) { + patch.assigneeSnapshot = { + name: assignee.name, + email: assignee.email, + avatarUrl: assignee.avatarUrl ?? undefined, + teams: assignee.teams ?? undefined, + } + } + } + if (!t.companySnapshot) { + const companyId = t.companyId + if (companyId) { + const company = await ctx.db.get(companyId) + if (company) { + patch.companySnapshot = { + name: company.name, + slug: company.slug, + isAvulso: company.isAvulso ?? undefined, + } + } + } + } + if (Object.keys(patch).length > 0) { + await ctx.db.patch(t._id, patch) + } + processed += 1 + } + return { processed } + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/queues.ts b/referência/sistema-de-chamados-main/convex/queues.ts new file mode 100644 index 0000000..b18d25b --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/queues.ts @@ -0,0 +1,247 @@ +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; + +import { requireAdmin, requireStaff } from "./rbac"; + +type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; + +const STATUS_NORMALIZE_MAP: Record = { + NEW: "PENDING", + PENDING: "PENDING", + OPEN: "AWAITING_ATTENDANCE", + AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", + ON_HOLD: "PAUSED", + PAUSED: "PAUSED", + RESOLVED: "RESOLVED", + CLOSED: "RESOLVED", +}; + +function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { + if (!status) return "PENDING"; + const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()]; + return normalized ?? "PENDING"; +} + +const QUEUE_RENAME_LOOKUP: Record = { + "Suporte N1": "Chamados", + "suporte-n1": "Chamados", + chamados: "Chamados", + "Suporte N2": "Laboratório", + "suporte-n2": "Laboratório", + laboratorio: "Laboratório", + Laboratorio: "Laboratório", + visitas: "Visitas", +}; + +function renameQueueString(value: string) { + const direct = QUEUE_RENAME_LOOKUP[value]; + if (direct) return direct; + const normalizedKey = value.replace(/\s+/g, "-").toLowerCase(); + return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value; +} + +function slugify(value: string) { + return value + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .toLowerCase(); +} + +type AnyCtx = QueryCtx | MutationCtx; + +async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, excludeId?: Id<"queues">) { + const existing = await ctx.db + .query("queues") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe uma fila com este identificador"); + } +} + +async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"queues">) { + const existing = await ctx.db + .query("queues") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe uma fila com este nome"); + } +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + const queues = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const teams = await ctx.db + .query("teams") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return queues.map((queue) => { + const team = queue.teamId ? teams.find((item) => item._id === queue.teamId) : null; + return { + id: queue._id, + name: queue.name, + slug: queue.slug, + team: team + ? { + id: team._id, + name: team.name, + } + : null, + }; + }); + }, +}); + +export const summary = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId); + const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect(); + const result = await Promise.all( + queues.map(async (qItem) => { + const tickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id)) + .collect(); + let pending = 0; + let inProgress = 0; + let paused = 0; + let breached = 0; + const now = Date.now(); + for (const ticket of tickets) { + const status = normalizeStatus(ticket.status); + if (status === "PENDING") { + pending += 1; + } else if (status === "AWAITING_ATTENDANCE") { + inProgress += 1; + } else if (status === "PAUSED") { + paused += 1; + } + if (status !== "RESOLVED") { + const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null; + if (dueAt && dueAt < now) { + breached += 1; + } + } + } + return { + id: qItem._id, + name: renameQueueString(qItem.name), + pending, + inProgress, + paused, + breached, + }; + }) + ); + return result; + }, +}); + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + teamId: v.optional(v.id("teams")), + }, + handler: async (ctx, { tenantId, actorId, name, teamId }) => { + await requireAdmin(ctx, actorId, tenantId); + const trimmed = name.trim(); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a fila"); + } + await ensureUniqueName(ctx, tenantId, trimmed); + const slug = slugify(trimmed); + await ensureUniqueSlug(ctx, tenantId, slug); + if (teamId) { + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time inválido"); + } + } + const id = await ctx.db.insert("queues", { + tenantId, + name: trimmed, + slug, + teamId: teamId ?? undefined, + }); + return id; + }, +}); + +export const update = mutation({ + args: { + queueId: v.id("queues"), + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + teamId: v.optional(v.id("teams")), + }, + handler: async (ctx, { queueId, tenantId, actorId, name, teamId }) => { + await requireAdmin(ctx, actorId, tenantId); + const queue = await ctx.db.get(queueId); + if (!queue || queue.tenantId !== tenantId) { + throw new ConvexError("Fila não encontrada"); + } + const trimmed = name.trim(); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a fila"); + } + await ensureUniqueName(ctx, tenantId, trimmed, queueId); + let slug = queue.slug; + if (queue.name !== trimmed) { + slug = slugify(trimmed); + await ensureUniqueSlug(ctx, tenantId, slug, queueId); + } + if (teamId) { + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time inválido"); + } + } + await ctx.db.patch(queueId, { + name: trimmed, + slug, + teamId: teamId ?? undefined, + }); + }, +}); + +export const remove = mutation({ + args: { + queueId: v.id("queues"), + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { queueId, tenantId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId); + const queue = await ctx.db.get(queueId); + if (!queue || queue.tenantId !== tenantId) { + throw new ConvexError("Fila não encontrada"); + } + + const ticketUsingQueue = await ctx.db + .query("tickets") + .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId)) + .first(); + if (ticketUsingQueue) { + throw new ConvexError("Não é possível remover uma fila vinculada a tickets"); + } + + await ctx.db.delete(queueId); + }, +}); diff --git a/referência/sistema-de-chamados-main/convex/rbac.ts b/referência/sistema-de-chamados-main/convex/rbac.ts new file mode 100644 index 0000000..f224e51 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/rbac.ts @@ -0,0 +1,74 @@ +import { ConvexError } from "convex/values" + +import type { Id } from "./_generated/dataModel" +import type { MutationCtx, QueryCtx } from "./_generated/server" + +const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]) +const MANAGER_ROLE = "MANAGER" + +type Ctx = QueryCtx | MutationCtx + +function normalizeRole(role?: string | null) { + return role?.toUpperCase() ?? null +} + +async function getUser(ctx: Ctx, userId: Id<"users">) { + const user = await ctx.db.get(userId) + if (!user) { + throw new ConvexError("Usuário não encontrado") + } + return user +} + +export async function requireUser(ctx: Ctx, userId: Id<"users">, tenantId?: string) { + const user = await getUser(ctx, userId) + if (tenantId && user.tenantId !== tenantId) { + throw new ConvexError("Usuário não pertence a este tenant") + } + return { user, role: normalizeRole(user.role) } +} + +export async function requireStaff(ctx: Ctx, userId: Id<"users">, tenantId?: string) { + const result = await requireUser(ctx, userId, tenantId) + if (!result.role || !STAFF_ROLES.has(result.role)) { + throw new ConvexError("Acesso restrito à equipe interna") + } + return result +} + +export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: string) { + const result = await requireStaff(ctx, userId, tenantId) + if (result.role !== "ADMIN") { + throw new ConvexError("Apenas administradores podem executar esta ação") + } + return result +} + +// removed customer role; use requireCompanyManager or requireStaff as appropriate + +export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) { + const result = await requireUser(ctx, userId, tenantId) + if (result.role !== MANAGER_ROLE) { + throw new ConvexError("Apenas gestores da empresa podem executar esta ação") + } + if (!result.user.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada") + } + return result +} + +export async function requireCompanyAssociation( + ctx: Ctx, + userId: Id<"users">, + companyId: Id<"companies">, + tenantId?: string, +) { + const result = await requireUser(ctx, userId, tenantId) + if (!result.user.companyId) { + throw new ConvexError("Usuário não possui empresa vinculada") + } + if (result.user.companyId !== companyId) { + throw new ConvexError("Usuário não pertence a esta empresa") + } + return result +} diff --git a/referência/sistema-de-chamados-main/convex/reports.ts b/referência/sistema-de-chamados-main/convex/reports.ts new file mode 100644 index 0000000..69d819b --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/reports.ts @@ -0,0 +1,1413 @@ +import { query } from "./_generated/server"; +import type { QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; + +import { requireStaff } from "./rbac"; +import { getOfflineThresholdMs, getStaleThresholdMs } from "./machines"; + +export type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; +type QueryFilterBuilder = { lt: (field: unknown, value: number) => unknown; field: (name: string) => unknown }; +export const STATUS_NORMALIZE_MAP: Record = { + NEW: "PENDING", + PENDING: "PENDING", + OPEN: "AWAITING_ATTENDANCE", + AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", + ON_HOLD: "PAUSED", + PAUSED: "PAUSED", + RESOLVED: "RESOLVED", + CLOSED: "RESOLVED", +}; + +export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { + if (!status) return "PENDING"; + const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()]; + return normalized ?? "PENDING"; +} + +function average(values: number[]) { + if (values.length === 0) return null; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function resolveCategoryName( + categoryId: string | null, + snapshot: { categoryName?: string } | null, + categories: Map> +) { + if (categoryId) { + const category = categories.get(categoryId) + if (category?.name) { + return category.name + } + } + if (snapshot?.categoryName && snapshot.categoryName.trim().length > 0) { + return snapshot.categoryName.trim() + } + return "Sem categoria" +} + +export const OPEN_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]); +export const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +function percentageChange(current: number, previous: number) { + if (previous === 0) { + return current === 0 ? 0 : null; + } + return ((current - previous) / previous) * 100; +} + +function extractScore(payload: unknown): number | null { + if (typeof payload === "number") return payload; + if (payload && typeof payload === "object" && "score" in payload) { + const value = (payload as { score: unknown }).score; + if (typeof value === "number") { + return value; + } + } + return null; +} + +function extractMaxScore(payload: unknown): number | null { + if (payload && typeof payload === "object" && "maxScore" in payload) { + const value = (payload as { maxScore: unknown }).maxScore; + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + } + return null; +} + +function extractComment(payload: unknown): string | null { + if (payload && typeof payload === "object" && "comment" in payload) { + const value = (payload as { comment: unknown }).comment; + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + } + return null; +} + +function extractAssignee(payload: unknown): { id: string | null; name: string | null } { + if (!payload || typeof payload !== "object") { + return { id: null, name: null } + } + const record = payload as Record + const rawId = record["assigneeId"] + const rawName = record["assigneeName"] + const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null + const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : null + return { id, name } +} + +function isNotNull(value: T | null): value is T { + return value !== null; +} + +export async function fetchTickets(ctx: QueryCtx, tenantId: string) { + return ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); +} + +async function fetchCategoryMap(ctx: QueryCtx, tenantId: string) { + const categories = await ctx.db + .query("ticketCategories") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const map = new Map>(); + for (const category of categories) { + map.set(String(category._id), category); + } + return map; +} + +export async function fetchScopedTickets( + ctx: QueryCtx, + tenantId: string, + viewer: Awaited>, +) { + if (viewer.role === "MANAGER") { + if (!viewer.user.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada"); + } + return ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => + q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!) + ) + .collect(); + } + return fetchTickets(ctx, tenantId); +} + +export async function fetchScopedTicketsByCreatedRange( + ctx: QueryCtx, + tenantId: string, + viewer: Awaited>, + startMs: number, + endMs: number, + companyId?: Id<"companies">, +) { + const collectRange = async (buildQuery: (chunkStart: number) => unknown) => { + const results: Doc<"tickets">[] = []; + const chunkSize = 7 * ONE_DAY_MS; + for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) { + const chunkEnd = Math.min(chunkStart + chunkSize, endMs); + const baseQuery = buildQuery(chunkStart); + const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter; + const queryForChunk = + typeof filterFn === "function" + ? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("createdAt"), chunkEnd)) + : baseQuery; + const collectFn = (queryForChunk as { collect?: () => Promise[]> }).collect; + if (typeof collectFn !== "function") { + throw new ConvexError("Indexed query does not support collect (createdAt)"); + } + const page = await collectFn.call(queryForChunk); + if (!page || page.length === 0) continue; + for (const ticket of page) { + const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; + if (createdAt === null) continue; + if (createdAt < chunkStart || createdAt >= endMs) continue; + results.push(ticket); + } + } + return results; + }; + + if (viewer.role === "MANAGER") { + if (!viewer.user.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada"); + } + return collectRange((chunkStart) => + ctx.db + .query("tickets") + .withIndex("by_tenant_company_created", (q) => + q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("createdAt", chunkStart) + ) + ); + } + + if (companyId) { + return collectRange((chunkStart) => + ctx.db + .query("tickets") + .withIndex("by_tenant_company_created", (q) => + q.eq("tenantId", tenantId).eq("companyId", companyId).gte("createdAt", chunkStart) + ) + ); + } + + return collectRange((chunkStart) => + ctx.db + .query("tickets") + .withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", chunkStart)) + ); +} + +export async function fetchScopedTicketsByResolvedRange( + ctx: QueryCtx, + tenantId: string, + viewer: Awaited>, + startMs: number, + endMs: number, + companyId?: Id<"companies">, +) { + const collectRange = async (buildQuery: (chunkStart: number) => unknown) => { + const results: Doc<"tickets">[] = []; + const chunkSize = 7 * ONE_DAY_MS; + for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) { + const chunkEnd = Math.min(chunkStart + chunkSize, endMs); + const baseQuery = buildQuery(chunkStart); + const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter; + const queryForChunk = + typeof filterFn === "function" + ? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("resolvedAt"), chunkEnd)) + : baseQuery; + const collectFn = (queryForChunk as { collect?: () => Promise[]> }).collect; + if (typeof collectFn !== "function") { + throw new ConvexError("Indexed query does not support collect (resolvedAt)"); + } + const page = await collectFn.call(queryForChunk); + if (!page || page.length === 0) continue; + for (const ticket of page) { + const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null; + if (resolvedAt === null) continue; + if (resolvedAt < chunkStart || resolvedAt >= endMs) continue; + results.push(ticket); + } + } + return results; + }; + + if (viewer.role === "MANAGER") { + if (!viewer.user.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada"); + } + return collectRange((chunkStart) => + ctx.db + .query("tickets") + .withIndex("by_tenant_company_resolved", (q) => + q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", chunkStart) + ) + ); + } + + if (companyId) { + return collectRange((chunkStart) => + ctx.db + .query("tickets") + .withIndex("by_tenant_company_resolved", (q) => + q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", chunkStart) + ) + ); + } + + return collectRange((chunkStart) => + ctx.db + .query("tickets") + .withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", chunkStart)) + ); +} + +async function fetchQueues(ctx: QueryCtx, tenantId: string) { + return ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); +} + +function deriveMachineStatus(machine: Doc<"machines">, now: number) { + if (machine.isActive === false) { + return "deactivated"; + } + const manualStatus = (machine.status ?? "").toLowerCase(); + if (manualStatus === "maintenance" || manualStatus === "blocked") { + return manualStatus; + } + const offlineMs = getOfflineThresholdMs(); + const staleMs = getStaleThresholdMs(offlineMs); + if (machine.lastHeartbeatAt) { + const age = now - machine.lastHeartbeatAt; + if (age <= offlineMs) return "online"; + if (age <= staleMs) return "offline"; + return "stale"; + } + return (machine.status ?? "unknown") || "unknown"; +} + +function formatOsLabel(osName?: string | null, osVersion?: string | null) { + const name = osName?.trim(); + if (!name) return "Desconhecido"; + const version = osVersion?.trim(); + if (!version) return name; + const conciseVersion = version.split(" ")[0]; + if (!conciseVersion) return name; + return `${name} ${conciseVersion}`.trim(); +} + +type CsatSurvey = { + ticketId: Id<"tickets">; + reference: number; + score: number; + maxScore: number; + comment: string | null; + receivedAt: number; + assigneeId: string | null; + assigneeName: string | null; +}; + +async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise { + const perTicket = await Promise.all( + tickets.map(async (ticket) => { + if (typeof ticket.csatScore === "number") { + const snapshot = (ticket.csatAssigneeSnapshot ?? null) as { + name?: string + email?: string + } | null + const assigneeId = + ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string" + ? ticket.csatAssigneeId + : ticket.csatAssigneeId + ? String(ticket.csatAssigneeId) + : null + const assigneeName = + snapshot && typeof snapshot.name === "string" && snapshot.name.trim().length > 0 + ? snapshot.name.trim() + : null + return [ + { + ticketId: ticket._id, + reference: ticket.reference, + score: ticket.csatScore, + maxScore: ticket.csatMaxScore && Number.isFinite(ticket.csatMaxScore) ? (ticket.csatMaxScore as number) : 5, + comment: + typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0 + ? ticket.csatComment.trim() + : null, + receivedAt: ticket.csatRatedAt ?? ticket.updatedAt ?? ticket.createdAt, + assigneeId, + assigneeName, + } satisfies CsatSurvey, + ]; + } + const events = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) + .collect(); + return events + .filter((event) => event.type === "CSAT_RECEIVED" || event.type === "CSAT_RATED") + .map((event) => { + const score = extractScore(event.payload); + if (score === null) return null; + const assignee = extractAssignee(event.payload) + return { + ticketId: ticket._id, + reference: ticket.reference, + score, + maxScore: extractMaxScore(event.payload) ?? 5, + comment: extractComment(event.payload), + receivedAt: event.createdAt, + assigneeId: assignee.id, + assigneeName: assignee.name, + } as CsatSurvey; + }) + .filter(isNotNull); + }) + ); + return perTicket.flat(); +} + +function formatDateKey(timestamp: number) { + const date = new Date(timestamp); + const year = date.getUTCFullYear(); + const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); + const day = `${date.getUTCDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export async function slaOverviewHandler( + ctx: QueryCtx, + { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId); + let tickets = await fetchScopedTickets(ctx, tenantId, viewer); + if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) + // Optional range filter (createdAt) for reporting purposes, similar ao backlog/csat + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const endMs = end.getTime() + ONE_DAY_MS; + const startMs = endMs - days * ONE_DAY_MS; + const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs); + const queues = await fetchQueues(ctx, tenantId); + const categoriesMap = await fetchCategoryMap(ctx, tenantId); + + const now = Date.now(); + const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); + const resolvedTickets = inRange.filter((ticket) => { + const status = normalizeStatus(ticket.status); + return status === "RESOLVED"; + }); + const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); + + const firstResponseTimes = inRange + .filter((ticket) => ticket.firstResponseAt) + .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); + const resolutionTimes = resolvedTickets + .filter((ticket) => ticket.resolvedAt) + .map((ticket) => (ticket.resolvedAt! - ticket.createdAt) / 60000); + + const queueBreakdown = queues.map((queue) => { + const count = openTickets.filter((ticket) => ticket.queueId === queue._id).length; + return { + id: queue._id, + name: queue.name, + open: count, + }; + }); + + const categoryStats = new Map< + string, + { + categoryId: string | null + categoryName: string + priority: string + total: number + responseMet: number + solutionMet: number + } + >() + + for (const ticket of inRange) { + const snapshot = (ticket.slaSnapshot ?? null) as { categoryId?: Id<"ticketCategories">; categoryName?: string; priority?: string } | null + const rawCategoryId = ticket.categoryId ? String(ticket.categoryId) : snapshot?.categoryId ? String(snapshot.categoryId) : null + const categoryName = resolveCategoryName(rawCategoryId, snapshot, categoriesMap) + const priority = (snapshot?.priority ?? ticket.priority ?? "MEDIUM").toUpperCase() + const key = `${rawCategoryId ?? "uncategorized"}::${priority}` + let stat = categoryStats.get(key) + if (!stat) { + stat = { + categoryId: rawCategoryId, + categoryName, + priority, + total: 0, + responseMet: 0, + solutionMet: 0, + } + categoryStats.set(key, stat) + } + stat.total += 1 + if (ticket.slaResponseStatus === "met") { + stat.responseMet += 1 + } + if (ticket.slaSolutionStatus === "met") { + stat.solutionMet += 1 + } + } + + const categoryBreakdown = Array.from(categoryStats.values()) + .map((entry) => ({ + ...entry, + responseRate: entry.total > 0 ? entry.responseMet / entry.total : null, + solutionRate: entry.total > 0 ? entry.solutionMet / entry.total : null, + })) + .sort((a, b) => b.total - a.total) + + return { + totals: { + total: inRange.length, + open: openTickets.length, + resolved: resolvedTickets.length, + overdue: overdueTickets.length, + }, + response: { + averageFirstResponseMinutes: average(firstResponseTimes), + responsesRegistered: firstResponseTimes.length, + }, + resolution: { + averageResolutionMinutes: average(resolutionTimes), + resolvedCount: resolutionTimes.length, + }, + queueBreakdown, + categoryBreakdown, + rangeDays: days, + }; +} + +export const slaOverview = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + handler: slaOverviewHandler, +}); + +export async function csatOverviewHandler( + ctx: QueryCtx, + { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId); + let tickets = await fetchScopedTickets(ctx, tenantId, viewer); + if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) + const surveysAll = await collectCsatSurveys(ctx, tickets); + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const endMs = end.getTime() + ONE_DAY_MS; + const startMs = endMs - days * ONE_DAY_MS; + const surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); + + const normalizeToFive = (value: CsatSurvey) => { + if (!value.maxScore || value.maxScore <= 0) return value.score; + return Math.min(5, Math.max(1, (value.score / value.maxScore) * 5)); + }; + + const averageScore = average(surveys.map((item) => normalizeToFive(item))); + const distribution = [1, 2, 3, 4, 5].map((score) => ({ + score, + total: surveys.filter((item) => Math.round(normalizeToFive(item)) === score).length, + })); + + const positiveThreshold = 4; + const positiveCount = surveys.filter((item) => normalizeToFive(item) >= positiveThreshold).length; + const positiveRate = surveys.length > 0 ? positiveCount / surveys.length : null; + + const agentStats = new Map< + string, + { id: string; name: string; total: number; sum: number; positive: number } + >(); + + for (const survey of surveys) { + const normalizedScore = normalizeToFive(survey); + const key = survey.assigneeId ?? "unassigned"; + const existing = agentStats.get(key) ?? { + id: key, + name: survey.assigneeName ?? "Sem responsável", + total: 0, + sum: 0, + positive: 0, + }; + existing.total += 1; + existing.sum += normalizedScore; + if (normalizedScore >= positiveThreshold) { + existing.positive += 1; + } + if (survey.assigneeName && survey.assigneeName.trim().length > 0) { + existing.name = survey.assigneeName.trim(); + } + agentStats.set(key, existing); + } + + const byAgent = Array.from(agentStats.values()) + .map((entry) => ({ + agentId: entry.id === "unassigned" ? null : entry.id, + agentName: entry.id === "unassigned" ? "Sem responsável" : entry.name, + totalResponses: entry.total, + averageScore: entry.total > 0 ? entry.sum / entry.total : null, + positiveRate: entry.total > 0 ? entry.positive / entry.total : null, + })) + .sort((a, b) => { + const diff = (b.averageScore ?? 0) - (a.averageScore ?? 0); + if (Math.abs(diff) > 0.0001) return diff; + return (b.totalResponses ?? 0) - (a.totalResponses ?? 0); + }); + + return { + totalSurveys: surveys.length, + averageScore, + distribution, + recent: surveys + .slice() + .sort((a, b) => b.receivedAt - a.receivedAt) + .slice(0, 10) + .map((item) => ({ + ticketId: item.ticketId, + reference: item.reference, + score: item.score, + maxScore: item.maxScore, + comment: item.comment, + receivedAt: item.receivedAt, + assigneeId: item.assigneeId, + assigneeName: item.assigneeName, + })), + rangeDays: days, + positiveRate, + byAgent, + }; +} + +export const csatOverview = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + handler: csatOverviewHandler, +}); + +export async function openedResolvedByDayHandler( + ctx: QueryCtx, + { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId); + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const endMs = end.getTime() + ONE_DAY_MS; + const startMs = endMs - days * ONE_DAY_MS; + + const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); + const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId); + + const opened: Record = {} + const resolved: Record = {} + + for (let i = days - 1; i >= 0; i--) { + const d = new Date(endMs - (i + 1) * ONE_DAY_MS) + const key = formatDateKey(d.getTime()) + opened[key] = 0 + resolved[key] = 0 + } + + for (const ticket of openedTickets) { + if (ticket.createdAt >= startMs && ticket.createdAt < endMs) { + const key = formatDateKey(ticket.createdAt) + opened[key] = (opened[key] ?? 0) + 1 + } + } + + for (const ticket of resolvedTickets) { + if (typeof ticket.resolvedAt !== "number") { + continue + } + const key = formatDateKey(ticket.resolvedAt) + resolved[key] = (resolved[key] ?? 0) + 1 + } + + const series: Array<{ date: string; opened: number; resolved: number }> = [] + for (let i = days - 1; i >= 0; i--) { + const d = new Date(endMs - (i + 1) * ONE_DAY_MS) + const key = formatDateKey(d.getTime()) + series.push({ date: key, opened: opened[key] ?? 0, resolved: resolved[key] ?? 0 }) + } + + return { rangeDays: days, series } +} + +export const openedResolvedByDay = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + handler: openedResolvedByDayHandler, +}) + +export async function backlogOverviewHandler( + ctx: QueryCtx, + { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId); + // Optional range filter (createdAt) for reporting purposes + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const endMs = end.getTime() + ONE_DAY_MS; + const startMs = endMs - days * ONE_DAY_MS; + const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); + + const statusCounts = inRange.reduce>((acc, ticket) => { + const status = normalizeStatus(ticket.status); + acc[status] = (acc[status] ?? 0) + 1; + return acc; + }, {} as Record); + + const priorityCounts = inRange.reduce>((acc, ticket) => { + acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1; + return acc; + }, {}); + + const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); + + const queueMap = new Map(); + for (const ticket of openTickets) { + const queueId = ticket.queueId ? ticket.queueId : "sem-fila"; + const current = queueMap.get(queueId) ?? { name: queueId === "sem-fila" ? "Sem fila" : "", count: 0 }; + current.count += 1; + queueMap.set(queueId, current); + } + + const queues = await fetchQueues(ctx, tenantId); + + for (const queue of queues) { + const entry = queueMap.get(queue._id) ?? { name: queue.name, count: 0 }; + entry.name = queue.name; + queueMap.set(queue._id, entry); + } + + return { + rangeDays: days, + statusCounts, + priorityCounts, + queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({ + id, + name: data.name, + total: data.count, + })), + totalOpen: openTickets.length, + }; +} + +export const backlogOverview = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + handler: backlogOverviewHandler, +}); + +// Touch to ensure CI convex_deploy runs and that agentProductivity is deployed +export async function agentProductivityHandler( + ctx: QueryCtx, + { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId) + let tickets = await fetchScopedTickets(ctx, tenantId, viewer) + if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) + + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + + const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs) + type Acc = { + agentId: Id<"users"> + name: string | null + email: string | null + open: number + resolved: number + avgFirstResponseMinValues: number[] + avgResolutionMinValues: number[] + workedMs: number + } + const map = new Map() + + for (const t of inRange) { + const assigneeId = t.assigneeId ?? null + if (!assigneeId) continue + let acc = map.get(assigneeId) + if (!acc) { + const user = await ctx.db.get(assigneeId) + acc = { + agentId: assigneeId, + name: user?.name ?? null, + email: user?.email ?? null, + open: 0, + resolved: 0, + avgFirstResponseMinValues: [], + avgResolutionMinValues: [], + workedMs: 0, + } + map.set(assigneeId, acc) + } + const status = normalizeStatus(t.status) + if (OPEN_STATUSES.has(status)) acc.open += 1 + if (status === "RESOLVED") acc.resolved += 1 + if (t.firstResponseAt) acc.avgFirstResponseMinValues.push((t.firstResponseAt - t.createdAt) / 60000) + if (t.resolvedAt) acc.avgResolutionMinValues.push((t.resolvedAt - t.createdAt) / 60000) + } + + for (const [agentId, acc] of map) { + const sessions = await ctx.db + .query("ticketWorkSessions") + .withIndex("by_agent", (q) => q.eq("agentId", agentId as Id<"users">)) + .collect() + let total = 0 + for (const s of sessions) { + const started = s.startedAt + const ended = s.stoppedAt ?? s.startedAt + if (ended < startMs || started >= endMs) continue + total += s.durationMs ?? Math.max(0, (s.stoppedAt ?? Date.now()) - s.startedAt) + } + acc.workedMs = total + } + + const items = Array.from(map.values()).map((acc) => ({ + agentId: acc.agentId, + name: acc.name, + email: acc.email, + open: acc.open, + resolved: acc.resolved, + avgFirstResponseMinutes: average(acc.avgFirstResponseMinValues), + avgResolutionMinutes: average(acc.avgResolutionMinValues), + workedHours: Math.round((acc.workedMs / 3600000) * 100) / 100, + })) + items.sort((a, b) => b.resolved - a.resolved) + return { rangeDays: days, items } +} + +export const agentProductivity = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + handler: agentProductivityHandler, +}) + +type CategoryAgentAccumulator = { + id: Id<"ticketCategories"> | null + name: string + total: number + resolved: number + agents: Map | null; name: string | null; total: number }> +} + +export async function ticketCategoryInsightsHandler( + ctx: QueryCtx, + { + tenantId, + viewerId, + range, + companyId, + }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + + const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) + const categories = await ctx.db + .query("ticketCategories") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + const categoriesById = new Map, Doc<"ticketCategories">>() + for (const category of categories) { + categoriesById.set(category._id, category) + } + + const stats = new Map() + + for (const ticket of inRange) { + const categoryKey = ticket.categoryId ? String(ticket.categoryId) : "uncategorized" + let stat = stats.get(categoryKey) + if (!stat) { + const categoryDoc = ticket.categoryId ? categoriesById.get(ticket.categoryId) : null + stat = { + id: ticket.categoryId ?? null, + name: categoryDoc?.name ?? (ticket.categoryId ? "Categoria removida" : "Sem categoria"), + total: 0, + resolved: 0, + agents: new Map(), + } + stats.set(categoryKey, stat) + } + stat.total += 1 + if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) { + stat.resolved += 1 + } + + const agentKey = ticket.assigneeId ? String(ticket.assigneeId) : "unassigned" + let agent = stat.agents.get(agentKey) + if (!agent) { + const snapshotName = ticket.assigneeSnapshot?.name ?? null + const fallbackName = ticket.assigneeId ? null : "Sem responsável" + agent = { + agentId: ticket.assigneeId ?? null, + name: snapshotName ?? fallbackName ?? "Agente", + total: 0, + } + stat.agents.set(agentKey, agent) + } + agent.total += 1 + } + + const categoriesData = Array.from(stats.values()) + .map((stat) => { + const agents = Array.from(stat.agents.values()).sort((a, b) => b.total - a.total) + const topAgent = agents[0] ?? null + return { + id: stat.id ? String(stat.id) : null, + name: stat.name, + total: stat.total, + resolved: stat.resolved, + topAgent: topAgent + ? { + id: topAgent.agentId ? String(topAgent.agentId) : null, + name: topAgent.name, + total: topAgent.total, + } + : null, + agents: agents.slice(0, 5).map((agent) => ({ + id: agent.agentId ? String(agent.agentId) : null, + name: agent.name, + total: agent.total, + })), + } + }) + .sort((a, b) => b.total - a.total) + + const spotlight = categoriesData.reduce< + | null + | { + categoryId: string | null + categoryName: string + agentId: string | null + agentName: string | null + tickets: number + } + >((best, current) => { + if (!current.topAgent) return best + if (!best || current.topAgent.total > best.tickets) { + return { + categoryId: current.id, + categoryName: current.name, + agentId: current.topAgent.id, + agentName: current.topAgent.name, + tickets: current.topAgent.total, + } + } + return best + }, null) + + return { + rangeDays: days, + totalTickets: inRange.length, + categories: categoriesData, + spotlight, + } +} + +export const categoryInsights = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + }, + handler: ticketCategoryInsightsHandler, +}) + +export async function dashboardOverviewHandler( + ctx: QueryCtx, + { tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId); + const tickets = await fetchScopedTickets(ctx, tenantId, viewer); + const now = Date.now(); + + const lastDayStart = now - ONE_DAY_MS; + const previousDayStart = now - 2 * ONE_DAY_MS; + + const newTickets = tickets.filter((ticket) => ticket.createdAt >= lastDayStart); + const previousTickets = tickets.filter( + (ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart + ); + + const trend = percentageChange(newTickets.length, previousTickets.length); + + const inProgressCurrent = tickets.filter((ticket) => { + if (!ticket.firstResponseAt) return false; + const status = normalizeStatus(ticket.status); + if (status === "RESOLVED") return false; + return !ticket.resolvedAt; + }); + + const inProgressPrevious = tickets.filter((ticket) => { + if (!ticket.firstResponseAt || ticket.firstResponseAt >= lastDayStart) return false; + if (ticket.resolvedAt && ticket.resolvedAt < lastDayStart) return false; + const status = normalizeStatus(ticket.status); + return status !== "RESOLVED" || !ticket.resolvedAt; + }); + + const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length); + + const lastWindowStart = now - 7 * ONE_DAY_MS; + const previousWindowStart = now - 14 * ONE_DAY_MS; + + const firstResponseWindow = tickets + .filter( + (ticket) => + ticket.createdAt >= lastWindowStart && + ticket.createdAt < now && + ticket.firstResponseAt + ) + .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); + const firstResponsePrevious = tickets + .filter( + (ticket) => + ticket.createdAt >= previousWindowStart && + ticket.createdAt < lastWindowStart && + ticket.firstResponseAt + ) + .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); + + const averageWindow = average(firstResponseWindow); + const averagePrevious = average(firstResponsePrevious); + const deltaMinutes = + averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null; + + const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); + const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); + + const resolvedLastWindow = tickets.filter( + (ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now + ); + const resolvedPreviousWindow = tickets.filter( + (ticket) => + ticket.resolvedAt && + ticket.resolvedAt >= previousWindowStart && + ticket.resolvedAt < lastWindowStart + ); + const resolutionRate = tickets.length > 0 ? (resolvedLastWindow.length / tickets.length) * 100 : null; + const resolutionDelta = + resolvedPreviousWindow.length > 0 + ? ((resolvedLastWindow.length - resolvedPreviousWindow.length) / resolvedPreviousWindow.length) * 100 + : null; + + return { + newTickets: { + last24h: newTickets.length, + previous24h: previousTickets.length, + trendPercentage: trend, + }, + inProgress: { + current: inProgressCurrent.length, + previousSnapshot: inProgressPrevious.length, + trendPercentage: inProgressTrend, + }, + firstResponse: { + averageMinutes: averageWindow, + previousAverageMinutes: averagePrevious, + deltaMinutes, + responsesCount: firstResponseWindow.length, + }, + awaitingAction: { + total: awaitingTickets.length, + atRisk: atRiskTickets.length, + }, + resolution: { + resolvedLast7d: resolvedLastWindow.length, + previousResolved: resolvedPreviousWindow.length, + rate: resolutionRate, + deltaPercentage: resolutionDelta, + }, + }; +} + +export const dashboardOverview = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: dashboardOverviewHandler, +}); + +export async function ticketsByChannelHandler( + ctx: QueryCtx, + { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId); + let tickets = await fetchScopedTickets(ctx, tenantId, viewer); + if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; + + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const endMs = end.getTime() + ONE_DAY_MS; + const startMs = endMs - days * ONE_DAY_MS; + + const timeline = new Map>(); + for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) { + timeline.set(formatDateKey(ts), new Map()); + } + + const channels = new Set(); + + for (const ticket of tickets) { + if (ticket.createdAt < startMs || ticket.createdAt >= endMs) continue; + const dateKey = formatDateKey(ticket.createdAt); + const channelKey = ticket.channel ?? "OUTRO"; + channels.add(channelKey); + const dayMap = timeline.get(dateKey) ?? new Map(); + dayMap.set(channelKey, (dayMap.get(channelKey) ?? 0) + 1); + timeline.set(dateKey, dayMap); + } + + const sortedChannels = Array.from(channels).sort(); + + const points = Array.from(timeline.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([date, map]) => { + const values: Record = {}; + for (const channel of sortedChannels) { + values[channel] = map.get(channel) ?? 0; + } + return { date, values }; + }); + + return { + rangeDays: days, + channels: sortedChannels, + points, + }; +} + +export const ticketsByChannel = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + }, + handler: ticketsByChannelHandler, +}); + +export async function hoursByClientHandler( + ctx: QueryCtx, + { tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const tickets = await fetchScopedTickets(ctx, tenantId, viewer) + + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + + type Acc = { + companyId: Id<"companies"> + name: string + isAvulso: boolean + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth?: number | null + } + const map = new Map() + + for (const t of tickets) { + if (t.updatedAt < startMs || t.updatedAt >= endMs) continue + const companyId = t.companyId ?? null + if (!companyId) continue + + let acc = map.get(companyId) + if (!acc) { + const company = await ctx.db.get(companyId) + acc = { + companyId, + name: company?.name ?? "Sem empresa", + isAvulso: Boolean(company?.isAvulso ?? false), + internalMs: 0, + externalMs: 0, + totalMs: 0, + contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null, + } + map.set(companyId, acc) + } + const internal = t.internalWorkedMs ?? 0 + const external = t.externalWorkedMs ?? 0 + acc.internalMs += internal + acc.externalMs += external + acc.totalMs += internal + external + } + + const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) + return { + rangeDays: days, + items: items.map((i) => ({ + companyId: i.companyId, + name: i.name, + isAvulso: i.isAvulso, + internalMs: i.internalMs, + externalMs: i.externalMs, + totalMs: i.totalMs, + contractedHoursPerMonth: i.contractedHoursPerMonth ?? null, + })), + } +} + +export const hoursByClient = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, + handler: hoursByClientHandler, +}) + +// Internal variant used by scheduled jobs: skips viewer scoping and aggregates for the whole tenant +export async function hoursByClientInternalHandler( + ctx: QueryCtx, + { tenantId, range }: { tenantId: string; range?: string } +) { + const tickets = await fetchTickets(ctx, tenantId) + + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + + type Acc = { + companyId: Id<"companies"> + name: string + isAvulso: boolean + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth?: number | null + } + const map = new Map() + + for (const t of tickets) { + if (t.updatedAt < startMs || t.updatedAt >= endMs) continue + const companyId = t.companyId ?? null + if (!companyId) continue + + let acc = map.get(companyId) + if (!acc) { + const company = await ctx.db.get(companyId) + acc = { + companyId, + name: company?.name ?? "Sem empresa", + isAvulso: Boolean(company?.isAvulso ?? false), + internalMs: 0, + externalMs: 0, + totalMs: 0, + contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null, + } + map.set(companyId, acc) + } + const internal = t.internalWorkedMs ?? 0 + const external = t.externalWorkedMs ?? 0 + acc.internalMs += internal + acc.externalMs += external + acc.totalMs += internal + external + } + + const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) + return { + rangeDays: days, + items: items.map((i) => ({ + companyId: i.companyId, + name: i.name, + isAvulso: i.isAvulso, + internalMs: i.internalMs, + externalMs: i.externalMs, + totalMs: i.totalMs, + contractedHoursPerMonth: i.contractedHoursPerMonth ?? null, + })), + } +} + +export const hoursByClientInternal = query({ + args: { tenantId: v.string(), range: v.optional(v.string()) }, + handler: hoursByClientInternalHandler, +}) + + +export const companyOverview = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + companyId: v.id("companies"), + range: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, viewerId, companyId, range }) => { + const viewer = await requireStaff(ctx, viewerId, tenantId); + if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) { + throw new ConvexError("Gestores só podem consultar relatórios da própria empresa"); + } + + const company = await ctx.db.get(companyId); + if (!company || company.tenantId !== tenantId) { + throw new ConvexError("Empresa não encontrada"); + } + + const normalizedRange = (range ?? "30d").toLowerCase(); + const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30; + const now = Date.now(); + const startMs = now - rangeDays * ONE_DAY_MS; + + const tickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect(); + + const machines = await ctx.db + .query("machines") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect(); + + const users = await ctx.db + .query("users") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect(); + + const statusCounts = {} as Record; + const priorityCounts = {} as Record; + const channelCounts = {} as Record; + const trendMap = new Map(); + const openTickets: Doc<"tickets">[] = []; + + tickets.forEach((ticket) => { + const normalizedStatus = normalizeStatus(ticket.status); + statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] ?? 0) + 1; + + const priorityKey = (ticket.priority ?? "MEDIUM").toUpperCase(); + priorityCounts[priorityKey] = (priorityCounts[priorityKey] ?? 0) + 1; + + const channelKey = (ticket.channel ?? "MANUAL").toUpperCase(); + channelCounts[channelKey] = (channelCounts[channelKey] ?? 0) + 1; + + if (normalizedStatus !== "RESOLVED") { + openTickets.push(ticket); + } + + if (ticket.createdAt >= startMs) { + const key = formatDateKey(ticket.createdAt); + if (!trendMap.has(key)) { + trendMap.set(key, { opened: 0, resolved: 0 }); + } + trendMap.get(key)!.opened += 1; + } + + if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs) { + const key = formatDateKey(ticket.resolvedAt); + if (!trendMap.has(key)) { + trendMap.set(key, { opened: 0, resolved: 0 }); + } + trendMap.get(key)!.resolved += 1; + } + }); + + const machineStatusCounts: Record = {}; + const machineOsCounts: Record = {}; + const machineLookup = new Map>(); + + machines.forEach((machine) => { + machineLookup.set(String(machine._id), machine); + const status = deriveMachineStatus(machine, now); + machineStatusCounts[status] = (machineStatusCounts[status] ?? 0) + 1; + const osLabel = formatOsLabel(machine.osName ?? null, machine.osVersion ?? null); + machineOsCounts[osLabel] = (machineOsCounts[osLabel] ?? 0) + 1; + }); + + const roleCounts: Record = {}; + users.forEach((user) => { + const roleKey = (user.role ?? "COLLABORATOR").toUpperCase(); + roleCounts[roleKey] = (roleCounts[roleKey] ?? 0) + 1; + }); + + const trend = Array.from(trendMap.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([date, value]) => ({ date, opened: value.opened, resolved: value.resolved })); + + const machineOsDistribution = Object.entries(machineOsCounts) + .map(([label, value]) => ({ label, value })) + .sort((a, b) => b.value - a.value); + + const openTicketSummaries = openTickets + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, 20) + .map((ticket) => { + const machineSnapshot = ticket.machineSnapshot as { hostname?: string } | undefined; + const machine = ticket.machineId ? machineLookup.get(String(ticket.machineId)) : null; + return { + id: ticket._id, + reference: ticket.reference, + subject: ticket.subject, + status: normalizeStatus(ticket.status), + priority: ticket.priority, + updatedAt: ticket.updatedAt, + createdAt: ticket.createdAt, + machine: ticket.machineId + ? { + id: ticket.machineId, + hostname: machine?.hostname ?? machineSnapshot?.hostname ?? null, + } + : null, + assignee: ticket.assigneeSnapshot + ? { + name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null, + email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null, + } + : null, + }; + }); + + return { + company: { + id: company._id, + name: company.name, + isAvulso: company.isAvulso ?? false, + }, + rangeDays, + generatedAt: now, + tickets: { + total: tickets.length, + byStatus: statusCounts, + byPriority: priorityCounts, + byChannel: channelCounts, + trend, + open: openTicketSummaries, + }, + machines: { + total: machines.length, + byStatus: machineStatusCounts, + byOs: machineOsDistribution, + }, + users: { + total: users.length, + byRole: roleCounts, + }, + }; + }, +}); diff --git a/referência/sistema-de-chamados-main/convex/revision.ts b/referência/sistema-de-chamados-main/convex/revision.ts new file mode 100644 index 0000000..dc033ed --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/revision.ts @@ -0,0 +1,3 @@ +// Chore: force Convex deploy through path filtering. +export const CONVEX_REVISION = "2025-10-21T11:59:00Z"; + diff --git a/referência/sistema-de-chamados-main/convex/schema.ts b/referência/sistema-de-chamados-main/convex/schema.ts new file mode 100644 index 0000000..b0f1bc3 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/schema.ts @@ -0,0 +1,700 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +const gridLayoutItem = v.object({ + i: v.string(), + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + minW: v.optional(v.number()), + minH: v.optional(v.number()), + static: v.optional(v.boolean()), +}); + +const widgetLayout = v.object({ + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + minW: v.optional(v.number()), + minH: v.optional(v.number()), + static: v.optional(v.boolean()), +}); + +const tvSection = v.object({ + id: v.string(), + title: v.optional(v.string()), + description: v.optional(v.string()), + widgetKeys: v.array(v.string()), + durationSeconds: v.optional(v.number()), +}); + +export default defineSchema({ + users: defineTable({ + tenantId: v.string(), + name: v.string(), + email: v.string(), + role: v.optional(v.string()), + jobTitle: v.optional(v.string()), + managerId: v.optional(v.id("users")), + avatarUrl: v.optional(v.string()), + teams: v.optional(v.array(v.string())), + companyId: v.optional(v.id("companies")), + }) + .index("by_tenant_email", ["tenantId", "email"]) + .index("by_tenant_role", ["tenantId", "role"]) + .index("by_tenant", ["tenantId"]) + .index("by_tenant_company", ["tenantId", "companyId"]) + .index("by_tenant_manager", ["tenantId", "managerId"]), + + companies: defineTable({ + tenantId: v.string(), + name: v.string(), + slug: v.string(), + provisioningCode: v.optional(v.string()), + isAvulso: v.optional(v.boolean()), + contractedHoursPerMonth: v.optional(v.number()), + cnpj: v.optional(v.string()), + domain: v.optional(v.string()), + phone: v.optional(v.string()), + description: v.optional(v.string()), + address: v.optional(v.string()), + legalName: v.optional(v.string()), + tradeName: v.optional(v.string()), + stateRegistration: v.optional(v.string()), + stateRegistrationType: v.optional(v.string()), + primaryCnae: v.optional(v.string()), + timezone: v.optional(v.string()), + businessHours: v.optional(v.any()), + supportEmail: v.optional(v.string()), + billingEmail: v.optional(v.string()), + contactPreferences: v.optional(v.any()), + clientDomains: v.optional(v.array(v.string())), + communicationChannels: v.optional(v.any()), + fiscalAddress: v.optional(v.any()), + hasBranches: v.optional(v.boolean()), + regulatedEnvironments: v.optional(v.array(v.string())), + privacyPolicyAccepted: v.optional(v.boolean()), + privacyPolicyReference: v.optional(v.string()), + privacyPolicyMetadata: v.optional(v.any()), + contracts: v.optional(v.any()), + contacts: v.optional(v.any()), + locations: v.optional(v.any()), + sla: v.optional(v.any()), + tags: v.optional(v.array(v.string())), + customFields: v.optional(v.any()), + notes: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_tenant_slug", ["tenantId", "slug"]) + .index("by_tenant", ["tenantId"]) + .index("by_provisioning_code", ["provisioningCode"]), + + alerts: defineTable({ + tenantId: v.string(), + companyId: v.optional(v.id("companies")), + companyName: v.string(), + usagePct: v.number(), + threshold: v.number(), + range: v.string(), + recipients: v.array(v.string()), + createdAt: v.number(), + deliveredCount: v.number(), + }) + .index("by_tenant_created", ["tenantId", "createdAt"]) + .index("by_tenant", ["tenantId"]), + + dashboards: defineTable({ + tenantId: v.string(), + name: v.string(), + description: v.optional(v.string()), + aspectRatio: v.optional(v.string()), + theme: v.optional(v.string()), + filters: v.optional(v.any()), + layout: v.optional(v.array(gridLayoutItem)), + sections: v.optional(v.array(tvSection)), + tvIntervalSeconds: v.optional(v.number()), + readySelector: v.optional(v.string()), + createdBy: v.id("users"), + updatedBy: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + isArchived: v.optional(v.boolean()), + }) + .index("by_tenant", ["tenantId"]) + .index("by_tenant_created", ["tenantId", "createdAt"]), + + dashboardWidgets: defineTable({ + tenantId: v.string(), + dashboardId: v.id("dashboards"), + widgetKey: v.string(), + title: v.optional(v.string()), + type: v.string(), + config: v.any(), + layout: v.optional(widgetLayout), + order: v.number(), + createdBy: v.id("users"), + updatedBy: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + isHidden: v.optional(v.boolean()), + }) + .index("by_dashboard", ["dashboardId"]) + .index("by_dashboard_order", ["dashboardId", "order"]) + .index("by_dashboard_key", ["dashboardId", "widgetKey"]) + .index("by_tenant", ["tenantId"]), + + metricDefinitions: defineTable({ + tenantId: v.string(), + key: v.string(), + name: v.string(), + description: v.optional(v.string()), + version: v.number(), + definition: v.optional(v.any()), + createdBy: v.id("users"), + updatedBy: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + tags: v.optional(v.array(v.string())), + }) + .index("by_tenant_key", ["tenantId", "key"]) + .index("by_tenant", ["tenantId"]), + + dashboardShares: defineTable({ + tenantId: v.string(), + dashboardId: v.id("dashboards"), + audience: v.string(), + token: v.optional(v.string()), + expiresAt: v.optional(v.number()), + canEdit: v.boolean(), + createdBy: v.id("users"), + createdAt: v.number(), + lastAccessAt: v.optional(v.number()), + }) + .index("by_dashboard", ["dashboardId"]) + .index("by_token", ["token"]) + .index("by_tenant", ["tenantId"]), + + queues: defineTable({ + tenantId: v.string(), + name: v.string(), + slug: v.string(), + teamId: v.optional(v.id("teams")), + }) + .index("by_tenant_slug", ["tenantId", "slug"]) + .index("by_tenant_team", ["tenantId", "teamId"]) + .index("by_tenant_name", ["tenantId", "name"]) + .index("by_tenant", ["tenantId"]), + + teams: defineTable({ + tenantId: v.string(), + name: v.string(), + description: v.optional(v.string()), + }).index("by_tenant_name", ["tenantId", "name"]), + + slaPolicies: defineTable({ + tenantId: v.string(), + name: v.string(), + description: v.optional(v.string()), + timeToFirstResponse: v.optional(v.number()), // minutes + timeToResolution: v.optional(v.number()), // minutes + }).index("by_tenant_name", ["tenantId", "name"]), + + tickets: defineTable({ + tenantId: v.string(), + reference: v.number(), + subject: v.string(), + summary: v.optional(v.string()), + description: v.optional(v.string()), + status: v.string(), + priority: v.string(), + channel: v.string(), + queueId: v.optional(v.id("queues")), + categoryId: v.optional(v.id("ticketCategories")), + subcategoryId: v.optional(v.id("ticketSubcategories")), + requesterId: v.id("users"), + requesterSnapshot: v.optional( + v.object({ + name: v.string(), + email: v.optional(v.string()), + avatarUrl: v.optional(v.string()), + teams: v.optional(v.array(v.string())), + }) + ), + assigneeId: v.optional(v.id("users")), + assigneeSnapshot: v.optional( + v.object({ + name: v.string(), + email: v.optional(v.string()), + avatarUrl: v.optional(v.string()), + teams: v.optional(v.array(v.string())), + }) + ), + companyId: v.optional(v.id("companies")), + companySnapshot: v.optional( + v.object({ + name: v.string(), + slug: v.optional(v.string()), + isAvulso: v.optional(v.boolean()), + }) + ), + machineId: v.optional(v.id("machines")), + machineSnapshot: v.optional( + v.object({ + hostname: v.optional(v.string()), + persona: v.optional(v.string()), + assignedUserName: v.optional(v.string()), + assignedUserEmail: v.optional(v.string()), + status: v.optional(v.string()), + }) + ), + working: v.optional(v.boolean()), + slaPolicyId: v.optional(v.id("slaPolicies")), + slaSnapshot: v.optional( + v.object({ + categoryId: v.optional(v.id("ticketCategories")), + categoryName: v.optional(v.string()), + priority: v.optional(v.string()), + responseTargetMinutes: v.optional(v.number()), + responseMode: v.optional(v.string()), + solutionTargetMinutes: v.optional(v.number()), + solutionMode: v.optional(v.string()), + alertThreshold: v.optional(v.number()), + pauseStatuses: v.optional(v.array(v.string())), + }) + ), + slaResponseDueAt: v.optional(v.number()), + slaSolutionDueAt: v.optional(v.number()), + slaResponseStatus: v.optional(v.string()), + slaSolutionStatus: v.optional(v.string()), + slaPausedAt: v.optional(v.number()), + slaPausedBy: v.optional(v.string()), + slaPausedMs: v.optional(v.number()), + dueAt: v.optional(v.number()), // ms since epoch + firstResponseAt: v.optional(v.number()), + resolvedAt: v.optional(v.number()), + closedAt: v.optional(v.number()), + 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()), + }) + ) + ), + csatScore: v.optional(v.number()), + csatMaxScore: v.optional(v.number()), + csatComment: v.optional(v.string()), + csatRatedAt: v.optional(v.number()), + csatRatedBy: v.optional(v.id("users")), + csatAssigneeId: v.optional(v.id("users")), + csatAssigneeSnapshot: v.optional( + v.object({ + name: v.string(), + email: v.optional(v.string()), + avatarUrl: v.optional(v.string()), + teams: v.optional(v.array(v.string())), + }) + ), + formTemplate: v.optional(v.string()), + formTemplateLabel: v.optional(v.string()), + relatedTicketIds: v.optional(v.array(v.id("tickets"))), + resolvedWithTicketId: v.optional(v.id("tickets")), + reopenDeadline: v.optional(v.number()), + reopenedAt: v.optional(v.number()), + chatEnabled: v.optional(v.boolean()), + totalWorkedMs: v.optional(v.number()), + internalWorkedMs: v.optional(v.number()), + externalWorkedMs: v.optional(v.number()), + activeSessionId: v.optional(v.id("ticketWorkSessions")), + }) + .index("by_tenant_status", ["tenantId", "status"]) + .index("by_tenant_queue", ["tenantId", "queueId"]) + .index("by_tenant_assignee", ["tenantId", "assigneeId"]) + .index("by_tenant_reference", ["tenantId", "reference"]) + .index("by_tenant_requester", ["tenantId", "requesterId"]) + .index("by_tenant_company", ["tenantId", "companyId"]) + .index("by_tenant_machine", ["tenantId", "machineId"]) + .index("by_tenant_category", ["tenantId", "categoryId"]) + .index("by_tenant_subcategory", ["tenantId", "subcategoryId"]) + .index("by_tenant_sla_policy", ["tenantId", "slaPolicyId"]) + .index("by_tenant", ["tenantId"]) + .index("by_tenant_created", ["tenantId", "createdAt"]) + .index("by_tenant_resolved", ["tenantId", "resolvedAt"]) + .index("by_tenant_company_created", ["tenantId", "companyId", "createdAt"]) + .index("by_tenant_company_resolved", ["tenantId", "companyId", "resolvedAt"]), + + ticketComments: defineTable({ + ticketId: v.id("tickets"), + authorId: v.id("users"), + visibility: v.string(), // PUBLIC | INTERNAL + body: v.string(), + authorSnapshot: v.optional( + v.object({ + name: v.string(), + email: v.optional(v.string()), + avatarUrl: v.optional(v.string()), + teams: v.optional(v.array(v.string())), + }) + ), + attachments: v.optional( + v.array( + v.object({ + storageId: v.id("_storage"), + name: v.string(), + size: v.optional(v.number()), + type: v.optional(v.string()), + }) + ) + ), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_ticket", ["ticketId"]) + .index("by_author", ["authorId"]), + + ticketEvents: defineTable({ + ticketId: v.id("tickets"), + type: v.string(), + payload: v.optional(v.any()), + createdAt: v.number(), + }).index("by_ticket", ["ticketId"]), + + ticketChatMessages: defineTable({ + ticketId: v.id("tickets"), + authorId: v.id("users"), + authorSnapshot: v.optional( + v.object({ + name: v.string(), + email: v.optional(v.string()), + avatarUrl: v.optional(v.string()), + teams: v.optional(v.array(v.string())), + }) + ), + body: v.string(), + attachments: v.optional( + v.array( + v.object({ + storageId: v.id("_storage"), + name: v.string(), + size: v.optional(v.number()), + type: v.optional(v.string()), + }) + ) + ), + notifiedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + tenantId: v.string(), + companyId: v.optional(v.id("companies")), + readBy: v.optional( + v.array( + v.object({ + userId: v.id("users"), + readAt: v.number(), + }) + ) + ), + }) + .index("by_ticket_created", ["ticketId", "createdAt"]) + .index("by_tenant_created", ["tenantId", "createdAt"]), + + commentTemplates: defineTable({ + tenantId: v.string(), + kind: v.optional(v.string()), + title: v.string(), + body: v.string(), + createdBy: v.id("users"), + updatedBy: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_tenant", ["tenantId"]) + .index("by_tenant_title", ["tenantId", "title"]) + .index("by_tenant_kind", ["tenantId", "kind"]), + + ticketWorkSessions: defineTable({ + ticketId: v.id("tickets"), + agentId: v.id("users"), + workType: v.optional(v.string()), // INTERNAL | EXTERNAL + startedAt: v.number(), + stoppedAt: v.optional(v.number()), + durationMs: v.optional(v.number()), + pauseReason: v.optional(v.string()), + pauseNote: v.optional(v.string()), + }) + .index("by_ticket", ["ticketId"]) + .index("by_ticket_agent", ["ticketId", "agentId"]) + .index("by_agent", ["agentId"]), + + ticketCategories: defineTable({ + tenantId: v.string(), + name: v.string(), + slug: v.string(), + description: v.optional(v.string()), + order: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_tenant_slug", ["tenantId", "slug"]) + .index("by_tenant_order", ["tenantId", "order"]) + .index("by_tenant", ["tenantId"]), + + ticketSubcategories: defineTable({ + tenantId: v.string(), + categoryId: v.id("ticketCategories"), + name: v.string(), + slug: v.string(), + order: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_category_order", ["categoryId", "order"]) + .index("by_category_slug", ["categoryId", "slug"]) + .index("by_tenant_slug", ["tenantId", "slug"]), + + categorySlaSettings: defineTable({ + tenantId: v.string(), + categoryId: v.id("ticketCategories"), + priority: v.string(), + responseTargetMinutes: v.optional(v.number()), + responseMode: v.optional(v.string()), + solutionTargetMinutes: v.optional(v.number()), + solutionMode: v.optional(v.string()), + alertThreshold: v.optional(v.number()), + pauseStatuses: v.optional(v.array(v.string())), + calendarType: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + actorId: v.optional(v.id("users")), + }) + .index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"]) + .index("by_tenant_category", ["tenantId", "categoryId"]), + + ticketFields: defineTable({ + tenantId: v.string(), + key: v.string(), + label: v.string(), + type: v.string(), + companyId: v.optional(v.id("companies")), + description: v.optional(v.string()), + required: v.boolean(), + order: v.number(), + options: v.optional( + v.array( + v.object({ + value: v.string(), + label: v.string(), + }) + ) + ), + scope: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_tenant_key", ["tenantId", "key"]) + .index("by_tenant_order", ["tenantId", "order"]) + .index("by_tenant_scope", ["tenantId", "scope"]) + .index("by_tenant_company", ["tenantId", "companyId"]) + .index("by_tenant", ["tenantId"]), + + ticketFormSettings: defineTable({ + tenantId: v.string(), + template: v.string(), + scope: v.string(), // tenant | company | user + companyId: v.optional(v.id("companies")), + userId: v.optional(v.id("users")), + enabled: v.boolean(), + createdAt: v.number(), + updatedAt: v.number(), + actorId: v.optional(v.id("users")), + }) + .index("by_tenant_template_scope", ["tenantId", "template", "scope"]) + .index("by_tenant_template_company", ["tenantId", "template", "companyId"]) + .index("by_tenant_template_user", ["tenantId", "template", "userId"]) + .index("by_tenant", ["tenantId"]), + + ticketFormTemplates: defineTable({ + tenantId: v.string(), + key: v.string(), + label: v.string(), + description: v.optional(v.string()), + defaultEnabled: v.optional(v.boolean()), + baseTemplateKey: v.optional(v.string()), + isSystem: v.optional(v.boolean()), + isArchived: v.optional(v.boolean()), + order: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + createdBy: v.optional(v.id("users")), + updatedBy: v.optional(v.id("users")), + }) + .index("by_tenant", ["tenantId"]) + .index("by_tenant_key", ["tenantId", "key"]) + .index("by_tenant_active", ["tenantId", "isArchived"]), + + 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"]), + + machines: defineTable({ + tenantId: v.string(), + companyId: v.optional(v.id("companies")), + companySlug: v.optional(v.string()), + authUserId: v.optional(v.string()), + authEmail: v.optional(v.string()), + persona: v.optional(v.string()), + assignedUserId: v.optional(v.id("users")), + assignedUserEmail: v.optional(v.string()), + assignedUserName: v.optional(v.string()), + assignedUserRole: v.optional(v.string()), + linkedUserIds: v.optional(v.array(v.id("users"))), + hostname: v.string(), + osName: v.string(), + osVersion: v.optional(v.string()), + architecture: v.optional(v.string()), + macAddresses: v.array(v.string()), + serialNumbers: v.array(v.string()), + fingerprint: v.string(), + metadata: v.optional(v.any()), + displayName: v.optional(v.string()), + deviceType: v.optional(v.string()), + devicePlatform: v.optional(v.string()), + deviceProfile: v.optional(v.any()), + managementMode: v.optional(v.string()), + customFields: v.optional( + v.array( + v.object({ + fieldId: v.id("deviceFields"), + fieldKey: v.string(), + label: v.string(), + type: v.string(), + value: v.any(), + displayValue: v.optional(v.string()), + }) + ) + ), + lastHeartbeatAt: v.optional(v.number()), + status: v.optional(v.string()), + isActive: v.optional(v.boolean()), + createdAt: v.number(), + updatedAt: v.number(), + registeredBy: v.optional(v.string()), + remoteAccess: v.optional(v.any()), + }) + .index("by_tenant", ["tenantId"]) + .index("by_tenant_company", ["tenantId", "companyId"]) + .index("by_tenant_fingerprint", ["tenantId", "fingerprint"]) + .index("by_tenant_assigned_email", ["tenantId", "assignedUserEmail"]) + .index("by_auth_email", ["authEmail"]), + + machineAlerts: defineTable({ + tenantId: v.string(), + machineId: v.id("machines"), + companyId: v.optional(v.id("companies")), + kind: v.string(), + message: v.string(), + severity: v.string(), + createdAt: v.number(), + }) + .index("by_machine_created", ["machineId", "createdAt"]) + .index("by_tenant_created", ["tenantId", "createdAt"]) + .index("by_tenant_machine", ["tenantId", "machineId"]), + + machineTokens: defineTable({ + tenantId: v.string(), + machineId: v.id("machines"), + tokenHash: v.string(), + expiresAt: v.number(), + revoked: v.boolean(), + createdAt: v.number(), + lastUsedAt: v.optional(v.number()), + usageCount: v.optional(v.number()), + type: v.optional(v.string()), + }) + .index("by_token_hash", ["tokenHash"]) + .index("by_machine", ["machineId"]) + .index("by_tenant_machine", ["tenantId", "machineId"]) + .index("by_machine_created", ["machineId", "createdAt"]) + .index("by_machine_revoked_expires", ["machineId", "revoked", "expiresAt"]), + + deviceFields: defineTable({ + tenantId: v.string(), + key: v.string(), + label: v.string(), + description: v.optional(v.string()), + type: v.string(), + required: v.optional(v.boolean()), + options: v.optional( + v.array( + v.object({ + value: v.string(), + label: v.string(), + }) + ) + ), + scope: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + order: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + createdBy: v.optional(v.id("users")), + updatedBy: v.optional(v.id("users")), + }) + .index("by_tenant_order", ["tenantId", "order"]) + .index("by_tenant_key", ["tenantId", "key"]) + .index("by_tenant_company", ["tenantId", "companyId"]) + .index("by_tenant_scope", ["tenantId", "scope"]) + .index("by_tenant", ["tenantId"]), + + deviceExportTemplates: defineTable({ + tenantId: v.string(), + name: v.string(), + slug: v.string(), + description: v.optional(v.string()), + columns: v.array( + v.object({ + key: v.string(), + label: v.optional(v.string()), + }) + ), + filters: v.optional(v.any()), + companyId: v.optional(v.id("companies")), + isDefault: v.optional(v.boolean()), + isActive: v.optional(v.boolean()), + createdBy: v.optional(v.id("users")), + updatedBy: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_tenant_slug", ["tenantId", "slug"]) + .index("by_tenant_company", ["tenantId", "companyId"]) + .index("by_tenant_default", ["tenantId", "isDefault"]) + .index("by_tenant", ["tenantId"]), +}); diff --git a/referência/sistema-de-chamados-main/convex/seed.ts b/referência/sistema-de-chamados-main/convex/seed.ts new file mode 100644 index 0000000..a26f12b --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/seed.ts @@ -0,0 +1,157 @@ +import type { Id } from "./_generated/dataModel" +import { mutation } from "./_generated/server" + +export const seedDemo = mutation({ + args: {}, + handler: async (ctx) => { + const tenantId = "tenant-atlas"; + const desiredQueues = [ + { name: "Chamados", slug: "chamados" }, + { name: "Laboratório", slug: "laboratorio" }, + { name: "Visitas", slug: "visitas" }, + ]; + + // Ensure queues + const existingQueues = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const normalizedQueues = await Promise.all( + existingQueues.map(async (queue) => { + if (!queue) return queue; + if (queue.name === "Suporte N1" || queue.slug === "suporte-n1") { + await ctx.db.patch(queue._id, { name: "Chamados", slug: "chamados" }); + return (await ctx.db.get(queue._id)) ?? queue; + } + if (queue.name === "Suporte N2" || queue.slug === "suporte-n2") { + await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" }); + return (await ctx.db.get(queue._id)) ?? queue; + } + return queue; + }) + ); + + const presentQueues = normalizedQueues.filter( + (queue): queue is NonNullable<(typeof normalizedQueues)[number]> => Boolean(queue) + ); + const queuesBySlug = new Map(presentQueues.map((queue) => [queue.slug, queue])); + const queuesByName = new Map(presentQueues.map((queue) => [queue.name, queue])); + const queues = [] as typeof presentQueues; + + for (const def of desiredQueues) { + let queue = queuesBySlug.get(def.slug) ?? queuesByName.get(def.name); + if (!queue) { + const newId = await ctx.db.insert("queues", { tenantId, name: def.name, slug: def.slug, teamId: undefined }); + queue = (await ctx.db.get(newId))!; + queuesBySlug.set(queue.slug, queue); + queuesByName.set(queue.name, queue); + } + queues.push(queue); + } + + const queueChamados = queuesBySlug.get("chamados"); + const queueLaboratorio = queuesBySlug.get("laboratorio"); + const queueVisitas = queuesBySlug.get("visitas"); + if (!queueChamados || !queueLaboratorio || !queueVisitas) { + throw new Error("Filas padrão não foram inicializadas"); + } + + // Ensure users + function defaultAvatar(name: string, email: string, role: string) { + const normalizedRole = role.toUpperCase(); + if (normalizedRole === "MANAGER") { + return `https://i.pravatar.cc/150?u=${encodeURIComponent(email)}`; + } + const first = name.split(" ")[0] ?? email; + return `https://avatar.vercel.sh/${encodeURIComponent(first)}`; + } + async function ensureUser(params: { + name: string; + email: string; + role?: string; + companyId?: Id<"companies">; + avatarUrl?: string; + }): Promise> { + const normalizedEmail = params.email.trim().toLowerCase(); + const normalizedRole = (params.role ?? "MANAGER").toUpperCase(); + const desiredAvatar = params.avatarUrl ?? defaultAvatar(params.name, normalizedEmail, normalizedRole); + const existing = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedEmail)) + .first(); + if (existing) { + const updates: Record = {}; + if (existing.name !== params.name) updates.name = params.name; + if ((existing.role ?? "MANAGER") !== normalizedRole) updates.role = normalizedRole; + if ((existing.avatarUrl ?? undefined) !== desiredAvatar) updates.avatarUrl = desiredAvatar; + if ((existing.companyId ?? undefined) !== (params.companyId ?? undefined)) updates.companyId = params.companyId ?? undefined; + if (Object.keys(updates).length > 0) { + await ctx.db.patch(existing._id, updates); + } + return existing._id; + } + return await ctx.db.insert("users", { + tenantId, + name: params.name, + email: normalizedEmail, + role: normalizedRole, + avatarUrl: desiredAvatar, + companyId: params.companyId ?? undefined, + }); + } + + const adminId = await ensureUser({ name: "Administrador", email: "admin@sistema.dev", role: "ADMIN" }); + const staffRoster = [ + { name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br" }, + { name: "George Araujo", email: "george.araujo@rever.com.br" }, + { name: "Hugo Soares", email: "hugo.soares@rever.com.br" }, + { name: "Julio Cesar", email: "julio@rever.com.br" }, + { name: "Lorena Magalhães", email: "lorena@rever.com.br" }, + { name: "Rever", email: "renan.pac@paulicon.com.br" }, + { name: "Thiago Medeiros", email: "thiago.medeiros@rever.com.br" }, + { name: "Weslei Magalhães", email: "weslei@rever.com.br" }, + ]; + + await Promise.all( + staffRoster.map((staff) => ensureUser({ name: staff.name, email: staff.email, role: "AGENT" })), + ); + + const templateDefinitions = [ + { + title: "A Rever agradece seu contato", + body: "

A Rever agradece seu contato. Recebemos sua solicitação e nossa equipe já está analisando os detalhes. Retornaremos com atualizações em breve.

", + }, + { + title: "Atualização do chamado", + body: "

Seu chamado foi atualizado. Caso tenha novas informações ou dúvidas, basta responder a esta mensagem.

", + }, + { + title: "Chamado resolvido", + body: "

Concluímos o atendimento deste chamado. A Rever agradece a parceria e permanecemos à disposição para novos suportes.

", + }, + ]; + + const existingTemplates = await ctx.db + .query("commentTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + for (const definition of templateDefinitions) { + const already = existingTemplates.find((template) => template?.title === definition.title); + if (already) continue; + const timestamp = Date.now(); + await ctx.db.insert("commentTemplates", { + tenantId, + title: definition.title, + body: definition.body, + createdBy: adminId, + updatedBy: adminId, + createdAt: timestamp, + updatedAt: timestamp, + }); + } + + // No demo tickets are seeded; rely on real data from the database. + }, +}); diff --git a/referência/sistema-de-chamados-main/convex/slas.ts b/referência/sistema-de-chamados-main/convex/slas.ts new file mode 100644 index 0000000..5cfdc6a --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/slas.ts @@ -0,0 +1,137 @@ +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; + +import { requireAdmin } from "./rbac"; + +function normalizeName(value: string) { + return value.trim(); +} + +type AnyCtx = QueryCtx | MutationCtx; + +async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) { + const existing = await ctx.db + .query("slaPolicies") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe uma política SLA com este nome"); + } +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + const items = await ctx.db + .query("slaPolicies") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return items.map((policy) => ({ + id: policy._id, + name: policy.name, + description: policy.description ?? "", + timeToFirstResponse: policy.timeToFirstResponse ?? null, + timeToResolution: policy.timeToResolution ?? null, + })); + }, +}); + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + timeToFirstResponse: v.optional(v.number()), + timeToResolution: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { + await requireAdmin(ctx, actorId, tenantId); + const trimmed = normalizeName(name); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a política"); + } + await ensureUniqueName(ctx, tenantId, trimmed); + if (timeToFirstResponse !== undefined && timeToFirstResponse < 0) { + throw new ConvexError("Tempo para primeira resposta deve ser positivo"); + } + if (timeToResolution !== undefined && timeToResolution < 0) { + throw new ConvexError("Tempo para resolução deve ser positivo"); + } + + const id = await ctx.db.insert("slaPolicies", { + tenantId, + name: trimmed, + description, + timeToFirstResponse, + timeToResolution, + }); + return id; + }, +}); + +export const update = mutation({ + args: { + policyId: v.id("slaPolicies"), + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + timeToFirstResponse: v.optional(v.number()), + timeToResolution: v.optional(v.number()), + }, + handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { + await requireAdmin(ctx, actorId, tenantId); + const policy = await ctx.db.get(policyId); + if (!policy || policy.tenantId !== tenantId) { + throw new ConvexError("Política não encontrada"); + } + const trimmed = normalizeName(name); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a política"); + } + if (timeToFirstResponse !== undefined && timeToFirstResponse < 0) { + throw new ConvexError("Tempo para primeira resposta deve ser positivo"); + } + if (timeToResolution !== undefined && timeToResolution < 0) { + throw new ConvexError("Tempo para resolução deve ser positivo"); + } + await ensureUniqueName(ctx, tenantId, trimmed, policyId); + + await ctx.db.patch(policyId, { + name: trimmed, + description, + timeToFirstResponse, + timeToResolution, + }); + }, +}); + +export const remove = mutation({ + args: { + policyId: v.id("slaPolicies"), + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { policyId, tenantId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId); + const policy = await ctx.db.get(policyId); + if (!policy || policy.tenantId !== tenantId) { + throw new ConvexError("Política não encontrada"); + } + + const ticketLinked = await ctx.db + .query("tickets") + .withIndex("by_tenant_sla_policy", (q) => q.eq("tenantId", tenantId).eq("slaPolicyId", policyId)) + .first(); + if (ticketLinked) { + throw new ConvexError("Remova a associação de tickets antes de excluir a política"); + } + + await ctx.db.delete(policyId); + }, +}); diff --git a/referência/sistema-de-chamados-main/convex/teams.ts b/referência/sistema-de-chamados-main/convex/teams.ts new file mode 100644 index 0000000..f856e0d --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/teams.ts @@ -0,0 +1,231 @@ +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; +import { ConvexError, v } from "convex/values"; + +import { requireAdmin } from "./rbac"; + +function normalizeName(value: string) { + return value.trim(); +} + +type AnyCtx = QueryCtx | MutationCtx; + +async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"teams">) { + const existing = await ctx.db + .query("teams") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe um time com este nome"); + } +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + const teams = await ctx.db + .query("teams") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const queues = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return teams.map((team) => { + const members = users + .filter((user) => (user.teams ?? []).includes(team.name)) + .map((user) => ({ + id: user._id, + name: user.name, + email: user.email, + role: user.role ?? "AGENT", + })); + + const linkedQueues = queues.filter((queue) => queue.teamId === team._id); + + return { + id: team._id, + name: team.name, + description: team.description ?? "", + members, + queueCount: linkedQueues.length, + createdAt: team._creationTime, + }; + }); + }, +}); + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, actorId, name, description }) => { + await requireAdmin(ctx, actorId, tenantId); + const trimmed = normalizeName(name); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para o time"); + } + await ensureUniqueName(ctx, tenantId, trimmed); + const id = await ctx.db.insert("teams", { + tenantId, + name: trimmed, + description, + }); + return id; + }, +}); + +export const update = mutation({ + args: { + teamId: v.id("teams"), + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + }, + handler: async (ctx, { teamId, tenantId, actorId, name, description }) => { + await requireAdmin(ctx, actorId, tenantId); + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time não encontrado"); + } + const trimmed = normalizeName(name); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para o time"); + } + await ensureUniqueName(ctx, tenantId, trimmed, teamId); + + if (team.name !== trimmed) { + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const now = users + .filter((user) => (user.teams ?? []).includes(team.name)) + .map(async (user) => { + const teams = (user.teams ?? []).map((entry) => (entry === team.name ? trimmed : entry)); + await ctx.db.patch(user._id, { teams }); + }); + await Promise.all(now); + } + + await ctx.db.patch(teamId, { name: trimmed, description }); + }, +}); + +export const remove = mutation({ + args: { + teamId: v.id("teams"), + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { teamId, tenantId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId); + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time não encontrado"); + } + + const queuesLinked = await ctx.db + .query("queues") + .withIndex("by_tenant_team", (q) => q.eq("tenantId", tenantId).eq("teamId", teamId)) + .first(); + if (queuesLinked) { + throw new ConvexError("Remova ou realoque as filas associadas antes de excluir o time"); + } + + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + await Promise.all( + users + .filter((user) => (user.teams ?? []).includes(team.name)) + .map((user) => { + const teams = (user.teams ?? []).filter((entry) => entry !== team.name); + return ctx.db.patch(user._id, { teams }); + }) + ); + + await ctx.db.delete(teamId); + }, +}); + +export const setMembers = mutation({ + args: { + teamId: v.id("teams"), + tenantId: v.string(), + actorId: v.id("users"), + memberIds: v.array(v.id("users")), + }, + handler: async (ctx, { teamId, tenantId, actorId, memberIds }) => { + await requireAdmin(ctx, actorId, tenantId); + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time não encontrado"); + } + + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const tenantUserIds = new Set(users.map((user) => user._id)); + for (const memberId of memberIds) { + if (!tenantUserIds.has(memberId)) { + throw new ConvexError("Usuário inválido para este tenant"); + } + } + const target = new Set(memberIds); + + await Promise.all( + users.map(async (user) => { + const teams = new Set(user.teams ?? []); + const hasTeam = teams.has(team.name); + const shouldHave = target.has(user._id); + + if (shouldHave && !hasTeam) { + teams.add(team.name); + await ctx.db.patch(user._id, { teams: Array.from(teams) }); + } + + if (!shouldHave && hasTeam) { + teams.delete(team.name); + await ctx.db.patch(user._id, { teams: Array.from(teams) }); + } + }) + ); + }, +}); + +export const directory = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return users.map((user) => ({ + id: user._id, + name: user.name, + email: user.email, + role: user.role ?? "AGENT", + teams: user.teams ?? [], + })); + }, +}); diff --git a/referência/sistema-de-chamados-main/convex/ticketFormSettings.ts b/referência/sistema-de-chamados-main/convex/ticketFormSettings.ts new file mode 100644 index 0000000..e85d794 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/ticketFormSettings.ts @@ -0,0 +1,158 @@ +import { mutation, query } from "./_generated/server" +import type { MutationCtx, QueryCtx } from "./_generated/server" +import { ConvexError, v } from "convex/values" +import type { Id } from "./_generated/dataModel" + +import { requireAdmin } from "./rbac" +import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates" +import { TICKET_FORM_CONFIG } from "./ticketForms.config" + +const VALID_SCOPES = new Set(["tenant", "company", "user"]) + +function normalizeScope(input: string) { + const normalized = input.trim().toLowerCase() + if (!VALID_SCOPES.has(normalized)) { + throw new ConvexError("Escopo inválido") + } + return normalized +} + +async function ensureTemplateExists(ctx: MutationCtx | QueryCtx, tenantId: string, template: string) { + const normalized = normalizeFormTemplateKey(template) + if (!normalized) { + throw new ConvexError("Template desconhecido") + } + const existing = await getTemplateByKey(ctx, tenantId, normalized) + if (existing && existing.isArchived !== true) { + return normalized + } + const fallback = TICKET_FORM_CONFIG.find((tpl) => tpl.key === normalized) + if (fallback) { + return normalized + } + throw new ConvexError("Template desconhecido") +} + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + template: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, viewerId, template }) => { + await requireAdmin(ctx, viewerId, tenantId) + const normalizedTemplate = template ? normalizeFormTemplateKey(template) : null + const settings = await ctx.db + .query("ticketFormSettings") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + return settings + .filter((setting) => !normalizedTemplate || setting.template === normalizedTemplate) + .map((setting) => ({ + id: setting._id, + template: setting.template, + scope: setting.scope, + companyId: setting.companyId ?? null, + userId: setting.userId ?? null, + enabled: setting.enabled, + createdAt: setting.createdAt, + updatedAt: setting.updatedAt, + actorId: setting.actorId ?? null, + })) + }, +}) + +export const upsert = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + template: v.string(), + scope: v.string(), + companyId: v.optional(v.id("companies")), + userId: v.optional(v.id("users")), + enabled: v.boolean(), + }, + handler: async (ctx, { tenantId, actorId, template, scope, companyId, userId, enabled }) => { + await requireAdmin(ctx, actorId, tenantId) + const normalizedTemplate = await ensureTemplateExists(ctx, tenantId, template) + const normalizedScope = normalizeScope(scope) + + if (normalizedScope === "company" && !companyId) { + throw new ConvexError("Informe a empresa para configurar o template") + } + if (normalizedScope === "user" && !userId) { + throw new ConvexError("Informe o usuário para configurar o template") + } + if (normalizedScope === "tenant") { + if (companyId || userId) { + throw new ConvexError("Escopo global não aceita empresa ou usuário") + } + } + + const existing = await findExisting(ctx, tenantId, normalizedTemplate, normalizedScope, companyId, userId) + const now = Date.now() + if (existing) { + await ctx.db.patch(existing._id, { + enabled, + updatedAt: now, + actorId, + }) + return existing._id + } + + const id = await ctx.db.insert("ticketFormSettings", { + tenantId, + template: normalizedTemplate, + scope: normalizedScope, + companyId: normalizedScope === "company" ? (companyId as Id<"companies">) : undefined, + userId: normalizedScope === "user" ? (userId as Id<"users">) : undefined, + enabled, + createdAt: now, + updatedAt: now, + actorId, + }) + return id + }, +}) + +export const remove = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + settingId: v.id("ticketFormSettings"), + }, + handler: async (ctx, { tenantId, actorId, settingId }) => { + await requireAdmin(ctx, actorId, tenantId) + const setting = await ctx.db.get(settingId) + if (!setting || setting.tenantId !== tenantId) { + throw new ConvexError("Configuração não encontrada") + } + await ctx.db.delete(settingId) + return { ok: true } + }, +}) + +async function findExisting( + ctx: MutationCtx | QueryCtx, + tenantId: string, + template: string, + scope: string, + companyId?: Id<"companies">, + userId?: Id<"users">, +) { + const candidates = await ctx.db + .query("ticketFormSettings") + .withIndex("by_tenant_template_scope", (q) => q.eq("tenantId", tenantId).eq("template", template).eq("scope", scope)) + .collect() + + return candidates.find((setting) => { + if (scope === "tenant") return true + if (scope === "company") { + return setting.companyId && companyId && String(setting.companyId) === String(companyId) + } + if (scope === "user") { + return setting.userId && userId && String(setting.userId) === String(userId) + } + return false + }) ?? null +} diff --git a/referência/sistema-de-chamados-main/convex/ticketFormTemplates.ts b/referência/sistema-de-chamados-main/convex/ticketFormTemplates.ts new file mode 100644 index 0000000..92c25ed --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/ticketFormTemplates.ts @@ -0,0 +1,281 @@ +"use server"; + +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; + +import { requireAdmin, requireStaff } from "./rbac"; +import { TICKET_FORM_CONFIG } from "./ticketForms.config"; + +type AnyCtx = MutationCtx | QueryCtx; + +function slugify(input: string) { + return input + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function normalizeFormTemplateKey(input: string | null | undefined): string | null { + if (!input) return null; + const normalized = slugify(input); + return normalized || null; +} + +async function templateKeyExists(ctx: AnyCtx, tenantId: string, key: string) { + const existing = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) + .first(); + return Boolean(existing); +} + +export async function ensureTicketFormTemplatesForTenant(ctx: MutationCtx, tenantId: string) { + const existing = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + let order = existing.reduce((max, tpl) => Math.max(max, tpl.order ?? 0), 0); + const now = Date.now(); + for (const template of TICKET_FORM_CONFIG) { + const match = existing.find((tpl) => tpl.key === template.key); + if (match) { + const updates: Partial> = {}; + if (!match.baseTemplateKey) { + updates.baseTemplateKey = template.key; + } + if (match.isSystem !== true) { + updates.isSystem = true; + } + if (typeof match.defaultEnabled === "undefined") { + updates.defaultEnabled = template.defaultEnabled; + } + if (Object.keys(updates).length) { + await ctx.db.patch(match._id, { + ...updates, + updatedAt: now, + }); + } + continue; + } + order += 1; + await ctx.db.insert("ticketFormTemplates", { + tenantId, + key: template.key, + label: template.label, + description: template.description ?? undefined, + defaultEnabled: template.defaultEnabled, + baseTemplateKey: template.key, + isSystem: true, + isArchived: false, + order, + createdAt: now, + updatedAt: now, + }); + } +} + +export async function getTemplateByKey(ctx: AnyCtx, tenantId: string, key: string): Promise | null> { + return ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) + .first(); +} + +async function generateTemplateKey(ctx: MutationCtx, tenantId: string, label: string) { + const base = slugify(label) || `template-${Date.now()}`; + let candidate = base; + let suffix = 1; + while (await templateKeyExists(ctx, tenantId, candidate)) { + candidate = `${base}-${suffix}`; + suffix += 1; + } + return candidate; +} + +async function cloneFieldsFromTemplate(ctx: MutationCtx, tenantId: string, sourceKey: string, targetKey: string) { + const sourceFields = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", sourceKey)) + .collect(); + if (sourceFields.length === 0) return; + const ordered = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + let order = ordered.reduce((max, field) => Math.max(max, field.order ?? 0), 0); + const now = Date.now(); + for (const field of sourceFields.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) { + order += 1; + await ctx.db.insert("ticketFields", { + tenantId, + key: field.key, + label: field.label, + description: field.description ?? undefined, + type: field.type, + required: field.required, + options: field.options ?? undefined, + scope: targetKey, + companyId: field.companyId ?? undefined, + order, + createdAt: now, + updatedAt: now, + }); + } +} + +function mapTemplate(template: Doc<"ticketFormTemplates">) { + return { + id: template._id, + key: template.key, + label: template.label, + description: template.description ?? "", + defaultEnabled: template.defaultEnabled ?? true, + baseTemplateKey: template.baseTemplateKey ?? null, + isSystem: Boolean(template.isSystem), + isArchived: Boolean(template.isArchived), + order: template.order ?? 0, + createdAt: template.createdAt, + updatedAt: template.updatedAt, + }; +} + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + includeArchived: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, viewerId, includeArchived }) => { + await requireAdmin(ctx, viewerId, tenantId); + const templates = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + return templates + .filter((tpl) => includeArchived || tpl.isArchived !== true) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR")) + .map(mapTemplate); + }, +}); + +export const listActive = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId); + const templates = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + return templates + .filter((tpl) => tpl.isArchived !== true) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR")) + .map(mapTemplate); + }, +}); + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + label: v.string(), + description: v.optional(v.string()), + baseTemplateKey: v.optional(v.string()), + cloneFields: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, actorId, label, description, baseTemplateKey, cloneFields }) => { + await requireAdmin(ctx, actorId, tenantId); + const trimmedLabel = label.trim(); + if (trimmedLabel.length < 3) { + throw new ConvexError("Informe um nome com pelo menos 3 caracteres"); + } + const key = await generateTemplateKey(ctx, tenantId, trimmedLabel); + const templates = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const order = (templates.reduce((max, tpl) => Math.max(max, tpl.order ?? 0), 0) ?? 0) + 1; + const now = Date.now(); + const templateId = await ctx.db.insert("ticketFormTemplates", { + tenantId, + key, + label: trimmedLabel, + description: description?.trim() || undefined, + defaultEnabled: true, + baseTemplateKey: baseTemplateKey ?? undefined, + isSystem: false, + isArchived: false, + order, + createdAt: now, + updatedAt: now, + createdBy: actorId, + updatedBy: actorId, + }); + if (baseTemplateKey && cloneFields) { + await cloneFieldsFromTemplate(ctx, tenantId, baseTemplateKey, key); + } + return templateId; + }, +}); + +export const update = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + templateId: v.id("ticketFormTemplates"), + label: v.string(), + description: v.optional(v.string()), + isArchived: v.optional(v.boolean()), + defaultEnabled: v.optional(v.boolean()), + order: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, actorId, templateId, label, description, isArchived, defaultEnabled, order }) => { + await requireAdmin(ctx, actorId, tenantId); + const template = await ctx.db.get(templateId); + if (!template || template.tenantId !== tenantId) { + throw new ConvexError("Template não encontrado"); + } + const trimmedLabel = label.trim(); + if (trimmedLabel.length < 3) { + throw new ConvexError("Informe um nome com pelo menos 3 caracteres"); + } + await ctx.db.patch(templateId, { + label: trimmedLabel, + description: description?.trim() || undefined, + isArchived: typeof isArchived === "boolean" ? isArchived : template.isArchived ?? false, + defaultEnabled: typeof defaultEnabled === "boolean" ? defaultEnabled : template.defaultEnabled ?? true, + order: typeof order === "number" ? order : template.order ?? 0, + updatedAt: Date.now(), + updatedBy: actorId, + }); + }, +}); + +export const archive = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + templateId: v.id("ticketFormTemplates"), + archived: v.boolean(), + }, + handler: async (ctx, { tenantId, actorId, templateId, archived }) => { + await requireAdmin(ctx, actorId, tenantId); + const template = await ctx.db.get(templateId); + if (!template || template.tenantId !== tenantId) { + throw new ConvexError("Template não encontrado"); + } + await ctx.db.patch(templateId, { + isArchived: archived, + updatedAt: Date.now(), + updatedBy: actorId, + }); + }, +}); diff --git a/referência/sistema-de-chamados-main/convex/ticketForms.config.ts b/referência/sistema-de-chamados-main/convex/ticketForms.config.ts new file mode 100644 index 0000000..1b4ac4a --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/ticketForms.config.ts @@ -0,0 +1,130 @@ +"use server"; + +import type { Id } from "./_generated/dataModel"; + +export type TicketFormFieldSeed = { + key: string; + label: string; + type: "text" | "number" | "date" | "select" | "boolean"; + required?: boolean; + description?: string; + options?: Array<{ value: string; label: string }>; + companyId?: Id<"companies"> | null; +}; + +export const TICKET_FORM_CONFIG = [ + { + key: "admissao" as const, + label: "Admissão de colaborador", + description: "Coleta dados completos para novos colaboradores, incluindo informações pessoais e provisionamento de acesso.", + defaultEnabled: true, + }, + { + key: "desligamento" as const, + label: "Desligamento de colaborador", + description: "Checklist de desligamento com orientações para revogar acessos e coletar equipamentos.", + defaultEnabled: true, + }, +]; + +export const OPTIONAL_ADMISSION_FIELD_KEYS = [ + "colaborador_observacoes", + "colaborador_permissoes_pasta", + "colaborador_equipamento", + "colaborador_grupos_email", + "colaborador_cpf", + "colaborador_rg", + "colaborador_patrimonio", +]; + +export const TICKET_FORM_DEFAULT_FIELDS: Record = { + admissao: [ + { key: "solicitante_nome", label: "Nome do solicitante", type: "text", required: true, description: "Quem está solicitando a admissão." }, + { key: "solicitante_telefone", label: "Telefone do solicitante", type: "text", required: true }, + { key: "solicitante_ramal", label: "Ramal", type: "text" }, + { + key: "solicitante_email", + label: "E-mail do solicitante", + type: "text", + required: true, + description: "Informe um e-mail válido para retornarmos atualizações.", + }, + { key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true }, + { key: "colaborador_email_desejado", label: "E-mail do colaborador", type: "text", required: true, description: "Endereço de e-mail que deverá ser criado." }, + { key: "colaborador_data_nascimento", label: "Data de nascimento", type: "date", required: true }, + { key: "colaborador_rg", label: "RG", type: "text", required: false }, + { key: "colaborador_cpf", label: "CPF", type: "text", required: false }, + { key: "colaborador_data_inicio", label: "Data de início", type: "date", required: true }, + { key: "colaborador_departamento", label: "Departamento", type: "text", required: true }, + { + key: "colaborador_nova_contratacao", + label: "O colaborador é uma nova contratação?", + type: "select", + required: true, + description: "Informe se é uma nova contratação ou substituição.", + options: [ + { value: "nova", label: "Sim, nova contratação" }, + { value: "substituicao", label: "Não, irá substituir alguém" }, + ], + }, + { + key: "colaborador_substituicao", + label: "Quem será substituído?", + type: "text", + description: "Preencha somente se for uma substituição.", + }, + { + key: "colaborador_grupos_email", + label: "Grupos de e-mail necessários", + type: "text", + required: false, + description: "Liste os grupos ou escreva 'Não se aplica'.", + }, + { + key: "colaborador_equipamento", + label: "Equipamento disponível", + type: "text", + required: false, + description: "Informe se já existe equipamento ou qual deverá ser disponibilizado.", + }, + { + key: "colaborador_permissoes_pasta", + label: "Permissões de pastas", + type: "text", + required: false, + description: "Indique quais pastas ou qual colaborador servirá de referência.", + }, + { + key: "colaborador_observacoes", + label: "Observações adicionais", + type: "text", + required: false, + }, + { + key: "colaborador_patrimonio", + label: "Patrimônio do computador (se houver)", + type: "text", + required: false, + }, + ], + desligamento: [ + { key: "contato_nome", label: "Contato responsável", type: "text", required: true }, + { key: "contato_email", label: "E-mail do contato", type: "text", required: true }, + { key: "contato_telefone", label: "Telefone do contato", type: "text" }, + { key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true }, + { key: "colaborador_departamento", label: "Departamento do colaborador", type: "text", required: true }, + { + key: "colaborador_email", + label: "E-mail do colaborador", + type: "text", + required: true, + description: "Informe o e-mail que deve ser desativado.", + }, + { + key: "colaborador_patrimonio", + label: "Patrimônio do computador", + type: "text", + description: "Informe o patrimônio se houver equipamento vinculado.", + }, + ], +}; diff --git a/referência/sistema-de-chamados-main/convex/ticketNotifications.ts b/referência/sistema-de-chamados-main/convex/ticketNotifications.ts new file mode 100644 index 0000000..e19a6b4 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/ticketNotifications.ts @@ -0,0 +1,159 @@ +"use node" + +import tls from "tls" +import { action } from "./_generated/server" +import { v } from "convex/values" + +function b64(input: string) { + return Buffer.from(input, "utf8").toString("base64") +} + +async function sendSmtpMail(cfg: { host: string; port: number; username: string; password: string; from: string }, to: string, subject: string, html: string) { + return new Promise((resolve, reject) => { + const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => { + let buffer = "" + const send = (line: string) => socket.write(line + "\r\n") + const wait = (expected: string | RegExp) => + new Promise((res) => { + const onData = (data: Buffer) => { + buffer += data.toString() + const lines = buffer.split(/\r?\n/) + const last = lines.filter(Boolean).slice(-1)[0] ?? "" + if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) { + socket.removeListener("data", onData) + res() + } + } + socket.on("data", onData) + socket.on("error", reject) + }) + + ;(async () => { + await wait(/^220 /) + send(`EHLO ${cfg.host}`) + await wait(/^250-/) + await wait(/^250 /) + send("AUTH LOGIN") + await wait(/^334 /) + send(b64(cfg.username)) + await wait(/^334 /) + send(b64(cfg.password)) + await wait(/^235 /) + send(`MAIL FROM:<${cfg.from.match(/<(.+)>/)?.[1] ?? cfg.from}>`) + await wait(/^250 /) + send(`RCPT TO:<${to}>`) + await wait(/^250 /) + send("DATA") + await wait(/^354 /) + const headers = [ + `From: ${cfg.from}`, + `To: ${to}`, + `Subject: ${subject}`, + "MIME-Version: 1.0", + "Content-Type: text/html; charset=UTF-8", + ].join("\r\n") + send(headers + "\r\n\r\n" + html + "\r\n.") + await wait(/^250 /) + send("QUIT") + socket.end() + resolve() + })().catch(reject) + }) + socket.on("error", reject) + }) +} + +function buildBaseUrl() { + return process.env.NEXT_PUBLIC_APP_URL || process.env.APP_BASE_URL || "http://localhost:3000" +} + +function emailTemplate({ title, message, ctaLabel, ctaUrl }: { title: string; message: string; ctaLabel: string; ctaUrl: string }) { + return ` + + + + +
+ + + + +
+
+ Raven + Raven +
+

${title}

+

${message}

+ ${ctaLabel} +

Se o botão não funcionar, copie e cole esta URL no navegador:
${ctaUrl}

+
+

© ${new Date().getFullYear()} Raven — Rever Tecnologia

+
` +} + +export const sendPublicCommentEmail = action({ + args: { + to: v.string(), + ticketId: v.string(), + reference: v.number(), + subject: v.string(), + }, + handler: async (_ctx, { to, ticketId, reference, subject }) => { + const smtp = { + host: process.env.SMTP_ADDRESS!, + port: Number(process.env.SMTP_PORT ?? 465), + username: process.env.SMTP_USERNAME!, + password: process.env.SMTP_PASSWORD!, + from: process.env.MAILER_SENDER_EMAIL || "Raven ", + } + if (!smtp.host || !smtp.username || !smtp.password) { + console.warn("SMTP not configured; skipping ticket comment email") + return { skipped: true } + } + const baseUrl = buildBaseUrl() + const url = `${baseUrl}/portal/tickets/${ticketId}` + const mailSubject = `Atualização no chamado #${reference}: ${subject}` + const html = emailTemplate({ + title: `Nova atualização no seu chamado #${reference}`, + message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`, + ctaLabel: "Abrir e responder", + ctaUrl: url, + }) + await sendSmtpMail(smtp, to, mailSubject, html) + return { ok: true } + }, +}) + +export const sendResolvedEmail = action({ + args: { + to: v.string(), + ticketId: v.string(), + reference: v.number(), + subject: v.string(), + }, + handler: async (_ctx, { to, ticketId, reference, subject }) => { + const smtp = { + host: process.env.SMTP_ADDRESS!, + port: Number(process.env.SMTP_PORT ?? 465), + username: process.env.SMTP_USERNAME!, + password: process.env.SMTP_PASSWORD!, + from: process.env.MAILER_SENDER_EMAIL || "Raven ", + } + if (!smtp.host || !smtp.username || !smtp.password) { + console.warn("SMTP not configured; skipping ticket resolution email") + return { skipped: true } + } + const baseUrl = buildBaseUrl() + const url = `${baseUrl}/portal/tickets/${ticketId}` + const mailSubject = `Seu chamado #${reference} foi encerrado` + const html = emailTemplate({ + title: `Chamado #${reference} encerrado`, + message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`, + ctaLabel: "Ver detalhes", + ctaUrl: url, + }) + await sendSmtpMail(smtp, to, mailSubject, html) + return { ok: true } + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/tickets.ts b/referência/sistema-de-chamados-main/convex/tickets.ts new file mode 100644 index 0000000..a9f1b51 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/tickets.ts @@ -0,0 +1,4212 @@ +// CI touch: enable server-side assignee filtering and trigger redeploy +import { mutation, query } from "./_generated/server"; +import { api } from "./_generated/api"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import { Id, type Doc } from "./_generated/dataModel"; + +import { requireAdmin, requireStaff, requireUser } from "./rbac"; +import { + OPTIONAL_ADMISSION_FIELD_KEYS, + TICKET_FORM_CONFIG, + TICKET_FORM_DEFAULT_FIELDS, + type TicketFormFieldSeed, +} from "./ticketForms.config"; +import { + ensureTicketFormTemplatesForTenant, + getTemplateByKey, + normalizeFormTemplateKey, +} from "./ticketFormTemplates"; + +const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]); +const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); +const PAUSE_REASON_LABELS: Record = { + NO_CONTACT: "Falta de contato", + WAITING_THIRD_PARTY: "Aguardando terceiro", + IN_PROCEDURE: "Em procedimento", +}; +const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; + +const STATUS_LABELS: Record = { + PENDING: "Pendente", + AWAITING_ATTENDANCE: "Em andamento", + PAUSED: "Pausado", + RESOLVED: "Resolvido", +}; + +const LEGACY_STATUS_MAP: Record = { + NEW: "PENDING", + PENDING: "PENDING", + OPEN: "AWAITING_ATTENDANCE", + AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", + ON_HOLD: "PAUSED", + PAUSED: "PAUSED", + RESOLVED: "RESOLVED", + CLOSED: "RESOLVED", +}; + +function normalizePriorityFilter(input: string | string[] | null | undefined): string[] { + if (!input) return []; + const list = Array.isArray(input) ? input : [input]; + const set = new Set(); + for (const entry of list) { + if (typeof entry !== "string") continue; + const normalized = entry.trim().toUpperCase(); + if (!normalized) continue; + set.add(normalized); + } + return Array.from(set); +} + +const missingRequesterLogCache = new Set(); +const missingCommentAuthorLogCache = new Set(); + +// Character limits (generous but bounded) +const MAX_SUMMARY_CHARS = 600; +const MAX_COMMENT_CHARS = 20000; +const DEFAULT_REOPEN_DAYS = 7; +const MAX_REOPEN_DAYS = 14; + +type AnyCtx = QueryCtx | MutationCtx; + +type TemplateSummary = { + key: string; + label: string; + description: string; + defaultEnabled: boolean; +}; + +type TicketFieldScopeMap = Map[]>; + +function plainTextLength(html: string): number { + try { + const text = String(html) + .replace(/<[^>]*>/g, "") // strip tags + .replace(/ /g, " ") + .trim(); + return text.length; + } catch { + return String(html ?? "").length; + } +} + +const SLA_DEFAULT_ALERT_THRESHOLD = 0.8; +const BUSINESS_DAY_START_HOUR = 8; +const BUSINESS_DAY_END_HOUR = 18; + +type SlaTimeMode = "business" | "calendar"; + +type TicketSlaSnapshot = { + categoryId?: Id<"ticketCategories">; + categoryName?: string; + priority: string; + responseTargetMinutes?: number; + responseMode: SlaTimeMode; + solutionTargetMinutes?: number; + solutionMode: SlaTimeMode; + alertThreshold: number; + pauseStatuses: TicketStatusNormalized[]; +}; + +type SlaStatusValue = "pending" | "met" | "breached" | "n/a"; + +function normalizeSlaMode(input?: string | null): SlaTimeMode { + if (!input) return "calendar"; + return input.toLowerCase() === "business" ? "business" : "calendar"; +} + +function normalizeSnapshotPauseStatuses(statuses?: string[] | null): TicketStatusNormalized[] { + if (!Array.isArray(statuses)) { + return ["PAUSED"]; + } + const set = new Set(); + for (const value of statuses) { + if (typeof value !== "string") continue; + const normalized = normalizeStatus(value); + set.add(normalized); + } + if (set.size === 0) { + set.add("PAUSED"); + } + return Array.from(set); +} + +async function resolveTicketSlaSnapshot( + ctx: AnyCtx, + tenantId: string, + category: Doc<"ticketCategories"> | null, + priority: string +): Promise { + if (!category) { + return null; + } + const normalizedPriority = priority.trim().toUpperCase(); + const rule = + (await ctx.db + .query("categorySlaSettings") + .withIndex("by_tenant_category_priority", (q) => + q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority) + ) + .first()) ?? + (await ctx.db + .query("categorySlaSettings") + .withIndex("by_tenant_category_priority", (q) => + q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT") + ) + .first()); + if (!rule) { + return null; + } + + return { + categoryId: category._id, + categoryName: category.name, + priority: normalizedPriority, + responseTargetMinutes: rule.responseTargetMinutes ?? undefined, + responseMode: normalizeSlaMode(rule.responseMode), + solutionTargetMinutes: rule.solutionTargetMinutes ?? undefined, + solutionMode: normalizeSlaMode(rule.solutionMode), + alertThreshold: + typeof rule.alertThreshold === "number" && Number.isFinite(rule.alertThreshold) + ? rule.alertThreshold + : SLA_DEFAULT_ALERT_THRESHOLD, + pauseStatuses: normalizeSnapshotPauseStatuses(rule.pauseStatuses), + }; +} + +function computeSlaDueDates(snapshot: TicketSlaSnapshot, startAt: number) { + return { + responseDueAt: addMinutesWithMode(startAt, snapshot.responseTargetMinutes, snapshot.responseMode), + solutionDueAt: addMinutesWithMode(startAt, snapshot.solutionTargetMinutes, snapshot.solutionMode), + }; +} + +function addMinutesWithMode(startAt: number, minutes: number | null | undefined, mode: SlaTimeMode): number | null { + if (minutes === null || minutes === undefined || minutes <= 0) { + return null; + } + if (mode === "calendar") { + return startAt + minutes * 60000; + } + + let remaining = minutes; + let cursor = alignToBusinessStart(new Date(startAt)); + + while (remaining > 0) { + if (!isBusinessDay(cursor)) { + cursor = advanceToNextBusinessStart(cursor); + continue; + } + const endOfDay = new Date(cursor); + endOfDay.setHours(BUSINESS_DAY_END_HOUR, 0, 0, 0); + const minutesAvailable = (endOfDay.getTime() - cursor.getTime()) / 60000; + if (minutesAvailable >= remaining) { + cursor = new Date(cursor.getTime() + remaining * 60000); + remaining = 0; + } else { + remaining -= minutesAvailable; + cursor = advanceToNextBusinessStart(endOfDay); + } + } + + return cursor.getTime(); +} + +function alignToBusinessStart(date: Date): Date { + let result = new Date(date); + if (!isBusinessDay(result)) { + return advanceToNextBusinessStart(result); + } + if (result.getHours() >= BUSINESS_DAY_END_HOUR) { + return advanceToNextBusinessStart(result); + } + if (result.getHours() < BUSINESS_DAY_START_HOUR) { + result.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0); + } + return result; +} + +function advanceToNextBusinessStart(date: Date): Date { + const next = new Date(date); + next.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0); + next.setDate(next.getDate() + 1); + while (!isBusinessDay(next)) { + next.setDate(next.getDate() + 1); + } + return next; +} + +function isBusinessDay(date: Date) { + const day = date.getDay(); + return day !== 0 && day !== 6; +} + +function applySlaSnapshot(snapshot: TicketSlaSnapshot | null, now: number) { + if (!snapshot) return {}; + const { responseDueAt, solutionDueAt } = computeSlaDueDates(snapshot, now); + return { + slaSnapshot: snapshot, + slaResponseDueAt: responseDueAt ?? undefined, + slaSolutionDueAt: solutionDueAt ?? undefined, + slaResponseStatus: responseDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue), + slaSolutionStatus: solutionDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue), + dueAt: solutionDueAt ?? undefined, + }; +} + +function buildSlaStatusPatch(ticketDoc: Doc<"tickets">, nextStatus: TicketStatusNormalized, now: number) { + const snapshot = ticketDoc.slaSnapshot as TicketSlaSnapshot | undefined; + if (!snapshot) return {}; + const pauseSet = new Set(snapshot.pauseStatuses); + const currentlyPaused = typeof ticketDoc.slaPausedAt === "number"; + + if (pauseSet.has(nextStatus)) { + if (currentlyPaused) { + return {}; + } + return { + slaPausedAt: now, + slaPausedBy: nextStatus, + }; + } + + if (currentlyPaused) { + const pauseStart = ticketDoc.slaPausedAt ?? now; + const delta = Math.max(0, now - pauseStart); + const patch: Record = { + slaPausedAt: undefined, + slaPausedBy: undefined, + slaPausedMs: (ticketDoc.slaPausedMs ?? 0) + delta, + }; + if (ticketDoc.slaResponseDueAt && ticketDoc.slaResponseStatus !== "met" && ticketDoc.slaResponseStatus !== "breached") { + patch.slaResponseDueAt = ticketDoc.slaResponseDueAt + delta; + } + if (ticketDoc.slaSolutionDueAt && ticketDoc.slaSolutionStatus !== "met" && ticketDoc.slaSolutionStatus !== "breached") { + patch.slaSolutionDueAt = ticketDoc.slaSolutionDueAt + delta; + patch.dueAt = ticketDoc.slaSolutionDueAt + delta; + } + return patch; + } + + return {}; +} + +function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record): Doc<"tickets"> { + const merged = { ...ticketDoc } as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + delete merged[key]; + } else { + merged[key] = value; + } + } + return merged as Doc<"tickets">; +} + +function buildResponseCompletionPatch(ticketDoc: Doc<"tickets">, now: number) { + if (ticketDoc.firstResponseAt) { + return {}; + } + if (!ticketDoc.slaResponseDueAt) { + return { + firstResponseAt: now, + slaResponseStatus: "n/a", + }; + } + const status = now <= ticketDoc.slaResponseDueAt ? "met" : "breached"; + return { + firstResponseAt: now, + slaResponseStatus: status, + }; +} + +function buildSolutionCompletionPatch(ticketDoc: Doc<"tickets">, now: number) { + if (ticketDoc.slaSolutionStatus === "met" || ticketDoc.slaSolutionStatus === "breached") { + return {}; + } + if (!ticketDoc.slaSolutionDueAt) { + return { slaSolutionStatus: "n/a" }; + } + const status = now <= ticketDoc.slaSolutionDueAt ? "met" : "breached"; + return { + slaSolutionStatus: status, + }; +} + +function resolveFormTemplateLabel( + templateKey: string | null | undefined, + storedLabel: string | null | undefined +): string | null { + if (storedLabel && storedLabel.trim().length > 0) { + return storedLabel.trim(); + } + const normalizedKey = templateKey?.trim(); + if (!normalizedKey) { + return null; + } + const fallback = TICKET_FORM_CONFIG.find((entry) => entry.key === normalizedKey); + return fallback ? fallback.label : null; +} + +function escapeHtml(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function resolveReopenWindowDays(input?: number | null): number { + if (typeof input !== "number" || !Number.isFinite(input)) { + return DEFAULT_REOPEN_DAYS; + } + const rounded = Math.round(input); + if (rounded < 1) return 1; + if (rounded > MAX_REOPEN_DAYS) return MAX_REOPEN_DAYS; + return rounded; +} + +function computeReopenDeadline(now: number, windowDays: number): number { + return now + windowDays * 24 * 60 * 60 * 1000; +} + +function inferExistingReopenDeadline(ticket: Doc<"tickets">): number | null { + if (typeof ticket.reopenDeadline === "number") { + return ticket.reopenDeadline; + } + if (typeof ticket.closedAt === "number") { + return ticket.closedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000; + } + if (typeof ticket.resolvedAt === "number") { + return ticket.resolvedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000; + } + return null; +} + +function isWithinReopenWindow(ticket: Doc<"tickets">, now: number): boolean { + const deadline = inferExistingReopenDeadline(ticket); + if (!deadline) { + return true; + } + return now <= deadline; +} + +function findLatestSetting(entries: T[], predicate: (entry: T) => boolean): T | null { + let latest: T | null = null; + for (const entry of entries) { + if (!predicate(entry)) continue; + if (!latest || entry.updatedAt > latest.updatedAt) { + latest = entry; + } + } + return latest; +} + +function resolveFormEnabled( + template: string, + baseEnabled: boolean, + settings: Doc<"ticketFormSettings">[], + context: { companyId?: Id<"companies"> | null; userId: Id<"users"> } +): boolean { + const scoped = settings.filter((setting) => setting.template === template) + if (scoped.length === 0) { + return baseEnabled + } + const userSetting = findLatestSetting(scoped, (setting) => { + if (setting.scope !== "user") { + return false + } + if (!setting.userId) { + return false + } + return String(setting.userId) === String(context.userId) + }) + if (userSetting) { + return userSetting.enabled ?? baseEnabled + } + const companyId = context.companyId ? String(context.companyId) : null + if (companyId) { + const companySetting = findLatestSetting(scoped, (setting) => { + if (setting.scope !== "company") { + return false + } + if (!setting.companyId) { + return false + } + return String(setting.companyId) === companyId + }) + if (companySetting) { + return companySetting.enabled ?? baseEnabled + } + } + const tenantSetting = findLatestSetting(scoped, (setting) => setting.scope === "tenant") + if (tenantSetting) { + return tenantSetting.enabled ?? baseEnabled + } + return baseEnabled +} + +async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise { + const templates = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + if (!templates.length) { + return TICKET_FORM_CONFIG.map((template) => ({ + key: template.key, + label: template.label, + description: template.description, + defaultEnabled: template.defaultEnabled, + })); + } + return templates + .filter((tpl) => tpl.isArchived !== true) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR")) + .map((tpl) => ({ + key: tpl.key, + label: tpl.label, + description: tpl.description ?? "", + defaultEnabled: tpl.defaultEnabled ?? true, + })); +} + +async function fetchTicketFieldsByScopes( + ctx: QueryCtx, + tenantId: string, + scopes: string[], + companyId: Id<"companies"> | null +): Promise { + const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope)))); + if (uniqueScopes.length === 0) { + return new Map(); + } + const scopeSet = new Set(uniqueScopes); + const companyIdStr = companyId ? String(companyId) : null; + const result: TicketFieldScopeMap = new Map(); + const allFields = await ctx.db + .query("ticketFields") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + for (const field of allFields) { + const scope = field.scope ?? ""; + if (!scopeSet.has(scope)) { + continue; + } + const fieldCompanyId = field.companyId ? String(field.companyId) : null; + if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) { + continue; + } + const current = result.get(scope); + if (current) { + current.push(field); + } else { + result.set(scope, [field]); + } + } + return result; +} + +async function fetchViewerScopedFormSettings( + ctx: QueryCtx, + tenantId: string, + templateKeys: string[], + viewerId: Id<"users">, + viewerCompanyId: Id<"companies"> | null +): Promise[]>> { + const uniqueTemplates = Array.from(new Set(templateKeys)); + if (uniqueTemplates.length === 0) { + return new Map(); + } + const keySet = new Set(uniqueTemplates); + const viewerIdStr = String(viewerId); + const viewerCompanyIdStr = viewerCompanyId ? String(viewerCompanyId) : null; + const scopedMap = new Map[]>(); + + const allSettings = await ctx.db + .query("ticketFormSettings") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + for (const setting of allSettings) { + if (!keySet.has(setting.template)) { + continue; + } + if (setting.scope === "company") { + if (!viewerCompanyIdStr || !setting.companyId || String(setting.companyId) !== viewerCompanyIdStr) { + continue; + } + } else if (setting.scope === "user") { + if (!setting.userId || String(setting.userId) !== viewerIdStr) { + continue; + } + } else if (setting.scope !== "tenant") { + continue; + } + + if (scopedMap.has(setting.template)) { + scopedMap.get(setting.template)!.push(setting); + } else { + scopedMap.set(setting.template, [setting]); + } + } + + return scopedMap; +} + +function normalizeDateOnlyValue(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return null; + if (DATE_ONLY_REGEX.test(trimmed)) { + return trimmed; + } + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed.toISOString().slice(0, 10); + } + const date = + value instanceof Date + ? value + : typeof value === "number" + ? new Date(value) + : null; + if (!date || Number.isNaN(date.getTime())) { + return null; + } + return date.toISOString().slice(0, 10); +} + +async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) { + await ensureTicketFormTemplatesForTenant(ctx, tenantId); + const now = Date.now(); + for (const template of TICKET_FORM_CONFIG) { + const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? []; + if (!defaults.length) { + continue; + } + const existing = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key)) + .collect(); + if (template.key === "admissao") { + for (const key of OPTIONAL_ADMISSION_FIELD_KEYS) { + const field = existing.find((f) => f.key === key); + if (!field) continue; + const updates: Partial> = {}; + if (field.required) { + updates.required = false; + } + if (key === "colaborador_patrimonio") { + const desiredLabel = "Patrimônio do computador (se houver)"; + if ((field.label ?? "").trim() !== desiredLabel) { + updates.label = desiredLabel; + } + } + if (Object.keys(updates).length) { + await ctx.db.patch(field._id, { + ...updates, + updatedAt: now, + }); + } + } + } + const existingKeys = new Set(existing.map((field) => field.key)); + let order = existing.reduce((max, field) => Math.max(max, field.order ?? 0), 0); + for (const field of defaults) { + if (existingKeys.has(field.key)) { + // Campo já existe: não sobrescrevemos personalizações do cliente, exceto hotfix acima + continue; + } + order += 1; + await ctx.db.insert("ticketFields", { + tenantId, + key: field.key, + label: field.label, + description: field.description ?? "", + type: field.type, + required: Boolean(field.required), + options: field.options?.map((option) => ({ + value: option.value, + label: option.label, + })), + scope: template.key, + companyId: field.companyId ?? undefined, + order, + createdAt: now, + updatedAt: now, + }); + } + } +} + +export function buildAssigneeChangeComment( + reason: string, + context: { previousName: string; nextName: string }, +): string { + const normalized = reason.replace(/\r\n/g, "\n").trim(); + const lines = normalized + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const previous = escapeHtml(context.previousName || "Não atribuído"); + const next = escapeHtml(context.nextName || "Não atribuído"); + const reasonHtml = lines.length + ? lines.map((line) => `

${escapeHtml(line)}

`).join("") + : `

`; + return `

Responsável atualizado: ${previous} → ${next}

Motivo da troca:

${reasonHtml}`; +} + +function truncateSubject(subject: string) { + if (subject.length <= 60) return subject + return `${subject.slice(0, 57)}…` +} + +const TICKET_MENTION_ANCHOR_CLASSES = + "ticket-mention inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200" +const TICKET_MENTION_REF_CLASSES = "ticket-mention-ref text-neutral-900" +const TICKET_MENTION_SEP_CLASSES = "ticket-mention-sep text-neutral-400" +const TICKET_MENTION_SUBJECT_CLASSES = "ticket-mention-subject max-w-[220px] truncate text-neutral-700" +const TICKET_MENTION_DOT_BASE_CLASSES = "ticket-mention-dot inline-flex size-2 rounded-full" +const TICKET_MENTION_STATUS_TONE: Record = { + PENDING: "bg-amber-400", + AWAITING_ATTENDANCE: "bg-sky-500", + PAUSED: "bg-violet-500", + RESOLVED: "bg-emerald-500", +} + +function buildTicketMentionAnchor(ticket: Doc<"tickets">): string { + const reference = ticket.reference + const subject = escapeHtml(ticket.subject ?? "") + const truncated = truncateSubject(subject) + const status = (ticket.status ?? "PENDING").toString().toUpperCase() + const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase() + const normalizedStatus = normalizeStatus(status) + const dotTone = TICKET_MENTION_STATUS_TONE[normalizedStatus] ?? "bg-slate-400" + const dotClass = `${TICKET_MENTION_DOT_BASE_CLASSES} ${dotTone}` + return `#${reference}${truncated}` +} + +function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) { + if (viewerRole === "ADMIN" || viewerRole === "AGENT") return true + if (viewerRole === "COLLABORATOR") { + return String(ticket.requesterId) === String(viewerId) + } + if (viewerRole === "MANAGER") { + // Gestores compartilham contexto interno; permitem apenas tickets da mesma empresa do solicitante + return String(ticket.requesterId) === String(viewerId) + } + return false +} + +async function normalizeTicketMentions( + ctx: MutationCtx, + html: string, + viewer: { user: Doc<"users">; role: string }, + tenantId: string, +): Promise { + if (!html || (html.indexOf("data-ticket-mention") === -1 && html.indexOf("ticket-mention") === -1)) { + return html + } + + const mentionPattern = /]*(?:data-ticket-mention="true"|class="[^"]*ticket-mention[^"]*")[^>]*>[\s\S]*?<\/a>/gi + const matches = Array.from(html.matchAll(mentionPattern)) + if (!matches.length) { + return html + } + + let output = html + + const attributePattern = /(data-[\w-]+|class|href)="([^"]*)"/gi + + for (const match of matches) { + const full = match[0] + attributePattern.lastIndex = 0 + const attributes: Record = {} + let attrMatch: RegExpExecArray | null + while ((attrMatch = attributePattern.exec(full)) !== null) { + attributes[attrMatch[1]] = attrMatch[2] + } + + let ticketIdRaw: string | null = attributes["data-ticket-id"] ?? null + if (!ticketIdRaw && attributes.href) { + const hrefPath = attributes.href.split("?")[0] + const segments = hrefPath.split("/").filter(Boolean) + ticketIdRaw = segments.pop() ?? null + } + let replacement = "" + + if (ticketIdRaw) { + const ticket = await ctx.db.get(ticketIdRaw as Id<"tickets">) + if (ticket && ticket.tenantId === tenantId && canMentionTicket(viewer.role, viewer.user._id, ticket)) { + replacement = buildTicketMentionAnchor(ticket) + } else { + const inner = match[0].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() + replacement = escapeHtml(inner || `#${ticketIdRaw}`) + } + } else { + replacement = escapeHtml(full.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()) + } + + output = output.replace(full, replacement) + } + + return output +} + +export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { + if (!status) return "PENDING"; + const normalized = LEGACY_STATUS_MAP[status.toUpperCase()]; + return normalized ?? "PENDING"; +} + +function formatWorkDuration(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) { + return "0m"; + } + const totalMinutes = Math.round(ms / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.abs(totalMinutes % 60); + const parts: string[] = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (parts.length === 0) { + return "0m"; + } + return parts.join(" "); +} + +function formatWorkDelta(deltaMs: number): string { + if (deltaMs === 0) return "0m"; + const sign = deltaMs > 0 ? "+" : "-"; + const absolute = formatWorkDuration(Math.abs(deltaMs)); + return `${sign}${absolute}`; +} + +type AgentWorkTotals = { + agentId: Id<"users">; + agentName: string | null; + agentEmail: string | null; + avatarUrl: string | null; + totalWorkedMs: number; + internalWorkedMs: number; + externalWorkedMs: number; +}; + +async function computeAgentWorkTotals( + ctx: MutationCtx | QueryCtx, + ticketId: Id<"tickets">, + referenceNow: number, +): Promise { + const sessions = await ctx.db + .query("ticketWorkSessions") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect(); + + if (!sessions.length) { + return []; + } + + const totals = new Map< + string, + { totalWorkedMs: number; internalWorkedMs: number; externalWorkedMs: number } + >(); + + for (const session of sessions) { + const baseDuration = typeof session.durationMs === "number" + ? session.durationMs + : typeof session.stoppedAt === "number" + ? session.stoppedAt - session.startedAt + : referenceNow - session.startedAt; + const durationMs = Math.max(0, baseDuration); + if (durationMs <= 0) continue; + const key = session.agentId as string; + const bucket = totals.get(key) ?? { + totalWorkedMs: 0, + internalWorkedMs: 0, + externalWorkedMs: 0, + }; + bucket.totalWorkedMs += durationMs; + const workType = (session.workType ?? "INTERNAL").toUpperCase(); + if (workType === "EXTERNAL") { + bucket.externalWorkedMs += durationMs; + } else { + bucket.internalWorkedMs += durationMs; + } + totals.set(key, bucket); + } + + if (totals.size === 0) { + return []; + } + + const agentIds = Array.from(totals.keys()); + const agents = await Promise.all(agentIds.map((agentId) => ctx.db.get(agentId as Id<"users">))); + + return agentIds + .map((agentId, index) => { + const bucket = totals.get(agentId)!; + const agentDoc = agents[index] as Doc<"users"> | null; + return { + agentId: agentId as Id<"users">, + agentName: agentDoc?.name ?? null, + agentEmail: agentDoc?.email ?? null, + avatarUrl: agentDoc?.avatarUrl ?? null, + totalWorkedMs: bucket.totalWorkedMs, + internalWorkedMs: bucket.internalWorkedMs, + externalWorkedMs: bucket.externalWorkedMs, + }; + }) + .sort((a, b) => b.totalWorkedMs - a.totalWorkedMs); +} + +async function ensureManagerTicketAccess( + ctx: MutationCtx | QueryCtx, + manager: Doc<"users">, + ticket: Doc<"tickets">, +): Promise | null> { + if (!manager.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada") + } + if (ticket.companyId && ticket.companyId === manager.companyId) { + return null + } + const requester = await ctx.db.get(ticket.requesterId) + if (!requester || requester.companyId !== manager.companyId) { + throw new ConvexError("Acesso restrito à empresa") + } + return requester as Doc<"users"> +} + +async function requireTicketStaff( + ctx: MutationCtx | QueryCtx, + actorId: Id<"users">, + ticket: Doc<"tickets"> +) { + const viewer = await requireStaff(ctx, actorId, ticket.tenantId) + if (viewer.role === "MANAGER") { + await ensureManagerTicketAccess(ctx, viewer.user, ticket) + } + return viewer +} + +type TicketChatParticipant = { + user: Doc<"users">; + role: string | null; + kind: "staff" | "manager" | "requester"; +}; + +async function requireTicketChatParticipant( + ctx: MutationCtx | QueryCtx, + actorId: Id<"users">, + ticket: Doc<"tickets"> +): Promise { + const viewer = await requireUser(ctx, actorId, ticket.tenantId); + const normalizedRole = viewer.role ?? ""; + if (normalizedRole === "ADMIN" || normalizedRole === "AGENT") { + return { user: viewer.user, role: normalizedRole, kind: "staff" }; + } + if (normalizedRole === "MANAGER") { + await ensureManagerTicketAccess(ctx, viewer.user, ticket); + return { user: viewer.user, role: normalizedRole, kind: "manager" }; + } + if (normalizedRole === "COLLABORATOR") { + if (String(ticket.requesterId) !== String(viewer.user._id)) { + throw new ConvexError("Apenas o solicitante pode conversar neste chamado"); + } + return { user: viewer.user, role: normalizedRole, kind: "requester" }; + } + throw new ConvexError("Usuário não possui acesso ao chat deste chamado"); +} + +const QUEUE_RENAME_LOOKUP: Record = { + "Suporte N1": "Chamados", + "suporte-n1": "Chamados", + chamados: "Chamados", + "Suporte N2": "Laboratório", + "suporte-n2": "Laboratório", + laboratorio: "Laboratório", + Laboratorio: "Laboratório", + visitas: "Visitas", +}; + +function renameQueueString(value?: string | null): string | null { + if (!value) return value ?? null; + const direct = QUEUE_RENAME_LOOKUP[value]; + if (direct) return direct; + const normalizedKey = value.replace(/\s+/g, "-").toLowerCase(); + return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value; +} + +function normalizeQueueName(queue?: Doc<"queues"> | null): string | null { + if (!queue) return null; + const normalized = renameQueueString(queue.name); + if (normalized) { + return normalized; + } + if (queue.slug) { + const fromSlug = renameQueueString(queue.slug); + if (fromSlug) return fromSlug; + } + return queue.name; +} + +function normalizeTeams(teams?: string[] | null): string[] { + if (!teams) return []; + return teams.map((team) => renameQueueString(team) ?? team); +} + +type RequesterFallbackContext = { + ticketId?: Id<"tickets">; + fallbackName?: string | null; + fallbackEmail?: string | null; +}; + +function buildRequesterSummary( + requester: Doc<"users"> | null, + requesterId: Id<"users">, + context?: RequesterFallbackContext, +) { + if (requester) { + return { + id: requester._id, + name: requester.name, + email: requester.email, + avatarUrl: requester.avatarUrl, + teams: normalizeTeams(requester.teams), + }; + } + + const idString = String(requesterId); + const fallbackName = + typeof context?.fallbackName === "string" && context.fallbackName.trim().length > 0 + ? context.fallbackName.trim() + : "Solicitante não encontrado"; + const fallbackEmailCandidate = + typeof context?.fallbackEmail === "string" && context.fallbackEmail.includes("@") + ? context.fallbackEmail + : null; + const fallbackEmail = fallbackEmailCandidate ?? `requester-${idString}@example.invalid`; + + if (process.env.NODE_ENV !== "test") { + const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : ""; + const cacheKey = `${idString}:${context?.ticketId ? String(context.ticketId) : "unknown"}`; + if (!missingRequesterLogCache.has(cacheKey)) { + missingRequesterLogCache.add(cacheKey); + console.warn( + `[tickets] requester ${idString} ausente ao hidratar resposta${ticketInfo}; usando placeholders.`, + ); + } + } + + return { + id: requesterId, + name: fallbackName, + email: fallbackEmail, + teams: [], + }; +} + +type UserSnapshot = { name: string; email?: string; avatarUrl?: string; teams?: string[] }; +type CompanySnapshot = { name: string; slug?: string; isAvulso?: boolean }; + +function buildRequesterFromSnapshot( + requesterId: Id<"users">, + snapshot: UserSnapshot | null | undefined, + fallback?: RequesterFallbackContext +) { + if (snapshot) { + const name = typeof snapshot.name === "string" && snapshot.name.trim().length > 0 ? snapshot.name.trim() : (fallback?.fallbackName ?? "Solicitante não encontrado") + const emailCandidate = typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null + const email = emailCandidate ?? (fallback?.fallbackEmail ?? `requester-${String(requesterId)}@example.invalid`) + return { + id: requesterId, + name, + email, + avatarUrl: snapshot.avatarUrl ?? undefined, + teams: normalizeTeams(snapshot.teams ?? []), + } + } + return buildRequesterSummary(null, requesterId, fallback) +} + +function buildAssigneeFromSnapshot( + assigneeId: Id<"users">, + snapshot: UserSnapshot | null | undefined +) { + const name = snapshot?.name?.trim?.() || "Usuário removido" + const emailCandidate = typeof snapshot?.email === "string" && snapshot.email.includes("@") ? snapshot.email : null + const email = emailCandidate ?? `user-${String(assigneeId)}@example.invalid` + return { + id: assigneeId, + name, + email, + avatarUrl: snapshot?.avatarUrl ?? undefined, + teams: normalizeTeams(snapshot?.teams ?? []), + } +} + +function buildCompanyFromSnapshot( + companyId: Id<"companies"> | undefined, + snapshot: CompanySnapshot | null | undefined +) { + if (!snapshot) return null + return { + id: (companyId ? companyId : ("snapshot" as unknown as Id<"companies">)) as Id<"companies">, + name: snapshot.name, + isAvulso: Boolean(snapshot.isAvulso ?? false), + } +} + +type CommentAuthorFallbackContext = { + ticketId?: Id<"tickets">; + commentId?: Id<"ticketComments">; +}; + +type CommentAuthorSnapshot = { + name: string; + email?: string; + avatarUrl?: string; + teams?: string[]; +}; + +export function buildCommentAuthorSummary( + comment: Doc<"ticketComments">, + author: Doc<"users"> | null, + context?: CommentAuthorFallbackContext, +) { + if (author) { + return { + id: author._id, + name: author.name, + email: author.email, + avatarUrl: author.avatarUrl, + teams: normalizeTeams(author.teams), + }; + } + + if (process.env.NODE_ENV !== "test") { + const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : ""; + const commentInfo = context?.commentId ? ` (comentário ${String(context.commentId)})` : ""; + const cacheKeyParts = [String(comment.authorId), context?.ticketId ? String(context.ticketId) : "unknown"]; + if (context?.commentId) cacheKeyParts.push(String(context.commentId)); + const cacheKey = cacheKeyParts.join(":"); + if (!missingCommentAuthorLogCache.has(cacheKey)) { + missingCommentAuthorLogCache.add(cacheKey); + console.warn( + `[tickets] autor ${String(comment.authorId)} ausente ao hidratar comentário${ticketInfo}${commentInfo}; usando placeholders.`, + ); + } + } + + const idString = String(comment.authorId); + const fallbackName = "Usuário removido"; + const fallbackEmail = `author-${idString}@example.invalid`; + const snapshot = comment.authorSnapshot as CommentAuthorSnapshot | undefined; + if (snapshot) { + const name = + typeof snapshot.name === "string" && snapshot.name.trim().length > 0 + ? snapshot.name.trim() + : fallbackName; + const emailCandidate = + typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null; + const email = emailCandidate ?? fallbackEmail; + return { + id: comment.authorId, + name, + email, + avatarUrl: snapshot.avatarUrl ?? undefined, + teams: normalizeTeams(snapshot.teams ?? []), + }; + } + + return { + id: comment.authorId, + name: fallbackName, + email: fallbackEmail, + teams: [], + }; +} + +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": { + const normalized = normalizeDateOnlyValue(raw); + if (!normalized) { + throw new ConvexError(`Data inválida para o campo ${field.label}`); + } + return { value: normalized }; + } + 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, + tenantId: string, + inputs: CustomFieldInput[] | undefined, + scope?: string | null +): Promise { + const normalizedScope = scope?.trim() ? scope.trim().toLowerCase() : null; + const definitions = await ctx.db + .query("ticketFields") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const scopedDefinitions = definitions.filter((definition) => { + const fieldScope = (definition.scope ?? "all").toLowerCase(); + if (fieldScope === "all" || fieldScope.length === 0) { + return true; + } + if (!normalizedScope) { + return false; + } + return fieldScope === normalizedScope; + }); + + if (!scopedDefinitions.length) { + if (inputs && inputs.length > 0) { + throw new ConvexError("Campos personalizados não configurados para este formulário"); + } + return []; + } + + const provided = new Map, unknown>(); + for (const entry of inputs ?? []) { + provided.set(entry.fieldId, entry.value); + } + + const normalized: NormalizedCustomField[] = []; + + for (const definition of scopedDefinitions.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>((acc, entry) => { + let value: unknown = entry.value; + if (entry.type === "date") { + value = normalizeDateOnlyValue(entry.value) ?? entry.value; + } + acc[entry.fieldKey] = { + label: entry.label, + type: entry.type, + value, + displayValue: entry.displayValue, + }; + return acc; + }, {}); +} + +type CustomFieldRecordEntry = { label: string; type: string; value: unknown; displayValue?: string } | undefined; + +function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean { + return serializeCustomFieldEntry(a) === serializeCustomFieldEntry(b); +} + +function serializeCustomFieldEntry(entry: CustomFieldRecordEntry): string { + if (!entry) return "__undefined__"; + return JSON.stringify({ + value: normalizeEntryValue(entry.value), + displayValue: entry.displayValue ?? null, + }); +} + +function normalizeEntryValue(value: unknown): unknown { + if (value === undefined || value === null) return null; + if (value instanceof Date) return value.toISOString(); + if (typeof value === "number" && Number.isNaN(value)) return "__nan__"; + if (Array.isArray(value)) { + return value.map((item) => normalizeEntryValue(item)); + } + if (typeof value === "object") { + const record = value as Record; + const normalized: Record = {}; + Object.keys(record) + .sort() + .forEach((key) => { + normalized[key] = normalizeEntryValue(record[key]); + }); + return normalized; + } + return value; +} + +function getCustomFieldRecordEntry( + record: Record, + key: string +): CustomFieldRecordEntry { + return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined; +} + +const DEFAULT_TICKETS_LIST_LIMIT = 250; +const MIN_TICKETS_LIST_LIMIT = 25; +const MAX_TICKETS_LIST_LIMIT = 600; +const MAX_FETCH_LIMIT = 1000; +const FETCH_MULTIPLIER_NO_SEARCH = 3; +const FETCH_MULTIPLIER_WITH_SEARCH = 5; + +function clampTicketLimit(limit: number) { + if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT; + return Math.max(MIN_TICKETS_LIST_LIMIT, Math.min(MAX_TICKETS_LIST_LIMIT, Math.floor(limit))); +} + +function computeFetchLimit(limit: number, hasSearch: boolean) { + const multiplier = hasSearch ? FETCH_MULTIPLIER_WITH_SEARCH : FETCH_MULTIPLIER_NO_SEARCH; + const target = limit * multiplier; + return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target)); +} + +function dedupeTicketsById(tickets: Doc<"tickets">[]) { + const seen = new Set(); + const result: Doc<"tickets">[] = []; + for (const ticket of tickets) { + const key = String(ticket._id); + if (seen.has(key)) continue; + seen.add(key); + result.push(ticket); + } + return result; +} + +export const list = query({ + args: { + viewerId: v.optional(v.id("users")), + tenantId: v.string(), + status: v.optional(v.string()), + priority: v.optional(v.union(v.string(), v.array(v.string()))), + channel: v.optional(v.string()), + queueId: v.optional(v.id("queues")), + assigneeId: v.optional(v.id("users")), + requesterId: v.optional(v.id("users")), + search: v.optional(v.string()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + if (!args.viewerId) { + return []; + } + const viewerId = args.viewerId as Id<"users">; + const { user, role } = await requireUser(ctx, viewerId, args.tenantId); + if (role === "MANAGER" && !user.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada"); + } + + const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; + const normalizedPriorityFilter = normalizePriorityFilter(args.priority); + const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null; + const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null; + const searchTerm = args.search?.trim().toLowerCase() ?? null; + + const requestedLimitRaw = typeof args.limit === "number" ? args.limit : DEFAULT_TICKETS_LIST_LIMIT; + const requestedLimit = clampTicketLimit(requestedLimitRaw); + const fetchLimit = computeFetchLimit(requestedLimit, Boolean(searchTerm)); + + let base: Doc<"tickets">[] = []; + + if (role === "MANAGER") { + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)); + base = await baseQuery.order("desc").take(fetchLimit); + } else if (args.assigneeId) { + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!)); + base = await baseQuery.order("desc").take(fetchLimit); + } else if (args.requesterId) { + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!)); + base = await baseQuery.order("desc").take(fetchLimit); + } else if (args.queueId) { + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!)); + base = await baseQuery.order("desc").take(fetchLimit); + } else if (normalizedStatusFilter) { + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter)); + base = await baseQuery.order("desc").take(fetchLimit); + } else if (role === "COLLABORATOR") { + const viewerEmail = user.email.trim().toLowerCase(); + const directQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId)); + const directTickets = await directQuery.order("desc").take(fetchLimit); + + let combined = directTickets; + if (directTickets.length < fetchLimit) { + const fallbackQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)); + const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit); + const fallbackMatches = fallbackRaw.filter((ticket) => { + const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email; + if (typeof snapshotEmail !== "string") return false; + return snapshotEmail.trim().toLowerCase() === viewerEmail; + }); + combined = dedupeTicketsById([...directTickets, ...fallbackMatches]); + } + base = combined.slice(0, fetchLimit); + } else { + const baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)); + base = await baseQuery.order("desc").take(fetchLimit); + } + + let filtered = base; + + if (role === "MANAGER") { + filtered = filtered.filter((t) => t.companyId === user.companyId); + } + if (prioritySet) filtered = filtered.filter((t) => prioritySet.has(t.priority)); + if (normalizedChannelFilter) filtered = filtered.filter((t) => t.channel === normalizedChannelFilter); + if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId)); + if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId)); + if (normalizedStatusFilter) { + filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter); + } + if (searchTerm) { + filtered = filtered.filter( + (t) => + t.subject.toLowerCase().includes(searchTerm) || + t.summary?.toLowerCase().includes(searchTerm) || + `#${t.reference}`.toLowerCase().includes(searchTerm) + ); + } + + const limited = filtered.slice(0, requestedLimit); + const categoryCache = new Map | null>(); + const subcategoryCache = new Map | null>(); + const machineCache = new Map | null>(); + // hydrate requester and assignee + const result = await Promise.all( + limited.map(async (t) => { + const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null; + const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; + const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; + const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null; + const queueName = normalizeQueueName(queue); + const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; + let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null; + let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null; + if (t.categoryId) { + if (!categoryCache.has(t.categoryId)) { + categoryCache.set(t.categoryId, await ctx.db.get(t.categoryId)); + } + const category = categoryCache.get(t.categoryId); + if (category) { + categorySummary = { id: category._id, name: category.name }; + } + } + if (t.subcategoryId) { + if (!subcategoryCache.has(t.subcategoryId)) { + subcategoryCache.set(t.subcategoryId, await ctx.db.get(t.subcategoryId)); + } + const subcategory = subcategoryCache.get(t.subcategoryId); + if (subcategory) { + subcategorySummary = { id: subcategory._id, name: subcategory.name }; + } + } + const machineSnapshot = t.machineSnapshot as + | { + hostname?: string + persona?: string + assignedUserName?: string + assignedUserEmail?: string + status?: string + } + | undefined; + let machineSummary: + | { + id: Id<"machines"> | null + hostname: string | null + persona: string | null + assignedUserName: string | null + assignedUserEmail: string | null + status: string | null + } + | null = null; + if (t.machineId) { + const cacheKey = String(t.machineId); + if (!machineCache.has(cacheKey)) { + machineCache.set(cacheKey, (await ctx.db.get(t.machineId)) as Doc<"machines"> | null); + } + const machineDoc = machineCache.get(cacheKey); + machineSummary = { + id: t.machineId, + hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, + persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, + assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, + assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, + status: machineDoc?.status ?? machineSnapshot?.status ?? null, + }; + } else if (machineSnapshot) { + machineSummary = { + id: null, + hostname: machineSnapshot.hostname ?? null, + persona: machineSnapshot.persona ?? null, + assignedUserName: machineSnapshot.assignedUserName ?? null, + assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, + status: machineSnapshot.status ?? null, + }; + } + const serverNow = Date.now() + return { + id: t._id, + reference: t.reference, + tenantId: t.tenantId, + subject: t.subject, + summary: t.summary, + status: normalizeStatus(t.status), + priority: t.priority, + channel: t.channel, + queue: queueName, + csatScore: typeof t.csatScore === "number" ? t.csatScore : null, + csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null, + csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null, + csatRatedAt: t.csatRatedAt ?? null, + csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, + formTemplate: t.formTemplate ?? null, + formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null), + company: company + ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } + : t.companyId || t.companySnapshot + ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined) + : null, + requester: requester + ? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id }) + : buildRequesterFromSnapshot( + t.requesterId, + t.requesterSnapshot ?? undefined, + { ticketId: t._id } + ), + assignee: t.assigneeId + ? assignee + ? { + id: assignee._id, + name: assignee.name, + email: assignee.email, + avatarUrl: assignee.avatarUrl, + teams: normalizeTeams(assignee.teams), + } + : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined) + : null, + slaPolicy: null, + dueAt: t.dueAt ?? null, + firstResponseAt: t.firstResponseAt ?? null, + resolvedAt: t.resolvedAt ?? null, + updatedAt: t.updatedAt, + createdAt: t.createdAt, + tags: t.tags ?? [], + lastTimelineEntry: null, + metrics: null, + category: categorySummary, + subcategory: subcategorySummary, + machine: machineSummary, + workSummary: { + totalWorkedMs: t.totalWorkedMs ?? 0, + internalWorkedMs: t.internalWorkedMs ?? 0, + externalWorkedMs: t.externalWorkedMs ?? 0, + serverNow, + activeSession: activeSession + ? { + id: activeSession._id, + agentId: activeSession.agentId, + startedAt: activeSession.startedAt, + workType: activeSession.workType ?? "INTERNAL", + } + : null, + }, + }; + }) + ); + // sort by updatedAt desc + return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + }, +}); + +export const getById = query({ + args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, id, viewerId }) => { + const { user, role } = await requireUser(ctx, viewerId, tenantId) + const t = await ctx.db.get(id); + if (!t || t.tenantId !== tenantId) return null; + if (role === "COLLABORATOR") { + const isOwnerById = String(t.requesterId) === String(viewerId) + const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null + const viewerEmail = user.email.trim().toLowerCase() + const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail) + if (!isOwnerById && !isOwnerByEmail) { + return null + } + } + // no customer role; managers are constrained to company via ensureManagerTicketAccess + let requester: Doc<"users"> | null = null + if (role === "MANAGER") { + requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null + } + if (!requester) { + requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null + } + const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; + const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; + const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null; + const machineSnapshot = t.machineSnapshot as + | { + hostname?: string + persona?: string + assignedUserName?: string + assignedUserEmail?: string + status?: string + } + | undefined; + let machineSummary: + | { + id: Id<"machines"> | null + hostname: string | null + persona: string | null + assignedUserName: string | null + assignedUserEmail: string | null + status: string | null + } + | null = null; + if (t.machineId) { + const machineDoc = (await ctx.db.get(t.machineId)) as Doc<"machines"> | null; + machineSummary = { + id: t.machineId, + hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, + persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, + assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, + assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, + status: machineDoc?.status ?? machineSnapshot?.status ?? null, + }; + } else if (machineSnapshot) { + machineSummary = { + id: null, + hostname: machineSnapshot.hostname ?? null, + persona: machineSnapshot.persona ?? null, + assignedUserName: machineSnapshot.assignedUserName ?? null, + assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, + status: machineSnapshot.status ?? null, + }; + } + const queueName = normalizeQueueName(queue); + const category = t.categoryId ? await ctx.db.get(t.categoryId) : null; + const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null; + const comments = await ctx.db + .query("ticketComments") + .withIndex("by_ticket", (q) => q.eq("ticketId", id)) + .collect(); + const canViewInternalComments = role === "ADMIN" || role === "AGENT"; + const visibleComments = canViewInternalComments + ? comments + : comments.filter((comment) => comment.visibility !== "INTERNAL"); + const visibleCommentKeys = new Set( + visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`) + ) + const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt)) + const serverNow = Date.now() + + let timelineRecords = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", id)) + .collect(); + + if (!(role === "ADMIN" || role === "AGENT")) { + timelineRecords = timelineRecords.filter((event) => { + const payload = (event.payload ?? {}) as Record + switch (event.type) { + case "CREATED": + return true + case "QUEUE_CHANGED": + return true + case "ASSIGNEE_CHANGED": + return true + case "CATEGORY_CHANGED": + return true + case "COMMENT_ADDED": { + const authorIdRaw = (payload as { authorId?: string }).authorId + if (typeof authorIdRaw === "string" && authorIdRaw.trim().length > 0) { + const key = `${event.createdAt}:${authorIdRaw}` + if (visibleCommentKeys.has(key)) { + return true + } + } + return visibleCommentTimestamps.has(event.createdAt) + } + case "STATUS_CHANGED": { + const toLabelRaw = (payload as { toLabel?: string }).toLabel + const toRaw = (payload as { to?: string }).to + const normalized = (typeof toLabelRaw === "string" && toLabelRaw.trim().length > 0 + ? toLabelRaw.trim() + : typeof toRaw === "string" + ? toRaw.trim() + : "").toUpperCase() + if (!normalized) return false + return ( + normalized === "RESOLVED" || + normalized === "RESOLVIDO" || + normalized === "CLOSED" || + normalized === "FINALIZADO" || + normalized === "FINALIZED" + ) + } + default: + return false + } + }) + } + + const customFieldsRecord = mapCustomFieldsToRecord( + (t.customFields as NormalizedCustomField[] | undefined) ?? undefined + ); + + const commentsHydrated = await Promise.all( + visibleComments.map(async (c) => { + const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null; + const attachments = await Promise.all( + (c.attachments ?? []).map(async (att) => ({ + id: att.storageId, + name: att.name, + size: att.size, + type: att.type, + url: await ctx.storage.getUrl(att.storageId), + })) + ); + const authorSummary = buildCommentAuthorSummary(c, author, { + ticketId: t._id, + commentId: c._id, + }); + return { + id: c._id, + author: authorSummary, + visibility: c.visibility, + body: c.body, + attachments, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + }; + }) + ); + + const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; + const perAgentTotals = await computeAgentWorkTotals(ctx, id, serverNow); + + return { + id: t._id, + reference: t.reference, + tenantId: t.tenantId, + subject: t.subject, + summary: t.summary, + status: normalizeStatus(t.status), + priority: t.priority, + channel: t.channel, + queue: queueName, + company: company + ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } + : t.companyId || t.companySnapshot + ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined) + : null, + requester: requester + ? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id }) + : buildRequesterFromSnapshot( + t.requesterId, + t.requesterSnapshot ?? undefined, + { ticketId: t._id } + ), + assignee: t.assigneeId + ? assignee + ? { + id: assignee._id, + name: assignee.name, + email: assignee.email, + avatarUrl: assignee.avatarUrl, + teams: normalizeTeams(assignee.teams), + } + : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined) + : null, + slaPolicy: null, + dueAt: t.dueAt ?? null, + firstResponseAt: t.firstResponseAt ?? null, + resolvedAt: t.resolvedAt ?? null, + updatedAt: t.updatedAt, + createdAt: t.createdAt, + tags: t.tags ?? [], + lastTimelineEntry: null, + metrics: null, + csatScore: typeof t.csatScore === "number" ? t.csatScore : null, + csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null, + csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null, + csatRatedAt: t.csatRatedAt ?? null, + csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, + machine: machineSummary, + category: category + ? { + id: category._id, + name: category.name, + } + : null, + subcategory: subcategory + ? { + id: subcategory._id, + name: subcategory.name, + categoryId: subcategory.categoryId, + } + : null, + workSummary: { + totalWorkedMs: t.totalWorkedMs ?? 0, + internalWorkedMs: t.internalWorkedMs ?? 0, + externalWorkedMs: t.externalWorkedMs ?? 0, + serverNow, + activeSession: activeSession + ? { + id: activeSession._id, + agentId: activeSession.agentId, + startedAt: activeSession.startedAt, + workType: activeSession.workType ?? "INTERNAL", + } + : null, + perAgentTotals: perAgentTotals.map((item) => ({ + agentId: item.agentId, + agentName: item.agentName, + agentEmail: item.agentEmail, + avatarUrl: item.avatarUrl, + totalWorkedMs: item.totalWorkedMs, + internalWorkedMs: item.internalWorkedMs, + externalWorkedMs: item.externalWorkedMs, + })), + }, + formTemplate: t.formTemplate ?? null, + formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null), + chatEnabled: Boolean(t.chatEnabled), + relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [], + resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null, + reopenDeadline: t.reopenDeadline ?? null, + reopenedAt: t.reopenedAt ?? null, + description: undefined, + customFields: customFieldsRecord, + timeline: timelineRecords.map((ev) => { + let payload = ev.payload; + if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) { + const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null); + if (normalized && normalized !== (payload as { queueName?: string }).queueName) { + payload = { ...payload, queueName: normalized }; + } + } + return { + id: ev._id, + type: ev.type, + payload, + createdAt: ev.createdAt, + }; + }), + comments: commentsHydrated, + }; + }, +}); + +export const create = mutation({ + args: { + actorId: v.id("users"), + tenantId: v.string(), + subject: v.string(), + summary: v.optional(v.string()), + priority: v.string(), + channel: v.string(), + queueId: v.optional(v.id("queues")), + requesterId: v.id("users"), + assigneeId: v.optional(v.id("users")), + categoryId: v.id("ticketCategories"), + subcategoryId: v.id("ticketSubcategories"), + machineId: v.optional(v.id("machines")), + customFields: v.optional( + v.array( + v.object({ + fieldId: v.id("ticketFields"), + value: v.any(), + }) + ) + ), + formTemplate: v.optional(v.string()), + chatEnabled: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId) + // no customer role; managers validated below + + if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) { + throw new ConvexError("Somente a equipe interna pode definir o responsável") + } + + let initialAssigneeId: Id<"users"> | undefined + let initialAssignee: Doc<"users"> | null = null + + if (args.assigneeId) { + const assignee = (await ctx.db.get(args.assigneeId)) as Doc<"users"> | null + if (!assignee || assignee.tenantId !== args.tenantId) { + throw new ConvexError("Responsável inválido") + } + const normalizedAssigneeRole = (assignee.role ?? "AGENT").toUpperCase() + if (!STAFF_ROLES.has(normalizedAssigneeRole)) { + throw new ConvexError("Responsável inválido") + } + initialAssigneeId = assignee._id + initialAssignee = assignee + } else if (role && INTERNAL_STAFF_ROLES.has(role)) { + initialAssigneeId = actorUser._id + initialAssignee = actorUser + } + + const subject = args.subject.trim(); + if (subject.length < 3) { + throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); + } + if (args.summary && args.summary.trim().length > MAX_SUMMARY_CHARS) { + throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`); + } + const category = await ctx.db.get(args.categoryId); + if (!category || category.tenantId !== args.tenantId) { + throw new ConvexError("Categoria inválida"); + } + const subcategory = await ctx.db.get(args.subcategoryId); + if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) { + throw new ConvexError("Subcategoria inválida"); + } + + const requester = (await ctx.db.get(args.requesterId)) as Doc<"users"> | null + if (!requester || requester.tenantId !== args.tenantId) { + throw new ConvexError("Solicitante inválido") + } + if (role === "MANAGER") { + if (!actorUser.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada") + } + if (requester.companyId !== actorUser.companyId) { + throw new ConvexError("Gestores só podem abrir chamados para sua própria empresa") + } + } + + let machineDoc: Doc<"machines"> | null = null + if (args.machineId) { + const machine = (await ctx.db.get(args.machineId)) as Doc<"machines"> | null + if (!machine || machine.tenantId !== args.tenantId) { + throw new ConvexError("Dispositivo inválida para este chamado") + } + machineDoc = machine + } + + let formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null); + let formTemplateLabel: string | null = null; + if (formTemplateKey) { + const templateDoc = await getTemplateByKey(ctx, args.tenantId, formTemplateKey); + if (templateDoc && templateDoc.isArchived !== true) { + formTemplateLabel = templateDoc.label; + } else { + const fallbackTemplate = TICKET_FORM_CONFIG.find((tpl) => tpl.key === formTemplateKey); + if (fallbackTemplate) { + formTemplateLabel = fallbackTemplate.label; + } else { + formTemplateKey = null; + } + } + } + const chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true; + const normalizedCustomFields = await normalizeCustomFieldValues( + ctx, + args.tenantId, + args.customFields ?? undefined, + formTemplateKey, + ); + // compute next reference (simple monotonic counter per tenant) + const existing = await ctx.db + .query("tickets") + .withIndex("by_tenant_reference", (q) => q.eq("tenantId", args.tenantId)) + .order("desc") + .take(1); + const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000; + const now = Date.now(); + const initialStatus: TicketStatusNormalized = "PENDING"; + const requesterSnapshot = { + name: requester.name, + email: requester.email, + avatarUrl: requester.avatarUrl ?? undefined, + teams: requester.teams ?? undefined, + } + const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority) + let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null + if (!companyDoc && machineDoc?.companyId) { + const candidateCompany = await ctx.db.get(machineDoc.companyId) + if (candidateCompany && candidateCompany.tenantId === args.tenantId) { + companyDoc = candidateCompany as Doc<"companies"> + } + } + const companySnapshot = companyDoc + ? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined } + : undefined + + const assigneeSnapshot = initialAssignee + ? { + name: initialAssignee.name, + email: initialAssignee.email, + avatarUrl: initialAssignee.avatarUrl ?? undefined, + teams: initialAssignee.teams ?? undefined, + } + : undefined + + // default queue: if none provided, prefer "Chamados" + let resolvedQueueId = args.queueId as Id<"queues"> | undefined + if (!resolvedQueueId) { + const queues = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) + .collect() + const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null + if (preferred) { + resolvedQueueId = preferred._id as Id<"queues"> + } + } + + const slaFields = applySlaSnapshot(slaSnapshot, now) + const id = await ctx.db.insert("tickets", { + tenantId: args.tenantId, + reference: nextRef, + subject, + summary: args.summary?.trim() || undefined, + status: initialStatus, + priority: args.priority, + channel: args.channel, + queueId: resolvedQueueId, + categoryId: args.categoryId, + subcategoryId: args.subcategoryId, + requesterId: args.requesterId, + requesterSnapshot, + assigneeId: initialAssigneeId, + assigneeSnapshot, + companyId: companyDoc?._id ?? requester.companyId ?? undefined, + companySnapshot, + machineId: machineDoc?._id ?? undefined, + machineSnapshot: machineDoc + ? { + hostname: machineDoc.hostname ?? undefined, + persona: machineDoc.persona ?? undefined, + assignedUserName: machineDoc.assignedUserName ?? undefined, + assignedUserEmail: machineDoc.assignedUserEmail ?? undefined, + status: machineDoc.status ?? undefined, + } + : undefined, + formTemplate: formTemplateKey ?? undefined, + formTemplateLabel: formTemplateLabel ?? undefined, + chatEnabled, + working: false, + activeSessionId: undefined, + totalWorkedMs: 0, + createdAt: now, + updatedAt: now, + firstResponseAt: undefined, + resolvedAt: undefined, + closedAt: undefined, + tags: [], + slaPolicyId: undefined, + dueAt: undefined, + customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined, + ...slaFields, + }); + await ctx.db.insert("ticketEvents", { + ticketId: id, + type: "CREATED", + payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl }, + createdAt: now, + }); + + if (initialAssigneeId && initialAssignee) { + await ctx.db.insert("ticketEvents", { + ticketId: id, + type: "ASSIGNEE_CHANGED", + payload: { + assigneeId: initialAssigneeId, + assigneeName: initialAssignee.name, + actorId: args.actorId, + actorName: actorUser.name, + actorAvatar: actorUser.avatarUrl ?? undefined, + previousAssigneeId: null, + previousAssigneeName: "Não atribuído", + }, + createdAt: now, + }) + } + + return id; + }, +}); + +export const addComment = mutation({ + args: { + ticketId: v.id("tickets"), + authorId: v.id("users"), + visibility: v.string(), + body: v.string(), + attachments: v.optional( + v.array( + v.object({ + storageId: v.id("_storage"), + name: v.string(), + size: v.optional(v.number()), + type: v.optional(v.string()), + }) + ) + ), + }, + handler: async (ctx, args) => { + const ticket = await ctx.db.get(args.ticketId); + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + + const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null + if (!author || author.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Autor do comentário inválido") + } + + const normalizedRole = (author.role ?? "AGENT").toUpperCase() + + const requestedVisibility = (args.visibility ?? "").toUpperCase() + if (requestedVisibility !== "PUBLIC" && requestedVisibility !== "INTERNAL") { + throw new ConvexError("Visibilidade inválida") + } + + if (normalizedRole === "MANAGER") { + await ensureManagerTicketAccess(ctx, author, ticketDoc) + if (requestedVisibility !== "PUBLIC") { + throw new ConvexError("Gestores só podem registrar comentários públicos") + } + } + const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT" + if (requestedVisibility === "INTERNAL" && !canUseInternalComments) { + throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos") + } + + // Regra: a equipe (ADMIN/AGENT/MANAGER) só pode comentar se o ticket tiver responsável. + // O solicitante (colaborador) pode comentar sempre. + const isRequester = String(ticketDoc.requesterId) === String(author._id) + const isAdminOrAgent = normalizedRole === "ADMIN" || normalizedRole === "AGENT" + const hasAssignee = Boolean(ticketDoc.assigneeId) + // Gestores podem comentar mesmo sem responsável; admin/agent só com responsável + if (!isRequester && isAdminOrAgent && !hasAssignee) { + throw new ConvexError("Somente é possível comentar quando o chamado possui um responsável.") + } + + if (ticketDoc.requesterId === args.authorId) { + // O próprio solicitante pode comentar seu ticket. + // Comentários internos já são bloqueados acima para quem não é STAFF. + // Portanto, nada a fazer aqui. + } else { + await requireTicketStaff(ctx, args.authorId, ticketDoc) + } + + const attachments = args.attachments ?? [] + if (attachments.length > 5) { + throw new ConvexError("É permitido anexar no máximo 5 arquivos por comentário") + } + const maxAttachmentSize = 5 * 1024 * 1024 + for (const attachment of attachments) { + if (typeof attachment.size === "number" && attachment.size > maxAttachmentSize) { + throw new ConvexError("Cada anexo pode ter até 5MB") + } + } + + const authorSnapshot: CommentAuthorSnapshot = { + name: author.name, + email: author.email, + avatarUrl: author.avatarUrl ?? undefined, + teams: author.teams ?? undefined, + }; + + const normalizedBody = await normalizeTicketMentions(ctx, args.body, { user: author, role: normalizedRole }, ticketDoc.tenantId) + const bodyPlainLen = plainTextLength(normalizedBody) + if (bodyPlainLen > MAX_COMMENT_CHARS) { + throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) + } + + const now = Date.now(); + const id = await ctx.db.insert("ticketComments", { + ticketId: args.ticketId, + authorId: args.authorId, + visibility: requestedVisibility, + body: normalizedBody, + authorSnapshot, + attachments, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("ticketEvents", { + ticketId: args.ticketId, + type: "COMMENT_ADDED", + payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl }, + createdAt: now, + }); + const isStaffResponder = + requestedVisibility === "PUBLIC" && + !isRequester && + (normalizedRole === "ADMIN" || normalizedRole === "AGENT" || normalizedRole === "MANAGER"); + const responsePatch = + isStaffResponder && !ticketDoc.firstResponseAt ? buildResponseCompletionPatch(ticketDoc, now) : {}; + await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch }); + // Notificação por e-mail: comentário público para o solicitante + try { + const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email + if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) { + const schedulerRunAfter = ctx.scheduler?.runAfter + if (typeof schedulerRunAfter === "function") { + await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, { + to: snapshotEmail, + ticketId: String(ticketDoc._id), + reference: ticketDoc.reference ?? 0, + subject: ticketDoc.subject ?? "", + }) + } + } + } catch (e) { + console.warn("[tickets] Falha ao agendar e-mail de comentário", e) + } + return id; + }, +}); + +export const updateComment = mutation({ + args: { + ticketId: v.id("tickets"), + commentId: v.id("ticketComments"), + actorId: v.id("users"), + body: v.string(), + }, + handler: async (ctx, { ticketId, commentId, actorId, body }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null + if (!actor || actor.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Autor do comentário inválido") + } + const comment = await ctx.db.get(commentId); + if (!comment || comment.ticketId !== ticketId) { + throw new ConvexError("Comentário não encontrado"); + } + if (comment.authorId !== actorId) { + throw new ConvexError("Você não tem permissão para editar este comentário"); + } + const normalizedRole = (actor.role ?? "AGENT").toUpperCase() + if (ticketDoc.requesterId === actorId) { + if (STAFF_ROLES.has(normalizedRole)) { + await requireTicketStaff(ctx, actorId, ticketDoc) + } else { + throw new ConvexError("Autor não possui permissão para editar") + } + } else { + await requireTicketStaff(ctx, actorId, ticketDoc) + } + + const normalizedBody = await normalizeTicketMentions(ctx, body, { user: actor, role: normalizedRole }, ticketDoc.tenantId) + const bodyPlainLen = plainTextLength(normalizedBody) + if (bodyPlainLen > MAX_COMMENT_CHARS) { + throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) + } + + const now = Date.now(); + await ctx.db.patch(commentId, { + body: normalizedBody, + updatedAt: now, + }); + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "COMMENT_EDITED", + payload: { + commentId, + actorId, + actorName: actor?.name, + actorAvatar: actor?.avatarUrl, + }, + createdAt: now, + }); + + await ctx.db.patch(ticketId, { updatedAt: now }); + }, +}); + +export const removeCommentAttachment = mutation({ + args: { + ticketId: v.id("tickets"), + commentId: v.id("ticketComments"), + attachmentId: v.id("_storage"), + actorId: v.id("users"), + }, + handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null + if (!actor || actor.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Autor do comentário inválido") + } + const comment = await ctx.db.get(commentId); + if (!comment || comment.ticketId !== ticketId) { + throw new ConvexError("Comentário não encontrado"); + } + if (comment.authorId !== actorId) { + throw new ConvexError("Você não pode alterar anexos de outro usuário") + } + + const normalizedRole = (actor.role ?? "AGENT").toUpperCase() + if (ticketDoc.requesterId === actorId) { + if (STAFF_ROLES.has(normalizedRole)) { + await requireTicketStaff(ctx, actorId, ticketDoc) + } else { + throw new ConvexError("Autor não possui permissão para alterar anexos") + } + } else { + await requireTicketStaff(ctx, actorId, ticketDoc) + } + + const attachments = comment.attachments ?? []; + const target = attachments.find((att) => att.storageId === attachmentId); + if (!target) { + throw new ConvexError("Anexo não encontrado"); + } + + await ctx.storage.delete(attachmentId); + + const now = Date.now(); + await ctx.db.patch(commentId, { + attachments: attachments.filter((att) => att.storageId !== attachmentId), + updatedAt: now, + }); + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "ATTACHMENT_REMOVED", + payload: { + attachmentId, + attachmentName: target.name, + actorId, + actorName: actor?.name, + actorAvatar: actor?.avatarUrl, + }, + createdAt: now, + }); + + await ctx.db.patch(ticketId, { updatedAt: now }); + }, +}); + +export const updateStatus = mutation({ + args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") }, + handler: async (ctx, { ticketId, status, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + await requireTicketStaff(ctx, actorId, ticketDoc) + const normalizedStatus = normalizeStatus(status) + if (normalizedStatus === "AWAITING_ATTENDANCE" && !ticketDoc.activeSessionId) { + throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.") + } + const now = Date.now(); + const slaPatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now); + await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now, ...slaPatch }); + await ctx.db.insert("ticketEvents", { + ticketId, + type: "STATUS_CHANGED", + payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId }, + createdAt: now, + }); + }, +}); + +export async function resolveTicketHandler( + ctx: MutationCtx, + { ticketId, actorId, resolvedWithTicketId, relatedTicketIds, reopenWindowDays }: { + ticketId: Id<"tickets"> + actorId: Id<"users"> + resolvedWithTicketId?: Id<"tickets"> + relatedTicketIds?: Id<"tickets">[] + reopenWindowDays?: number | null + } +) { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const now = Date.now() + + const baseRelated = new Set() + for (const rel of relatedTicketIds ?? []) { + if (String(rel) === String(ticketId)) continue + baseRelated.add(String(rel)) + } + if (resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)) { + baseRelated.add(String(resolvedWithTicketId)) + } + + const linkedTickets: Doc<"tickets">[] = [] + for (const id of baseRelated) { + const related = await ctx.db.get(id as Id<"tickets">) + if (!related) continue + if (related.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Chamado vinculado pertence a outro tenant") + } + linkedTickets.push(related as Doc<"tickets">) + } + + const resolvedWith = + resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId) + ? (await ctx.db.get(resolvedWithTicketId)) ?? null + : null + if (resolvedWith && resolvedWith.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Chamado vinculado pertence a outro tenant") + } + if (resolvedWithTicketId && !resolvedWith) { + throw new ConvexError("Chamado vinculado não encontrado") + } + + const reopenDays = resolveReopenWindowDays(reopenWindowDays) + const reopenDeadline = computeReopenDeadline(now, reopenDays) + const normalizedStatus = "RESOLVED" + const relatedIdList = Array.from( + new Set( + linkedTickets.map((rel) => String(rel._id)), + ), + ).map((id) => id as Id<"tickets">) + + const slaPausePatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now); + const mergedTicket = mergeTicketState(ticketDoc, slaPausePatch); + const slaSolutionPatch = buildSolutionCompletionPatch(mergedTicket, now); + + await ctx.db.patch(ticketId, { + status: normalizedStatus, + resolvedAt: now, + closedAt: now, + updatedAt: now, + reopenDeadline, + reopenedAt: undefined, + resolvedWithTicketId: resolvedWith ? resolvedWith._id : undefined, + relatedTicketIds: relatedIdList.length ? relatedIdList : undefined, + activeSessionId: undefined, + working: false, + ...slaPausePatch, + ...slaSolutionPatch, + }) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "STATUS_CHANGED", + payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId }, + createdAt: now, + }) + + // Notificação por e-mail: encerramento do chamado + try { + const requesterDoc = await ctx.db.get(ticketDoc.requesterId) + const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null + if (email) { + const schedulerRunAfter = ctx.scheduler?.runAfter + if (typeof schedulerRunAfter === "function") { + await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, { + to: email, + ticketId: String(ticketId), + reference: ticketDoc.reference ?? 0, + subject: ticketDoc.subject ?? "", + }) + } + } + } catch (e) { + console.warn("[tickets] Falha ao agendar e-mail de encerramento", e) + } + + for (const rel of linkedTickets) { + const existing = new Set((rel.relatedTicketIds ?? []).map((value) => String(value))) + existing.add(String(ticketId)) + await ctx.db.patch(rel._id, { + relatedTicketIds: Array.from(existing).map((value) => value as Id<"tickets">), + updatedAt: now, + }) + const linkKind = resolvedWith && String(resolvedWith._id) === String(rel._id) ? "resolved_with" : "related" + await ctx.db.insert("ticketEvents", { + ticketId, + type: "TICKET_LINKED", + payload: { + actorId, + actorName: viewer.user.name, + linkedTicketId: rel._id, + linkedReference: rel.reference ?? null, + linkedSubject: rel.subject ?? null, + kind: linkKind, + }, + createdAt: now, + }) + await ctx.db.insert("ticketEvents", { + ticketId: rel._id, + type: "TICKET_LINKED", + payload: { + actorId, + actorName: viewer.user.name, + linkedTicketId: ticketId, + linkedReference: ticketDoc.reference ?? null, + linkedSubject: ticketDoc.subject ?? null, + kind: linkKind === "resolved_with" ? "resolution_parent" : "related", + }, + createdAt: now, + }) + } + + return { ok: true, reopenDeadline, reopenWindowDays: reopenDays } +} + +export const resolveTicket = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + resolvedWithTicketId: v.optional(v.id("tickets")), + relatedTicketIds: v.optional(v.array(v.id("tickets"))), + reopenWindowDays: v.optional(v.number()), + }, + handler: resolveTicketHandler, +}) + +export async function reopenTicketHandler( + ctx: MutationCtx, + { ticketId, actorId }: { ticketId: Id<"tickets">; actorId: Id<"users"> } +) { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireUser(ctx, actorId, ticketDoc.tenantId) + const normalizedRole = viewer.role ?? "" + const now = Date.now() + const status = normalizeStatus(ticketDoc.status) + if (status !== "RESOLVED") { + throw new ConvexError("Somente chamados resolvidos podem ser reabertos") + } + if (!isWithinReopenWindow(ticketDoc, now)) { + throw new ConvexError("O prazo para reabrir este chamado expirou") + } + if (normalizedRole === "COLLABORATOR") { + if (String(ticketDoc.requesterId) !== String(actorId)) { + throw new ConvexError("Somente o solicitante pode reabrir este chamado") + } + } else if (normalizedRole === "MANAGER") { + await ensureManagerTicketAccess(ctx, viewer.user, ticketDoc) + } else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { + throw new ConvexError("Usuário não possui permissão para reabrir este chamado") + } + + const slaPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now) + await ctx.db.patch(ticketId, { + status: "AWAITING_ATTENDANCE", + reopenedAt: now, + resolvedAt: undefined, + closedAt: undefined, + updatedAt: now, + ...slaPatch, + }) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "TICKET_REOPENED", + payload: { actorId, actorName: viewer.user.name, actorRole: normalizedRole }, + createdAt: now, + }) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "STATUS_CHANGED", + payload: { to: "AWAITING_ATTENDANCE", toLabel: STATUS_LABELS.AWAITING_ATTENDANCE, actorId }, + createdAt: now, + }) + + return { ok: true, reopenedAt: now } +} + +export const reopenTicket = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + }, + handler: reopenTicketHandler, +}) + +export const changeAssignee = mutation({ + args: { + ticketId: v.id("tickets"), + assigneeId: v.id("users"), + actorId: v.id("users"), + reason: v.optional(v.string()), + }, + handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const viewerUser = viewer.user + const isAdmin = viewer.role === "ADMIN" + const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null + if (!assignee || assignee.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Responsável inválido") + } + if (viewer.role === "MANAGER") { + throw new ConvexError("Gestores não podem reatribuir chamados") + } + const normalizedStatus = normalizeStatus(ticketDoc.status) + if (normalizedStatus === "AWAITING_ATTENDANCE" || ticketDoc.activeSessionId) { + throw new ConvexError("Pause o atendimento antes de reatribuir o chamado") + } + const currentAssigneeId = ticketDoc.assigneeId ?? null + if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) { + throw new ConvexError("Somente o responsável atual pode reatribuir este chamado") + } + + const normalizedReason = (typeof reason === "string" ? reason : "").replace(/\r\n/g, "\n").trim() + if (normalizedReason.length > 0 && normalizedReason.length < 5) { + throw new ConvexError("Informe um motivo com pelo menos 5 caracteres ou deixe em branco") + } + if (normalizedReason.length > 1000) { + throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)") + } + const previousAssigneeName = + ((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ?? + "Não atribuído" + const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável" + + const now = Date.now(); + const assigneeSnapshot = { + name: assignee.name, + email: assignee.email, + avatarUrl: assignee.avatarUrl ?? undefined, + teams: assignee.teams ?? undefined, + } + await ctx.db.patch(ticketId, { assigneeId, assigneeSnapshot, updatedAt: now }); + await ctx.db.insert("ticketEvents", { + ticketId, + type: "ASSIGNEE_CHANGED", + payload: { + assigneeId, + assigneeName: assignee.name, + actorId, + actorName: viewerUser.name, + actorAvatar: viewerUser.avatarUrl ?? undefined, + previousAssigneeId: currentAssigneeId, + previousAssigneeName, + reason: normalizedReason.length > 0 ? normalizedReason : undefined, + }, + createdAt: now, + }); + + if (normalizedReason.length > 0) { + const commentBody = buildAssigneeChangeComment(normalizedReason, { + previousName: previousAssigneeName, + nextName: nextAssigneeName, + }) + const commentPlainLength = plainTextLength(commentBody) + if (commentPlainLength > MAX_COMMENT_CHARS) { + throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) + } + const authorSnapshot: CommentAuthorSnapshot = { + name: viewerUser.name, + email: viewerUser.email, + avatarUrl: viewerUser.avatarUrl ?? undefined, + teams: viewerUser.teams ?? undefined, + } + await ctx.db.insert("ticketComments", { + ticketId, + authorId: actorId, + visibility: "INTERNAL", + body: commentBody, + authorSnapshot, + attachments: [], + createdAt: now, + updatedAt: now, + }) + await ctx.db.insert("ticketEvents", { + ticketId, + type: "COMMENT_ADDED", + payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl }, + createdAt: now, + }) + } + }, +}); + +export const listChatMessages = query({ + args: { + ticketId: v.id("tickets"), + viewerId: v.id("users"), + }, + handler: async (ctx, { ticketId, viewerId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + await requireTicketChatParticipant(ctx, viewerId, ticketDoc) + const now = Date.now() + const status = normalizeStatus(ticketDoc.status) + const chatEnabled = Boolean(ticketDoc.chatEnabled) + const withinWindow = isWithinReopenWindow(ticketDoc, now) + const canPost = chatEnabled && (status !== "RESOLVED" || withinWindow) + const messages = await ctx.db + .query("ticketChatMessages") + .withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId)) + .collect() + + return { + ticketId: String(ticketId), + chatEnabled, + status, + canPost, + reopenDeadline: ticketDoc.reopenDeadline ?? null, + messages: messages + .sort((a, b) => a.createdAt - b.createdAt) + .map((message) => ({ + id: message._id, + body: message.body, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + authorId: String(message.authorId), + authorName: message.authorSnapshot?.name ?? null, + authorEmail: message.authorSnapshot?.email ?? null, + attachments: (message.attachments ?? []).map((attachment) => ({ + storageId: attachment.storageId, + name: attachment.name, + size: attachment.size ?? null, + type: attachment.type ?? null, + })), + readBy: (message.readBy ?? []).map((entry) => ({ + userId: String(entry.userId), + readAt: entry.readAt, + })), + })), + } + }, +}) + +export const listTicketForms = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + companyId: v.optional(v.id("companies")), + }, + handler: async (ctx, { tenantId, viewerId, companyId }) => { + const viewer = await requireUser(ctx, viewerId, tenantId) + const viewerCompanyId = companyId ?? viewer.user.companyId ?? null + const viewerRole = (viewer.role ?? "").toUpperCase() + const templates = await fetchTemplateSummaries(ctx, tenantId) + + const scopes = templates.map((template) => template.key) + const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId) + + const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT" + const settingsByTemplate = staffOverride + ? new Map[]>() + : await fetchViewerScopedFormSettings(ctx, tenantId, scopes, viewer.user._id, viewerCompanyId) + + const forms = [] as Array<{ + key: string + label: string + description: string + fields: Array<{ + id: Id<"ticketFields"> + key: string + label: string + type: string + required: boolean + description: string + options: { value: string; label: string }[] + }> + }> + + for (const template of templates) { + const templateSettings = settingsByTemplate.get(template.key) ?? [] + let enabled = staffOverride + ? true + : resolveFormEnabled(template.key, template.defaultEnabled, templateSettings, { + companyId: viewerCompanyId, + userId: viewer.user._id, + }) + if (!enabled) { + continue + } + const scopedFields = fieldsByScope.get(template.key) ?? [] + forms.push({ + key: template.key, + label: template.label, + description: template.description, + fields: scopedFields + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((field) => ({ + id: field._id, + key: field.key, + label: field.label, + type: field.type, + required: Boolean(field.required), + description: field.description ?? "", + options: field.options ?? [], + })), + }) + } + + return forms + }, +}) + +export const findByReference = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + reference: v.number(), + }, + handler: async (ctx, { tenantId, viewerId, reference }) => { + const viewer = await requireUser(ctx, viewerId, tenantId) + const ticket = await ctx.db + .query("tickets") + .withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId).eq("reference", reference)) + .first() + if (!ticket) { + return null + } + const normalizedRole = viewer.role ?? "" + if (normalizedRole === "MANAGER") { + await ensureManagerTicketAccess(ctx, viewer.user, ticket as Doc<"tickets">) + } else if (normalizedRole === "COLLABORATOR") { + if (String(ticket.requesterId) !== String(viewer.user._id)) { + return null + } + } else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { + return null + } + return { + id: ticket._id, + reference: ticket.reference, + subject: ticket.subject, + status: ticket.status, + } + }, +}) + +export const postChatMessage = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + body: v.string(), + attachments: v.optional( + v.array( + v.object({ + storageId: v.id("_storage"), + name: v.string(), + size: v.optional(v.number()), + type: v.optional(v.string()), + }) + ) + ), + }, + handler: async (ctx, { ticketId, actorId, body, attachments }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + if (!ticketDoc.chatEnabled) { + throw new ConvexError("Chat não habilitado para este chamado") + } + const participant = await requireTicketChatParticipant(ctx, actorId, ticketDoc) + const now = Date.now() + if (!isWithinReopenWindow(ticketDoc, now) && normalizeStatus(ticketDoc.status) === "RESOLVED") { + throw new ConvexError("O chat deste chamado está encerrado") + } + + const trimmedBody = body.replace(/\r\n/g, "\n").trim() + if (trimmedBody.length === 0) { + throw new ConvexError("Digite uma mensagem para enviar no chat") + } + if (trimmedBody.length > 4000) { + throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)") + } + + const files = attachments ?? [] + if (files.length > 5) { + throw new ConvexError("Envie até 5 arquivos por mensagem") + } + const maxAttachmentSize = 5 * 1024 * 1024 + for (const file of files) { + if (typeof file.size === "number" && file.size > maxAttachmentSize) { + throw new ConvexError("Cada arquivo pode ter até 5MB") + } + } + + const normalizedBody = await normalizeTicketMentions(ctx, trimmedBody, { user: participant.user, role: participant.role ?? "" }, ticketDoc.tenantId) + const plainLength = plainTextLength(normalizedBody) + if (plainLength === 0) { + throw new ConvexError("A mensagem está vazia após a formatação") + } + if (plainLength > 4000) { + throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)") + } + + const authorSnapshot: CommentAuthorSnapshot = { + name: participant.user.name, + email: participant.user.email, + avatarUrl: participant.user.avatarUrl ?? undefined, + teams: participant.user.teams ?? undefined, + } + + const messageId = await ctx.db.insert("ticketChatMessages", { + ticketId, + tenantId: ticketDoc.tenantId, + companyId: ticketDoc.companyId ?? undefined, + authorId: actorId, + authorSnapshot, + body: normalizedBody, + attachments: files, + notifiedAt: undefined, + createdAt: now, + updatedAt: now, + readBy: [{ userId: actorId, readAt: now }], + }) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "CHAT_MESSAGE_ADDED", + payload: { + messageId, + authorId: actorId, + authorName: participant.user.name, + actorRole: participant.role ?? null, + }, + createdAt: now, + }) + + await ctx.db.patch(ticketId, { updatedAt: now }) + + return { ok: true, messageId } + }, +}) + +export const markChatRead = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + messageIds: v.array(v.id("ticketChatMessages")), + }, + handler: async (ctx, { ticketId, actorId, messageIds }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + await requireTicketChatParticipant(ctx, actorId, ticketDoc) + const uniqueIds = Array.from(new Set(messageIds.map((id) => String(id)))) + const now = Date.now() + for (const id of uniqueIds) { + const message = await ctx.db.get(id as Id<"ticketChatMessages">) + if (!message || String(message.ticketId) !== String(ticketId)) { + continue + } + const readBy = new Map; readAt: number }>() + for (const entry of message.readBy ?? []) { + readBy.set(String(entry.userId), { userId: entry.userId, readAt: entry.readAt }) + } + readBy.set(String(actorId), { userId: actorId, readAt: now }) + await ctx.db.patch(id as Id<"ticketChatMessages">, { + readBy: Array.from(readBy.values()), + updatedAt: now, + }) + } + return { ok: true } + }, +}) + +export const ensureTicketFormDefaults = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { tenantId, actorId }) => { + await requireUser(ctx, actorId, tenantId); + await ensureTicketFormDefaultsForTenant(ctx, tenantId); + return { ok: true }; + }, +}); + +export async function submitCsatHandler( + ctx: MutationCtx, + { ticketId, actorId, score, maxScore, comment }: { ticketId: Id<"tickets">; actorId: Id<"users">; score: number; maxScore?: number | null; comment?: string | null } +) { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + + const normalizedStatus = normalizeStatus(ticket.status) + if (normalizedStatus !== "RESOLVED") { + throw new ConvexError("Avaliações só são permitidas após o encerramento do chamado") + } + + const viewer = await requireUser(ctx, actorId, ticket.tenantId) + const normalizedRole = (viewer.role ?? "").toUpperCase() + if (normalizedRole !== "COLLABORATOR") { + throw new ConvexError("Somente o solicitante pode avaliar o chamado") + } + + const viewerEmail = viewer.user.email.trim().toLowerCase() + const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase() ?? null + const isOwnerById = String(ticket.requesterId) === String(viewer.user._id) + const isOwnerByEmail = snapshotEmail ? snapshotEmail === viewerEmail : false + if (!isOwnerById && !isOwnerByEmail) { + throw new ConvexError("Avaliação permitida apenas ao solicitante deste chamado") + } + + if (typeof ticket.csatScore === "number") { + throw new ConvexError("Este chamado já possui uma avaliação registrada") + } + + if (!Number.isFinite(score)) { + throw new ConvexError("Pontuação inválida") + } + const resolvedMaxScore = + Number.isFinite(maxScore) && maxScore && maxScore > 0 ? Math.min(10, Math.round(maxScore)) : 5 + const normalizedScore = Math.max(1, Math.min(resolvedMaxScore, Math.round(score))) + const normalizedComment = + typeof comment === "string" + ? comment + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.trim()) + .join("\n") + .trim() + : "" + if (normalizedComment.length > 2000) { + throw new ConvexError("Comentário muito longo (máx. 2000 caracteres)") + } + + const now = Date.now() + + let csatAssigneeId: Id<"users"> | undefined + let csatAssigneeSnapshot: + | { + name: string + email?: string + avatarUrl?: string + teams?: string[] + } + | undefined + + if (ticket.assigneeId) { + const assigneeDoc = (await ctx.db.get(ticket.assigneeId)) as Doc<"users"> | null + if (assigneeDoc) { + csatAssigneeId = assigneeDoc._id + csatAssigneeSnapshot = { + name: assigneeDoc.name, + email: assigneeDoc.email, + avatarUrl: assigneeDoc.avatarUrl ?? undefined, + teams: Array.isArray(assigneeDoc.teams) ? assigneeDoc.teams : undefined, + } + } else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") { + const snapshot = ticket.assigneeSnapshot as { + name?: string + email?: string + avatarUrl?: string + teams?: string[] + } + if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) { + csatAssigneeId = ticket.assigneeId + csatAssigneeSnapshot = { + name: snapshot.name, + email: snapshot.email ?? undefined, + avatarUrl: snapshot.avatarUrl ?? undefined, + teams: snapshot.teams ?? undefined, + } + } + } + } else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") { + const snapshot = ticket.assigneeSnapshot as { + name?: string + email?: string + avatarUrl?: string + teams?: string[] + } + if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) { + csatAssigneeSnapshot = { + name: snapshot.name, + email: snapshot.email ?? undefined, + avatarUrl: snapshot.avatarUrl ?? undefined, + teams: snapshot.teams ?? undefined, + } + } + } + + await ctx.db.patch(ticketId, { + csatScore: normalizedScore, + csatMaxScore: resolvedMaxScore, + csatComment: normalizedComment.length > 0 ? normalizedComment : undefined, + csatRatedAt: now, + csatRatedBy: actorId, + csatAssigneeId, + csatAssigneeSnapshot, + }) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "CSAT_RATED", + payload: { + score: normalizedScore, + maxScore: resolvedMaxScore, + comment: normalizedComment.length > 0 ? normalizedComment : undefined, + ratedBy: actorId, + assigneeId: csatAssigneeId ?? null, + assigneeName: csatAssigneeSnapshot?.name ?? null, + }, + createdAt: now, + }) + + return { + ok: true, + score: normalizedScore, + maxScore: resolvedMaxScore, + comment: normalizedComment.length > 0 ? normalizedComment : null, + ratedAt: now, + } +} + +export const submitCsat = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + score: v.number(), + maxScore: v.optional(v.number()), + comment: v.optional(v.string()), + }, + handler: submitCsatHandler, +}) + +export const changeRequester = mutation({ + args: { ticketId: v.id("tickets"), requesterId: v.id("users"), actorId: v.id("users") }, + handler: async (ctx, { ticketId, requesterId, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const viewerRole = (viewer.role ?? "AGENT").toUpperCase() + const actor = viewer.user + + if (String(ticketDoc.requesterId) === String(requesterId)) { + return { status: "unchanged" } + } + + const requester = (await ctx.db.get(requesterId)) as Doc<"users"> | null + if (!requester || requester.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Solicitante inválido") + } + + if (viewerRole === "MANAGER") { + if (!actor.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada") + } + if (requester.companyId !== actor.companyId) { + throw new ConvexError("Gestores só podem alterar para usuários da própria empresa") + } + } + + const now = Date.now() + const requesterSnapshot = { + name: requester.name, + email: requester.email, + avatarUrl: requester.avatarUrl ?? undefined, + teams: requester.teams ?? undefined, + } + + let companyId: Id<"companies"> | undefined + let companySnapshot: { name: string; slug?: string; isAvulso?: boolean } | undefined + + if (requester.companyId) { + const company = await ctx.db.get(requester.companyId) + if (company) { + companyId = company._id as Id<"companies"> + companySnapshot = { + name: company.name, + slug: company.slug ?? undefined, + isAvulso: company.isAvulso ?? undefined, + } + } + } + + const patch: Record = { + requesterId, + requesterSnapshot, + updatedAt: now, + } + if (companyId) { + patch["companyId"] = companyId + patch["companySnapshot"] = companySnapshot + } else { + patch["companyId"] = undefined + patch["companySnapshot"] = undefined + } + + await ctx.db.patch(ticketId, patch) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "REQUESTER_CHANGED", + payload: { + requesterId, + requesterName: requester.name, + requesterEmail: requester.email, + companyId: companyId ?? null, + companyName: companySnapshot?.name ?? null, + actorId, + actorName: actor.name, + actorAvatar: actor.avatarUrl, + }, + createdAt: now, + }) + + return { status: "updated" } + }, +}) + +export const purgeTicketsForUsers = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + userIds: v.array(v.id("users")), + }, + handler: async (ctx, { tenantId, actorId, userIds }) => { + await requireAdmin(ctx, actorId, tenantId) + if (userIds.length === 0) { + return { deleted: 0 } + } + const uniqueIds = Array.from(new Set(userIds.map((id) => id))) + let deleted = 0 + for (const userId of uniqueIds) { + const requesterTickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", userId)) + .collect() + for (const ticket of requesterTickets) { + await ctx.db.delete(ticket._id) + deleted += 1 + } + const assigneeTickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", tenantId).eq("assigneeId", userId)) + .collect() + for (const ticket of assigneeTickets) { + await ctx.db.delete(ticket._id) + deleted += 1 + } + } + return { deleted } + }, +}) + + +export const changeQueue = mutation({ + args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") }, + handler: async (ctx, { ticketId, queueId, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + if (viewer.role === "MANAGER") { + throw new ConvexError("Gestores não podem alterar a fila do chamado") + } + const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null + if (!queue || queue.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Fila inválida") + } + const now = Date.now(); + await ctx.db.patch(ticketId, { queueId, updatedAt: now }); + const queueName = normalizeQueueName(queue); + await ctx.db.insert("ticketEvents", { + ticketId, + type: "QUEUE_CHANGED", + payload: { queueId, queueName, actorId }, + createdAt: now, + }); + }, +}); + +export const updateCategories = mutation({ + args: { + ticketId: v.id("tickets"), + categoryId: v.union(v.id("ticketCategories"), v.null()), + subcategoryId: v.union(v.id("ticketSubcategories"), v.null()), + actorId: v.id("users"), + }, + handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + if (viewer.role === "MANAGER") { + throw new ConvexError("Gestores não podem alterar a categorização do chamado") + } + + if (categoryId === null) { + if (subcategoryId !== null) { + throw new ConvexError("Subcategoria inválida") + } + if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) { + return { status: "unchanged" } + } + const now = Date.now() + await ctx.db.patch(ticketId, { + categoryId: undefined, + subcategoryId: undefined, + updatedAt: now, + }) + const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null + await ctx.db.insert("ticketEvents", { + ticketId, + type: "CATEGORY_CHANGED", + payload: { + categoryId: null, + categoryName: null, + subcategoryId: null, + subcategoryName: null, + actorId, + actorName: actor?.name, + actorAvatar: actor?.avatarUrl, + }, + createdAt: now, + }) + return { status: "cleared" } + } + + const category = await ctx.db.get(categoryId) + if (!category || category.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Categoria inválida") + } + + let subcategoryName: string | null = null + if (subcategoryId !== null) { + const subcategory = await ctx.db.get(subcategoryId) + if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Subcategoria inválida") + } + subcategoryName = subcategory.name + } + + if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) { + return { status: "unchanged" } + } + + const now = Date.now() + await ctx.db.patch(ticketId, { + categoryId, + subcategoryId: subcategoryId ?? undefined, + updatedAt: now, + }) + + const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null + await ctx.db.insert("ticketEvents", { + ticketId, + type: "CATEGORY_CHANGED", + payload: { + categoryId, + categoryName: category.name, + subcategoryId, + subcategoryName, + actorId, + actorName: actor?.name, + actorAvatar: actor?.avatarUrl, + }, + createdAt: now, + }) + + return { status: "updated" } + }, +}) + +export const workSummary = query({ + args: { ticketId: v.id("tickets"), viewerId: v.id("users") }, + handler: async (ctx, { ticketId, viewerId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) return null + await requireStaff(ctx, viewerId, ticket.tenantId) + + const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null + const serverNow = Date.now() + const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, serverNow) + return { + ticketId, + totalWorkedMs: ticket.totalWorkedMs ?? 0, + internalWorkedMs: ticket.internalWorkedMs ?? 0, + externalWorkedMs: ticket.externalWorkedMs ?? 0, + serverNow, + activeSession: activeSession + ? { + id: activeSession._id, + agentId: activeSession.agentId, + startedAt: activeSession.startedAt, + workType: activeSession.workType ?? "INTERNAL", + } + : null, + perAgentTotals: perAgentTotals.map((item) => ({ + agentId: item.agentId, + agentName: item.agentName, + agentEmail: item.agentEmail, + avatarUrl: item.avatarUrl, + totalWorkedMs: item.totalWorkedMs, + internalWorkedMs: item.internalWorkedMs, + externalWorkedMs: item.externalWorkedMs, + })), + } + }, +}) + +export const updatePriority = mutation({ + args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") }, + handler: async (ctx, { ticketId, priority, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + await requireStaff(ctx, actorId, ticket.tenantId) + const now = Date.now(); + await ctx.db.patch(ticketId, { priority, updatedAt: now }); + const pt: Record = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" }; + await ctx.db.insert("ticketEvents", { + ticketId, + type: "PRIORITY_CHANGED", + payload: { to: priority, toLabel: pt[priority] ?? priority, actorId }, + createdAt: now, + }); + }, +}); + +export const startWork = mutation({ + args: { ticketId: v.id("tickets"), actorId: v.id("users"), workType: v.optional(v.string()) }, + handler: async (ctx, { ticketId, actorId, workType }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const isAdmin = viewer.role === "ADMIN" + const currentAssigneeId = ticketDoc.assigneeId ?? null + const now = Date.now() + + if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) { + throw new ConvexError("Somente o responsável atual pode iniciar este chamado") + } + + if (ticketDoc.activeSessionId) { + const session = await ctx.db.get(ticketDoc.activeSessionId) + return { + status: "already_started", + sessionId: ticketDoc.activeSessionId, + startedAt: session?.startedAt ?? now, + serverNow: now, + } + } + + let assigneePatched = false + const previousAssigneeIdForStart = currentAssigneeId + const previousAssigneeNameForStart = + ((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ?? "Não atribuído" + + if (!currentAssigneeId) { + const assigneeSnapshot = { + name: viewer.user.name, + email: viewer.user.email, + avatarUrl: viewer.user.avatarUrl ?? undefined, + teams: viewer.user.teams ?? undefined, + } + await ctx.db.patch(ticketId, { assigneeId: actorId, assigneeSnapshot, updatedAt: now }) + ticketDoc.assigneeId = actorId + assigneePatched = true + } + + const sessionId = await ctx.db.insert("ticketWorkSessions", { + ticketId, + agentId: actorId, + workType: (workType ?? "INTERNAL").toUpperCase(), + startedAt: now, + }) + + const slaStartPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now); + await ctx.db.patch(ticketId, { + working: true, + activeSessionId: sessionId, + status: "AWAITING_ATTENDANCE", + updatedAt: now, + ...slaStartPatch, + }) + + if (assigneePatched) { + await ctx.db.insert("ticketEvents", { + ticketId, + type: "ASSIGNEE_CHANGED", + payload: { + assigneeId: actorId, + assigneeName: viewer.user.name, + actorId, + actorName: viewer.user.name, + actorAvatar: viewer.user.avatarUrl ?? undefined, + previousAssigneeId: previousAssigneeIdForStart, + previousAssigneeName: previousAssigneeNameForStart, + }, + createdAt: now, + }) + } + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "WORK_STARTED", + payload: { + actorId, + actorName: viewer.user.name, + actorAvatar: viewer.user.avatarUrl, + sessionId, + workType: (workType ?? "INTERNAL").toUpperCase(), + }, + createdAt: now, + }) + + return { status: "started", sessionId, startedAt: now, serverNow: now } + }, +}) + +export const pauseWork = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + reason: v.string(), + note: v.optional(v.string()), + }, + handler: async (ctx, { ticketId, actorId, reason, note }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const isAdmin = viewer.role === "ADMIN" + if (ticketDoc.assigneeId && ticketDoc.assigneeId !== actorId && !isAdmin) { + throw new ConvexError("Somente o responsável atual pode pausar este chamado") + } + + if (!ticketDoc.activeSessionId) { + const normalizedStatus = normalizeStatus(ticketDoc.status) + if (normalizedStatus === "AWAITING_ATTENDANCE") { + const now = Date.now() + const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now) + await ctx.db.patch(ticketId, { + status: "PAUSED", + working: false, + updatedAt: now, + ...slaPausePatch, + }) + await ctx.db.insert("ticketEvents", { + ticketId, + type: "STATUS_CHANGED", + payload: { + to: "PAUSED", + toLabel: STATUS_LABELS.PAUSED, + actorId, + }, + createdAt: now, + }) + return { status: "paused", durationMs: 0, pauseReason: reason, pauseNote: note ?? "", serverNow: now } + } + return { status: "already_paused" } + } + + if (!PAUSE_REASON_LABELS[reason]) { + throw new ConvexError("Motivo de pausa inválido") + } + + const session = await ctx.db.get(ticketDoc.activeSessionId) + if (!session) { + await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false }) + return { status: "session_missing" } + } + + const now = Date.now() + const durationMs = now - session.startedAt + + await ctx.db.patch(ticketDoc.activeSessionId, { + stoppedAt: now, + durationMs, + pauseReason: reason, + pauseNote: note ?? "", + }) + + const sessionType = (session.workType ?? "INTERNAL").toUpperCase() + const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0 + const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0 + + const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now) + await ctx.db.patch(ticketId, { + working: false, + activeSessionId: undefined, + status: "PAUSED", + totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, + internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal, + externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal, + updatedAt: now, + ...slaPausePatch, + }) + + const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null + await ctx.db.insert("ticketEvents", { + ticketId, + type: "WORK_PAUSED", + payload: { + actorId, + actorName: actor?.name, + actorAvatar: actor?.avatarUrl, + sessionId: session._id, + sessionDurationMs: durationMs, + workType: sessionType, + pauseReason: reason, + pauseReasonLabel: PAUSE_REASON_LABELS[reason], + pauseNote: note ?? "", + }, + createdAt: now, + }) + + return { + status: "paused", + durationMs, + pauseReason: reason, + pauseNote: note ?? "", + serverNow: now, + } + }, +}) + +export const adjustWorkSummary = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + internalWorkedMs: v.number(), + externalWorkedMs: v.number(), + reason: v.string(), + }, + handler: async (ctx, { ticketId, actorId, internalWorkedMs, externalWorkedMs, reason }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const normalizedRole = (viewer.role ?? "").toUpperCase() + if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { + throw new ConvexError("Somente administradores e agentes podem ajustar as horas de um chamado.") + } + if (ticketDoc.activeSessionId) { + throw new ConvexError("Pause o atendimento antes de ajustar as horas do chamado.") + } + + const trimmedReason = reason.trim() + if (trimmedReason.length < 5) { + throw new ConvexError("Informe um motivo com pelo menos 5 caracteres.") + } + if (trimmedReason.length > 1000) { + throw new ConvexError("Motivo muito longo (máx. 1000 caracteres).") + } + + const previousInternal = Math.max(0, ticketDoc.internalWorkedMs ?? 0) + const previousExternal = Math.max(0, ticketDoc.externalWorkedMs ?? 0) + const previousTotal = Math.max(0, ticketDoc.totalWorkedMs ?? previousInternal + previousExternal) + + const nextInternal = Math.max(0, Math.round(internalWorkedMs)) + const nextExternal = Math.max(0, Math.round(externalWorkedMs)) + const nextTotal = nextInternal + nextExternal + + const deltaInternal = nextInternal - previousInternal + const deltaExternal = nextExternal - previousExternal + const deltaTotal = nextTotal - previousTotal + + const now = Date.now() + await ctx.db.patch(ticketId, { + internalWorkedMs: nextInternal, + externalWorkedMs: nextExternal, + totalWorkedMs: nextTotal, + updatedAt: now, + }) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "WORK_ADJUSTED", + payload: { + actorId, + actorName: viewer.user.name, + actorAvatar: viewer.user.avatarUrl, + previousInternalMs: previousInternal, + previousExternalMs: previousExternal, + previousTotalMs: previousTotal, + nextInternalMs: nextInternal, + nextExternalMs: nextExternal, + nextTotalMs: nextTotal, + deltaInternalMs: deltaInternal, + deltaExternalMs: deltaExternal, + deltaTotalMs: deltaTotal, + }, + createdAt: now, + }) + + const bodyHtml = [ + "

Ajuste manual de horas

", + "
    ", + `
  • Horas internas: ${escapeHtml(formatWorkDuration(previousInternal))} → ${escapeHtml(formatWorkDuration(nextInternal))} (${escapeHtml(formatWorkDelta(deltaInternal))})
  • `, + `
  • Horas externas: ${escapeHtml(formatWorkDuration(previousExternal))} → ${escapeHtml(formatWorkDuration(nextExternal))} (${escapeHtml(formatWorkDelta(deltaExternal))})
  • `, + `
  • Total: ${escapeHtml(formatWorkDuration(previousTotal))} → ${escapeHtml(formatWorkDuration(nextTotal))} (${escapeHtml(formatWorkDelta(deltaTotal))})
  • `, + "
", + `

Motivo: ${escapeHtml(trimmedReason)}

`, + ].join("") + + const authorSnapshot: CommentAuthorSnapshot = { + name: viewer.user.name, + email: viewer.user.email, + avatarUrl: viewer.user.avatarUrl ?? undefined, + teams: viewer.user.teams ?? undefined, + } + + await ctx.db.insert("ticketComments", { + ticketId, + authorId: actorId, + visibility: "INTERNAL", + body: bodyHtml, + authorSnapshot, + attachments: [], + createdAt: now, + updatedAt: now, + }) + + const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, now) + + return { + ticketId, + totalWorkedMs: nextTotal, + internalWorkedMs: nextInternal, + externalWorkedMs: nextExternal, + serverNow: now, + perAgentTotals: perAgentTotals.map((item) => ({ + agentId: item.agentId, + agentName: item.agentName, + agentEmail: item.agentEmail, + avatarUrl: item.avatarUrl, + totalWorkedMs: item.totalWorkedMs, + internalWorkedMs: item.internalWorkedMs, + externalWorkedMs: item.externalWorkedMs, + })), + } + }, +}) + +export const updateSubject = mutation({ + args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") }, + handler: async (ctx, { ticketId, subject, actorId }) => { + const now = Date.now(); + const t = await ctx.db.get(ticketId); + if (!t) { + throw new ConvexError("Ticket não encontrado") + } + await requireStaff(ctx, actorId, t.tenantId) + const trimmed = subject.trim(); + if (trimmed.length < 3) { + throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); + } + await ctx.db.patch(ticketId, { subject: trimmed, updatedAt: now }); + const actor = await ctx.db.get(actorId); + await ctx.db.insert("ticketEvents", { + ticketId, + type: "SUBJECT_CHANGED", + payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl }, + createdAt: now, + }); + }, +}); + +export const updateSummary = mutation({ + args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") }, + handler: async (ctx, { ticketId, summary, actorId }) => { + const now = Date.now(); + const t = await ctx.db.get(ticketId); + if (!t) { + throw new ConvexError("Ticket não encontrado") + } + await requireStaff(ctx, actorId, t.tenantId) + if (summary && summary.trim().length > MAX_SUMMARY_CHARS) { + throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`) + } + await ctx.db.patch(ticketId, { summary, updatedAt: now }); + const actor = await ctx.db.get(actorId); + await ctx.db.insert("ticketEvents", { + ticketId, + type: "SUMMARY_CHANGED", + payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl }, + createdAt: now, + }); + }, +}); + +export const updateCustomFields = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + fields: v.array( + v.object({ + fieldId: v.id("ticketFields"), + value: v.any(), + }) + ), + }, + handler: async (ctx, { ticketId, actorId, fields }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const normalizedRole = (viewer.role ?? "").toUpperCase() + if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { + throw new ConvexError("Somente administradores e agentes podem editar campos personalizados.") + } + + const previousEntries = (ticketDoc.customFields as NormalizedCustomField[] | undefined) ?? [] + const previousRecord = mapCustomFieldsToRecord(previousEntries) + + const sanitizedInputs: CustomFieldInput[] = fields + .filter((entry) => entry.value !== undefined) + .map((entry) => ({ + fieldId: entry.fieldId, + value: entry.value, + })) + + const normalized = await normalizeCustomFieldValues( + ctx, + ticketDoc.tenantId, + sanitizedInputs, + ticketDoc.formTemplate ?? null + ) + + const nextRecord = mapCustomFieldsToRecord(normalized) + const metaByFieldKey = new Map< + string, + { fieldId?: Id<"ticketFields">; label: string; type: string } + >() + for (const entry of previousEntries) { + metaByFieldKey.set(entry.fieldKey, { + fieldId: entry.fieldId, + label: entry.label, + type: entry.type, + }) + } + for (const entry of normalized) { + metaByFieldKey.set(entry.fieldKey, { + fieldId: entry.fieldId, + label: entry.label, + type: entry.type, + }) + } + + const keyOrder = [...Object.keys(previousRecord), ...Object.keys(nextRecord)] + const changedKeys = Array.from(new Set(keyOrder)).filter((key) => { + const previousEntry = getCustomFieldRecordEntry(previousRecord, key) + const nextEntry = getCustomFieldRecordEntry(nextRecord, key) + return !areCustomFieldEntriesEqual(previousEntry, nextEntry) + }) + + if (changedKeys.length === 0) { + return { + customFields: previousRecord, + updatedAt: ticketDoc.updatedAt ?? Date.now(), + } + } + + const now = Date.now() + + await ctx.db.patch(ticketId, { + customFields: normalized.length > 0 ? normalized : undefined, + updatedAt: now, + }) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "CUSTOM_FIELDS_UPDATED", + payload: { + actorId, + actorName: viewer.user.name, + actorAvatar: viewer.user.avatarUrl ?? undefined, + fields: changedKeys.map((fieldKey) => { + const meta = metaByFieldKey.get(fieldKey) + const previous = getCustomFieldRecordEntry(previousRecord, fieldKey) + const next = getCustomFieldRecordEntry(nextRecord, fieldKey) + return { + fieldId: meta?.fieldId, + fieldKey, + label: meta?.label ?? fieldKey, + type: meta?.type ?? "text", + previousValue: previous?.value ?? null, + nextValue: next?.value ?? null, + previousDisplayValue: previous?.displayValue ?? null, + nextDisplayValue: next?.displayValue ?? null, + changeType: !previous ? "added" : !next ? "removed" : "updated", + } + }), + }, + createdAt: now, + }) + + return { + customFields: nextRecord, + updatedAt: now, + } + }, +}) + +export const playNext = mutation({ + args: { + tenantId: v.string(), + queueId: v.optional(v.id("queues")), + agentId: v.id("users"), + }, + handler: async (ctx, { tenantId, queueId, agentId }) => { + const { user: agent } = await requireStaff(ctx, agentId, tenantId) + // Find eligible tickets: not resolved/closed and not assigned + let candidates: Doc<"tickets">[] = [] + if (queueId) { + candidates = await ctx.db + .query("tickets") + .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId)) + .collect() + } else { + candidates = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + } + + candidates = candidates.filter( + (t) => t.status !== "RESOLVED" && !t.assigneeId + ); + + if (candidates.length === 0) return null; + + // prioritize by priority then createdAt + const rank: Record = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 } + candidates.sort((a, b) => { + const pa = rank[a.priority] ?? 999 + const pb = rank[b.priority] ?? 999 + if (pa !== pb) return pa - pb + return a.createdAt - b.createdAt + }) + + const chosen = candidates[0]; + const now = Date.now(); + const currentStatus = normalizeStatus(chosen.status); + const nextStatus: TicketStatusNormalized = currentStatus; + const assigneeSnapshot = { + name: agent.name, + email: agent.email, + avatarUrl: agent.avatarUrl ?? undefined, + teams: agent.teams ?? undefined, + } + await ctx.db.patch(chosen._id, { + assigneeId: agentId, + assigneeSnapshot, + status: nextStatus, + working: false, + activeSessionId: undefined, + updatedAt: now, + }); + await ctx.db.insert("ticketEvents", { + ticketId: chosen._id, + type: "ASSIGNEE_CHANGED", + payload: { assigneeId: agentId, assigneeName: agent.name }, + createdAt: now, + }); + + // hydrate minimal public ticket like in list + const requester = (await ctx.db.get(chosen.requesterId)) as Doc<"users"> | null + const assignee = chosen.assigneeId ? ((await ctx.db.get(chosen.assigneeId)) as Doc<"users"> | null) : null + const queue = chosen.queueId ? ((await ctx.db.get(chosen.queueId)) as Doc<"queues"> | null) : null + const queueName = normalizeQueueName(queue) + return { + id: chosen._id, + reference: chosen.reference, + tenantId: chosen.tenantId, + subject: chosen.subject, + summary: chosen.summary, + status: nextStatus, + priority: chosen.priority, + channel: chosen.channel, + queue: queueName, + requester: requester + ? buildRequesterSummary(requester, chosen.requesterId, { ticketId: chosen._id }) + : buildRequesterFromSnapshot( + chosen.requesterId, + chosen.requesterSnapshot ?? undefined, + { ticketId: chosen._id } + ), + assignee: chosen.assigneeId + ? assignee + ? { + id: assignee._id, + name: assignee.name, + email: assignee.email, + avatarUrl: assignee.avatarUrl, + teams: normalizeTeams(assignee.teams), + } + : buildAssigneeFromSnapshot(chosen.assigneeId, chosen.assigneeSnapshot ?? undefined) + : null, + slaPolicy: null, + dueAt: chosen.dueAt ?? null, + firstResponseAt: chosen.firstResponseAt ?? null, + resolvedAt: chosen.resolvedAt ?? null, + updatedAt: chosen.updatedAt, + createdAt: chosen.createdAt, + tags: chosen.tags ?? [], + lastTimelineEntry: null, + metrics: null, + } + }, +}); + +export const remove = mutation({ + args: { ticketId: v.id("tickets"), actorId: v.id("users") }, + handler: async (ctx, { ticketId, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + await requireAdmin(ctx, actorId, ticket.tenantId) + // delete comments (and attachments) + const comments = await ctx.db + .query("ticketComments") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect(); + for (const c of comments) { + for (const att of c.attachments ?? []) { + try { await ctx.storage.delete(att.storageId); } catch {} + } + await ctx.db.delete(c._id); + } + // delete events + const events = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect(); + for (const ev of events) await ctx.db.delete(ev._id); + // delete ticket + await ctx.db.delete(ticketId); + // (optional) event is moot after deletion + return true; + }, +}); + +export const reassignTicketsByEmail = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + fromEmail: v.string(), + toUserId: v.id("users"), + dryRun: v.optional(v.boolean()), + limit: v.optional(v.number()), + updateSnapshot: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, actorId, fromEmail, toUserId, dryRun, limit, updateSnapshot }) => { + await requireAdmin(ctx, actorId, tenantId) + + const normalizedFrom = fromEmail.trim().toLowerCase() + if (!normalizedFrom || !normalizedFrom.includes("@")) { + throw new ConvexError("E-mail de origem inválido") + } + + const toUser = await ctx.db.get(toUserId) + if (!toUser || toUser.tenantId !== tenantId) { + throw new ConvexError("Usuário de destino inválido para o tenant") + } + + // Coletar tickets por requesterId (quando possível via usuário antigo) + const fromUser = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedFrom)) + .first() + + const byRequesterId: Doc<"tickets">[] = fromUser + ? await ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", fromUser._id)) + .collect() + : [] + + // Coletar tickets por e-mail no snapshot para cobrir casos sem user antigo + const allTenant = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + const bySnapshotEmail = allTenant.filter((t) => { + const rs = t.requesterSnapshot as { email?: string } | undefined + const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null + if (!email || email !== normalizedFrom) return false + // Evita duplicar os já coletados por requesterId + if (fromUser && t.requesterId === fromUser._id) return false + return true + }) + + const candidatesMap = new Map>() + for (const t of byRequesterId) candidatesMap.set(String(t._id), t) + for (const t of bySnapshotEmail) candidatesMap.set(String(t._id), t) + const candidates = Array.from(candidatesMap.values()) + + const maxToProcess = Math.max(0, Math.min(limit && limit > 0 ? limit : candidates.length, candidates.length)) + const toProcess = candidates.slice(0, maxToProcess) + + if (dryRun) { + return { + dryRun: true as const, + fromEmail: normalizedFrom, + toUserId, + candidates: candidates.length, + willUpdate: toProcess.length, + } + } + + const now = Date.now() + let updated = 0 + for (const t of toProcess) { + const patch: Record = { requesterId: toUserId, updatedAt: now } + if (updateSnapshot) { + patch.requesterSnapshot = { + name: toUser.name, + email: toUser.email, + avatarUrl: toUser.avatarUrl ?? undefined, + teams: toUser.teams ?? undefined, + } + } + await ctx.db.patch(t._id, patch) + await ctx.db.insert("ticketEvents", { + ticketId: t._id, + type: "REQUESTER_CHANGED", + payload: { + fromUserId: fromUser?._id ?? null, + fromEmail: normalizedFrom, + toUserId, + toUserName: toUser.name, + }, + createdAt: now, + }) + updated += 1 + } + + return { + dryRun: false as const, + fromEmail: normalizedFrom, + toUserId, + candidates: candidates.length, + updated, + } + }, +}) diff --git a/referência/sistema-de-chamados-main/convex/tsconfig.json b/referência/sistema-de-chamados-main/convex/tsconfig.json new file mode 100644 index 0000000..7374127 --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/tsconfig.json @@ -0,0 +1,25 @@ +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings are required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2021", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} diff --git a/referência/sistema-de-chamados-main/convex/users.ts b/referência/sistema-de-chamados-main/convex/users.ts new file mode 100644 index 0000000..0c047ff --- /dev/null +++ b/referência/sistema-de-chamados-main/convex/users.ts @@ -0,0 +1,292 @@ +import { mutation, query } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; +import { requireAdmin, requireStaff } from "./rbac"; + +const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); +const CUSTOMER_ROLES = new Set(["COLLABORATOR", "MANAGER"]); + +export const ensureUser = mutation({ + args: { + tenantId: v.string(), + email: v.string(), + name: v.string(), + avatarUrl: v.optional(v.string()), + role: v.optional(v.string()), + teams: v.optional(v.array(v.string())), + companyId: v.optional(v.id("companies")), + jobTitle: v.optional(v.string()), + managerId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email)) + .first(); + const reconcile = async (record: typeof existing) => { + if (!record) return null; + const hasJobTitleArg = Object.prototype.hasOwnProperty.call(args, "jobTitle"); + const hasManagerArg = Object.prototype.hasOwnProperty.call(args, "managerId"); + const jobTitleChanged = hasJobTitleArg ? (record.jobTitle ?? null) !== (args.jobTitle ?? null) : false; + const managerChanged = hasManagerArg + ? String(record.managerId ?? "") !== String(args.managerId ?? "") + : false; + const shouldPatch = + record.tenantId !== args.tenantId || + (args.role && record.role !== args.role) || + (args.avatarUrl && record.avatarUrl !== args.avatarUrl) || + record.name !== args.name || + (args.teams && JSON.stringify(args.teams) !== JSON.stringify(record.teams ?? [])) || + (args.companyId && record.companyId !== args.companyId) || + jobTitleChanged || + managerChanged; + + if (shouldPatch) { + const patch: Record = { + tenantId: args.tenantId, + role: args.role ?? record.role, + avatarUrl: args.avatarUrl ?? record.avatarUrl, + name: args.name, + teams: args.teams ?? record.teams, + companyId: args.companyId ?? record.companyId, + }; + if (hasJobTitleArg) { + patch.jobTitle = args.jobTitle ?? undefined; + } + if (hasManagerArg) { + patch.managerId = args.managerId ?? undefined; + } + await ctx.db.patch(record._id, patch); + const updated = await ctx.db.get(record._id); + if (updated) { + return updated; + } + } + return record; + }; + + if (existing) { + const reconciled = await reconcile(existing); + if (reconciled) { + return reconciled; + } + } else { + const anyTenant = (await ctx.db.query("users").collect()).find((user) => user.email === args.email); + if (anyTenant) { + const reconciled = await reconcile(anyTenant); + if (reconciled) { + return reconciled; + } + } + } + const id = await ctx.db.insert("users", { + tenantId: args.tenantId, + email: args.email, + name: args.name, + avatarUrl: args.avatarUrl, + role: args.role ?? "AGENT", + teams: args.teams ?? [], + companyId: args.companyId, + jobTitle: args.jobTitle, + managerId: args.managerId, + }); + return await ctx.db.get(id); + }, +}); + +export const listAgents = query({ + args: { tenantId: v.string() }, + handler: async (ctx, { tenantId }) => { + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + // Only internal staff (ADMIN/AGENT) should appear as responsáveis + return users + .filter((user) => INTERNAL_STAFF_ROLES.has((user.role ?? "AGENT").toUpperCase())) + .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")); + }, +}); + +export const listCustomers = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const viewerRole = (viewer.role ?? "AGENT").toUpperCase() + let managerCompanyId: Id<"companies"> | null = null + if (viewerRole === "MANAGER") { + managerCompanyId = viewer.user.companyId ?? null + if (!managerCompanyId) { + throw new ConvexError("Gestor não possui empresa vinculada") + } + } + + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const allowed = users.filter((user) => { + const role = (user.role ?? "COLLABORATOR").toUpperCase() + if (!CUSTOMER_ROLES.has(role)) return false + if (managerCompanyId && user.companyId !== managerCompanyId) return false + return true + }) + + const companyIds = Array.from( + new Set( + allowed + .map((user) => user.companyId) + .filter((companyId): companyId is Id<"companies"> => Boolean(companyId)) + ) + ) + + const companyMap = new Map() + if (companyIds.length > 0) { + await Promise.all( + companyIds.map(async (companyId) => { + const company = await ctx.db.get(companyId) + if (company) { + companyMap.set(String(companyId), { + name: company.name, + isAvulso: company.isAvulso ?? undefined, + }) + } + }) + ) + } + + return allowed + .map((user) => { + const companyId = user.companyId ? String(user.companyId) : null + const company = companyId ? companyMap.get(companyId) ?? null : null + return { + id: String(user._id), + name: user.name, + email: user.email, + role: (user.role ?? "COLLABORATOR").toUpperCase(), + companyId, + companyName: company?.name ?? null, + companyIsAvulso: Boolean(company?.isAvulso), + avatarUrl: user.avatarUrl ?? null, + jobTitle: user.jobTitle ?? null, + managerId: user.managerId ? String(user.managerId) : null, + } + }) + .sort((a, b) => a.name.localeCompare(b.name ?? "", "pt-BR")) + }, +}) + + +export const findByEmail = query({ + args: { tenantId: v.string(), email: v.string() }, + handler: async (ctx, { tenantId, email }) => { + const record = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email)) + .first(); + return record ?? null; + }, +}); + +export const deleteUser = mutation({ + args: { userId: v.id("users"), actorId: v.id("users") }, + handler: async (ctx, { userId, actorId }) => { + const user = await ctx.db.get(userId); + if (!user) { + return { status: "not_found" }; + } + + await requireAdmin(ctx, actorId, user.tenantId); + + const assignedTickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", userId)) + .take(1); + + if (assignedTickets.length > 0) { + throw new ConvexError("Usuário ainda está atribuído a tickets"); + } + + const comments = await ctx.db + .query("ticketComments") + .withIndex("by_author", (q) => q.eq("authorId", userId)) + .collect(); + if (comments.length > 0) { + const authorSnapshot = { + name: user.name, + email: user.email, + avatarUrl: user.avatarUrl ?? undefined, + teams: user.teams ?? undefined, + }; + await Promise.all( + comments.map(async (comment) => { + const existingSnapshot = comment.authorSnapshot; + const shouldUpdate = + !existingSnapshot || + existingSnapshot.name !== authorSnapshot.name || + existingSnapshot.email !== authorSnapshot.email || + existingSnapshot.avatarUrl !== authorSnapshot.avatarUrl || + JSON.stringify(existingSnapshot.teams ?? []) !== JSON.stringify(authorSnapshot.teams ?? []); + if (shouldUpdate) { + await ctx.db.patch(comment._id, { authorSnapshot }); + } + }), + ); + } + + // Preserve requester snapshot on tickets where this user is the requester + const requesterTickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", user.tenantId).eq("requesterId", userId)) + .collect(); + if (requesterTickets.length > 0) { + const requesterSnapshot = { + name: user.name, + email: user.email, + avatarUrl: user.avatarUrl ?? undefined, + teams: user.teams ?? undefined, + }; + for (const t of requesterTickets) { + const needsPatch = !t.requesterSnapshot || + t.requesterSnapshot.name !== requesterSnapshot.name || + t.requesterSnapshot.email !== requesterSnapshot.email || + t.requesterSnapshot.avatarUrl !== requesterSnapshot.avatarUrl || + JSON.stringify(t.requesterSnapshot.teams ?? []) !== JSON.stringify(requesterSnapshot.teams ?? []); + if (needsPatch) { + await ctx.db.patch(t._id, { requesterSnapshot }); + } + } + } + + // Limpa vínculo de subordinados + const directReports = await ctx.db + .query("users") + .withIndex("by_tenant_manager", (q) => q.eq("tenantId", user.tenantId).eq("managerId", userId)) + .collect(); + await Promise.all( + directReports.map(async (report) => { + await ctx.db.patch(report._id, { managerId: undefined }); + }) + ); + + await ctx.db.delete(userId); + return { status: "deleted" }; + }, +}); + +export const assignCompany = mutation({ + args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") }, + handler: async (ctx, { tenantId, email, companyId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId) + const user = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email)) + .first() + if (!user) throw new ConvexError("Usuário não encontrado no Convex") + await ctx.db.patch(user._id, { companyId }) + const updated = await ctx.db.get(user._id) + return updated + }, +}) diff --git a/referência/sistema-de-chamados-main/docs/DEPLOY-MANUAL.md b/referência/sistema-de-chamados-main/docs/DEPLOY-MANUAL.md new file mode 100644 index 0000000..4487d5c --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/DEPLOY-MANUAL.md @@ -0,0 +1,46 @@ +# Deploy Manual via VPS + +## Acesso rápido +- Host: 31.220.78.20 +- Usuário: root +- Caminho do projeto: /srv/apps/sistema +- Chave SSH (local): ./codex_ed25519 (chmod 600) +- Login: `ssh -i ./codex_ed25519 root@31.220.78.20` + +## Passo a passo resumido +1. Conectar na VPS usando o comando acima. +2. Dentro de `/srv/apps/sistema`, atualizar o código: + ```bash + cd /srv/apps/sistema + git pull + ``` +3. Aplicar a stack Docker (web + Convex): + ```bash + docker stack deploy --with-registry-auth -c stack.yml sistema + ``` +4. (Opcional) Forçar o rollout do serviço web após o deploy: + ```bash + docker service update --force sistema_web + ``` +5. Verificar status dos serviços: + ```bash + docker stack services sistema + docker service ps sistema_web + ``` +6. Consultar logs em tempo real quando necessário: + ```bash + docker service logs -f sistema_web + docker service logs -f sistema_convex_backend + ``` + +## Quando o GitHub Actions travar +- Execute o fluxo acima manualmente para liberar o deploy. +- Se somente funções Convex mudaram: + ```bash + docker service update --force sistema_convex_backend + ``` +- Se precisar ajustar variáveis de ambiente, edite `/srv/apps/sistema/.env` e reexecute o passo 3. + +## Referências +- Runbook completo: docs/OPERATIONS.md +- Workflow automatizado: .github/workflows/ci-cd-web-desktop.yml diff --git a/referência/sistema-de-chamados-main/docs/DEV.md b/referência/sistema-de-chamados-main/docs/DEV.md new file mode 100644 index 0000000..9e6137d --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/DEV.md @@ -0,0 +1,198 @@ +# Guia de Desenvolvimento — 18/10/2025 + +Este documento consolida o estado atual do ambiente de desenvolvimento, descreve como rodar lint/test/build localmente (e no CI) e registra erros recorrentes com as respectivas soluções. + +## Resumo rápido + +- **Bun (runtime padrão)**: 1.3+ já instalado no runner e VPS (`bun --version`). Após instalar localmente, exporte `PATH="$HOME/.bun/bin:$PATH"` para tornar o binário disponível. Use `bun install`, `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun` e `bun test` como fluxo principal (scripts Node continuam disponíveis como fallback). +- **Node.js**: mantenha a versão 20.9+ instalada para ferramentas auxiliares (Prisma CLI, scripts legados em Node) quando não estiver usando o runtime do Bun. +- **Next.js 16**: Projeto roda em `next@16.0.1` com Turbopack apenas no ambiente de desenvolvimento; builds de produção usam o webpack padrão do framework. +- **Lint/Test/Build**: `bun run lint`, `bun test`, `bun run build:bun`. O test runner do Bun já roda em modo não interativo; utilize `bunx vitest --watch` apenas quando precisar do modo watch manualmente. +- **Banco DEV**: SQLite em `prisma/prisma/db.dev.sqlite`. Defina `DATABASE_URL="file:./prisma/db.dev.sqlite"` ao chamar CLI do Prisma. +- **Desktop (Tauri)**: fonte em `apps/desktop`. Usa Radix tabs + componentes shadcn-like, integra com os endpoints `/api/machines/*` e suporta atualização automática via GitHub Releases. +- **CI**: Workflow `Quality Checks` roda lint/test/build para pushes e PRs na `main`, além do pipeline de deploy existente. + +## Banco de dados (Prisma) + +1. Gere/atualize o schema local: + + ```bash + bun install + DATABASE_URL="file:./prisma/db.dev.sqlite" bunx prisma db push + DATABASE_URL="file:./prisma/db.dev.sqlite" bun run prisma:generate + DATABASE_URL="file:./prisma/db.dev.sqlite" bun run auth:seed + ``` + +2. Rode o app Next.js: + + ```bash + bun run dev:bun + ``` + > Alternativas: `bun run dev` (Node) ou `bun run dev:webpack` se precisar do fallback oficial. + +3. Credenciais padrão (seed): `admin@sistema.dev / admin123`. +4. Herdou dados antigos? Execute `node scripts/remove-legacy-demo-users.mjs` para limpar contas demo legadas. + +> **Por quê inline?** Evitamos declarar `DATABASE_URL` em `prisma/.env` porque o Prisma lê também o `.env` da raiz (produção). O override inline garante isolamento do banco DEV. + +## Next.js 16 (estável) + +- Mantemos o projeto em `next@16.0.1`, com React 19 e o App Router completo. +- **Bundlers**: Turbopack permanece habilitado no `next dev`/`bun run dev:bun` pela velocidade, mas o `next build --webpack` é o caminho oficial para produção. Execute `bun run build:turbopack` apenas para reproduzir bugs. +- **Whitelist de hosts**: o release estável continua sem aceitar `server.allowedHosts` (vide [`invalid-next-config`](https://nextjs.org/docs/messages/invalid-next-config)), portanto bloqueamos domínios exclusivamente via `middleware.ts`. + +### Editor rich text (TipTap) — menções de ticket + +- Menções (`ticketMention`) agora têm prioridade maior (`priority: 1000`) e um Link seguro (`SafeLinkExtension`) foi introduzido para ignorar ``. Isso evita que o `Link` do StarterKit capture as âncoras na hidratação, garantindo que as menções continuem como nodes (não como marks) durante a edição. +- O mesmo helper `normalizeTicketMentionHtml` é aplicado ao carregar/atualizar conteúdo no editor, dentro dos fluxos de comentários e no Convex. Esse helper reescreve qualquer HTML legado (`#123•Assunto`) no formato de chip completo (datasets, spans, dot). +- Resultado: o chip mantém layout e comportamento ao editar (Backspace/Delete removem o node inteiro, node view continua ativo) sem exigir reload. +- Se precisar adicionar novos comportamentos, importe `SafeLinkExtension` e mantenha a ordem `[TicketMentionExtension, StarterKit (link:false), SafeLinkExtension, Placeholder]` para que o parser continue estável. + +## Comandos de qualidade + +- `bun run lint`: executa ESLint (flat config) sobre os arquivos do projeto. +- `bun test`: roda a suíte de testes utilizando o runner nativo do Bun. Para modo watch, use `bunx vitest --watch` manualmente. +- `bun run build:bun`: `next build --webpack` usando o runtime Bun (webpack). +- Scripts com Bun (padrão atual): `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun`, `bun run start:bun`. Eles mantêm os scripts existentes, apenas forçando o runtime do Bun via `bun run --bun`. O `cross-env` garante `NODE_ENV` consistente (`development`/`production`). +- `bun run build:turbopack`: build experimental com Turbopack. Use apenas para debugging/local, pois ainda causa inconsistências em produção. +- `bun run dev:webpack`: fallback do Next em dev quando o Turbopack apresentar problemas. +- `bun run prisma:generate`: necessário antes do build quando o client Prisma muda. Para migrações use `bunx prisma migrate deploy`. + +### Automação no CI + +Arquivo: `.github/workflows/quality-checks.yml` + +Etapas: + +1. Instala dependências (`bun install --frozen-lockfile`). +2. `bun run prisma:generate`. +3. `bun run lint`. +4. `bun test`. +5. `bun run build:bun`. + +O workflow dispara em todo `push`/`pull_request` para `main` e fornece feedback imediato sem depender do pipeline de deploy. + +## Testes rápidos via curl (Convites & acessos) + +1. Rode `bun run dev:bun` (ou `bun run dev` se preferir Node) e autentique-se em `http://localhost:3000/login` usando `admin@sistema.dev / admin123`. +2. Copie o valor do cookie `BETTER_AUTH_SESSION` e exporte no shell: `export COOKIE="BETTER_AUTH_SESSION="`. + +### Usuários + +```bash +# Listar usuários com acesso web +curl -s http://localhost:3000/api/admin/users \ + -H "Cookie: $COOKIE" \ + -H "Accept: application/json" | jq '.users | map({ id, email, role })' # remova o pipe se não tiver jq + +# Criar usuário gestor (ajuste o e-mail se necessário) +NEW_EMAIL="api.teste.$(date +%s)@sistema.dev" +curl -s -X POST http://localhost:3000/api/admin/users \ + -H "Cookie: $COOKIE" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"Usuário via curl\",\"email\":\"$NEW_EMAIL\",\"role\":\"manager\",\"tenantId\":\"tenant-atlas\"}" \ + | tee /tmp/user-created.json + +# Remover o usuário recém-criado +USER_ID=$(jq -r '.user.id' /tmp/user-created.json) +curl -i -X DELETE http://localhost:3000/api/admin/users/$USER_ID \ + -H "Cookie: $COOKIE" +``` + +> Os exemplos acima utilizam `jq` para facilitar a leitura. Se não estiver disponível, remova os pipes e leia o JSON bruto. + +### Convites + +```bash +# Criar convite válido por 7 dias +INVITE_EMAIL="convite.$(date +%s)@sistema.dev" +curl -s -X POST http://localhost:3000/api/admin/invites \ + -H "Cookie: $COOKIE" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$INVITE_EMAIL\",\"name\":\"Convite via curl\",\"role\":\"collaborator\",\"tenantId\":\"tenant-atlas\",\"expiresInDays\":7}" \ + | tee /tmp/invite-created.json + +# Revogar convite pendente +INVITE_ID=$(jq -r '.invite.id' /tmp/invite-created.json) +curl -i -X PATCH http://localhost:3000/api/admin/invites/$INVITE_ID \ + -H "Cookie: $COOKIE" \ + -H "Content-Type: application/json" \ + -d '{"reason":"Revogado via curl"}' + +# Reativar (até 7 dias após a revogação) +curl -i -X PATCH http://localhost:3000/api/admin/invites/$INVITE_ID \ + -H "Cookie: $COOKIE" \ + -H "Content-Type: application/json" \ + -d '{"action":"reactivate"}' +``` + +> Dica: ao receber `409` na criação de convite, há outro convite pendente/aceito para o mesmo e-mail. Revogue ou remova o usuário antes. + +## Desktop (Tauri) + +- Tabs Radix + estilos shadcn: `apps/desktop/src/components/ui/tabs.tsx`. +- Painel principal: `apps/desktop/src/main.tsx` — abas Resumo/Inventário/Diagnóstico/Configurações, envio manual de inventário, seleção de persona (colaborador/gestor) e vínculo com usuário. +- Coleta/hardware: `apps/desktop/src-tauri/src/agent.rs`. +- Variáveis de build: + - `VITE_APP_URL` (URL Web). + - `VITE_API_BASE_URL` (API). + +### Build local + +```bash +bun install +bun install --cwd apps/desktop +VITE_APP_URL=http://localhost:3000 \ +VITE_API_BASE_URL=http://localhost:3000 \ +bun run --cwd apps/desktop tauri build +``` + +Artefatos: `apps/desktop/src-tauri/target/release/bundle/`. + +#### Ícone do instalador (NSIS) + +- O Windows espera que `apps/desktop/src-tauri/icons/icon.ico` contenha sprites em 16, 24, 32, 48, 64, 128 e 256 px, todos com fundo transparente. Sem esses tamanhos o Explorer gera uma miniatura reduzida com bordas acinzentadas. +- Para atualizar o ícone a partir do `icon-512.png`, execute: + + ```bash + cd apps/desktop/src-tauri + python3 - <<'PY' +from PIL import Image +img = Image.open("icons/icon-512.png") +img.save("icons/icon.ico", sizes=[(16,16),(24,24),(32,32),(48,48),(64,64),(128,128),(256,256)]) +PY + ``` + +- Depois de regerar `icon.ico`, faça o commit e rode novamente `bun run --cwd apps/desktop tauri build` para empacotar o instalador com o ícone correto. + +### Atualizações OTA + +1. Gere chaves (`bun run --cwd apps/desktop tauri signer generate`). +2. Defina `TAURI_SIGNING_PRIVATE_KEY` (+ password) no ambiente de build. +3. Publique os pacotes e um `latest.json` em release GitHub. +4. O app verifica ao iniciar e pelo botão “Verificar atualizações”. + +## Erros recorrentes e soluções + +| Sintoma | Causa | Correção | +| --- | --- | --- | +| `ERR_BUN_LOCKFILE_OUTDATED` no pipeline | Dependências do desktop alteradas sem atualizar o `bun.lock` | Rodar `bun install` (raiz e `apps/desktop`) e commitar o lockfile. | +| Prisma falha com `P2021` / tabelas Better Auth inexistentes | CLI leu `.env` da raiz (produção) | Usar `DATABASE_URL="file:./prisma/db.dev.sqlite"` nos comandos. | +| Vitest trava em modo watch | Script `bun test` sem `--run` e CI detecta TTY | Ajustado para `vitest --run --passWithNoTests`. Localmente, use `bun test -- --watch` se quiser. | +| Desktop não encontra updater | Falta `latest.json` ou assinatura inválida | Publicar release com `*.sig` e `latest.json` apontando para os pacotes corretos. | + +## Cronômetro dos tickets + +- A UI e o backend agora compartilham um relógio real alinhado via `serverNow`. Toda resposta de `tickets.workSummary`, listagens de tickets e mutations `startWork/pauseWork` envia `serverNow` (epoch UTC). +- O frontend (`ticket-summary-header` e tabela) calcula um deslocamento (`offset = Date.now() - serverNow`) e projeta `Date.now()` para a linha do tempo do servidor usando `toServerTimestamp`. Isso elimina drift quando o navegador está adiantado/atrasado. +- Durante o `startWork`, se a mutation retornar `status = already_started` sem `startedAt`, a UI usa `getServerNow()` como fallback, e assim que o Convex reenviar a sessão ativa, o reconciliador (`reconcileLocalSessionStart`) substitui o valor local. +- O tempo em execução exibido “ao vivo” torna-se idêntico ao acumulado após pausar, com tolerância de ±1 s por conta do tick do cronômetro. Ao depurar inconsistências, verifique se `serverNow` está chegando (painel de rede) e se o offset em `ticket-timer.utils.ts` está sendo calibrado. + +## Referências úteis + +- **Deploy (Swarm)**: veja `docs/DEPLOY-RUNBOOK.md`. +- **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`. +- **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.md`. + +> Última revisão: 18/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem. +- **Next.js 16 (beta)**: comportamento sujeito a mudanças. Antes de subir para stable, acompanhe o changelog oficial (quebra: `revalidateTag` com segundo argumento, params assíncronos, etc.). Já estamos compatíveis com os breaking changes atuais. diff --git a/referência/sistema-de-chamados-main/docs/OPERACAO-PRODUCAO.md b/referência/sistema-de-chamados-main/docs/OPERACAO-PRODUCAO.md new file mode 100644 index 0000000..6eeeef1 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/OPERACAO-PRODUCAO.md @@ -0,0 +1,342 @@ +# Runbook de Operação — Produção (Traefik + Convex Self‑Hosted) — Arquivo + +Nota: este documento foi substituído por `docs/operations.md` e permanece aqui como histórico. + +> Documento vivo. Guia completo para (1) preparar a VPS, (2) fazer deploy com Traefik/Swarm, (3) publicar o backend Convex self‑hosted, (4) popular seeds e (5) operar/atualizar com ou sem CI/CD. Tudo em PT‑BR. + +## Visão geral +- Frontend (Next.js) público em `tickets.esdrasrenan.com.br`. +- Backend Convex self‑hosted em `convex.esdrasrenan.com.br` (imagem oficial Convex). +- Traefik no Docker Swarm (rede `traefik_public`) roteando por hostname (sem conflito de portas). +- Banco Prisma (SQLite) persistente via volume `sistema_db` (mapeado em `/app/data`). +- Estado do Convex persistente via volume `convex_data`. +- Seeds prontos (Better Auth e dados demo Convex). +- Seeds Better Auth automáticos: o container do web executa `bun run auth:seed` após `prisma migrate deploy`, garantindo usuários padrão em toda inicialização (sem resetar senha existente por padrão). + +### Sessão de dispositivo (Desktop/Tauri) +- A rota `GET /machines/handshake?token=...&redirect=/portal|/dashboard` é pública no middleware para permitir a criação da sessão "machine" a partir do agente desktop, mesmo sem login prévio. +- Após o handshake, o servidor grava cookies de sessão (Better Auth) e um cookie `machine_ctx` com o vínculo (persona, assignedUser*, etc.). Em seguida, o App carrega `/api/machines/session` para preencher o contexto no cliente (`machineContext`). +- Com esse contexto, o Portal exibe corretamente o nome/e‑mail do colaborador/gestor no cabeçalho e permite abrir chamados em nome do usuário vinculado. +- Em sessões de dispositivo, o botão "Encerrar sessão" no menu do usuário é ocultado por padrão na UI interna. + +#### Detalhes importantes (aprendidos em produção) +- CORS com credenciais: as rotas `POST /api/machines/sessions` e `GET /machines/handshake` precisam enviar `Access-Control-Allow-Credentials: true` para que os cookies do Better Auth sejam aceitos na WebView. +- Vários `Set-Cookie`: alguns navegadores/ambientes colapsam cabeçalhos. Para confiabilidade, usamos `NextResponse.cookies.set(...)` para cada cookie, em vez de repassar o cabeçalho bruto. +- Top-level navigation: mesmo tentando criar a sessão via `POST /api/machines/sessions`, mantemos a navegação final pelo `GET /machines/handshake` (primeira parte) para maximizar a aceitação de cookies no WebView. +- Front tolerante: o portal preenche `machineContext` mesmo quando `/api/auth/get-session` retorna `null` na WebView e deriva a role "machine" do contexto — assim o colaborador consegue abrir tickets normalmente. +- Página de diagnóstico: `GET /portal/debug` exibe o status/JSON de `/api/auth/get-session` e `/api/machines/session` com os mesmos cookies da aba. + +#### Troubleshooting rápido +1. Abra o app desktop e deixe ele redirecionar para `/portal/debug`. +2. Se `machines/session` for 200 e `get-session` for `null`, está OK — o portal usa `machineContext` assim mesmo. +3. Se `machines/session` for 401/403: + - Verifique CORS/credenciais (`Access-Control-Allow-Credentials: true`). + - Garante que estamos usando `cookies.set` para aplicar todos os cookies da Better Auth. + - Refaça o handshake (feche reabra o desktop). Opcional: renomeie `EBWebView` para limpar cookies no Windows. + +## Requisitos +- VPS com Docker/Swarm e Traefik já em execução na rede externa `traefik_public`. +- Portainer opcional (para gerenciar a stack “sistema”). +- Domínios apontando para a VPS: + - `tickets.esdrasrenan.com.br` (frontend) + - `convex.esdrasrenan.com.br` (Convex backend) + +## Layout do servidor +- Código do projeto: `/srv/apps/sistema` (bind no stack). +- Volumes Swarm: + - `sistema_db` → `/app/data` (SQLite / Prisma) + - `convex_data` → `/convex/data` (Convex backend) + +## .env (produção) +Arquivo base: `.env` na raiz do projeto. Exemplo mínimo (substitua domínios/segredos): + +``` +NEXT_PUBLIC_APP_URL=https://tickets.esdrasrenan.com.br +BETTER_AUTH_URL=https://tickets.esdrasrenan.com.br +NEXT_PUBLIC_CONVEX_URL=https://convex.esdrasrenan.com.br +BETTER_AUTH_SECRET= +DATABASE_URL=file:./prisma/db.sqlite + +# Seeds automáticos (Better Auth) +# Garante usuários padrão sem resetar senhas existentes +SEED_ENSURE_ONLY=true + +# SMTP +SMTP_ADDRESS= +SMTP_PORT=465 +SMTP_DOMAIN= +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_AUTHENTICATION=login +SMTP_ENABLE_STARTTLS_AUTO=false +SMTP_TLS=true +MAILER_SENDER_EMAIL="Nome " + +# Dispositivo/inventário +MACHINE_PROVISIONING_SECRET= +MACHINE_TOKEN_TTL_MS=2592000000 +FLEET_SYNC_SECRET= + +# Outros +CONVEX_SYNC_SECRET=dev-sync-secret +ALERTS_LOCAL_HOUR=8 +SYNC_TENANT_ID=tenant-atlas +SEED_TENANT_ID=tenant-atlas + +# Importante para self-hosted: comentar se existir +# CONVEX_DEPLOYMENT=... +``` + +Atenção +- `MAILER_SENDER_EMAIL` precisa de aspas se contiver espaços. +- Em self‑hosted, NÃO usar `CONVEX_DEPLOYMENT`. + +## Stack (Docker Swarm + Traefik) +O arquivo do stack está versionado em `stack.yml`. Ele sobe: +- `web` (Next.js) — builda e roda na porta interna 3000 (roteada pelo Traefik por hostname). +- `convex_backend` — imagem oficial do Convex self‑hosted (porta interna 3210, roteada pelo Traefik). + +Bind dos volumes (absolutos, compatível com Portainer/Swarm): +- `/srv/apps/sistema:/app` → código do projeto dentro do container web. +- volume `sistema_db` → `/app/data` (SQLite do Prisma). +- volume `convex_data` → `/convex/data` (estado do Convex backend). + +Rótulos Traefik (labels) no stack mapeiam: +- `tickets.esdrasrenan.com.br` → serviço `web` porta 3000. +- `convex.esdrasrenan.com.br` → serviço `convex_backend` porta 3210. + +## Deploy da stack +Via Portainer (recomendado) +1. Abra o Portainer → Stacks → Add/Update e cole o conteúdo de `stack.yml` (ou vincule ao repositório para “Pull/Deploy”). +2. Clique em “Deploy the stack” (ou “Update the stack”). + +Via CLI +``` +docker stack deploy --with-registry-auth -c /srv/apps/sistema/stack.yml sistema +``` + +Verificação +- Serviços: `docker stack services sistema` +- Logs: + - `docker service logs -f sistema_web` + - `docker service logs -f sistema_convex_backend` + +Acesso +- App: `https://tickets.esdrasrenan.com.br` +- Convex: `https://convex.esdrasrenan.com.br` (o importante é o WebSocket do cliente conectar; o path `/version` responde para sanity‑check) + +## Zero‑downtime (sem queda durante deploy) + +Para evitar interrupção perceptível no deploy, habilitamos rollout "start-first". Para este projeto, mantemos **1 réplica** (web e Convex) por segurança, pois: +- O web usa SQLite (Prisma); múltiplas réplicas concorrendo gravação no mesmo arquivo podem causar erros de lock/readonly. +- O Convex backend self‑hosted não é clusterizado. + +O `stack.yml` já inclui: +- `replicas: 1` + `update_config.order: start-first` (Swarm sobe a nova task saudável antes de desligar a antiga). +- `failure_action: rollback`. +- `healthcheck` por porta local, garantindo que o Swarm só troque quando a nova task estiver OK. + +Se quiser ajustar recursos/estratégia: +``` +deploy: + replicas: 2 + update_config: + parallelism: 1 + order: start-first + failure_action: rollback + restart_policy: + condition: any +healthcheck: + # web + test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 30s +``` + +Observação: o CI já força `docker service update --force` após `stack deploy` e passa `RELEASE_SHA` no ambiente para variar a spec em todo commit, assegurando rollout. + +## App Desktop (Tauri) +- Build local por SO: + - Linux: `bun run --cwd apps/desktop tauri build` + - Windows/macOS: executar o mesmo comando no respectivo sistema (o Tauri gera .msi/.dmg/.app). +- Por padrão, o executável em modo release usa `https://tickets.esdrasrenan.com.br` como `APP_URL` e `API_BASE_URL`. +- Para customizar, crie `apps/desktop/.env` com `VITE_APP_URL` e `VITE_API_BASE_URL`. +- Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/`. + +### Alertas de postura (opcional) +- Variáveis de ambiente para geração automática de tickets em alertas de postura (CPU alta, serviço parado, SMART em falha): + - `MACHINE_ALERTS_CREATE_TICKETS=true|false` (padrão: true) + - `MACHINE_ALERTS_TICKET_REQUESTER_EMAIL=admin@sistema.dev` (usuário solicitante dos tickets automáticos) + +### CI de Release do Desktop +- Workflow: `.github/workflows/desktop-release.yml` (build Linux/Windows/macOS). +- Preencha os Secrets no repositório (Settings > Secrets > Actions): + - `TAURI_PRIVATE_KEY` + - `TAURI_KEY_PASSWORD` +- Disparo: tag `desktop-v*` ou via `workflow_dispatch`. + + +### Dashboard (opcional) +Você pode expor o painel do Convex para inspeção em produção. + +DNS +- Criar `convex-admin.esdrasrenan.com.br` apontando para a VPS. + +Stack +- O serviço `convex_dashboard` já está definido em `stack.yml` com Traefik. Após atualizar a stack: + - Acesse `https://convex-admin.esdrasrenan.com.br`. + - Use a Admin Key gerada por `./generate_admin_key.sh` para autenticar. + +## Convex self‑hosted — configuração inicial +1. Gerar Admin Key (uma vez, dentro do container do Convex): +``` +# Console/exec no container sistema_convex_backend +./generate_admin_key.sh +# Copiar/guardar a chave: convex-self-hosted|... +``` +2. Publicar o código Convex (deploy das functions) — sem instalar nada na VPS: +``` +docker run --rm -it \ + -v /srv/apps/sistema:/app \ + -w /app \ + -e CONVEX_SELF_HOSTED_URL=https://convex.esdrasrenan.com.br \ + -e CONVEX_SELF_HOSTED_ADMIN_KEY='COLE_A_CHAVE_AQUI' \ + oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x convex deploy" +``` + +Observação +- Sempre que alterar código em `convex/`, repita o comando acima para publicar as mudanças. + +### Variáveis do Convex (importante) +As functions do Convex leem variáveis via `convex env`, não do `.env` do container. +No CI, defina os seguintes Secrets (Repo → Settings → Secrets and variables → Actions): + +- `CONVEX_SELF_HOSTED_URL` — ex.: `https://convex.esdrasrenan.com.br` +- `CONVEX_SELF_HOSTED_ADMIN_KEY` — gerada por `./generate_admin_key.sh` +- `MACHINE_PROVISIONING_SECRET` — hex forte +- (opcional) `MACHINE_TOKEN_TTL_MS` — ex.: `2592000000` +- (opcional) `FLEET_SYNC_SECRET` + +O job `convex_deploy` sempre roda `convex env set` com os Secrets acima antes do `convex deploy`. +Se preferir setar manualmente: + +- `MACHINE_PROVISIONING_SECRET` — obrigatório para `/api/machines/register` +- (opcional) `MACHINE_TOKEN_TTL_MS`, `FLEET_SYNC_SECRET` + +CLI manual (exemplo): +``` +docker run --rm -it \ + -v /srv/apps/sistema:/app -w /app \ + -e CONVEX_SELF_HOSTED_URL=https://convex.esdrasrenan.com.br \ + -e CONVEX_SELF_HOSTED_ADMIN_KEY='COLE_A_CHAVE' \ + oven/bun:1 bash -lc "set -euo pipefail; bun install --frozen-lockfile; \ + unset CONVEX_DEPLOYMENT; \ + bun x convex env set MACHINE_PROVISIONING_SECRET 'seu-hex' -y; \ + bun x convex env list" +``` + +### Smoke test pós‑deploy (CI) +O pipeline executa um teste rápido após o deploy do Web: +- Registra uma dispositivo fake usando `MACHINE_PROVISIONING_SECRET` do `/srv/apps/sistema/.env` +- Espera `HTTP 201` e extrai `machineToken` +- Envia `heartbeat` e espera `HTTP 200` +- Se falhar, o job é marcado como erro (evita regressões silenciosas) + +## Seeds +- Dados de demonstração Convex: acesse uma vez `https://tickets.esdrasrenan.com.br/dev/seed`. +- Usuários (Better Auth): +``` +CONTAINER=$(docker ps --format '{{.ID}} {{.Names}}' | grep sistema_web | awk '{print $1}' | head -n1) +docker exec -it "$CONTAINER" bash -lc 'cd /app && bun run auth:seed' +``` +- Apenas um admin (em produção): +``` +CONTAINER=$(docker ps --format '{{.ID}} {{.Names}}' | grep sistema_web | awk '{print $1}' | head -n1) +docker exec -it "$CONTAINER" bash -lc 'cd /app && \ + SEED_USER_EMAIL="seu-email@dominio.com" \ + SEED_USER_PASSWORD="suaSenhaForte" \ + SEED_USER_NAME="Seu Nome" \ + SEED_USER_ROLE="admin" \ + bun run auth:seed' +``` +- Filas padrão: `docker exec -it "$CONTAINER" bash -lc 'cd /app && bun run queues:ensure'` + +## Atualizações (sem CI) +- App (Next.js): +``` +cd /srv/apps/sistema +git pull +docker stack deploy --with-registry-auth -c stack.yml sistema +``` +- Convex (functions): repetir o container `oven/bun:1` com `bun x convex deploy` (ver seção Convex). +- Reiniciar serviços sem alterar o stack: `docker service update --force sistema_web` (ou `sistema_convex_backend`). + +## CI/CD (GitHub Actions + runner self‑hosted) +1. Registrar runner na VPS: + - Repo → Settings → Actions → Runners → New self‑hosted → Linux + - Labels: `self-hosted, linux, vps` +2. Ajustar job `deploy` em `.github/workflows/ci-cd-web-desktop.yml` para: + - `cd /srv/apps/sistema && git pull` + - `docker stack deploy --with-registry-auth -c stack.yml sistema` +3. Adicionar job `convex_deploy` (opcional) no mesmo runner: + - Executar container `oven/bun:1` com envs `CONVEX_SELF_HOSTED_URL` e `CONVEX_SELF_HOSTED_ADMIN_KEY` (secrets do GitHub) + - Rodar `bun x convex deploy` + +Secrets necessários no GitHub (Repo → Settings → Secrets and variables → Actions) +- `CONVEX_SELF_HOSTED_URL` = `https://convex.esdrasrenan.com.br` +- `CONVEX_SELF_HOSTED_ADMIN_KEY` = chave retornada por `./generate_admin_key.sh` +- (Desktop) `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, `TAURI_PRIVATE_KEY`, `TAURI_KEY_PASSWORD` — se usar o job de release desktop + +Benefícios +- Push na `main` → pipeline atualiza app e (opcionalmente) publica mudanças no Convex. + +## Trocar domínio ou VPS (checklist) +1. Criar DNS para novos domínios (app/convex) apontando para a VPS nova. +2. Copiar o projeto para `/srv/apps/sistema` na nova VPS. +3. Ajustar `.env` com novos domínios e SEGREDOS novos (gire novamente em produção). +4. Deploy da stack. +5. Convex: gerar Admin Key no novo container e `convex deploy` apontando para a nova URL. +6. Testar front + WS (sem erro 1006) e seeds conforme necessário. + +## Problemas comuns e correções +- WebSocket 1006 no front: + - Convex não está recebendo/concluindo handshake WS → ver logs `sistema_convex_backend`. + - DNS/Traefik incorretos → confirmar labels/hostnames e DNS. +- `MAILER_SENDER_EMAIL` com erro de parsing: + - Adicionar aspas no `.env`. +- Lockfile desatualizado: + - Rode `bun install --frozen-lockfile` sempre que ajustar dependências para manter o `bun.lock` consistente em produção. +- Portainer erro de bind relativo: + - Usar caminho absoluto `/srv/apps/sistema:/app` no stack (feito). +- Prisma CLI “not found”: + - Execute `bun install` no container de build garantindo a instalação das devDependencies (Prisma CLI fica disponível via `bun x prisma ...`). +- Convex CLI pedindo interação: + - Não usar CLI em produção; usamos imagem oficial `convex-backend` e `convex deploy` via container transitório com Admin Key. + +## Comandos úteis +- Serviços: `docker stack services sistema` +- Logs: `docker service logs -f sistema_web` / `docker service logs -f sistema_convex_backend` +- Reiniciar: `docker service update --force ` +- Status Traefik (se exposto): `docker service logs -f traefik` + +## Segurança +- Nunca commit `.env` com segredos reais. +- Guarde a `Admin Key` do Convex em local seguro; use secrets no GitHub. +- Gire segredos ao migrar de VPS/domínio. + +## Referências +- Stack do projeto: `stack.yml` +- CI/CD (web + desktop): `.github/workflows/ci-cd-web-desktop.yml` +- Guia CI/CD Desktop: `apps/desktop/docs/guia-ci-cd-web-desktop.md` +- Docs Convex self‑hosted: imagem oficial `ghcr.io/get-convex/convex-backend` + +## Bundlers (Next.js) +- Em desenvolvimento utilizamos Turbopack (`next dev --turbopack`) pela velocidade incremental. +- Builds de produção rodam com `next build --webpack` para evitar mismatches de chunks vistos com o Turbopack em produção. +- Scripts principais (package.json): + - `dev`: `next dev --turbopack` + - `build`: `next build --webpack` + - `build:turbopack`: `next build --turbopack` (uso pontual para debug) +- O workflow de CI executa `bun run build:bun` (que agora roda `next build --webpack` via Bun) e a stack continua a usar `bun run start:bun` sobre o artefato gerado. diff --git a/referência/sistema-de-chamados-main/docs/OPERATIONS.md b/referência/sistema-de-chamados-main/docs/OPERATIONS.md new file mode 100644 index 0000000..6900346 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/OPERATIONS.md @@ -0,0 +1,221 @@ +# Operações — Sistema de Chamados (Prod) + +Este documento consolida as mudanças recentes, o racional por trás delas e o procedimento de operação/deploy (Web e Convex self‑hosted) do ambiente em produção. + +## 1) Mudanças Funcionais (Front + Server) + +- Empresas (admin) + - “Slug” renomeado para “Apelido” (mensagens e placeholders ajustados). + +- Fila padrão ao criar tickets + - Todo novo ticket entra na fila “Chamados”. + - Implementado no backend (fallback) e pré‑seleção na UI. + +- Status de tickets e interações + - Nomes/cores atualizados: + - Pendente (cinza), Em andamento (azul), Pausado (amarelo), Resolvido (verde). + - Transições automáticas: + - Iniciar (play) → status “Em andamento”. + - Pausar → status “Pausado”. + - “Encerrar” permanece manual. O dropdown foi substituído por badge + botão “Encerrar”. + - Diálogo de encerramento: botão “Cancelar” adicionado (além de “Limpar mensagem”/“Encerrar ticket”). + +- Dashboard — Últimos chamados + - Prioridade: sem responsável primeiro (dos mais antigos para os mais recentes), depois demais chamados. + +- Filtros de tickets + - Filtro por “Responsável” (agente/admin) adicionado. + - Salvar filtro como padrão por usuário (localStorage) + “Limpar padrão”. + - Empresas no filtro: lista completa via API admin (não só empresas presentes em tickets). + - Produção: filtro “Responsável” agora é feito no servidor (assigneeId); o front não envia mais parâmetros inválidos. + +- Editor de comentários (Tiptap) + - Correção: reativar edição quando um responsável é atribuído (o editor agora reflete mudanças em `disabled` via `setEditable`). + +## 2) Convex (Self‑Hosted) — Ajustes e Motivo + +- Problema observado: deploy do Convex falhava no CI por: + - Ausência de `convex.json` (link do projeto) no servidor. + - Uso incorreto de `CONVEX_DEPLOYMENT` junto a `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY` (não suportado pelo CLI ao usar self‑hosted). + - Divergência de schema (campo `provisioningCode` já existente nos dados, mas ausente no schema do Convex no servidor). + +- Medidas aplicadas: + - Atualização do schema do Convex no servidor: inclusão de `provisioningCode?: string` na tabela `companies` (e índice opcional `by_provisioning_code`). + - Criação do link de projeto (`convex.json`) no servidor via wizard do CLI (ver Passo a passo abaixo). + - Ajustes no workflow do GitHub Actions para self‑hosted: + - Adicionado passo “Acquire Convex admin key” no job de deploy do Convex. + - Removido `CONVEX_DEPLOYMENT` quando `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY` estão definidos. + - Cópia automática do `convex.json` de `/srv/apps/sistema` para o diretório de build temporário. + - Forçar redeploy das funções: tocar arquivos sob `convex/**` para acionar o filtro do job “Deploy Convex functions”. + +## 3) CI/CD — Visão Geral + +- Pipeline “CI/CD Web + Desktop” (GitHub Actions) + - Job “Detect changes” usa filtros por paths. + - Job “Deploy (VPS Linux)” cuida do Web (Next.js) e stack do Swarm. + - Job “Deploy Convex functions” roda quando há mudanças em `convex/**` ou via `workflow_dispatch`. + - Passos relevantes: + - “Acquire Convex admin key” (via container `sistema_convex_backend`). + - “Bring convex.json from live app if present” (usa o arquivo de link do projeto em `/srv/apps/sistema`). + - “convex env list” e “convex deploy” com `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY`. + +## 4) Troca de colaborador / reaproveitamento de dispositivo + +Quando um computador muda de dono (ex.: João entrega o equipamento antigo para Maria e recebe uma dispositivo nova), siga este checklist para manter o inventário consistente: + +1. **No painel (Admin → Dispositivos)** + - Abra os detalhes da dispositivo que será reaproveitada (ex.: a “amarela” que passará da TI/João para a Maria). + - Clique em **Resetar agente**. Isso revoga todos os tokens gerados para aquele equipamento; ele precisará ser reprovisionado antes de voltar a reportar dados. + - Abra **Ajustar acesso** e altere o e-mail para o do novo usuário (Maria). Assim, quando o agente se registrar novamente, o painel já mostrará a responsável correta. + +2. **Na dispositivo física que ficará com o novo colaborador** + - Desinstale o desktop agent (Painel de Controle → remover programas). + - Instale novamente o desktop agent. Use o mesmo **código da empresa/tenant** e informe o **e-mail do novo usuário** (Maria). O backend emite um token novo e reaproveita o registro da dispositivo, mantendo o histórico. + +3. **Dispositivo nova para o colaborador antigo** + - Instale o desktop agent do zero na dispositivo que o João vai usar (ex.: a “azul”). Utilize o mesmo código da empresa e o e-mail do João. + - A dispositivo azul aparecerá como um **novo registro** no painel (inventário/tickets começarão do zero). Renomeie/associe conforme necessário. + +4. **Verificação final** + - A dispositivo antiga (amarela) continua listada, agora vinculada à Maria, com seus tickets históricos. + - A dispositivo nova (azul) aparece como um segundo registro para o João. Ajuste hostname/descrição para facilitar a identificação. + +> Não é necessário excluir registros. Cada dispositivo mantém seu histórico; o reset garante apenas que o token antigo não volte a sobrescrever dados quando o hardware mudar de mãos. + - Importante: não usar `CONVEX_DEPLOYMENT` em conjunto com URL + ADMIN_KEY. + +- Como forçar o deploy do Convex + - Faça uma alteração mínima em `convex/**` (ex.: comentário em `convex/tickets.ts`) ou rode o workflow em “Run workflow” (workflow_dispatch). + +## 4) Convex — Provisionamento inicial (self‑hosted) + +Executar apenas 1x na VPS para criar o link do projeto (`convex.json`): + +```bash +cd /srv/apps/sistema +export CONVEX_SELF_HOSTED_URL="https://convex.esdrasrenan.com.br" +CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}') +export CONVEX_SELF_HOSTED_ADMIN_KEY="$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)" + +npx convex dev --once --configure=new +# Siga o wizard (self-hosted) e vincule/crie o projeto/deployment (ex.: "sistema" / "default"). +# Isso gera /srv/apps/sistema/convex.json +``` + +Depois disso, o job “Deploy Convex functions” funciona em modo não interativo. + +## 5) VPS — Acesso e Serviços + +- Acesso + - Host: `31.220.78.20` + - Usuário: `root` + - Chave SSH (repo raiz): `./codex_ed25519` (Atenção: manter permissões 600) + - Exemplo: `ssh -i ./codex_ed25519 root@31.220.78.20` + - Opcional (endurecimento): desabilitar login por senha após validar a chave. + +- Diretórios principais + - Código do app: `/srv/apps/sistema` + - Arquivo do projeto Convex: `/srv/apps/sistema/convex.json` + - Stack do Swarm: `stack.yml` (no repositório; aplicado no servidor via CI). + +- Serviços (Docker Swarm + Traefik) + - Web (Next.js): serviço `sistema_web`, exposto em `tickets.esdrasrenan.com.br`. + - Convex backend: serviço `sistema_convex_backend`, exposto em `convex.esdrasrenan.com.br`. + - Convex dashboard: `convex-admin.esdrasrenan.com.br`. + - Comandos úteis: + - `docker service ls` + - `docker service ps sistema_web` + - `docker service update --force sistema_web` (reiniciar) + - `docker service update --force sistema_convex_backend` (reiniciar Convex) + +- Convex admin key (diagnóstico) + - `docker exec -i $(docker ps | awk '/sistema_convex_backend/{print $1; exit}') /bin/sh -lc './generate_admin_key.sh'` + - Usada no CI para `convex env list` e `convex deploy`. + +## 6) Notas de Segurança + +- A chave privada `codex_ed25519` está na raiz do repo (ambiente atual). Em produção, recomenda‑se: + - Remover a chave do repositório ou armazená‑la em Secrets/Deploy keys do provedor. + - Desabilitar login por senha no SSH (apenas chave). + - Manter permissões: `chmod 600 ./codex_ed25519`. + +## 7) Testes, Build e Lint + +- Local + - `bun run build:bun` (Next + typecheck) + - `bun run lint` + - `bun test` + +- CI garante build, lint e testes antes do deploy. + +## 8) Troubleshooting Rápido + +- “No CONVEX_DEPLOYMENT set” durante o deploy do Convex + - Certifique‑se de que `/srv/apps/sistema/convex.json` existe (rodar wizard `npx convex dev --once --configure=new`). + - Não usar `CONVEX_DEPLOYMENT` com `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY`. + +- “Schema validation failed” (campo extra `provisioningCode`) + - Atualize o schema do Convex no servidor para incluir `provisioningCode?: string` em `companies`. + - Refaça o deploy. + +- Filtro “Responsável” não funciona + - Front envia `assigneeId` e o backend Convex deve aceitar esse parâmetro (função `tickets.list`). + - Se necessário, forçar redeploy das funções (`convex/**`). + +- Admin ▸ Dispositivos travado em skeleton infinito + - Abra o DevTools (console) e filtre por `admin-machine-details`. Se o log mostrar `machineId: undefined`, o componente não recebeu o parâmetro da rota. + - Verifique se o `page.tsx` está passando `params.id` corretamente ou se o componente client-side usa `useParams()` / `machineId` opcional. + - Deploys antigos antes de `fix(machines): derive machine id from router params` precisam desse patch; sem ele o fallback nunca dispara e o skeleton permanece. + +--- + +Última atualização: sincronizado após o deploy bem‑sucedido do Convex e do Front (20/10/2025). + +## 9) Admin ▸ Usuários e Dispositivos — Unificação e UX + +Resumo das mudanças aplicadas no painel administrativo para simplificar “Usuários” e “Agentes de dispositivo” e melhorar o filtro em Dispositivos: + +- Unificação de “Usuários” e “Agentes de dispositivo” + - Antes: abas separadas “Usuários” (pessoas) e “Agentes de dispositivo”. + - Agora: uma só aba “Usuários” com filtro de tipo (Todos | Pessoas | Dispositivos). + - Onde: `src/components/admin/admin-users-manager.tsx:923`, aba `value="users"` em `:1147`. + - Motivo: evitar confusão entre “usuário” e “agente”; agentes são um tipo especial de usuário (role=machine). A unificação torna “Convites e Acessos” mais direta. + +- Dispositivos ▸ Filtro por Empresa com busca e remoção do filtro de SO + - Adicionado dropdown de “Empresa” com busca (Popover + Input) e removido o filtro por Sistema Operacional. + - Onde: `src/components/admin/devices/admin-devices-overview.tsx:1038` e `:1084`. + - Motivo: fluxo real usa empresas com mais frequência; filtro por SO era menos útil agora. + +- Windows ▸ Rótulo do sistema sem duplicidade do “major” + - Exemplo: “Windows 11 Pro (26100)” em vez de “Windows 11 Pro 11 (26100)”. + - Onde: `src/components/admin/devices/admin-devices-overview.tsx` (função `formatOsVersionDisplay`). + - Motivo: legibilidade e padronização em chips/cartões. + +- Vínculos visuais entre dispositivos e pessoas + - Cards de dispositivos mostram “Usuário vinculado” quando disponível (assignment/metadata): `src/components/admin/devices/admin-devices-overview.tsx:3198`. + - Editor de usuário exibe “Dispositivos vinculadas” (derivado de assign/metadata): `src/components/admin/admin-users-manager.tsx` (seção “Dispositivos vinculadas” no sheet de edição). + - Observação: por ora é leitura; ajustes detalhados de vínculo permanecem em Admin ▸ Dispositivos. + +### Identidade, e‑mail e histórico (reinstalação) + +- Identificador imutável: o histórico (tickets, eventos) referencia o `userId` (imutável). O e‑mail é um atributo mutável. +- Reinstalação do desktop para o mesmo colaborador: reutilize a mesma conta de usuário (mesmo `userId`); se o e‑mail mudou, atualize o e‑mail dessa conta no painel. O histórico permanece, pois o `userId` não muda. +- Novo e‑mail como nova conta: se criar um usuário novo (novo `userId`), será considerado um colaborador distinto e não herdará o histórico. +- Caso precise migrar histórico entre contas diferentes (merge), recomendamos endpoint/rotina de “fusão de contas” (remapear `userId` antigo → novo). Não é necessário para a troca de e‑mail da mesma conta. + +### Vínculos múltiplos de usuários por dispositivo (Fase 2) + +- Estrutura (Convex): + - `machines.linkedUserIds: Id<"users">[]` — lista de vínculos adicionais além do `assignedUserId` (principal). + - Mutations: `machines.linkUser(machineId, email)`, `machines.unlinkUser(machineId, userId)`. + - APIs admin: `POST /api/admin/devices/links` (body: `{ machineId, email }`), `DELETE /api/admin/devices/links?machineId=..&userId=..`. +- UI: + - Detalhes da dispositivo mostram “Usuários vinculados” com remoção por item e campo para adicionar por e‑mail. + - Editor de usuário mostra “Dispositivos vinculadas” consolidando assignment, metadata e `linkedUserIds`. +- Racional: permitir que uma dispositivo tenha mais de um colaborador/gestor associado, mantendo um “principal” (persona) para políticas e contexto. + +### Onde editar + +- Usuários (pessoas): editar nome, e‑mail, papel, tenant e empresa; redefinir senha pelo painel. Arquivo: `src/components/admin/admin-users-manager.tsx`. +- Agentes (dispositivos): provisionamento automático; edição detalhada/vínculo principal em Admin ▸ Dispositivos. Arquivo: `src/components/admin/devices/admin-devices-overview.tsx`. + +> Observação operacional: mantivemos o provisionamento de dispositivos inalterado (token/e‑mail técnico), e o acesso web segue apenas para pessoas. A unificação é de UX/gestão. diff --git a/referência/sistema-de-chamados-main/docs/PROXIMOS_PASSOS.md b/referência/sistema-de-chamados-main/docs/PROXIMOS_PASSOS.md new file mode 100644 index 0000000..9163cef --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/PROXIMOS_PASSOS.md @@ -0,0 +1,113 @@ +# Roadmap de Próximos Passos + +Redeploy: atualização do segredo de provisionamento de máquinas aplicada na VPS. Este commit dispara o pipeline para atualizar a stack com o novo `.env`. + +Lista priorizada de evoluções propostas para o Sistema de Chamados. Confira `agents.md` para visão geral, escopo atual e diretrizes de uso. + +# 🧩 Permissões e acessos + +- [x] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas + - [x] Ver todos os chamados da sua empresa + - [x] Acessar relatórios e dashboards resumidos + - [x] Exportar relatórios em PDF ou CSV +- [x] Manter perfis: Administrador, Gestor, Agente e Colaborador + +--- + +# 🧾 Tickets e atendimentos + +- [x] Adicionar opção **Exportar histórico completo em PDF** (conversa, logs, movimentações) +- [x] Implementar **justificativa obrigatória ao pausar** o chamado + - [x] Categorias: Falta de contato / Aguardando terceiro / Em procedimento +- [x] Ajustar **status padrão dos tickets** + - [x] Pendentes + - [x] Aguardando atendimento + - [x] Pausados + - [x] (Remover “Aguardando resposta” e “Violados”) +- [x] Remover automaticamente da listagem ao finalizar o chamado + +--- + +# 📊 Dashboards e relatórios + +- [x] Criar **dashboard inicial com fila de atendimento** + - [x] Exibir chamados em: atendimento, laboratório, visitas + - [x] Indicadores: abertos, resolvidos, tempo médio, SLA +- [x] Criar **relatório de horas por cliente (CSV/Dashboard)** + - [x] Separar por atendimento interno e externo + - [x] Filtrar por período (dia, semana, mês) +- [x] Permitir exportar relatórios completos (CSV ou PDF) + +--- + +# ⏱️ Controle de tempo e contratos + +- [x] Adicionar botão **Play interno** (atendimento remoto) +- [x] Adicionar botão **Play externo** (atendimento presencial) +- [x] Separar contagem de horas por tipo (interno/externo) +- [x] Exibir e somar **horas gastas por cliente** (com base no tipo) +- [ ] Incluir no cadastro: + - [ ] Horas contratadas por mês (Convex pronto; falta migração Prisma) + - [x] Tipo de cliente: mensalista ou avulso +- [x] Enviar alerta automático por e-mail quando atingir limite de horas + +--- + +# 💬 Comunicação e notificações + +- [ ] Diferenciar **comentários públicos** e **privados** + - [ ] Público → envia e-mail ao cliente + - [ ] Privado → visível apenas internamente +- [ ] Enviar e-mail automático quando houver comentário público + - [ ] Incluir trecho da mensagem e link direto para o chamado +- [x] Criar **biblioteca de templates de comentário prontos** + - [x] Exemplo: “Agradecemos seu contato”, “Seu chamado foi atualizado”, etc. + +--- + +# ⚙️ Extras e automações (futuro) + +- [ ] Enviar alertas automáticos para gestores quando: + - [ ] Chamado estiver pausado há mais de 48h + - [x] Horas contratadas atingirem 90% do limite + +--- + +## Arestas e observações (implantado) + +- Alerta de horas por cliente (≥ 90%) + - SMTP precisa estar configurado no Next e no Convex (envs SMTP_ e MAILER_SENDER_EMAIL) — sem isso o envio é pulado. + - Cron executa de hora em hora e só dispara às 08:00 America/Sao_Paulo (config `ALERTS_LOCAL_HOUR`). Hoje o cálculo do “início do dia” usa `-03:00` fixo; revisar se houver mudança de offset/DST. + - Prevenção de duplicados por empresa/dia é feita via consulta por intervalo. Podemos otimizar com índice específico (`by_tenant_company_created`). + - Divergência de fonte de gestores: rota Next usa Prisma (role MANAGER), cron usa Convex. Ideal unificar a origem ou sincronizar regularmente. + - Cliente SMTP atual usa AUTH LOGIN e `rejectUnauthorized: false`. Melhorar: validar certificado, timeout/retry e suporte a múltiplos destinatários. + +- Painel de alertas enviados (Admin) + - Filtros atuais (empresa/período) aplicados no client; quando o Convex estiver atualizado, mover filtros para a query para melhor desempenho. + - Adicionar paginação/limite configurável e ordenação por colunas. + +- Admin > Empresas — “Último alerta” + - Endpoint agrega por slug em loop (N chamadas ao Convex). Melhorar com query em lote (por IDs/empresa) no Convex. + - Exibir também percentual do uso e threshold para contexto. + +- Relatórios com filtro de empresa (Backlog/SLA/CSAT) + - CSVs já aceitam `companyId`. Garantir que o Convex esteja na versão com `companyId` opcional nos relatórios. + - Considerar persistir seleção global de empresa (ex.: por sessão) para consistência entre páginas. + +- Entrada de tickets por canal (dashboard) + - Seletor de empresa com busca dentro do dropdown. Melhorias: limpar busca ao fechar o menu; paginar/virtualizar se lista for grande. + - CSV acompanha `companyId` e período. + +- Horas por cliente + - Busca por nome e filtro por empresa aplicados no UI; CSV aceita `q` e `companyId` para exportar filtrado. + - Considerar paginação e indicadores agregados (soma total por período; top N clientes). + +- PDF do ticket + - Usa Inter de `public/fonts` quando disponível; fallback para Helvetica. Em ambientes serverless, garantir acesso aos arquivos de fonte. + - Melhorias possíveis: incorporar logo/cores por tenant e fontes customizadas por cliente. + +- Tipos e testes + - Removidos `any` nas áreas alteradas. Ainda há `any` em módulos antigos (tickets/mappers/admin). Plano: tipificar por módulo gradualmente. + - Testes adicionados (CSV/TZ). Próximos: snapshots de CSVs de relatórios, validação de formatação de data/hora, e2e dos endpoints principais (modo mockado). +- [ ] Implementar **trilha de auditoria** (quem pausou, finalizou, comentou) +- [ ] Permitir exportar logs de auditoria (CSV/PDF) diff --git a/referência/sistema-de-chamados-main/docs/README.md b/referência/sistema-de-chamados-main/docs/README.md new file mode 100644 index 0000000..2daf3e5 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/README.md @@ -0,0 +1,25 @@ +# Documentação — Sistema de Chamados + +Este índice consolida a documentação viva e move conteúdos históricos para um arquivo. O objetivo é simplificar o onboarding e a operação. + +## Visão Geral +- Operações (produção): `docs/operations.md` +- Guia de desenvolvimento: `docs/DEV.md` +- Desktop (Tauri): + - Build: `docs/desktop/build.md` + - Updater: `docs/desktop/updater.md` + - Handshake/troubleshooting: `docs/desktop/handshake-troubleshooting.md` +- Tickets: `docs/ticket-snapshots.md` +- Administração (UI): `docs/admin/admin-inventory-ui.md` + +## Arquivo (histórico/planejamento) +- `docs/alteracoes-2025-11-08.md` +- `docs/archive/operacao-producao.md` (substituído por `docs/operations.md`) +- `docs/archive/deploy-runbook.md` +- `docs/archive/setup-historico.md` +- `docs/archive/status-2025-10-16.md` +- `docs/archive/convex-self-hosted-env.md` +- `docs/archive/plano-app-desktop-maquinas.md` +- `docs/archive/historico-agente-desktop-2025-10-10.md` + +Se algum conteúdo arquivado voltar a ser relevante, mova-o de volta, atualizando a data e o escopo. diff --git a/referência/sistema-de-chamados-main/docs/admin/.gitkeep b/referência/sistema-de-chamados-main/docs/admin/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/admin/.gitkeep @@ -0,0 +1 @@ + diff --git a/referência/sistema-de-chamados-main/docs/admin/admin-inventory-ui.md b/referência/sistema-de-chamados-main/docs/admin/admin-inventory-ui.md new file mode 100644 index 0000000..9a4c6d9 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/admin/admin-inventory-ui.md @@ -0,0 +1,46 @@ +# Admin UI — Inventário por dispositivo + +A página Admin > Dispositivos agora exibe um inventário detalhado e pesquisável do parque, com filtros e exportação. + +## Filtros e busca +- Busca livre por hostname, e-mail, MAC e número de série. +- Filtro por status: Online, Offline, Sem sinal (stale) e Desconhecido. +- Filtro por sistema operacional (OS). +- Filtro por empresa (slug). +- Marcação “Somente com alertas” para investigar postura. + +## Painel de detalhes +- Resumo: hostname, status derivado do heartbeat (badge verde “Online” até 10 minutos; amarelo “Sem sinal” entre 10 minutos e 2 horas; vermelho “Offline” após esse período), e-mail vinculado, empresa (quando houver), perfil de acesso (colaborador/gestor) com dados do usuário associado, SO/arch e sincronização do token (expiração/uso). +- Métricas recentes: CPU/Memory/Disco. +- Inventário básico: hardware (CPU/mem/serial, GPUs detectadas), rede (IP/MAC), labels. +- Discos e partições: nome, mount, FS, capacidade, livre. +- Inventário estendido (varia por SO): + - Linux: SMART (OK/ALERTA), `lspci`, `lsusb` (texto), `lsblk` (interno para discos). + - Windows: informações de SO (edição, versão, build, data de instalação, experiência, ativação), resumo de hardware (CPU/Memória/GPU/Discos físicos com suporte a payloads únicos), serviços, lista completa de softwares com ação “Ver todos”, Defender. + - macOS: pacotes (`pkgutil`), serviços (`launchctl`). +- Postura/Alertas: CPU alta, serviço parado, SMART em falha com severidade e última avaliação. +- Zona perigosa: ação para excluir a dispositivo (revoga tokens e remove inventário). +- Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a dispositivo. + +## Exportação +- Exportar CSV de softwares ou serviços diretamente da seção detalhada (quando disponíveis). +- Exportar planilha XLSX completa (`/admin/devices/:id/inventory.xlsx`). A partir de 31/10/2025 a planilha contém: + - **Resumo**: data de geração, filtros aplicados, contagem por status e total de acessos remotos/alertas. + - **Inventário**: colunas principais exibidas na UI (status, persona, hardware, token, build/licença do SO, domínio, colaborador, Fleet, etc.). + - **Vínculos**: usuários associados à dispositivo. + - **Softwares**: lista deduplicada (nome + versão + origem/publisher). A coluna “Softwares instalados” no inventário bate com o total desta aba. + - **Partições**: nome/mount/FS/capacidade/livre, convertendo unidades (ex.: 447 GB → bytes). + - **Discos físicos**: modelo, tamanho, interface, tipo e serial de cada drive. + - **Rede**: interfaces com MAC/IP de todas as fontes (agente, Fleet). + - **Acessos remotos**: TeamViewer/AnyDesk/etc. com notas, URL, última verificação e metadados brutos. + - **Serviços**: serviços coletados (Windows/Linux) com nome, display name e status. + - **Alertas**: postura recente (tipo, mensagem, severidade, criado em). + - **Métricas**: CPU/Memória/Disco/GPU com timestamp coletado. + - **Labels**: tags aplicadas à dispositivo. + - **Sistema**: visão categorizada (Sistema, Dispositivo, Hardware, Acesso, Token, Fleet) contendo build, licença, domínio, fabricante, serial, colaborador, contagem de acessos, etc. + +## Notas +- Os dados vêm de duas fontes: + - Agente desktop (Tauri): envia inventário básico + estendido por SO via `POST /api/machines/heartbeat`. + - FleetDM (osquery): opcionalmente, via webhook `POST /api/integrations/fleet/hosts`. +- Postura é avaliada no servidor (Convex) a cada heartbeat/upsert. Tickets automáticos podem ser gerados se habilitado. diff --git a/referência/sistema-de-chamados-main/docs/admin/companies-expanded-profile.md b/referência/sistema-de-chamados-main/docs/admin/companies-expanded-profile.md new file mode 100644 index 0000000..306e6d7 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/admin/companies-expanded-profile.md @@ -0,0 +1,30 @@ +# Admin ▸ Empresas — Perfil Ampliado + +Este documento resume a ampliação do cadastro de empresas (dados fiscais, contratos, SLA, canais de comunicação) e registra o que ainda falta concluir na camada de apresentação. + +## Base de dados e backend +- `prisma/schema.prisma`: modelo `Company` agora inclui razão social, inscrição estadual/tipo, CNAE, canais de comunicação consolidados, endereço fiscal, contatos, localizações, contratos, SLA, campos personalizados e observações. Foi criado o enum `CompanyStateRegistrationType`. +- `prisma/migrations/20251022120000_extend_company_profile/`: migração que adiciona todas as novas colunas ao banco (SQLite/Postgres). +- `scripts/apply-company-migration.mjs`: script idempotente para aplicar os `ALTER TABLE` diretamente em ambientes SQLite já existentes. +- `convex/schema.ts` e `convex/companies.ts`: schema do Convex espelha os campos adicionais e as mutations `ensureProvisioned/removeBySlug` passaram a sincronizar os metadados estendidos. +- `src/lib/schemas/company.ts`: novo módulo de validação/normalização (React Hook Form + zod) com tipos ricos (`CompanyFormValues`, contatos, contratos, SLA, horários, etc.). +- `src/server/company-service.ts`: serviço único que sanitiza input (`sanitizeCompanyInput`), gera payload para Prisma (`buildCompanyData`) e normaliza registros (`normalizeCompany` / `NormalizedCompany`). +- `src/server/companies-sync.ts`: reutilizado para garantir que Convex receba os campos novos quando houver provisionamento/remoção. + +## APIs e página server-side +- `src/app/api/admin/companies/route.ts`: `GET` devolve `NormalizedCompany`; `POST` aplica validação zod, normaliza, cria a empresa no Prisma e sincroniza com Convex. +- `src/app/api/admin/companies/[id]/route.ts`: `PATCH` faz merge seguro do payload parcial, reaproveita o serviço de normalização, trata erros de unicidade e replica as alterações para o Convex; `DELETE` desvincula usuários/tickets antes de remover e garante remoção no Convex. +- `src/app/api/admin/companies/last-alerts/route.ts`: continua servindo a UI atual, sem mudanças funcionais. +- `src/app/admin/companies/page.tsx`: carrega empresas via Prisma server-side e entrega `NormalizedCompany` para o front (a tela ainda usa o componente legado `AdminCompaniesManager`). + +## Componentes utilitários +- `src/components/ui/accordion.tsx` e `src/components/ui/multi-value-input.tsx`: helpers (Radix + badges/input) que darão suporte ao novo formulário seccional. + +## O que falta implementar +- **Nova UI de Empresas** (`AdminCompaniesManager`): substituir pelo layout com listagem filtrável (lista + quadro) e formulário seccional ligado a `companyFormSchema` / `sanitizeCompanyInput`. +- **Form dinâmico**: montar o formulário com React Hook Form + zod resolver usando os schemas/validações já prontos no backend. +- **Área de Clientes → Usuários**: renomear a seção, carregar os novos campos (contatos, localizações, contratos) e reaproveitar as transformações do serviço. +- **Dispositivos**: expor o novo identificador de acesso remoto previsto no schema Convex/Prisma. +- **Qualidade**: ajustar lint/testes após a nova UI e cobrir o fluxo de criação/edição com testes de integração. + +> Até que a nova interface seja publicada, a API já aceita todos os campos e qualquer cliente (front, automação, seed) deve usar `company-service.ts` para converter dados de/para Prisma, evitando divergências. diff --git a/referência/sistema-de-chamados-main/docs/alteracoes-2025-11-03.md b/referência/sistema-de-chamados-main/docs/alteracoes-2025-11-03.md new file mode 100644 index 0000000..aacefd7 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/alteracoes-2025-11-03.md @@ -0,0 +1,37 @@ +# Alterações — 03/11/2025 + +## Concluído +- [x] Calendário com dropdown de mês/ano redesenhado (admin e portal) para replicar o visual da referência shadcn, com navegação compacta e sombra suave. +- [x] Estruturado backend para `Dispositivos`: novos campos no Convex (`deviceType`, `deviceProfile`, custom fields), mutations (`saveDeviceProfile`, `saveDeviceCustomFields`) e tabelas auxiliares (`deviceFields`, `deviceExportTemplates`). +- [x] Refatorado gerador de inventário XLSX para suportar seleção dinâmica de colunas, campos personalizados e nomenclatura de dispositivos. +- [x] Renomeado "Máquinas" → "Dispositivos" em toda a navegação, rotas, botões (incluindo destaque superior) e mensagens de erro. +- [x] UI do painel ajustada com criação manual de dispositivos, gerenciamento de campos personalizados, templates de exportação e inclusão de dispositivos móveis. +- [x] Fluxo de CSAT revisado: mutation dedicada, timeline enriquecida, formulário de estrelas apenas para solicitante e dashboards com novos filtros/combobox. +- [x] Diálogo de encerramento de ticket com vínculo opcional a outro ticket, prazo configurável de reabertura (7 ou 14 dias) e mensagem pré-visualizada. +- [x] Botão de reabrir disponível para solicitante/equipe até o fim do prazo; timeline registra `TICKET_REOPENED`. +- [x] Chat em tempo real incorporado ao detalhe do ticket (listagem live, envio, leitura automática, bloqueio pós-prazo). +- [x] Formulários dinâmicos para admissão/desligamento com escopo e permissões por empresa/usuário; `create` envia `formTemplate` e `customFields`. +- [x] Corrigidos mocks/tipagens das rotinas de resolução e reabertura (`resolveTicketHandler`, `reopenTicketHandler`) garantindo `bun run lint`, `bun test` e `bun run build:bun` verdes. +- [x] Atualizado schema/tipagens (`TicketWithDetails`, `ChartTooltipContent`) e dashboards CSAT para suportar reabertura com prazos e tooltips formatados. +- [x] Reatribuição de chamado sem motivo obrigatório; comentário interno só é criado quando o motivo é preenchido. +- [x] Botão “Novo dispositivo” reutiliza o mesmo primário padrão do shadcn usado em “Nova empresa”, mantendo a identidade visual. +- [x] Cartão de CSAT respeita a role normalizada (inclusive em sessões de dispositivos), só aparece para a equipe após o início do atendimento e mostra aviso quando ainda não há avaliações. +- [x] Dashboard de abertos x resolvidos usa buscas indexadas por data e paginação semanal ( + sem `collect` massivo), evitando timeouts no Convex. +- [x] Filtro por tipo de dispositivo (desktop/celular/tablet) na listagem administrativa com exportação alinhada. +- [x] Consulta de alertas/tickets de dispositivos aceita `deviceId` além de `machineId`, eliminando falhas no painel. +- [x] Busca por ticket relacionado no encerramento reaproveita a lista de sugestões (Enter seleciona o primeiro resultado e o campo exibe `#referência`). +- [x] Portal (cliente e desktop) exibe os badges de status/prioridade em sentence case, alinhando com o padrão do painel web. +- [x] Filtros de empresa nos relatórios/dashboards (Backlog, SLA, Horas, alertas e gráficos) usam combobox pesquisável, facilitando encontrar clientes. +- [x] Campos adicionais de admissão/desligamento organizados em grid responsivo de duas colunas (admin e portal), mantendo booleanos/textareas em largura total. +- [x] Templates de admissão e desligamento com campos dinâmicos habilitados no painel e no portal/desktop, incluindo garantia automática dos campos padrão via `ensureTicketFormDefaults`. +- [x] Relatório de categorias e agentes com filtros por período/empresa, gráfico de volume e destaque do agente que mais atende cada tema. + +## Riscos +- Necessário validar migração dos dados existentes (máquinas → dispositivos) antes de entrar em produção. +- Testes de SMTP/entregabilidade precisam ser executados para garantir que notificações sigam as novas regras de pausa/comentário. + +## Pendências +- [ ] Validar comportamento de notificações (pausa/comentário) com infraestrutura de e-mail real. +- [ ] Executar migração de dados existente antes do deploy (mapear máquinas → dispositivos e revisar templates legados). +- [ ] Cobertura de testes automatizados para chat e formulários dinâmicos (resolve/reopen já cobertos). diff --git a/referência/sistema-de-chamados-main/docs/alteracoes-2025-11-08.md b/referência/sistema-de-chamados-main/docs/alteracoes-2025-11-08.md new file mode 100644 index 0000000..1068c31 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/alteracoes-2025-11-08.md @@ -0,0 +1,32 @@ +# Alterações — 08/11/2025 + +## Concluído + +- **Agenda (Resumo & Calendário)** — nova rota `/agenda` (AppShell + Tabs) com filtros persistentes (`AgendaFilters`), visão Resumo com KPIs por status/SLA e cards por seção, e Calendário mensal com eventos coloridos por SLA. Dataset derivado em `src/lib/agenda-utils.ts` normaliza tickets Convex → blocos (upcoming, overdue, unscheduled, completed) e gera eventos sintéticos até conectarmos ao modelo definitivo de agendamentos. +- **Sidebar & navegação** — link “Agenda” habilitado no `AppSidebar`, replicando permissões das páginas de tickets. +- **Datas de admissão/desligamento** — `use-local-time-zone` + `Calendar` atualizados; todos os fluxos (novo ticket, edição dentro do ticket, portal) aplicam o mesmo picker e normalizam valores em UTC, eliminando o deslocamento -1/-2 dias para nascimento e início. +- **Layout dos campos personalizados** — seção “Editar campos personalizados” reutiliza o grid/style do modal de criação, mantendo labels compactos, espaçamento consistente e colunas responsivas semelhantes ao layout do portal. +- **CSAT no ticket individual** — `ticket-csat-card.tsx` mantém a experiência para colaboradores, mas oculta a avaliação/“Obrigado pelo feedback!” de agentes/gestores. Também bloqueia o card inteiro para agentes (somente admins visualizam a nota rapidamente). +- **Toast global** — `src/lib/toast-patch.ts` higieniza títulos/descrições removendo pontuação final (`!`, `.`, reticências). Patch tipado evita `any` e replica o comportamento em todos os métodos (`success`, `error`, `loading`, `promise`, etc.). +- **Linha do tempo mais útil** — mutations de campos personalizados ignoram saves sem alteração e registram apenas os campos realmente modificados, reduzindo spam como “Campos personalizados atualizados (Nome do solicitante, …)” quando nada mudou. +- **SLA por categoria/prioridade** + - Convex: tabela `categorySlaSettings`, helpers (`categorySlas.ts`) e drawer na UI de categorias permitem definir alvos por prioridade (resposta/solução, modo business/calendar, pausas e alert threshold). + - Tickets: snapshot (`ticket.slaSnapshot`) no momento da criação inclui regra aplicada; `computeSlaDueDates` trata horas úteis (08h–18h, seg–sex) e calendário corrido; status respeita pausas configuradas, com `slaPausedAt/slaPausedMs` e `build*CompletionPatch`. + - Front-end: `ticket-details-panel` e `ticket-summary-header` exibem badges de SLA (on_track/at_risk/breached/met) com due dates; `sla-utils.ts` centraliza cálculo para UI. + - Prisma: modelo `Ticket` agora persiste `slaSnapshot`, due dates e estado de pausa; migration `20251108042551_add_ticket_sla_fields` aplicada e client regenerado. +- **Relatório “SLA & Produtividade” com corte por categoria/prioridade** — `/reports/sla` ganhou tabela dedicada mostrando para cada categoria/prioridade o volume e as taxas de cumprimento de resposta e solução (dados vêm de `categoryBreakdown` no `slaOverview`). O item correspondente na sidebar agora se chama “SLA & Produtividade” para deixar o destino mais claro. +- **Polyfill de performance** — `src/lib/performance-measure-polyfill.ts` previne `performance.measure` negativo em browsers/server; importado em `app/layout.tsx`. +- **Admin auth fallback** — páginas server-side (`/admin`, `/admin/users`) tratam bancos recém-criados onde `AuthUser` ainda não existe, exibindo cards vazios em vez do crash `AuthUser table does not exist`. +- **Chips de admissão/desligamento** — `convex/tickets.ts` garante `formTemplateLabel` com fallback nas labels configuradas (ex.: “Admissão de colaborador”), corrigindo etiquetas sem acentuação na listagem/título do ticket. +- **listTicketForms otimizada** — handler faz uma única leitura por tabela (templates, campos, configurações) e filtra em memória para o usuário/empresa atual. Remove o fan-out (3 queries x template) que excedia 1 s e derrubava o websocket com erro 1006/digest 500. + +## Observações e próximos passos + +- Agenda ainda usa eventos mockados (derivados de due dates). Integrar ao modelo real de `TicketSchedule` quando estiver pronto (drag & drop, eventos por agente e digest diário). +- SLA business-hours está fixo em 08h–18h (BR). Precisamos conectar com calendários por fila/categoria + feriados/expediente custom. +- Drawer de SLA nas categorias carece de validações finas (ex.: impedir negativos, tooltips com modo escolhido) e feedback de sucesso/erro. +- Relatórios/backlog ainda não consomem o snapshot Prisma recém-adicionado; alinhar APIs que leem tickets direto do banco. + +## Testes & build + +- `bun run lint`, `bun run test` e `bun run build` passam (webpack e prisma generate); Convex queries relacionadas foram atualizadas para manter índices consistentes. diff --git a/referência/sistema-de-chamados-main/docs/archive/.gitkeep b/referência/sistema-de-chamados-main/docs/archive/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/archive/.gitkeep @@ -0,0 +1 @@ + diff --git a/referência/sistema-de-chamados-main/docs/archive/convex-self-hosted-env.md b/referência/sistema-de-chamados-main/docs/archive/convex-self-hosted-env.md new file mode 100644 index 0000000..fc5a7bf --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/archive/convex-self-hosted-env.md @@ -0,0 +1,56 @@ +Convex Self‑Hosted — Configurar env e testar provisionamento (Arquivo) + +Nota: este documento foi arquivado. O fluxo atual de deploy/ops está em `docs/operations.md`. + +Pré‑requisitos +- Rodar na VPS com Docker. +- Projeto em `/srv/apps/sistema`. +- Admin Key do Convex (já obtida): + `convex-self-hosted|011c148069bd37e4a3f1c10b41b19459427a20e6d7ba81f53b659861f7658cd4985c8936e9` + +1) Exportar variáveis da sessão (URL + Admin Key) +export CONVEX_SELF_HOSTED_URL="https://convex.esdrasrenan.com.br" +export CONVEX_SELF_HOSTED_ADMIN_KEY='convex-self-hosted|011c148069bd37e4a3f1c10b41b19459427a20e6d7ba81f53b659861f7658cd4985c8936e9' + +2) Definir MACHINE_PROVISIONING_SECRET no Convex (obrigatório) +docker run --rm -it \ + -v /srv/apps/sistema:/app -w /app \ + -e CONVEX_SELF_HOSTED_URL -e CONVEX_SELF_HOSTED_ADMIN_KEY \ + oven/bun:1 bash -lc "set -euo pipefail; \ + bun install --frozen-lockfile; \ + unset CONVEX_DEPLOYMENT; \ + bun x convex env set MACHINE_PROVISIONING_SECRET '71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6' -y; \ + bun x convex env list" + +3) (Opcional) Definir MACHINE_TOKEN_TTL_MS (padrão 30 dias) +docker run --rm -it \ + -v /srv/apps/sistema:/app -w /app \ + -e CONVEX_SELF_HOSTED_URL -e CONVEX_SELF_HOSTED_ADMIN_KEY \ + oven/bun:1 bash -lc "set -euo pipefail; \ + bun install --frozen-lockfile; \ + unset CONVEX_DEPLOYMENT; \ + bun x convex env set MACHINE_TOKEN_TTL_MS '2592000000' -y; \ + bun x convex env list" + +4) (Opcional) Definir FLEET_SYNC_SECRET +docker run --rm -it \ + -v /srv/apps/sistema:/app -w /app \ + -e CONVEX_SELF_HOSTED_URL -e CONVEX_SELF_HOSTED_ADMIN_KEY \ + oven/bun:1 bash -lc "set -euo pipefail; \ + bun install --frozen-lockfile; \ + unset CONVEX_DEPLOYMENT; \ + bun x convex env set FLEET_SYNC_SECRET '' -y; \ + bun x convex env list" + +5) Testar registro (gera machineToken) — substitua o hostname se quiser +HOST="vm-teste-$(date +%s)"; \ +curl -sS -o resp.json -w "%{http_code}\n" -X POST 'https://tickets.esdrasrenan.com.br/api/machines/register' \ + -H 'Content-Type: application/json' \ + -d '{"provisioningSecret":"71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6","tenantId":"tenant-atlas","hostname":"'"$HOST"'","os":{"name":"Linux","version":"6.1.0","architecture":"x86_64"},"macAddresses":["AA:BB:CC:DD:EE:FF"],"serialNumbers":[],"metadata":{"inventario":{"cpu":"i7","ramGb":16}},"registeredBy":"manual-test"}'; \ +echo; tail -c 400 resp.json || true + +6) (Opcional) Enviar heartbeat com o token retornado +TOKEN=$(node -e 'try{const j=require("fs").readFileSync("resp.json","utf8");process.stdout.write(JSON.parse(j).machineToken||"");}catch(e){process.stdout.write("")}' ); \ +[ -n "$TOKEN" ] && curl -sS -o /dev/null -w "%{http_code}\n" -X POST 'https://tickets.esdrasrenan.com.br/api/machines/heartbeat' \ + -H 'Content-Type: application/json' \ + -d '{"machineToken":"'"$TOKEN"'","status":"online","metrics":{"cpuPct":12,"memFreePct":61}}' diff --git a/referência/sistema-de-chamados-main/docs/archive/deploy-runbook.md b/referência/sistema-de-chamados-main/docs/archive/deploy-runbook.md new file mode 100644 index 0000000..9da59d3 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/archive/deploy-runbook.md @@ -0,0 +1,126 @@ +# Deploy runbook (Swarm) — Arquivo + +Nota: este runbook foi arquivado. Utilize `docs/operations.md` para o fluxo atualizado de deploy. + +Este guia documenta o fluxo de deploy atual e os principais passos de diagnóstico/correção que resolveram o problema do front não atualizar mesmo com o CI verde. + +## Visão geral (como você trabalha) + +- Você dá push na `main` e aguarda o GitHub Actions concluir. +- O pipeline cria um build imutável no servidor em `/home/renan/apps/sistema.build.`. +- Um symlink estável aponta para o release ativo: `/home/renan/apps/sistema.current`. +- O serviço `sistema_web` monta sempre `/home/renan/apps/sistema.current:/app`. Para atualizar, basta mudar o symlink e forçar a task. + +Resultado: front/back sobem com o novo código sem editar o stack a cada release. + +## Fluxo de release (mínimo) + +1. Gerar build em `/home/renan/apps/sistema.build.` (CI faz isso). +2. Atualizar symlink: `ln -sfn /home/renan/apps/sistema.build. /home/renan/apps/sistema.current`. +3. Rollout do serviço web: `docker service update --force sistema_web`. +4. Opcional: se o `stack.yml` mudou, aplicar: `docker stack deploy --with-registry-auth -c /home/renan/apps/sistema.build./stack.yml sistema`. + +## Stack estável (essência) + +- Mount fixo: `/home/renan/apps/sistema.current:/app` (não interpolar APP_DIR). +- Comando inline (sem script), com migrations na subida: + - `command: ["bash","-lc","bun install --frozen-lockfile && bun x prisma migrate deploy && bun run start:bun"]` + - **Se você optar por usar `/app/scripts/start-web.sh`** (como no workflow atual), garanta que o script execute `bun install` antes de rodar Prisma/Next. Certifique-se de copiar esse arquivo para o build publicado; sem ele, a task cai com `bun: command not found`. +- Env obrigatórias (URLs válidas): + - `DATABASE_URL=file:/app/data/db.sqlite` + - `NEXT_PUBLIC_CONVEX_URL=http://sistema_convex_backend:3210` + - `NEXT_PUBLIC_APP_URL=https://tickets.esdrasrenan.com.br` + - `BETTER_AUTH_URL=https://tickets.esdrasrenan.com.br` +- Update com `stop-first` (evita `database is locked` em SQLite) + healthcheck. + +## Prisma/SQLite do stack + +- O volume do stack é namespaced: `sistema_sistema_db` (não `sistema_db`). +- Ao operar Prisma fora do Swarm, use SEMPRE este volume e a mesma `DATABASE_URL`: + +``` +APP_DIR=/home/renan/apps/sistema.current +docker run --rm -it \ + -e DATABASE_URL=file:/app/data/db.sqlite \ + -v "$APP_DIR:/app" -v sistema_sistema_db:/app/data -w /app \ + oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate status" +``` + +## Diagnóstico rápido + +- Ver a task atual + erros: `docker service ps --no-trunc sistema_web` +- Logs frescos do serviço: `docker service logs --since=2m -f sistema_web` +- Spec aplicado (Args + Mounts): + +``` +docker service inspect sistema_web \ + --format '{{json .Spec.TaskTemplate.ContainerSpec.Args}} {{json .Spec.TaskTemplate.ContainerSpec.Mounts}}' +``` + +- Envs do serviço: `docker service inspect sistema_web --format '{{json .Spec.TaskTemplate.ContainerSpec.Env}}'` + +## O incidente (front não atualizava) — causa e correções + +Sintomas: +- Actions verde, mas UI antiga; logs com rollbacks de `docker service update`. + +Causas encontradas: +1) Serviço ainda montava build antigo e comando antigo (spec não mudava). + - Inspect mostrava `Source=/home/renan/apps/sistema.build. -> /app` e comando inline antigo. + - Correção: redeploy do stack com mount em `/home/renan/apps/sistema.current` + `docker service update --force sistema_web`. + +2) Migration P3009 ("failed migrations") no SQLite do stack. + - Motivo: resolver/aplicar migrations no volume errado (`sistema_db`), enquanto o serviço usa `sistema_sistema_db`. + - Correção determinística: + - `docker service scale sistema_web=0` + - `prisma migrate resolve --rolled-back 20251015223259_add_company_provisioning_code` no volume `sistema_sistema_db` (comando acima em "Prisma/SQLite do stack"). + - `bun x prisma migrate deploy` + - `docker service scale sistema_web=1` (ou `update --force`). + +3) Rollback por script ausente (`/app/scripts/start-web.sh`). + - Task caía com exit 127 porque o build não continha o script. + - Correção: voltar ao comando inline no stack (sem depender do script) OU garantir o script no build e executável. + +4) Falha de env (Invalid URL em `NEXT_PUBLIC_APP_URL`/`BETTER_AUTH_URL`). + - Correção: definir URLs válidas no stack ou via `docker service update --env-add ...`. + +## Cheatsheet de correções + +- Forçar rollout da task: + - `docker service update --force sistema_web` + +- Aplicar build novo (sem tocar stack): + - `ln -sfn /home/renan/apps/sistema.build. /home/renan/apps/sistema.current` + - `docker service update --force sistema_web` + +- Corrigir mount/args no serviço (hotfix): + +``` +docker service update \ + --mount-rm target=/app \ + --mount-add type=bind,src=/home/renan/apps/sistema.current,dst=/app \ + --args 'bash -lc "bun install --frozen-lockfile && bun x prisma migrate deploy && bun run start:bun"' \ + sistema_web +``` + +- Resolver P3009 (volume certo) e aplicar migrations: + +``` +APP_DIR=/home/renan/apps/sistema.current +docker service scale sistema_web=0 +docker run --rm -it -e DATABASE_URL=file:/app/data/db.sqlite \ + -v "$APP_DIR:/app" -v sistema_sistema_db:/app/data -w /app \ + oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back 20251015223259_add_company_provisioning_code && bun x prisma migrate deploy" +docker service scale sistema_web=1 +``` + +- Criar DB se faltar (P1003): + - `docker run --rm -v sistema_sistema_db:/data busybox sh -lc ': >/data/db.sqlite'` + +- Ajustar envs em runtime: + - `docker service update --env-add NEXT_PUBLIC_APP_URL=https://tickets.esdrasrenan.com.br --env-add BETTER_AUTH_URL=https://tickets.esdrasrenan.com.br sistema_web` + +## Notas finais + +- Como o stack monta `/home/renan/apps/sistema.current`, um novo release exige apenas atualizar o symlink e forçar a task. O `stack.yml` só precisa ser redeployado quando você altera labels/envs/serviços. +- Se a UI parecer não mudar, valide o mount/args via inspect, confira logs da task atual e force hard‑reload no navegador. diff --git a/referência/sistema-de-chamados-main/docs/archive/plano-app-desktop-dispositivos.md b/referência/sistema-de-chamados-main/docs/archive/plano-app-desktop-dispositivos.md new file mode 100644 index 0000000..61874f4 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/archive/plano-app-desktop-dispositivos.md @@ -0,0 +1,101 @@ +# Plano Integrado – App Desktop & Inventário por Dispositivo (Arquivo) + +> Documento vivo. Atualize após cada marco relevante. + +## Contexto +- **Objetivo:** Expandir o Sistema de Chamados (Next.js + Convex + Better Auth) para suportar: + - Cliente desktop nativo (Tauri) mantendo UI web e realtime. + - Autenticação dispositivo-a-dispositivo usando tokens derivados do inventário. + - Integração com agente de inventário (osquery/FleetDM) para registrar hardware, software e heartbeats. + - Pipeline de distribuição para Windows/macOS/Linux. +- **Escopo inicial:** Focar no fluxo mínimo viável com inventário básico (hostname, OS, identificadores, carga resumida). Métricas avançadas e distribuição automatizada ficam para iteração seguinte. + +## Estado Geral +- Web atual permanece operacional com login por usuário/senha. +- Novas features serão adições compatíveis (machine login opcional). +- Melhor abordagem para inventário: usar **osquery + FleetDM** (stack pronta) integrando registros no Convex. + - Agente desktop coleta inventário básico + estendido por SO (Linux: dpkg/rpm + systemd + lsblk/lspci/lsusb/smartctl; Windows: WMI/registry via PowerShell; macOS: system_profiler/pkgutil/launchctl) e envia via heartbeat e/ou `/api/machines/inventory`. + +## Marcos & Progresso +| Macro-entrega | Status | Observações | +| --- | --- | --- | +| Documento de arquitetura e roadmap | 🔄 Em andamento | Estrutura criada, aguardando detalhamento incremental a cada etapa. | +| Projeto Tauri inicial apontando para UI Next | 🔄 Em andamento | Estrutura `apps/desktop` criada; pendente testar build após instalar toolchain Rust. | +| Schema Convex + tokens de dispositivo | ✅ Concluído | Tabelas `machines` / `machineTokens` criadas com TTL e fingerprint. | +| API de registro/heartbeat e exchange Better Auth | 🔄 Em andamento | Endpoints `/api/machines/*` disponíveis; falta testar fluxo end-to-end com app desktop. | +| Endpoint upsert de inventário dedicado | ✅ Concluído | `POST /api/machines/inventory` (modo por token ou provisioningSecret). | +| Integração FleetDM → Convex (inventário básico) | 🔄 Em andamento | Endpoint `/api/integrations/fleet/hosts` criado; falta validar payload real e ajustes de métricas/empresa. | +| Admin > Dispositivos (listagem, detalhes, métricas) | ✅ Concluído | Página `/admin/devices` exibe parque completo com status ao vivo, inventário e métricas. | +| Ajustes na UI/Next para sessão por dispositivo | ⏳ A fazer | Detectar token e exibir info da dispositivo em tickets. | +| Pipeline de build/distribuição Tauri | ⏳ A fazer | Definir estratégia CI/CD + auto-update. | +| Guia operacional (instalação, uso, suporte) | ⏳ A fazer | Gerar instruções finais com casos de uso. | + +Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer. + +## Dependências Técnicas +- **Tauri Desktop:** Rust + toolchain específico por SO, libwebkit2gtk (Linux), WebView2 (Windows), Xcode (macOS). +- **FleetDM/osquery:** Servidor Fleet (Docker ou VM), enrollment secret por tenant, agentes osquery instalados. +- **Better Auth:** Mechanismo para criar sessões usando subject `machine:*` com escopos restritos. +- **Convex:** Novas tabelas `machines` e `machineTokens`, mutações para registro/heartbeat/exchange. +- **Infra extra:** Endpoints públicos para updater do Tauri, armazenamento de inventário seguro, certificados para assinatura de builds. + +## Próximos Passos Imediatos +1. Desktop: finalizar UX das abas (mais detalhes em Diagnóstico e Configurações) e gráficos leves. +2. Coletores Windows/macOS: normalizar campos de software/serviços (nome/versão/fonte/status) e whitelists. +3. Regras: janela temporal real para CPU (dados de 5 min), whitelists por tenant, mais sinais SMART (temperatura e contadores). +4. Admin UI: diálogo “Inventário completo” com busca em JSON, export CSV de softwares/serviços, badges no grid com contagens. +5. Release: ativar secrets de assinatura e publicar binários por SO. + +## Notas de Implementação (Atual) +- Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`. +- O agente desktop agora possui fluxo próprio: coleta inventário local via comandos Rust, solicita o código de provisionamento, registra a dispositivo e inicia heartbeats periódicos (`src-tauri/src/agent.rs` + `src/main.ts`). +- Formulário inicial exibe resumo de hardware/OS e salva o token em `~/.config/Sistema de Chamados Desktop/machine-agent.json` (ou equivalente por SO) para reaproveitamento em relançamentos. +- URLs configuráveis via `.env` do app desktop: + - `VITE_APP_URL` → aponta para a interface Next (padrao produção: `https://tickets.esdrasrenan.com.br`). + - `VITE_API_BASE_URL` → base usada nas chamadas REST (`/api/machines/*`), normalmente igual ao `APP_URL`. +- Após provisionar ou encontrar token válido, o agente dispara `/machines/handshake?token=...` que autentica a dispositivo no Better Auth, devolve cookies e redireciona para a UI. Em produção, mantemos a navegação top‑level pelo handshake para garantir a aceitação de cookies na WebView (mesmo quando `POST /api/machines/sessions` é tentado antes). +- `apps/desktop/src-tauri/tauri.conf.json` ajustado para rodar `bun run dev/build`, servir `dist/` e abrir janela 1100x720. +- Novas tabelas Convex: `machines` (fingerprint, heartbeat, vínculo com AuthUser) e `machineTokens` (hash + TTL). +- Novos endpoints Next: + - `POST /api/machines/register` — provisiona dispositivo, gera token e usuário Better Auth (role `machine`). + - `POST /api/machines/heartbeat` — atualiza estado, métricas e renova TTL. + - `POST /api/machines/sessions` — troca `machineToken` por sessão Better Auth e devolve cookies. +- As rotas `/api/machines/*` respondem a preflight `OPTIONS` com CORS liberado para o agente (`https://tickets.esdrasrenan.com.br`, `tauri://localhost`, `http://localhost:1420`). +- Rota `GET /machines/handshake` realiza o login automático da dispositivo (seta cookies e redireciona). + - As rotas `sessions/handshake` foram ajustadas para usar `NextResponse.cookies.set(...)`, aplicando cada cookie da Better Auth (sessão e assinatura) individualmente. + - CORS: as respostas incluem `Access-Control-Allow-Credentials: true` para origens permitidas (Tauri WebView e app). +- Página de diagnóstico: `/portal/debug` exibe `get-session` e `machines/session` com os mesmos cookies da aba — útil para validar se o desktop está autenticado como dispositivo. + - O desktop pode redirecionar automaticamente para essa página durante os testes. +- Webhook FleetDM: `POST /api/integrations/fleet/hosts` (header `x-fleet-secret`) sincroniza inventário/métricas utilizando `machines.upsertInventory`. +- Script `ensureMachineAccount` garante usuário `AuthUser` e senha sincronizada com o token atual. + +### Inventário — Windows (fallback) +- O agente coleta `extended.windows.osInfo` via PowerShell/WMI. Caso o script falhe (política ou permissão), aplicamos um fallback com `sysinfo` para preencher ao menos `ProductName`, `Version` e `BuildNumber` — evitando bloco vazio no inventário exibido. + +### Portal do Cliente (UX) +- Quando autenticado como dispositivo (colaborador/gestor): + - O portal deriva o papel a partir do `machineContext` (mesmo se `/api/auth/get-session` vier `null`). + - A listagem exibe apenas os tickets onde o colaborador é o solicitante. + - Informações internas (Fila, Prioridade) são ocultadas; o responsável aparece quando definido. + - A descrição do chamado vira o primeiro comentário (editor rico + anexos). A permissão de comentar pelo solicitante foi ajustada no Convex. +- O botão “Sair” é ocultado no desktop (faz sentido apenas fechar o app). +- Variáveis `.env` novas: `MACHINE_PROVISIONING_SECRET` (obrigatória) e `MACHINE_TOKEN_TTL_MS` (opcional, padrão 30 dias). +- Variável adicional `FLEET_SYNC_SECRET` (opcional) para autenticar webhook do Fleet; se ausente, reutiliza `MACHINE_PROVISIONING_SECRET`. +- Dashboard administrativo: `/admin/devices` usa `AdminMachinesOverview` com dados em tempo real (status, heartbeat, token, inventário enviado pelo agente/Fleet). + +### Checklist de dependências Tauri (Linux) +```bash +sudo apt update +sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev +curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh +# reinicie o terminal e confirme: rustc --version +``` + +> Ajuste conforme seu sistema operacional (ver https://tauri.app/start/prerequisites/). + +--- + +> Histórico de atualizações: +> - 2025-02-20 — Fluxo completo do agente desktop, heartbeats e rota `/machines/handshake` documentados (assistente). +> - 2025-02-14 — Documento criado com visão geral e plano macro (assistente). diff --git a/referência/sistema-de-chamados-main/docs/archive/setup-historico.md b/referência/sistema-de-chamados-main/docs/archive/setup-historico.md new file mode 100644 index 0000000..377754c --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/archive/setup-historico.md @@ -0,0 +1,103 @@ +# Histórico de Setup e Decisões — Sistema de Chamados (Arquivo) + +Conteúdo mantido como registro. Para operação e deploy atuais, ver `docs/operations.md`. + +> Registro das etapas realizadas, problemas encontrados e decisões tomadas para colocar o projeto em produção (Traefik + Swarm + Convex self‑hosted) com CI/CD via runner self‑hosted. + +## Arquitetura final +- Frontend Next.js (app) em `tickets.esdrasrenan.com.br`. +- Convex self‑hosted (backend realtime) em `convex.esdrasrenan.com.br`. +- Traefik no Docker Swarm (rede `traefik_public`) roteando por hostname (HTTPS/LE). +- Banco Prisma (SQLite) persistente via volume `sistema_db`. +- Estado do Convex persistente via volume `convex_data`. +- Runner GitHub Actions self‑hosted na VPS (usuário `renan`). + +## Mudanças no repositório +- `stack.yml` — Stack Swarm (web + convex_backend + convex_dashboard opcional). +- `.env.example` e `apps/desktop/.env.example` — exemplos de ambiente. +- `.github/workflows/ci-cd-web-desktop.yml` — pipeline de deploy web + desktop + deploy do Convex. +- `docs/OPERACAO-PRODUCAO.md` — runbook de operação (deploy, seeds, CI/CD, troubleshooting). +- `docs/SETUP-HISTORICO.md` — este histórico. +- `scripts/deploy-from-git.sh` — fallback de deploy pull‑based na VPS (sem Actions). + +## Gestão de .env +- `.env` não é commitado; usamos o arquivo local na VPS: `/srv/apps/sistema/.env`. +- Workflow atualizado para NÃO apagar `.env` no destino (rsync com filtros `protect`). +- Valor com espaços deve ter aspas: `MAILER_SENDER_EMAIL="Nome "`. +- Em self‑hosted, comentar `CONVEX_DEPLOYMENT`, e usar `NEXT_PUBLIC_CONVEX_URL=https://convex...`. + +## Stack (Traefik/Swarm) +- Binds absolutos (Portainer/Swarm exigem): `/srv/apps/sistema:/app`. +- Volumes: `sistema_db` → `/app/data` (SQLite), `convex_data` → `/convex/data`. +- Labels Traefik por hostname; WebSocket do Convex funciona via TLS. + +## Convex self‑hosted +- Evitamos `convex dev` no Swarm (CLI interativo). Adotada imagem oficial `ghcr.io/get-convex/convex-backend`. +- Geração de Admin Key dentro do container: `./generate_admin_key.sh`. +- Publicação de functions com `bun x convex deploy` via container transitório (`CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY`). +- Adicionado `convex_dashboard` opcional (DNS `convex-admin.*`). + +## CI/CD (GitHub Actions) +- Runner self‑hosted `vps-sistema` com labels `self-hosted, linux, vps`. +- Job Deploy (web) roda sempre em push para `main`. +- Job Deploy Convex functions roda apenas quando arquivos em `convex/**` mudam (paths-filter). +- rsync com `--filter='protect .env*'` para preservar `.env` local. +- Secrets no repositório: `CONVEX_SELF_HOSTED_URL`, `CONVEX_SELF_HOSTED_ADMIN_KEY` (e secrets do desktop se usados). + +## Problemas e soluções (pitfalls) +1) WebSocket 1006 no front +- Causa: Convex não rodando corretamente (CLI interativo) → migração para imagem oficial self‑hosted. + +2) `.env` sendo apagado pelo rsync +- Causa: `--delete` no rsync no job de deploy. +- Solução: adicionar filtros `protect` e `exclude` para `.env*` (raiz/desktop/convex). + +3) `MAILER_SENDER_EMAIL` com erro de parsing +- Causa: valor com espaços sem aspas quando foi "sourced" por shell. +- Solução: sempre usar aspas. Depois, com backend oficial, não foi mais necessário `source`. + +4) `prisma: not found` no build +- Causa: instalar só prod deps. +- Solução: garantir `bun install` completo (não apenas deps de produção) no container de build. + +5) Lockfile/Workspace quebrando CI +- Causa: conflito de versões quando o desktop entrou no workspace. +- Solução: hoje mantemos `['.', 'apps/desktop']` e usamos filtros no CI/deploy. Alternativa: isolar o desktop fora do workspace. + +6) Bind relativo no Swarm/Portainer +- Causa: `./:/app` vira path inválido. +- Solução: usar path absoluto: `/srv/apps/sistema:/app`. + +7) Flags `--port/--hostname` no `convex dev` +- Causa: versão do CLI. +- Solução: remover flags, e posteriormente substituir por backend oficial. + +8) Billing do GitHub bloqueado +- Causa: cobrança travada bloqueia todos workflows (mesmo self‑hosted). +- Solução: regularizar billing; opcionalmente usar `scripts/deploy-from-git.sh` até normalizar. + +## Checklists de operação +- Deploy manual (pull‑based): `bash /srv/apps/sistema/scripts/deploy-from-git.sh`. +- Deploy via Actions: commit em `main`. +- Seeds: `https://tickets.../dev/seed` e `docker exec ... bun run auth:seed`. +- Verificação: `docker stack services sistema` e logs dos serviços. + +## Melhorias futuras +- Rodar job de "Detect changes" também em runner self‑hosted (zero minutos GitHub‑hosted). +- Fixar versão do `convex-backend` (ao invés de `latest`) para releases mais controladas. +- Substituir bind‑mount por imagens construídas no CI (tempo de deploy menor, reprodutibilidade). +- Adicionar cache das dependências do Bun no container de build. + +## TODOs (próximos técnicos) + +- Prisma Client desatualizado x schema (Company.isAvulso/contractedHoursPerMonth) + - Sintoma: Tipos gerados do Prisma não exibem os campos `isAvulso` e `contractedHoursPerMonth` em `CompanyCreateInput`/`CompanyUpdateInput`. + - Temporário: rotas `src/app/api/admin/companies/[id]/route.ts` e mapeamento em `src/app/admin/companies/page.tsx` possuem guardas/casts para compilar. + - Ação: + 1. Rodar `bun run prisma:generate` no mesmo ambiente de build/execução (VPS e local) para regenerar o client. + 2. Confirmar que os campos aparecem nos tipos gerados. + 3. Remover casts e `eslint-disable` do update; reintroduzir campos no `create` se desejado (com tipagem estrita). + 4. Se ainda não existirem fisicamente na base, aplicar migração que adicione os campos ao modelo `Company`. + +- Next TS x Desktop (plugin Keyring) + - Mantido `apps/desktop/**` no `tsconfig.exclude` para o type‑check do Next. Avaliar, em outro momento, ambient d.ts no desktop para editor. diff --git a/referência/sistema-de-chamados-main/docs/archive/status-2025-10-16.md b/referência/sistema-de-chamados-main/docs/archive/status-2025-10-16.md new file mode 100644 index 0000000..24c7a02 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/archive/status-2025-10-16.md @@ -0,0 +1,52 @@ +# Status do Projeto — 16/10/2025 (Arquivo) + +Documento de referência sobre o estado atual do sistema (web + desktop), melhorias recentes e pontos de atenção. + +## 1. Panorama + +- **Web (Next.js 15 + Convex)**: build limpo (`bun run build:bun`), lint sem avisos e testes estáveis (Vitest em modo não interativo). +- **Desktop (Tauri)**: fluxo de provisionamento e heartbeat operacional; inventário consolidado com coleta multi-plataforma; atualizações OTA suportadas. +- **CI**: workflow `Quality Checks` roda lint/test/build em todo push/PR na `main`; pipeline de deploy (`ci-cd-web-desktop.yml`) permanece responsável por sincronizar com a VPS. +- **Infra**: deploy documentado no runbook (Swarm com symlink `sistema.current`). Migrações Prisma e variáveis críticas mapeadas. + +## 2. Melhorias concluídas em 16/10/2025 + +| Item | Descrição | Impacto | +| --- | --- | --- | +| **Centralização Convex** | Helpers `createConvexClient` e normalização do cookie da dispositivo (`src/server/convex-client.ts`, `src/server/machines/context.ts`). | Código das rotas `/api/machines/*` ficou mais enxuto e resiliente a erros de configuração. | +| **Auth/Login redirect** | Redirecionamento baseado em role/persona sem uso de `any`, com dependências explícitas (`src/app/login/login-page-client.tsx`). | Evita warnings de hooks e garante rota correta para dispositivos/colaboradores. | +| **Ticket header** | Sincronização do responsável com dependências completas (`ticket-summary-header.tsx`). | Removeu warning do lint e previne estados inconsistentes. | +| **Upgrade para Next.js 16 beta** | Dependências atualizadas (`next@16.0.0-beta.0`, `eslint-config-next@16.0.0-beta.0`), cache de filesystem do Turbopack habilitado, scripts de lint/test/build ajustados ao novo fluxo. | Projeto pronto para validar as novidades do Next 16 (React Compiler opcional, prefetch incremental, etc.); builds e testes já rodando com sucesso. | +| **Posture / inventário** | Type guards e normalização de métricas SMART/serviços (`convex/machines.ts`). | Reduziu `any`, melhorou detecção de alertas e consistência do metadata. | +| **Docs** | Revisão completa de `docs/DEV.md`, novo `STATUS-2025-10-16.md`, estrutura uniforme e casos de erro registrados. | Documentação enxuta e atualizada, com trilhas claras para DEV/CI/Deploy. | +| **Testes no CI** | Novo workflow `.github/workflows/quality-checks.yml` e script `bun test` em modo não-interativo. | Previne “travamentos” e garante checagens de qualidade automáticas. | + +## 3. Pontos de atenção (curto prazo) + +- **Migrações Prisma em produção**: qualquer mudança requer executar no volume `sistema_sistema_db` (ver `docs/DEPLOY-RUNBOOK.md`). Atenção para evitar regressões P3009. +- **Atualização dos artefatos Tauri**: releases exigem `latest.json` atualizado e assinatura (`*.sig`). Automação via GitHub Actions já preparada, mas depende de manter as chaves seguras. +- **Seeds Better Auth**: se novos perfis/roles forem adicionados, atualizar `scripts/seed-auth.mjs` e o seed do Convex. +- **Variáveis críticas**: `NEXT_PUBLIC_APP_URL`, `BETTER_AUTH_URL`, `MACHINE_PROVISIONING_SECRET` e `NEXT_PUBLIC_CONVEX_URL` devem ser válidas no stack — qualquer alteração de domínio implica revisar `.env` e `stack.yml`. + +## 4. Backlog recomendado + +1. **Testes end-to-end**: cobrir fluxo de provisionamento (desktop ↔ API) com smoke automatizado (pode rodar condicional no CI). +2. **Autenticação agnóstica**: avaliar suporte para Clerk/Auth0 conforme docs do Convex (custom JWTs). +3. **Observabilidade**: adicionar métricas/alertas para heartbeats em atraso (Convex + dashboards). +4. **Documentação do Desktop Installer**: guias por SO sobre instalação/assinatura e troubleshooting do updater. + +## 5. Casos de erro conhecidos + +| Cenário | Sintoma | Como resolver | +| --- | --- | --- | +| Token de dispositivo revogado | POST `/api/machines/sessions` retorna 401 e desktop volta ao onboarding | Reprovisionar pela UI do agente; garantir que `machineToken` foi atualizado. | +| Falha de heartbeat | Logs com `Falha ao registrar heartbeat` + status 500 | Verificar `NEXT_PUBLIC_CONVEX_URL` e conectividade. Rode `bun run convex:dev:bun` em DEV para confirmar schema. | +| Updater sem atualização | Desktop fica em “Procurando atualização” indefinidamente | Confirmar release publicado com `latest.json` apontando para URLs públicas do bundle e assinaturas válidas. | + +## 6. Próximos passos imediatos + +- Monitorar execução do novo workflow de qualidade em PRs. +- Garantir que a equipe esteja ciente do procedimento atualizado de deploy (symlink + service update). +- Revisar backlog acima e priorizar smoke tests para o fluxo da dispositivo. + +_Última atualização: 16/10/2025 (UTC-3)._ diff --git a/referência/sistema-de-chamados-main/docs/desktop/build.md b/referência/sistema-de-chamados-main/docs/desktop/build.md new file mode 100644 index 0000000..305dfa5 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/desktop/build.md @@ -0,0 +1,52 @@ +# Build do App Desktop (Tauri) + +Guia rápido para gerar instaladores do app desktop em cada sistema operacional. + +## Pré‑requisitos +- Bun >= 1.3 instalado e disponível no `PATH`. +- Node.js 20+ (recomendado) caso precise executar scripts auxiliares em Node. +- Rust toolchain (stable) instalado. +- Dependências nativas por SO: + - Linux (Debian/Ubuntu): + ```bash + sudo apt update && sudo apt install -y \ + libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + ``` + - Windows: Visual Studio Build Tools + WebView2 Runtime. + - macOS: Xcode Command Line Tools. + +## Configuração de URLs +- Produção: por padrão o app usa `https://tickets.esdrasrenan.com.br`. +- Desenvolvimento: crie `apps/desktop/.env` a partir de `apps/desktop/.env.example` e ajuste: + ``` + VITE_APP_URL=http://localhost:3000 + VITE_API_BASE_URL= + ``` + +## Comandos de build +- Linux/macOS/Windows (rodar no próprio sistema): + ```bash + bun run --cwd apps/desktop tauri build + ``` +- Apenas frontend (Vite): + ```bash + bun run --cwd apps/desktop build + ``` + +Saída de artefatos: `apps/desktop/src-tauri/target/release/bundle/`. + +## Dicas +- Primeira compilação do Rust pode demorar (download de crates e linkedição). +- Se o link‑editor for lento no Linux, considere instalar `lld` e usar: + ```bash + RUSTFLAGS="-Clink-arg=-fuse-ld=lld" bun run --cwd apps/desktop tauri build + ``` +- Para logs detalhados em dev, rode `bun run --cwd apps/desktop tauri dev`. + +## Diagnóstico de sessão (Desktop → Portal) +- Durante testes, navegue até `/portal/debug` (o desktop pode redirecionar automaticamente) para ver: + - `/api/auth/get-session` — pode ser `null` na WebView; não é bloqueante. + - `/api/machines/session` — precisa retornar `200` com `assignedUserId/email`. +- Produção: as rotas de sessão/handshake enviam `Access-Control-Allow-Credentials: true` e aplicam cookies com `NextResponse.cookies.set(...)` para confiabilidade em navegadores/embeds. +- O desktop mantém a navegação top‑level via `/machines/handshake` para maximizar a aceitação de cookies. diff --git a/referência/sistema-de-chamados-main/docs/desktop/handshake-troubleshooting.md b/referência/sistema-de-chamados-main/docs/desktop/handshake-troubleshooting.md new file mode 100644 index 0000000..86e6b72 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/desktop/handshake-troubleshooting.md @@ -0,0 +1,71 @@ +# Desktop (Tauri) — Handshake, Sessão de Dispositivo e Antivírus + +Este documento consolida as orientações e diagnósticos sobre o fluxo do agente desktop, handshake na web e possíveis interferências de antivírus. + +## Sintomas observados +- Ao clicar em “Registrar dispositivo”, o antivírus aciona (ex.: ATC.SuspiciousBehavior) e o processo é interrompido. +- Após o registro, ao abrir a UI web: cabeçalho mostra “Cliente / Sem e‑mail definido” e o Portal não permite abrir chamados. +- No passado, mesmo quando o app “entrava direto”, o Portal não refletia o colaborador/gestor vinculado (sem assignedUser); receio de repetir o problema. + +## Causas prováveis +1) O antivírus interrompe o processo durante o handshake + - O app salva token/config, inicia heartbeat e abre `GET /machines/handshake?token=...&redirect=...` para gravar cookies de sessão. + - Se o processo cai neste momento, os cookies não são gravados e a UI fica sem sessão “machine”. + +2) Endpoint de contexto sem ler a sessão adequadamente + - O Portal preenche o colaborador/gestor via `GET /api/machines/session`. + - Em alguns ambientes, é mais estável rodar esse endpoint no runtime `nodejs` para ler `cookies()` do Next sem inconsistências. + +## O que já foi aplicado no projeto +- Middleware permite `GET /machines/handshake` sem exigir login (rota pública). +- Frontend preenche `machineContext` chamando `GET /api/machines/session` (assignedUserId/email/nome/persona) e usa esse ID ao abrir chamados. +- UI oculta “Sair” quando a sessão é de dispositivo (portal e shell interno). +- DevTools habilitado no desktop (F12, Ctrl+Shift+I ou botão direito com Ctrl/Shift). +- Desktop salva dados em `C:\\Raven\\data\\machine-agent.json` (ou equivalente ao lado do executável), com fallback para AppData se a pasta do app não permitir escrita. + +## Validação rápida (após “Registrar dispositivo”) +1) No executável, com DevTools: + ```js + fetch('/api/machines/session').then(r => r.status).then(console.log) + // Esperado: 200 + fetch('/api/machines/session').then(r => r.json()).then(console.log) + // Deve conter: persona (collaborator|manager), assignedUserEmail/nome/Id + ``` +2) Na UI (Portal/Topo): + - Mostrar nome/e‑mail do colaborador/gestor (não “Cliente / Sem e‑mail definido”). + - Sem botão “Sair” (sessão de dispositivo). +3) No Portal, o formulário “Abrir chamado” deve habilitar normalmente (usa `machineContext.assignedUserId`). + +Se `GET /api/machines/session` retornar 403: +- Verificar se o antivírus barrou o processo na hora do handshake. +- Adicionar exceção para `C:\\Raven\\appsdesktop.exe` e `C:\\Raven\\data\\`. +- Opcional: forçar `export const runtime = 'nodejs'` no endpoint de sessão para leitura consistente de cookies (Next `cookies()`). + +## Fluxo atual do desktop +- Antes de registrar: tela de onboarding (sem “Abrir sistema”). +- Após registrar: salva token/config, inicia heartbeat e mostra as abas com “Abrir sistema” e “Reprovisionar”. +- Auto‐abrir: o app já tenta abrir o sistema quando detecta token; pode ser reforçado chamando `openSystem()` ao fim do `register()`. + +## Melhorias opcionais +- Auto‑abrir imediatamente após `register()` (reforça o comportamento atual e reduz a janela para interferência do AV). +- Diagnóstico no desktop: exibir caminho completo do arquivo de dados e botão “Abrir pasta de dados”. +- Forçar `nodejs` no `GET /api/machines/session` para estabilidade total na leitura de cookies. + +## Notas sobre antivírus +- Apps Tauri não assinados com certificado de code signing do Windows são frequentemente marcados como “desconhecidos”. +- A assinatura Minisign (updater) não substitui o code signing do executável. +- Recomendações: + - Adicionar exceções para o executável e a pasta de dados. + - Avaliar aquisição de um certificado de code signing (EV/OV) para distribuição ampla sem alertas. + +## Checklist quando “não mudou nada” após registro +- Confirmar `GET /api/machines/session` (status 200 + JSON contendo assignedUser). Caso 403, tratar AV/endpoint runtime. +- Verificar cookies no navegador (DevTools → Application → Cookies): presença da sessão e `machine_ctx`. +- Validar que o Portal mostra o colaborador/gestor e permite abrir chamado. +- Conferir arquivo de dados: + - Preferencial: `C:\\Raven\\data\\machine-agent.json`. + - Fallback: AppData local do usuário (buscar por `machine-agent.json`). + +--- + +Última atualização: automatização do handshake no middleware, ocultação de “Sair” em sessão de dispositivo, dados persistidos junto ao executável e DevTools habilitado. diff --git a/referência/sistema-de-chamados-main/docs/desktop/updater.md b/referência/sistema-de-chamados-main/docs/desktop/updater.md new file mode 100644 index 0000000..d3ed4b1 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/desktop/updater.md @@ -0,0 +1,111 @@ +# Checklist de Publicação — Updater do Agente Desktop + +Este guia consolida tudo o que precisa ser feito para que o auto-update do Tauri funcione em cada release. + +--- + +## 1. Preparação (uma única vez) + +1. **Gerar o par de chaves** (Linux ou WSL) + ```bash + bun run --cwd apps/desktop tauri signer generate -w ~/.tauri/raven.key + ``` + - Privada: `~/.tauri/raven.key` (nunca compartilhar) + - Pública: `~/.tauri/raven.key.pub` (cole em `tauri.conf.json > plugins.updater.pubkey`) + - Se for buildar em outra dispositivo (ex.: Windows), copie os dois arquivos para `C:\Users\\.tauri\raven.key(.pub)`. + +2. **Verificar o `tauri.conf.json`** + ```json + { + "bundle": { "createUpdaterArtifacts": true }, + "plugins": { + "updater": { + "active": true, + "endpoints": ["https://.../latest.json"], + "pubkey": "" + } + } + } + ``` + +--- + +## 2. Antes de cada release + +1. **Sincronizar versão** (mesmo número nos três arquivos): + - `apps/desktop/package.json` + - `apps/desktop/src-tauri/tauri.conf.json` + - `apps/desktop/src-tauri/Cargo.toml` + +2. **Build do front (gera `dist/` para o Tauri)** + ```bash + bun run --cwd apps/desktop build + ``` + +3. **Exportar variáveis do assinador** (no mesmo shell em que vai buildar): + ```bash + export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/raven.key)" + export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" + ``` + > No PowerShell, use `setx` para persistir ou execute `set`/`$env:` no terminal atual. + +4. **Gerar os instaladores + `.sig`** + ```bash + bun run --cwd apps/desktop tauri build + ``` + Os artefatos ficam em `apps/desktop/src-tauri/target/release/bundle/`: + + | SO | Bundle principal | Assinatura gerada | + |----------|----------------------------------------------|-----------------------------------------| + | Windows | `nsis/Raven_0.X.Y_x64-setup.exe` | `nsis/Raven_0.X.Y_x64-setup.exe.sig` | + | Linux | `appimage/Raven_0.X.Y_amd64.AppImage` | `appimage/Raven_0.X.Y_amd64.AppImage.sig` | + | macOS | `macos/Raven.app.tar.gz` | `macos/Raven.app.tar.gz.sig` | + +--- + +## 3. Publicar no GitHub + +1. **Criar/atualizar release** (ex.: `v0.1.7`) anexando todos os instaladores e seus `.sig`. +2. **Atualizar `latest.json`** (no próprio repo ou em um gist público) com algo como: + ```json + { + "version": "0.1.7", + "notes": "Novidades do release", + "pub_date": "2025-10-12T08:00:00Z", + "platforms": { + "windows-x86_64": { + "signature": "", + "url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.7/Raven_0.1.7_x64-setup.exe" + }, + "linux-x86_64": { + "signature": "", + "url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven_0.1.6_amd64.AppImage" + }, + "darwin-x86_64": { + "signature": "", + "url": "https://github.com/esdrasrenan/sistema-de-chamados/releases/download/v0.1.6/Raven.app.tar.gz" + } + } + } + ``` + - Pegue o link **Raw** do `latest.json` e mantenha igual ao usado no `tauri.conf.json`. + +--- + +## 4. Validar rapidamente + +1. Instale a versão anterior (ex.: 0.1.5) e abra. +2. O agente deve avisar sobre a nova versão e reiniciar automaticamente ao concluir a instalação. +3. Caso queira forçar manualmente, abra a aba **Configurações → Verificar atualizações**. + +--- + +## 5. Resumo rápido + +1. `bun run --cwd apps/desktop build` +2. `export TAURI_SIGNING_PRIVATE_KEY=...` / `export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=...` +3. `bun run --cwd apps/desktop tauri build` +4. Upload dos bundles + `.sig` → atualizar `latest.json` +5. Testar o instalador antigo para garantir que atualiza sozinho + +Com isso, os usuários sempre receberão a versão mais recente assim que abrirem o agente desktop. diff --git a/referência/sistema-de-chamados-main/docs/historico-agente-desktop-2025-10-10.md b/referência/sistema-de-chamados-main/docs/historico-agente-desktop-2025-10-10.md new file mode 100644 index 0000000..20f7d6e --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/historico-agente-desktop-2025-10-10.md @@ -0,0 +1,100 @@ +# Histórico — Agente Desktop (Tauri) — 2025-10-10 + +> Registro consolidado do que foi feito no app desktop, problemas encontrados, diagnósticos e próximos passos. Complementa `docs/plano-app-desktop-maquinas.md` e `apps/desktop/README.md`. + +## Resumo do que mudou +- UI mais "shadcn-like" sem Tailwind (apenas CSS): tema claro forçado, card com sombras suaves, labels fortes, helper text opcional e estados de foco com ring. +- Campo "Código de provisionamento" com botão de visibilidade (olhinho) sem dependências extras. +- Cards de inventário na visão inicial (CPU, Memória, Sistema e Discos) e grid simplificada. +- Feedback de erro aprimorado no registro: exibe status HTTP e detalhes retornados pelo servidor. +- Botão "Abrir sistema" passou a abrir o navegador padrão (plugin opener) em vez de navegar dentro da WebView. +- Corrigidas permissões do plugin Store no Tauri v2 (antes: `store.load not allowed`). + +## Arquivos alterados +- `apps/desktop/index.html:1` + - Força tema claro com `` e estrutura do card. +- `apps/desktop/src/styles.css:1` + - Estilos do card, inputs, input-group, ícones, tabs e summary cards (visual shadcn-like sem Tailwind). Remove dark overrides. +- `apps/desktop/src/main.ts:3` + - Importa `openUrl` do plugin opener e usa para abrir o handshake no navegador padrão. +- `apps/desktop/src/main.ts:640` + - Tratamento de erros no registro com exibição de `status` e `details` quando o servidor retorna JSON/texto. +- `apps/desktop/src/main.ts:694` + - Função `redirectToApp` para abrir `APP_URL/machines/handshake?token=...` via `openUrl`, com fallback para `window.location.replace`. +- `apps/desktop/src-tauri/capabilities/default.json:1` + - Adicionadas permissões: `store:default`, `store:allow-load`, `store:allow-get`, `store:allow-set`, `store:allow-save`, `store:allow-delete` e `opener:default`. + +## Como rodar (Windows, dev) +1) Garantir `.env` do desktop em `apps/desktop/.env`: +``` +VITE_APP_URL=https://tickets.esdrasrenan.com.br +VITE_API_BASE_URL=https://tickets.esdrasrenan.com.br +``` +2) Rodar dev: +``` +cd apps\desktop +bun run tauri dev +``` +3) Provisionar: +- Usar o botão de olho para conferir o segredo, sem espaços. +- Deixar `Tenant` e `Empresa (slug)` vazios para o primeiro teste. +- Ao concluir, o app abre o navegador em `/machines/handshake?token=...`. + +Referências úteis: +- Defaults/URLs do app: `apps/desktop/src/main.ts:75` +- Handshake na web: `src/app/machines/handshake/route.ts:1` +- Endpoint de registro: `src/app/api/machines/register/route.ts:1` + +## Diagnósticos e soluções aplicadas +- Erro na Store (Tauri v2): + - Sintoma: `store.load not allowed` nos logs do DevTools. + - Causa: permissões do plugin Store ausentes. + - Ação: adicionar permissões em `apps/desktop/src-tauri/capabilities/default.json:1`. +- Erro 500 durante registro com empresa: + - Mensagem: `ConvexError: Empresa não encontrada para o tenant informado`. + - Causa: slug inválido em `Empresa (slug)`. + - Ação: validar com slug correto (Admin > Empresas & Clientes) ou registrar sem empresa. + - Observação: hoje o endpoint mapeia como 500 genérico; ver "Pendências" para remapear para 400/404. +- Redirecionando para `localhost` após registro: + - Causa: configuração antiga salva no Store (primeira tentativa em dev) ou navegação dentro da WebView. + - Ações: + - Abrir no navegador padrão com `openUrl` (`apps/desktop/src/main.ts:694`). + - Se necessário, limpar Store via botão "Reprovisionar" (Configurações) ou removendo o arquivo `machine-agent.json` no diretório de dados do app. +- Mensagem de erro genérica no desktop: + - Antes: "Erro desconhecido ao registrar a dispositivo". + - Agora: exibe `Falha ao registrar dispositivo (STATUS): mensagem — detalhes` (quando disponíveis), facilitando diagnóstico. + +## Provisionamento — segredo e boas práticas +- Variável: `MACHINE_PROVISIONING_SECRET` (VPS/Convex backend). +- Rotina de giro (secret exposto foi mostrado no chat): + 1. Gerar novo segredo (ex.: `openssl rand -hex 32`). + 2. Aplicar no serviço Convex (Swarm) e forçar redeploy: + ``` + docker service update --env-add MACHINE_PROVISIONING_SECRET='NOVO_HEX' sistema_convex_backend + docker service update --force sistema_convex_backend + ``` + 3. Validar com `POST /api/machines/register` (esperado 201). +- Dispositivos já registradas não são afetadas (token delas continua válido). + +## Pendências e próximos passos +- Mapear erros "esperados" para HTTP adequado no web (Next): + - Em `src/app/api/machines/register/route.ts:1`, detectar `ConvexError` conhecidos (empresa inválida, token inválido, etc.) e responder `400`/`404` em vez de `500`. +- Validar UX do botão "Abrir sistema": + - Confirmar que sempre abre no navegador padrão em produção (capability `opener:default` já presente). +- Polimento visual adicional (opcional): + - Botões com variações de cor/hover mais fiéis ao `/login`. + - Trocar ícones emoji por SVGs minimalistas. +- Métricas de CPU no agente (suavização): + - Avaliar média de 2–3 amostras no lado Rust antes de reportar a primeira leitura (Task Manager-like). +- Documentar re-provisionamento manual do Store por SO (paths exatos) no `apps/desktop/README.md`. + +## Checklist rápido de verificação (QA) +- `.env` do desktop contém apenas `VITE_APP_URL` e `VITE_API_BASE_URL` apontando para produção. +- Primeiro registro sem empresa retorna 201 e aparece "Dispositivo provisionada" nas abas. +- "Ambiente" e "API" em Configurações exibem `https://tickets.esdrasrenan.com.br`. +- "Abrir sistema" abre o navegador com `/machines/handshake?token=...` e loga a dispositivo. +- Reprovisionar limpa a Store e volta ao formulário inicial. + +--- + +Dúvidas/sugestões: ver `agents.md` para diretrizes gerais e `docs/desktop-build.md` para build de binários. diff --git a/referência/sistema-de-chamados-main/docs/propostas/melhoria-plataforma.md b/referência/sistema-de-chamados-main/docs/propostas/melhoria-plataforma.md new file mode 100644 index 0000000..d0b88f1 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/propostas/melhoria-plataforma.md @@ -0,0 +1,53 @@ +# Propostas de Evolução da Plataforma de Chamados + +## 1. Melhorias de Curto Prazo (baixo esforço) + +- **Filtro unificado em todas as listas** + Criar um componente padrão com busca, chips de filtros ativos, botão de limpar e layout responsivo. Isso remove duplicidade nas páginas de Tickets, Admin ▸ Usuários, Empresas e melhora coerência visual. + +- **Menções internas (@ticket e @usuário)** + Expandir o autocomplete recém-adicionado para permitir citar usuários internos relevantes (solicitantes ou responsáveis frequentes). Facilita handoffs e histórico contextual. + +- **Barra de ações em massa consistente** + Padronizar cabeçalhos de tabelas (seleção em massa, contador, botões como “Excluir”, “Exportar”) com feedback visual claro (toasts + badges). Aplica-se a Usuários, Convites, Empresas etc. + +- **Cards acionáveis no Dashboard** + Introduzir métricas com links para listas pré-filtradas (ex.: “6 tickets aguardando resposta”, “3 chamados com SLA a vencer hoje”) para navegação mais rápida. + +- **Templates de filtro salvos** + Permitir que agentes salvem combinações frequentes (ex.: “Meus tickets urgentes”) e exibí-las como botões rápidos acima da tabela. + +- **Uso do novo recurso de menção para relacionamentos** + Mostrar, no cabeçalho do ticket, os chamados relacionados via menção com contagem e navegação rápida. + +- **Portal do cliente com resumo inteligente** + Para solicitantes, exibir timeline simplificada e botão “Abrir chamado relacionado” reutilizando o autocomplete, mas limitado aos chamados próprios. + +## 2. Projetos de Médio Prazo + +- **Repaginação dos filtros avançados** + Adotar um layout split (filtros à esquerda, resultados à direita), com árvore de categorias, sliders de data e componentes consistentes. Incluir colapsáveis para telas pequenas. + +- **Automação simples no painel Admin** + Novo módulo “Automação” para regras básicas (ex.: “Se SLA crítico + sem responsável → notificar gestor e mover para fila X”). Iniciar com condições pré-definidas para reduzir esforço manual. + +- **Painel de chamados linkados** + No detalhe do ticket, exibir seção “Chamados relacionados” com preview, status e atalhos, alimentada automaticamente pelas menções internas. + +## 3. Iniciativas de Maior Impacto + +- **Modo de trabalho focado para agentes** + Modo “foco” com navegação reduzida, atalhos (“Próximo ticket da minha fila”), indicadores de tempo e layout em painel único para reduzir alternância de contexto. + +- **Integração com calendários externos** + Para tickets com datas de acompanhamento ou SLAs agendados, permitir agendar eventos no Google/Microsoft Calendar diretamente da plataforma. + +- **Construtor de relatórios personalizados** + Ferramenta drag-and-drop que reutiliza métricas já disponíveis, permitindo salvar e compartilhar dashboards internos (ex.: equipe, gestor). + +## Observações Gerais + +- As propostas priorizam reutilizar componentes já existentes (RichTextEditor, API de menções) e alinhar elementos de UI (filtros, tabelas, sidebars). +- As iniciativas estão ordenadas do mais simples ao mais complexo, facilitando entregas incrementais. +- Os itens de médio e longo prazo podem ser fatiados em MVPs para garantir feedback rápido dos usuários internos. + diff --git a/referência/sistema-de-chamados-main/docs/requests-status.md b/referência/sistema-de-chamados-main/docs/requests-status.md new file mode 100644 index 0000000..fbb2132 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/requests-status.md @@ -0,0 +1,35 @@ +# Solicitações mapeadas + +## Tickets e fluxo +- [feito] Revisão dos status para Pendente / Em andamento / Pausado / Resolvido em listagens e componente de fila. +- [feito] Botões de play interno/externo com rastreio de horas. +- [feito] Pausa com motivo obrigatório registrado na timeline. +- [feito] Encerramento manual com diálogo atualizado e templates. +- [pendente] Encerramento automático após 3 tentativas de contato (não implementado). + +## Comentários e notificações +- [feito] Comentários internos como padrão para equipe; públicos visíveis ao cliente. +- [feito] Exportação do histórico completo do ticket em PDF. +- [feito] E-mails automáticos para comentários públicos e encerramento. +- [não aplicável] Resumo com IA (feature solicitada, ainda não iniciada). + +## Relatórios e contratos +- [feito] Relatório de horas internas x externas por cliente com exportação XLSX. +- [feito] Campo "Cliente avulso" e horas contratadas no cadastro de empresas, com alertas configuráveis. +- [removido] Alertas automáticos por e-mail de uso de horas (cron desativado a pedido). + +## Inventário e dispositivos +- [feito] Unificação de máquinas em “Dispositivos”, suporte a desktops e celulares. +- [feito] Campos personalizados e templates de exportação no inventário. +- [aguardando uso] Tag de serviço pode ser adicionada como campo personalizado conforme necessidade. + +## Perfis e acesso +- [feito] Perfis Admin/Agente/Gestor/Usuário com restrições de visualização por empresa. +- [feito] Motivo da troca de responsável registrado como comentário interno. +- [feito] Templates de comentário e encerramento gerenciáveis na área de configurações. +- [feito] Ajuste de horas manual com motivo e log. + +## Itens adicionais +- [pendente] Dashboard personalizado para gestores (fora do escopo atual). +- [pendente] Integração com IA/assistente para resumo automático. +- [pendente] Gatilho automático para encerramento por falta de contato (ver item em Tickets e fluxo). diff --git a/referência/sistema-de-chamados-main/docs/testes-vitest.md b/referência/sistema-de-chamados-main/docs/testes-vitest.md new file mode 100644 index 0000000..8f32fea --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/testes-vitest.md @@ -0,0 +1,70 @@ +# Guia de Testes com Vitest 4 + +Este documento resume a configuração atual de testes e como aproveitá-la para automatizar novas verificações. + +## Comandos principais + +- `bun test` → roda a suíte unitária em ambiente Node/JSdom. +- `bun run test:browser` → executa os testes de navegador via Playwright (Chromium headless). +- `bun run test:all` → executa as duas suítes de uma vez (requer Playwright instalado). + +> Sempre que adicionar novos testes, priorize mantê-los compatíveis com esses dois ambientes. + +## Pré-requisitos + +1. Dependências JavaScript já estão listadas em `package.json` (`vitest`, `@vitest/browser-playwright`, `playwright`, `jsdom`, etc.). +2. Baixe os binários do Playwright uma vez: + ```bash + bun x playwright install chromium + ``` +3. Em ambientes Linux “puros”, instale as bibliotecas de sistema recomendadas: + ```bash + sudo apt-get install libnspr4 libnss3 libasound2t64 + # ou + sudo bun x playwright install-deps + ``` + +Se o Playwright avisar sobre dependências ausentes ao rodar `bun run test:browser`, instale-as e repita o comando. + +## Estrutura de setup + +- `vitest.setup.node.ts` → executado apenas na suíte Node. Aqui é seguro acessar `process`, configurar variáveis de ambiente, carregar `tsconfig-paths/register`, etc. +- `tests/setup.browser.ts` → setup vazio para a suíte de navegador. Não use `process` ou APIs do Node aqui; adicione polyfills/mocks específicos do browser quando necessário. + +O arquivo `vitest.config.mts` seleciona automaticamente o setup correto com base na env `VITEST_BROWSER`. + +```ts +setupFiles: process.env.VITEST_BROWSER + ? ["./tests/setup.browser.ts"] + : ["./vitest.setup.node.ts"], +``` + +## Boas práticas para novos testes + +- **Aliases (`@/`)**: continuam funcionando em ambos os ambientes graças ao `vite-tsconfig-paths`. +- **Variáveis de ambiente no browser**: use `import.meta.env.VITE_*`. Evite `process.env` no código que será executado no navegador. +- **Mocks Playwright**: para testes de browser, use os helpers de `vitest/browser`. Exemplo: + ```ts + import { expect, test } from "vitest" + import { page } from "vitest/browser" + + test("exemplo", async () => { + await page.goto("https://example.com") + await expect(page.getByRole("heading", { level: 1 })).toBeVisible() + }) + ``` + No nosso exemplo atual (`tests/browser/example.browser.test.ts`) manipulamos o DOM diretamente e geramos screenshots com `expect(...).toMatchScreenshot(...)`. +- **Snapshots visuais**: os arquivos de referência ficam em `tests/browser/__screenshots__/`. Ao criar ou atualizar um snapshot, revise e commite apenas se estiver correto. +- **Mocks que dependem de `vi.fn()`**: quando mockar classes/constructores (ex.: `ConvexHttpClient`), use funções nomeadas ou `class` ao definir a implementação para evitar os erros do Vitest 4 (“requires function or class”). + +## Fluxo sugerido no dia a dia + +1. Rode `bun test` localmente antes de abrir PRs. +2. Para alterações visuais/lógicas que afetem UI, adicione/atualize um teste em `tests/browser` e valide com `bun run test:browser`. +3. Se novos snapshots forem criados ou alterados, confirme visualmente e inclua os arquivos em commit. +4. Para tarefas de automação futuras (por exemplo, smoke-tests que renderizam componentes críticos), utilize a mesma estrutura: + - Setup mínimo no `tests/setup.browser.ts`. + - Testes localizados em `tests/browser/**.browser.test.ts`. + - Utilização de Playwright para interagir com a UI e gerar screenshots/asserts. + +Seguindo este guia, conseguimos manter a suíte rápida no ambiente Node e, ao mesmo tempo, aproveitar o modo browser do Vitest 4 para validações visuais e regressões de UI. Quilas regressões detectadas automaticamente economizam tempo de QA manual e agilizam o ciclo de entrega.*** diff --git a/referência/sistema-de-chamados-main/docs/ticket-snapshots.md b/referência/sistema-de-chamados-main/docs/ticket-snapshots.md new file mode 100644 index 0000000..33964a9 --- /dev/null +++ b/referência/sistema-de-chamados-main/docs/ticket-snapshots.md @@ -0,0 +1,33 @@ +Ticket snapshots e histórico + +Este projeto agora preserva o “lastro” de dados sensíveis em tickets através de snapshots gravados no momento da criação e atualizações chave: + +- requesterSnapshot: nome, e‑mail, avatar e equipes do solicitante no momento da abertura. +- assigneeSnapshot: nome, e‑mail, avatar e equipes do responsável atribuído. +- companySnapshot: nome, slug e flag isAvulso da empresa associada ao ticket. + +Benefícios + +- Tickets continuam exibindo nome do solicitante/empresa mesmo após exclusões ou renomeações. +- Comentários já utilizavam authorSnapshot; a lógica foi mantida e ampliada para tickets. + +Fluxos atualizados + +- Criação de ticket: snapshots do solicitante, responsável inicial (se houver) e da empresa são persistidos. +- Reatribuição e início de atendimento: atualizam o assigneeSnapshot. +- Exclusão de usuário: preenche requesterSnapshot de todos os tickets onde a pessoa é solicitante, antes da remoção. +- Exclusão de empresa: preenche companySnapshot de tickets vinculados, antes da remoção. + +Consultas e hidratação + +- Resolvers de lista e detalhes de tickets passaram a usar os snapshots como fallback quando o documento relacionado não existe mais (sem “Solicitante não encontrado”, salvo ausência total de dados). + +Índices novos + +- tickets.by_tenant_requester: otimiza buscas por histórico de um solicitante. + +Backfill + +- Há uma mutation de migração para preenchimento retroativo de snapshots: convex.migrations.backfillTicketSnapshots. +- Execute com um tenant por vez (e opcionalmente um limite) se necessário. + diff --git a/referência/sistema-de-chamados-main/eslint.config.mjs b/referência/sistema-de-chamados-main/eslint.config.mjs new file mode 100644 index 0000000..fb96187 --- /dev/null +++ b/referência/sistema-de-chamados-main/eslint.config.mjs @@ -0,0 +1,37 @@ +import nextConfig from "eslint-config-next" +import tseslint from "typescript-eslint" + +const eslintConfig = [ + ...nextConfig, + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "apps/desktop/dist/**", + "apps/desktop/src-tauri/target/**", + "nova-calendar-main/**", + "next-env.d.ts", + "convex/_generated/**", + ], + }, + { + files: ["**/*.ts", "**/*.tsx"], + plugins: { + "@typescript-eslint": tseslint.plugin, + }, + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/ban-ts-comment": "warn", + "react/no-unescaped-entities": "off", + "react-hooks/set-state-in-effect": "off", + "react-hooks/purity": "off", + "react-hooks/refs": "off", + "react-hooks/immutability": "off", + "react-hooks/incompatible-library": "off", + }, + }, +] + +export default eslintConfig diff --git a/referência/sistema-de-chamados-main/machine-inventory-plc-est-025 (2).xlsx b/referência/sistema-de-chamados-main/machine-inventory-plc-est-025 (2).xlsx new file mode 100644 index 0000000..6b2b933 Binary files /dev/null and b/referência/sistema-de-chamados-main/machine-inventory-plc-est-025 (2).xlsx differ diff --git a/referência/sistema-de-chamados-main/next.config.ts b/referência/sistema-de-chamados-main/next.config.ts new file mode 100644 index 0000000..b132fec --- /dev/null +++ b/referência/sistema-de-chamados-main/next.config.ts @@ -0,0 +1,7 @@ +const nextConfig = { + experimental: { + turbopackFileSystemCacheForDev: true, + }, +} + +export default nextConfig diff --git a/referência/sistema-de-chamados-main/package.json b/referência/sistema-de-chamados-main/package.json new file mode 100644 index 0000000..1e5a8ca --- /dev/null +++ b/referência/sistema-de-chamados-main/package.json @@ -0,0 +1,121 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "prebuild": "prisma generate", + "dev": "next dev --turbopack", + "dev:webpack": "next dev --webpack", + "build": "next build --webpack", + "build:turbopack": "next build --turbopack", + "start": "next start", + "lint": "eslint", + "prisma:generate": "prisma generate", + "convex:dev": "convex dev", + "test": "bun test", + "test:browser": "cross-env VITEST_BROWSER=true bunx vitest --run --browser.headless tests/browser/example.browser.test.ts --passWithNoTests", + "test:all": "cross-env VITEST_BROWSER=true bunx vitest --run --passWithNoTests", + "auth:seed": "node scripts/seed-auth.mjs", + "queues:ensure": "node scripts/ensure-default-queues.mjs", + "desktop:dev": "bun run --cwd apps/desktop tauri dev", + "desktop:build": "bun run --cwd apps/desktop tauri build", + "dev:bun": "cross-env NODE_ENV=development bun run --bun dev", + "convex:dev:bun": "cross-env NODE_ENV=development bun run --bun convex:dev", + "build:bun": "cross-env NODE_ENV=production bun run --bun build", + "start:bun": "cross-env NODE_ENV=production bun run --bun start" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^3.10.0", + "@noble/hashes": "^1.5.0", + "@paper-design/shaders-react": "^0.0.55", + "@prisma/client": "^6.19.0", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-pdf/renderer": "^4.1.5", + "@react-three/fiber": "^9.3.0", + "@tabler/icons-react": "^3.35.0", + "@tanstack/react-table": "^8.21.3", + "@tiptap/extension-link": "^3.10.0", + "@tiptap/extension-mention": "^3.10.0", + "@tiptap/extension-placeholder": "^3.10.0", + "@tiptap/markdown": "^3.10.0", + "@tiptap/react": "^3.10.0", + "@tiptap/starter-kit": "^3.10.0", + "@tiptap/suggestion": "^3.10.0", + "better-auth": "^1.3.26", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "convex": "^1.28.0", + "date-fns": "^4.1.0", + "dotenv": "^16.4.5", + "lucide-react": "^0.544.0", + "next": "^16.0.1", + "next-themes": "^0.4.6", + "pdfkit": "^0.17.2", + "postcss": "^8.5.6", + "react": "19.2.0", + "react-day-picker": "^9.4.2", + "react-dom": "19.2.0", + "react-hook-form": "^7.64.0", + "recharts": "^2.15.4", + "sanitize-html": "^2.17.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "three": "^0.180.0", + "tippy.js": "^6.3.7", + "unicornstudio-react": "^1.4.31", + "vaul": "^1.1.2", + "zod": "^4.1.9" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@tauri-apps/api": "^2.8.0", + "@tauri-apps/cli": "^2.8.4", + "@types/bun": "^1.1.10", + "@types/jsdom": "^21.1.7", + "@types/node": "^20", + "@types/pdfkit": "^0.17.3", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/sanitize-html": "^2.16.0", + "@types/three": "^0.180.0", + "@vitest/browser-playwright": "^4.0.1", + "better-sqlite3": "^12.4.1", + "cross-env": "^10.1.0", + "eslint": "^9", + "eslint-config-next": "^16.0.1", + "eslint-plugin-react-hooks": "^5.0.0", + "jsdom": "^27.0.1", + "playwright": "^1.56.1", + "prisma": "^6.19.0", + "tailwindcss": "^4", + "tsconfig-paths": "^4.2.0", + "tw-animate-css": "^1.3.8", + "typescript": "^5", + "typescript-eslint": "^8.46.2", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.0.1" + }, + "workspaces": [ + ".", + "apps/desktop" + ] +} diff --git a/referência/sistema-de-chamados-main/postcss.config.mjs b/referência/sistema-de-chamados-main/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/referência/sistema-de-chamados-main/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/referência/sistema-de-chamados-main/prisma/migrations/20251005183834_init/migration.sql b/referência/sistema-de-chamados-main/prisma/migrations/20251005183834_init/migration.sql new file mode 100644 index 0000000..d00f7ad --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/migrations/20251005183834_init/migration.sql @@ -0,0 +1,215 @@ +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "teamId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "isLead" BOOLEAN NOT NULL DEFAULT false, + "assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("teamId", "userId"), + CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT NOT NULL, + "timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo', + "avatarUrl" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Queue" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "teamId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Queue_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Ticket" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "reference" INTEGER NOT NULL DEFAULT 0, + "subject" TEXT NOT NULL, + "summary" TEXT, + "status" TEXT NOT NULL DEFAULT 'NEW', + "priority" TEXT NOT NULL DEFAULT 'MEDIUM', + "channel" TEXT NOT NULL DEFAULT 'EMAIL', + "queueId" TEXT, + "requesterId" TEXT NOT NULL, + "assigneeId" TEXT, + "slaPolicyId" TEXT, + "dueAt" DATETIME, + "firstResponseAt" DATETIME, + "resolvedAt" DATETIME, + "closedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Ticket_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Ticket_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Ticket_queueId_fkey" FOREIGN KEY ("queueId") REFERENCES "Queue" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Ticket_slaPolicyId_fkey" FOREIGN KEY ("slaPolicyId") REFERENCES "SlaPolicy" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "TicketEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "ticketId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "TicketEvent_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "TicketComment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "ticketId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "visibility" TEXT NOT NULL DEFAULT 'INTERNAL', + "body" TEXT NOT NULL, + "attachments" JSONB, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "TicketComment_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "TicketComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SlaPolicy" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "timeToFirstResponse" INTEGER, + "timeToResolution" INTEGER, + "calendar" JSONB, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "AuthUser" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "role" TEXT NOT NULL DEFAULT 'agent', + "tenantId" TEXT, + "avatarUrl" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "AuthSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AuthUser" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuthAccount" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "accessTokenExpiresAt" DATETIME, + "refreshTokenExpiresAt" DATETIME, + "scope" TEXT, + "idToken" TEXT, + "password" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AuthAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AuthUser" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuthVerification" ( + "id" TEXT NOT NULL PRIMARY KEY, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE INDEX "Team_tenantId_name_idx" ON "Team"("tenantId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "User_tenantId_role_idx" ON "User"("tenantId", "role"); + +-- CreateIndex +CREATE UNIQUE INDEX "Queue_tenantId_slug_key" ON "Queue"("tenantId", "slug"); + +-- CreateIndex +CREATE INDEX "Ticket_tenantId_status_idx" ON "Ticket"("tenantId", "status"); + +-- CreateIndex +CREATE INDEX "Ticket_tenantId_queueId_idx" ON "Ticket"("tenantId", "queueId"); + +-- CreateIndex +CREATE INDEX "Ticket_tenantId_assigneeId_idx" ON "Ticket"("tenantId", "assigneeId"); + +-- CreateIndex +CREATE INDEX "TicketEvent_ticketId_createdAt_idx" ON "TicketEvent"("ticketId", "createdAt"); + +-- CreateIndex +CREATE INDEX "TicketComment_ticketId_visibility_idx" ON "TicketComment"("ticketId", "visibility"); + +-- CreateIndex +CREATE UNIQUE INDEX "SlaPolicy_tenantId_name_key" ON "SlaPolicy"("tenantId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthUser_email_key" ON "AuthUser"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthSession_token_key" ON "AuthSession"("token"); + +-- CreateIndex +CREATE INDEX "AuthSession_userId_idx" ON "AuthSession"("userId"); + +-- CreateIndex +CREATE INDEX "AuthSession_token_idx" ON "AuthSession"("token"); + +-- CreateIndex +CREATE INDEX "AuthAccount_userId_idx" ON "AuthAccount"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthAccount_providerId_accountId_key" ON "AuthAccount"("providerId", "accountId"); + +-- CreateIndex +CREATE INDEX "AuthVerification_identifier_idx" ON "AuthVerification"("identifier"); diff --git a/referência/sistema-de-chamados-main/prisma/migrations/20251006235816_add_companies/migration.sql b/referência/sistema-de-chamados-main/prisma/migrations/20251006235816_add_companies/migration.sql new file mode 100644 index 0000000..0e880a3 --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/migrations/20251006235816_add_companies/migration.sql @@ -0,0 +1,121 @@ +-- CreateTable +CREATE TABLE "Company" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "cnpj" TEXT, + "domain" TEXT, + "phone" TEXT, + "description" TEXT, + "address" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "AuthInvite" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "name" TEXT, + "role" TEXT NOT NULL DEFAULT 'agent', + "tenantId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "createdById" TEXT, + "acceptedAt" DATETIME, + "acceptedById" TEXT, + "revokedAt" DATETIME, + "revokedById" TEXT, + "revokedReason" TEXT +); + +-- CreateTable +CREATE TABLE "AuthInviteEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "inviteId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "payload" JSONB, + "actorId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AuthInviteEvent_inviteId_fkey" FOREIGN KEY ("inviteId") REFERENCES "AuthInvite" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Ticket" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "reference" INTEGER NOT NULL DEFAULT 0, + "subject" TEXT NOT NULL, + "summary" TEXT, + "status" TEXT NOT NULL DEFAULT 'NEW', + "priority" TEXT NOT NULL DEFAULT 'MEDIUM', + "channel" TEXT NOT NULL DEFAULT 'EMAIL', + "queueId" TEXT, + "requesterId" TEXT NOT NULL, + "assigneeId" TEXT, + "slaPolicyId" TEXT, + "companyId" TEXT, + "dueAt" DATETIME, + "firstResponseAt" DATETIME, + "resolvedAt" DATETIME, + "closedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Ticket_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Ticket_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Ticket_queueId_fkey" FOREIGN KEY ("queueId") REFERENCES "Queue" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Ticket_slaPolicyId_fkey" FOREIGN KEY ("slaPolicyId") REFERENCES "SlaPolicy" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Ticket_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Ticket" ("assigneeId", "channel", "closedAt", "createdAt", "dueAt", "firstResponseAt", "id", "priority", "queueId", "reference", "requesterId", "resolvedAt", "slaPolicyId", "status", "subject", "summary", "tenantId", "updatedAt") SELECT "assigneeId", "channel", "closedAt", "createdAt", "dueAt", "firstResponseAt", "id", "priority", "queueId", "reference", "requesterId", "resolvedAt", "slaPolicyId", "status", "subject", "summary", "tenantId", "updatedAt" FROM "Ticket"; +DROP TABLE "Ticket"; +ALTER TABLE "new_Ticket" RENAME TO "Ticket"; +CREATE INDEX "Ticket_tenantId_status_idx" ON "Ticket"("tenantId", "status"); +CREATE INDEX "Ticket_tenantId_queueId_idx" ON "Ticket"("tenantId", "queueId"); +CREATE INDEX "Ticket_tenantId_assigneeId_idx" ON "Ticket"("tenantId", "assigneeId"); +CREATE INDEX "Ticket_tenantId_companyId_idx" ON "Ticket"("tenantId", "companyId"); +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT NOT NULL, + "timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo', + "avatarUrl" TEXT, + "companyId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_User" ("avatarUrl", "createdAt", "email", "id", "name", "role", "tenantId", "timezone", "updatedAt") SELECT "avatarUrl", "createdAt", "email", "id", "name", "role", "tenantId", "timezone", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE INDEX "User_tenantId_role_idx" ON "User"("tenantId", "role"); +CREATE INDEX "User_tenantId_companyId_idx" ON "User"("tenantId", "companyId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "Company_tenantId_name_idx" ON "Company"("tenantId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Company_tenantId_slug_key" ON "Company"("tenantId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthInvite_token_key" ON "AuthInvite"("token"); + +-- CreateIndex +CREATE INDEX "AuthInvite_tenantId_status_idx" ON "AuthInvite"("tenantId", "status"); + +-- CreateIndex +CREATE INDEX "AuthInvite_tenantId_email_idx" ON "AuthInvite"("tenantId", "email"); + +-- CreateIndex +CREATE INDEX "AuthInviteEvent_inviteId_createdAt_idx" ON "AuthInviteEvent"("inviteId", "createdAt"); diff --git a/referência/sistema-de-chamados-main/prisma/migrations/20251008003408_add_is_avulso_column/migration.sql b/referência/sistema-de-chamados-main/prisma/migrations/20251008003408_add_is_avulso_column/migration.sql new file mode 100644 index 0000000..b67fa4a --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/migrations/20251008003408_add_is_avulso_column/migration.sql @@ -0,0 +1,58 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Company" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "isAvulso" BOOLEAN NOT NULL DEFAULT false, + "contractedHoursPerMonth" REAL, + "cnpj" TEXT, + "domain" TEXT, + "phone" TEXT, + "description" TEXT, + "address" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Company" ("address", "cnpj", "createdAt", "description", "domain", "id", "name", "phone", "slug", "tenantId", "updatedAt") SELECT "address", "cnpj", "createdAt", "description", "domain", "id", "name", "phone", "slug", "tenantId", "updatedAt" FROM "Company"; +DROP TABLE "Company"; +ALTER TABLE "new_Company" RENAME TO "Company"; +CREATE INDEX "Company_tenantId_name_idx" ON "Company"("tenantId", "name"); +CREATE UNIQUE INDEX "Company_tenantId_slug_key" ON "Company"("tenantId", "slug"); +CREATE TABLE "new_Ticket" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "reference" INTEGER NOT NULL DEFAULT 0, + "subject" TEXT NOT NULL, + "summary" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "priority" TEXT NOT NULL DEFAULT 'MEDIUM', + "channel" TEXT NOT NULL DEFAULT 'EMAIL', + "queueId" TEXT, + "requesterId" TEXT NOT NULL, + "assigneeId" TEXT, + "slaPolicyId" TEXT, + "companyId" TEXT, + "dueAt" DATETIME, + "firstResponseAt" DATETIME, + "resolvedAt" DATETIME, + "closedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Ticket_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Ticket_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Ticket_queueId_fkey" FOREIGN KEY ("queueId") REFERENCES "Queue" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Ticket_slaPolicyId_fkey" FOREIGN KEY ("slaPolicyId") REFERENCES "SlaPolicy" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Ticket_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Ticket" ("assigneeId", "channel", "closedAt", "companyId", "createdAt", "dueAt", "firstResponseAt", "id", "priority", "queueId", "reference", "requesterId", "resolvedAt", "slaPolicyId", "status", "subject", "summary", "tenantId", "updatedAt") SELECT "assigneeId", "channel", "closedAt", "companyId", "createdAt", "dueAt", "firstResponseAt", "id", "priority", "queueId", "reference", "requesterId", "resolvedAt", "slaPolicyId", "status", "subject", "summary", "tenantId", "updatedAt" FROM "Ticket"; +DROP TABLE "Ticket"; +ALTER TABLE "new_Ticket" RENAME TO "Ticket"; +CREATE INDEX "Ticket_tenantId_status_idx" ON "Ticket"("tenantId", "status"); +CREATE INDEX "Ticket_tenantId_queueId_idx" ON "Ticket"("tenantId", "queueId"); +CREATE INDEX "Ticket_tenantId_assigneeId_idx" ON "Ticket"("tenantId", "assigneeId"); +CREATE INDEX "Ticket_tenantId_companyId_idx" ON "Ticket"("tenantId", "companyId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/referência/sistema-de-chamados-main/prisma/migrations/20251012062518_add_machine_persona/migration.sql b/referência/sistema-de-chamados-main/prisma/migrations/20251012062518_add_machine_persona/migration.sql new file mode 100644 index 0000000..ebef301 --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/migrations/20251012062518_add_machine_persona/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "AuthUser" ADD COLUMN "machinePersona" TEXT; diff --git a/referência/sistema-de-chamados-main/prisma/migrations/20251015223259_add_company_provisioning_code/migration.sql b/referência/sistema-de-chamados-main/prisma/migrations/20251015223259_add_company_provisioning_code/migration.sql new file mode 100644 index 0000000..4903803 --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/migrations/20251015223259_add_company_provisioning_code/migration.sql @@ -0,0 +1,35 @@ +/* + Warnings: + + - Added the required column `provisioningCode` to the `Company` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +DROP TABLE IF EXISTS "new_Company"; +CREATE TABLE "new_Company" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "provisioningCode" TEXT NOT NULL, + "isAvulso" BOOLEAN NOT NULL DEFAULT false, + "contractedHoursPerMonth" REAL, + "cnpj" TEXT, + "domain" TEXT, + "phone" TEXT, + "description" TEXT, + "address" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Company" ("address", "cnpj", "contractedHoursPerMonth", "createdAt", "description", "domain", "id", "isAvulso", "name", "phone", "slug", "tenantId", "updatedAt", "provisioningCode") +SELECT "address", "cnpj", "contractedHoursPerMonth", "createdAt", "description", "domain", "id", "isAvulso", "name", "phone", "slug", "tenantId", "updatedAt", lower(hex(randomblob(32))) FROM "Company"; +DROP TABLE "Company"; +ALTER TABLE "new_Company" RENAME TO "Company"; +CREATE UNIQUE INDEX "Company_provisioningCode_key" ON "Company"("provisioningCode"); +CREATE INDEX "Company_tenantId_name_idx" ON "Company"("tenantId", "name"); +CREATE UNIQUE INDEX "Company_tenantId_slug_key" ON "Company"("tenantId", "slug"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/referência/sistema-de-chamados-main/prisma/migrations/20251022120000_extend_company_profile/migration.sql b/referência/sistema-de-chamados-main/prisma/migrations/20251022120000_extend_company_profile/migration.sql new file mode 100644 index 0000000..bf91fe5 --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/migrations/20251022120000_extend_company_profile/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "Company" ADD COLUMN "legalName" TEXT; +ALTER TABLE "Company" ADD COLUMN "tradeName" TEXT; +ALTER TABLE "Company" ADD COLUMN "stateRegistration" TEXT; +ALTER TABLE "Company" ADD COLUMN "stateRegistrationType" TEXT; +ALTER TABLE "Company" ADD COLUMN "primaryCnae" TEXT; +ALTER TABLE "Company" ADD COLUMN "timezone" TEXT; +ALTER TABLE "Company" ADD COLUMN "businessHours" JSONB; +ALTER TABLE "Company" ADD COLUMN "supportEmail" TEXT; +ALTER TABLE "Company" ADD COLUMN "billingEmail" TEXT; +ALTER TABLE "Company" ADD COLUMN "contactPreferences" JSONB; +ALTER TABLE "Company" ADD COLUMN "clientDomains" JSONB; +ALTER TABLE "Company" ADD COLUMN "communicationChannels" JSONB; +ALTER TABLE "Company" ADD COLUMN "fiscalAddress" JSONB; +ALTER TABLE "Company" ADD COLUMN "hasBranches" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Company" ADD COLUMN "regulatedEnvironments" JSONB; +ALTER TABLE "Company" ADD COLUMN "privacyPolicyAccepted" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Company" ADD COLUMN "privacyPolicyReference" TEXT; +ALTER TABLE "Company" ADD COLUMN "privacyPolicyMetadata" JSONB; +ALTER TABLE "Company" ADD COLUMN "contracts" JSONB; +ALTER TABLE "Company" ADD COLUMN "contacts" JSONB; +ALTER TABLE "Company" ADD COLUMN "locations" JSONB; +ALTER TABLE "Company" ADD COLUMN "sla" JSONB; +ALTER TABLE "Company" ADD COLUMN "tags" JSONB; +ALTER TABLE "Company" ADD COLUMN "customFields" JSONB; +ALTER TABLE "Company" ADD COLUMN "notes" TEXT; diff --git a/referência/sistema-de-chamados-main/prisma/migrations/20251027195301_add_user_manager_fields/migration.sql b/referência/sistema-de-chamados-main/prisma/migrations/20251027195301_add_user_manager_fields/migration.sql new file mode 100644 index 0000000..831ea5c --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/migrations/20251027195301_add_user_manager_fields/migration.sql @@ -0,0 +1,28 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT NOT NULL, + "jobTitle" TEXT, + "managerId" TEXT, + "timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo', + "avatarUrl" TEXT, + "companyId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "User_managerId_fkey" FOREIGN KEY ("managerId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_User" ("avatarUrl", "companyId", "createdAt", "email", "id", "name", "role", "tenantId", "timezone", "updatedAt") SELECT "avatarUrl", "companyId", "createdAt", "email", "id", "name", "role", "tenantId", "timezone", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE INDEX "User_tenantId_role_idx" ON "User"("tenantId", "role"); +CREATE INDEX "User_tenantId_companyId_idx" ON "User"("tenantId", "companyId"); +CREATE INDEX "User_tenantId_managerId_idx" ON "User"("tenantId", "managerId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/referência/sistema-de-chamados-main/prisma/migrations/20251108042551_add_ticket_sla_fields/migration.sql b/referência/sistema-de-chamados-main/prisma/migrations/20251108042551_add_ticket_sla_fields/migration.sql new file mode 100644 index 0000000..db6b91c --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/migrations/20251108042551_add_ticket_sla_fields/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Ticket" ADD COLUMN "slaPausedAt" DATETIME; +ALTER TABLE "Ticket" ADD COLUMN "slaPausedBy" TEXT; +ALTER TABLE "Ticket" ADD COLUMN "slaPausedMs" INTEGER; +ALTER TABLE "Ticket" ADD COLUMN "slaResponseDueAt" DATETIME; +ALTER TABLE "Ticket" ADD COLUMN "slaResponseStatus" TEXT; +ALTER TABLE "Ticket" ADD COLUMN "slaSnapshot" JSONB; +ALTER TABLE "Ticket" ADD COLUMN "slaSolutionDueAt" DATETIME; +ALTER TABLE "Ticket" ADD COLUMN "slaSolutionStatus" TEXT; diff --git a/referência/sistema-de-chamados-main/prisma/migrations/migration_lock.toml b/referência/sistema-de-chamados-main/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/referência/sistema-de-chamados-main/prisma/schema.prisma b/referência/sistema-de-chamados-main/prisma/schema.prisma new file mode 100644 index 0000000..de8c42f --- /dev/null +++ b/referência/sistema-de-chamados-main/prisma/schema.prisma @@ -0,0 +1,355 @@ +generator client { + provider = "prisma-client-js" + // Include both OpenSSL variants to support host build (OpenSSL 3.x) + // and container runtime (Debian bullseye, OpenSSL 1.1.x) + binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"] +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +enum UserRole { + ADMIN + MANAGER + AGENT + COLLABORATOR +} + +enum TicketStatus { + PENDING + AWAITING_ATTENDANCE + PAUSED + RESOLVED +} + +enum TicketPriority { + LOW + MEDIUM + HIGH + URGENT +} + +enum TicketChannel { + EMAIL + WHATSAPP + CHAT + PHONE + API + MANUAL +} + +enum CommentVisibility { + PUBLIC + INTERNAL +} + +enum CompanyStateRegistrationType { + STANDARD + EXEMPT + SIMPLES +} + +model Team { + id String @id @default(cuid()) + tenantId String + name String + description String? + members TeamMember[] + queues Queue[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tenantId, name]) +} + +model TeamMember { + teamId String + userId String + isLead Boolean @default(false) + assignedAt DateTime @default(now()) + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([teamId, userId]) +} + +model Company { + id String @id @default(cuid()) + tenantId String + name String + slug String + provisioningCode String @unique + isAvulso Boolean @default(false) + contractedHoursPerMonth Float? + cnpj String? + domain String? + phone String? + description String? + address String? + legalName String? + tradeName String? + stateRegistration String? + stateRegistrationType CompanyStateRegistrationType? + primaryCnae String? + timezone String? + businessHours Json? + supportEmail String? + billingEmail String? + contactPreferences Json? + clientDomains Json? + communicationChannels Json? + fiscalAddress Json? + hasBranches Boolean @default(false) + regulatedEnvironments Json? + privacyPolicyAccepted Boolean @default(false) + privacyPolicyReference String? + privacyPolicyMetadata Json? + contacts Json? + locations Json? + contracts Json? + sla Json? + tags Json? + customFields Json? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] + tickets Ticket[] + + @@unique([tenantId, slug]) + @@index([tenantId, name]) +} + +model User { + id String @id @default(cuid()) + tenantId String + name String + email String @unique + role UserRole + jobTitle String? + managerId String? + timezone String @default("America/Sao_Paulo") + avatarUrl String? + companyId String? + teams TeamMember[] + requestedTickets Ticket[] @relation("TicketRequester") + assignedTickets Ticket[] @relation("TicketAssignee") + comments TicketComment[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + manager User? @relation("UserManager", fields: [managerId], references: [id]) + reports User[] @relation("UserManager") + company Company? @relation(fields: [companyId], references: [id]) + + @@index([tenantId, role]) + @@index([tenantId, companyId]) + @@index([tenantId, managerId]) +} + +model Queue { + id String @id @default(cuid()) + tenantId String + name String + slug String + teamId String? + tickets Ticket[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team? @relation(fields: [teamId], references: [id]) + + @@unique([tenantId, slug]) +} + +model Ticket { + id String @id @default(cuid()) + tenantId String + reference Int @default(0) + subject String + summary String? + status TicketStatus @default(PENDING) + priority TicketPriority @default(MEDIUM) + channel TicketChannel @default(EMAIL) + queueId String? + requesterId String + assigneeId String? + slaPolicyId String? + companyId String? + slaSnapshot Json? + slaResponseDueAt DateTime? + slaSolutionDueAt DateTime? + slaResponseStatus String? + slaSolutionStatus String? + slaPausedAt DateTime? + slaPausedBy String? + slaPausedMs Int? + dueAt DateTime? + firstResponseAt DateTime? + resolvedAt DateTime? + closedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + requester User @relation("TicketRequester", fields: [requesterId], references: [id]) + assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id]) + queue Queue? @relation(fields: [queueId], references: [id]) + slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id]) + company Company? @relation(fields: [companyId], references: [id]) + events TicketEvent[] + comments TicketComment[] + + @@index([tenantId, status]) + @@index([tenantId, queueId]) + @@index([tenantId, assigneeId]) + @@index([tenantId, companyId]) +} + +model TicketEvent { + id String @id @default(cuid()) + ticketId String + type String + payload Json + createdAt DateTime @default(now()) + + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + + @@index([ticketId, createdAt]) +} + +model TicketComment { + id String @id @default(cuid()) + ticketId String + authorId String + visibility CommentVisibility @default(INTERNAL) + body String + attachments Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + author User @relation(fields: [authorId], references: [id]) + + @@index([ticketId, visibility]) +} + +model SlaPolicy { + id String @id @default(cuid()) + tenantId String + name String + description String? + timeToFirstResponse Int? + timeToResolution Int? + calendar Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tickets Ticket[] + + @@unique([tenantId, name]) +} + +model AuthUser { + id String @id @default(cuid()) + name String? + email String @unique + emailVerified Boolean @default(false) + image String? + role String @default("agent") + tenantId String? + avatarUrl String? + machinePersona String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sessions AuthSession[] + accounts AuthAccount[] +} + +model AuthSession { + id String @id @default(cuid()) + userId String + token String @unique + expiresAt DateTime + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([token]) +} + +model AuthAccount { + id String @id @default(cuid()) + userId String + accountId String + providerId String + accessToken String? + refreshToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + idToken String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([providerId, accountId]) + @@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 + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([identifier]) +} diff --git a/referência/sistema-de-chamados-main/proxy.ts b/referência/sistema-de-chamados-main/proxy.ts new file mode 100644 index 0000000..a40b543 --- /dev/null +++ b/referência/sistema-de-chamados-main/proxy.ts @@ -0,0 +1,134 @@ +import { getCookieCache } from "better-auth/cookies" +import { NextRequest, NextResponse } from "next/server" + +import { isAllowedHost } from "@/config/allowed-hosts" + +// Rotas públicas explícitas (não autenticadas) +// Permite handshake de máquina sem sessão prévia para criar a sessão de máquina. +const PUBLIC_PATHS = [/^\/login$/, /^\/machines\/handshake$/] +// Rotas somente admin +const ADMIN_ONLY_PATHS = [/^\/admin(?:$|\/)/] +const APP_HOME = "/dashboard" + +export async function proxy(request: NextRequest) { + if (process.env.NODE_ENV === "production" && !isAllowedHost(request.headers.get("host"))) { + return new NextResponse("Invalid Host header", { status: 403 }) + } + + const { pathname, searchParams, search } = request.nextUrl + + if (pathname.startsWith("/api")) { + return NextResponse.next() + } + + if (PUBLIC_PATHS.some((pattern) => pattern.test(pathname))) return NextResponse.next() + + const session = await getCookieCache(request) + + if (!session?.user) { + const hasSessionCookie = Boolean(request.cookies.get("better-auth.session-token")) + const hasRefreshCookie = + Boolean(request.cookies.get("better-auth.refresh-token")) || + Boolean(request.cookies.get("better-auth.refresh-token-v2")) + + if (hasSessionCookie || hasRefreshCookie) { + const refreshed = await attemptSessionRefresh(request) + if (refreshed) { + return refreshed + } + } + + const redirectUrl = new URL("/login", request.url) + redirectUrl.searchParams.set("callbackUrl", pathname + search) + return NextResponse.redirect(redirectUrl) + } + + const role = (session.user as { role?: string })?.role?.toLowerCase() ?? "agent" + const machinePersona = + role === "machine" + ? ((session.user as unknown as { machinePersona?: string }).machinePersona ?? "").toLowerCase() + : null + + if (pathname === "/login") { + const callback = searchParams.get("callbackUrl") ?? undefined + const defaultDestination = + role === "machine" + ? machinePersona === "manager" + ? "/dashboard" + : "/portal/tickets" + : APP_HOME + const target = callback && !callback.startsWith("/login") ? callback : defaultDestination + return NextResponse.redirect(new URL(target, request.url)) + } + + // Ajusta destinos conforme persona da máquina para evitar loops login<->dashboard + if (role === "machine") { + // Evita enviar colaborador ao dashboard; redireciona para o Portal + if (pathname.startsWith("/dashboard") && machinePersona !== "manager") { + return NextResponse.redirect(new URL("/portal/tickets", request.url)) + } + // Evita mostrar login quando já há sessão de máquina + } + + const isAdmin = role === "admin" + // Em desenvolvimento, evitamos bloquear rotas admin por possíveis diferenças + // de cache de cookie/sessão entre dev server e middleware. Em produção, aplica o gate. + if ( + process.env.NODE_ENV === "production" && + !isAdmin && + ADMIN_ONLY_PATHS.some((pattern) => pattern.test(pathname)) + ) { + return NextResponse.redirect(new URL(APP_HOME, request.url)) + } + + return NextResponse.next() +} + +export const config = { + runtime: "nodejs", + // Evita executar para assets e imagens estáticas + matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.png).*)"], +} + +async function attemptSessionRefresh(request: NextRequest): Promise { + try { + const refreshUrl = new URL("/api/auth/get-session", request.url) + const response = await fetch(refreshUrl, { + method: "GET", + headers: { + cookie: request.headers.get("cookie") ?? "", + }, + }) + + if (!response.ok) { + return null + } + + const data = await response.json().catch(() => null) + if (!data?.user) { + return null + } + + const redirect = NextResponse.redirect(request.nextUrl) + const headersWithGetSetCookie = response.headers as Headers & { getSetCookie?: () => string[] | undefined } + let setCookieHeaders = + typeof headersWithGetSetCookie.getSetCookie === "function" + ? headersWithGetSetCookie.getSetCookie() ?? [] + : [] + + if (setCookieHeaders.length === 0) { + const single = response.headers.get("set-cookie") + if (single) { + setCookieHeaders = [single] + } + } + + for (const cookie of setCookieHeaders) { + redirect.headers.append("set-cookie", cookie) + } + + return redirect + } catch { + return null + } +} diff --git a/referência/sistema-de-chamados-main/public/file.svg b/referência/sistema-de-chamados-main/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/referência/sistema-de-chamados-main/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/referência/sistema-de-chamados-main/public/fonts/Inter-Italic-VariableFont_opsz,wght.ttf b/referência/sistema-de-chamados-main/public/fonts/Inter-Italic-VariableFont_opsz,wght.ttf new file mode 100644 index 0000000..43ed4f5 Binary files /dev/null and b/referência/sistema-de-chamados-main/public/fonts/Inter-Italic-VariableFont_opsz,wght.ttf differ diff --git a/referência/sistema-de-chamados-main/public/fonts/Inter-VariableFont_opsz,wght.ttf b/referência/sistema-de-chamados-main/public/fonts/Inter-VariableFont_opsz,wght.ttf new file mode 100644 index 0000000..e31b51e Binary files /dev/null and b/referência/sistema-de-chamados-main/public/fonts/Inter-VariableFont_opsz,wght.ttf differ diff --git a/referência/sistema-de-chamados-main/public/globe.svg b/referência/sistema-de-chamados-main/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/referência/sistema-de-chamados-main/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/referência/sistema-de-chamados-main/public/logo-raven-fund-azul.png b/referência/sistema-de-chamados-main/public/logo-raven-fund-azul.png new file mode 100644 index 0000000..9b95662 Binary files /dev/null and b/referência/sistema-de-chamados-main/public/logo-raven-fund-azul.png differ diff --git a/referência/sistema-de-chamados-main/public/logo-raven.png b/referência/sistema-de-chamados-main/public/logo-raven.png new file mode 100644 index 0000000..62b264e Binary files /dev/null and b/referência/sistema-de-chamados-main/public/logo-raven.png differ diff --git a/referência/sistema-de-chamados-main/public/next.svg b/referência/sistema-de-chamados-main/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/referência/sistema-de-chamados-main/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/referência/sistema-de-chamados-main/public/raven.png b/referência/sistema-de-chamados-main/public/raven.png new file mode 100644 index 0000000..b1c7cf7 Binary files /dev/null and b/referência/sistema-de-chamados-main/public/raven.png differ diff --git a/referência/sistema-de-chamados-main/public/vercel.svg b/referência/sistema-de-chamados-main/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/referência/sistema-de-chamados-main/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/referência/sistema-de-chamados-main/public/window.svg b/referência/sistema-de-chamados-main/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/referência/sistema-de-chamados-main/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/referência/sistema-de-chamados-main/resp.json b/referência/sistema-de-chamados-main/resp.json new file mode 100644 index 0000000..bfff84b --- /dev/null +++ b/referência/sistema-de-chamados-main/resp.json @@ -0,0 +1 @@ +{"machineId":"jn7dtb6hy25w8f6h3hkxfr66wx7s7mk4","tenantId":"tenant-atlas","machineToken":"eea4477d0f107b5291a2fcdf41945be12d0ff8775beeb92d7bbeb1b26df101ca","machineEmail":"machine-jn7dtb6hy25w8f6h3hkxfr66wx7s7mk4@machines.local","expiresAt":1762658209388} \ No newline at end of file diff --git a/referência/sistema-de-chamados-main/scripts/apply-company-migration.mjs b/referência/sistema-de-chamados-main/scripts/apply-company-migration.mjs new file mode 100644 index 0000000..fc68514 --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/apply-company-migration.mjs @@ -0,0 +1,57 @@ +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +const statements = [ + `ALTER TABLE "Company" ADD COLUMN "legalName" TEXT`, + `ALTER TABLE "Company" ADD COLUMN "tradeName" TEXT`, + `ALTER TABLE "Company" ADD COLUMN "stateRegistration" TEXT`, + `ALTER TABLE "Company" ADD COLUMN "stateRegistrationType" TEXT`, + `ALTER TABLE "Company" ADD COLUMN "primaryCnae" TEXT`, + `ALTER TABLE "Company" ADD COLUMN "timezone" TEXT`, + `ALTER TABLE "Company" ADD COLUMN "businessHours" JSON`, + `ALTER TABLE "Company" ADD COLUMN "supportEmail" TEXT`, + `ALTER TABLE "Company" ADD COLUMN "billingEmail" TEXT`, + `ALTER TABLE "Company" ADD COLUMN "contactPreferences" JSON`, + `ALTER TABLE "Company" ADD COLUMN "clientDomains" JSON`, + `ALTER TABLE "Company" ADD COLUMN "communicationChannels" JSON`, + `ALTER TABLE "Company" ADD COLUMN "fiscalAddress" JSON`, + `ALTER TABLE "Company" ADD COLUMN "hasBranches" BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE "Company" ADD COLUMN "regulatedEnvironments" JSON`, + `ALTER TABLE "Company" ADD COLUMN "privacyPolicyAccepted" BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE "Company" ADD COLUMN "privacyPolicyReference" TEXT`, + `ALTER TABLE "Company" ADD COLUMN "privacyPolicyMetadata" JSON`, + `ALTER TABLE "Company" ADD COLUMN "contracts" JSON`, + `ALTER TABLE "Company" ADD COLUMN "contacts" JSON`, + `ALTER TABLE "Company" ADD COLUMN "locations" JSON`, + `ALTER TABLE "Company" ADD COLUMN "sla" JSON`, + `ALTER TABLE "Company" ADD COLUMN "tags" JSON`, + `ALTER TABLE "Company" ADD COLUMN "customFields" JSON`, + `ALTER TABLE "Company" ADD COLUMN "notes" TEXT`, +] + +async function main() { + for (const statement of statements) { + try { + await prisma.$executeRawUnsafe(statement) + } catch (error) { + // Ignore errors caused by columns that already exist (idempotent execution) + if ( + !(error instanceof Error) || + !/duplicate column name|already exists/i.test(error.message ?? "") + ) { + console.error(`Failed to apply migration statement: ${statement}`) + throw error + } + } + } +} + +main() + .catch((error) => { + console.error(error) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/referência/sistema-de-chamados-main/scripts/debug-convex.mjs b/referência/sistema-de-chamados-main/scripts/debug-convex.mjs new file mode 100644 index 0000000..56df258 --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/debug-convex.mjs @@ -0,0 +1,47 @@ +import "dotenv/config" +import { ConvexHttpClient } from "convex/browser"; + +const url = process.env.NEXT_PUBLIC_CONVEX_URL; + +if (!url) { + console.error("Missing NEXT_PUBLIC_CONVEX_URL"); + process.exit(1); +} + +const client = new ConvexHttpClient(url); + +const tenantId = process.argv[2] ?? "tenant-atlas"; + +const ensureAdmin = await client.mutation("users:ensureUser", { + tenantId, + email: "admin@sistema.dev", + name: "Administrador", + role: "ADMIN", +}); + +console.log("Ensured admin user:", ensureAdmin); + +const agents = await client.query("users:listAgents", { tenantId }); +console.log("Agents:", agents); + +const viewerId = ensureAdmin?._id ?? agents[0]?._id; + +if (!viewerId) { + console.error("Unable to determine viewer id"); + process.exit(1); +} + +const tickets = await client.query("tickets:list", { + tenantId, + viewerId, + limit: 10, +}); + +console.log("Tickets:", tickets); + +const dashboard = await client.query("reports:dashboardOverview", { + tenantId, + viewerId, +}); + +console.log("Dashboard:", dashboard); diff --git a/referência/sistema-de-chamados-main/scripts/deploy-from-git.sh b/referência/sistema-de-chamados-main/scripts/deploy-from-git.sh new file mode 100644 index 0000000..19367d4 --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/deploy-from-git.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Simple pull-based deployer for the VPS, bypassing GitHub Actions. +# - Syncs repo with origin/main +# - Reapplies Swarm stack +# - If convex/ changed, deploys Convex functions to self-hosted backend +# +# Requirements: +# - Run from /srv/apps/sistema (or set APP_DIR below) +# - A file .ci.env with: +# CONVEX_SELF_HOSTED_URL=https://convex.esdrasrenan.com.br +# CONVEX_SELF_HOSTED_ADMIN_KEY=convex-self-hosted|... + +APP_DIR=${APP_DIR:-/srv/apps/sistema} +cd "$APP_DIR" + +echo "[deploy] Fetching origin/main..." +git fetch origin main --quiet + +CURRENT=$(git rev-parse HEAD || echo "") +REMOTE=$(git rev-parse origin/main) + +if [[ "$CURRENT" == "$REMOTE" ]]; then + echo "[deploy] Nothing to do (up-to-date)." + exit 0 +fi + +echo "[deploy] Computing changed paths..." +CHANGED=$(git diff --name-only "${CURRENT:-$REMOTE}" "$REMOTE" || true) + +echo "[deploy] Resetting to origin/main ($REMOTE)..." +git reset --hard "$REMOTE" + +echo "[deploy] Re-deploying Swarm stack..." +docker stack deploy --with-registry-auth -c stack.yml sistema + +if echo "$CHANGED" | grep -q '^convex/'; then + if [[ -f .ci.env ]]; then + echo "[deploy] convex/ changed -> deploying Convex functions..." + docker run --rm -i \ + -v "$APP_DIR":/app \ + -w /app \ + --env-file .ci.env \ + oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x convex deploy" + else + echo "[deploy] convex/ changed but .ci.env missing; skip Convex deploy" + fi +else + echo "[deploy] convex/ unchanged; skipping Convex deploy" +fi + +echo "[deploy] Done." diff --git a/referência/sistema-de-chamados-main/scripts/ensure-default-queues.mjs b/referência/sistema-de-chamados-main/scripts/ensure-default-queues.mjs new file mode 100644 index 0000000..4a029ed --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/ensure-default-queues.mjs @@ -0,0 +1,73 @@ +import "dotenv/config" +import { ConvexHttpClient } from "convex/browser" + +const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas" +const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + +if (!convexUrl) { + console.error("NEXT_PUBLIC_CONVEX_URL não configurado. Ajuste o .env antes de executar o script.") + process.exit(1) +} + +const DEFAULT_QUEUES = [ + { name: "Chamados" }, + { name: "Laboratório" }, + { name: "Visitas" }, +] + +function slugify(value) { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .toLowerCase() +} + +async function main() { + const client = new ConvexHttpClient(convexUrl) + + const agents = await client.query("users:listAgents", { tenantId }) + const admin = + agents.find((user) => (user.role ?? "").toUpperCase() === "ADMIN") ?? + agents[0] + + if (!admin?._id) { + console.error("Nenhum usuário ADMIN encontrado no Convex para criar filas padrão.") + process.exit(1) + } + + const existing = await client.query("queues:list", { + tenantId, + viewerId: admin._id, + }) + + const existingSlugs = new Set(existing.map((queue) => queue.slug)) + const created = [] + + for (const def of DEFAULT_QUEUES) { + const slug = slugify(def.name) + if (existingSlugs.has(slug)) { + continue + } + await client.mutation("queues:create", { + tenantId, + actorId: admin._id, + name: def.name, + }) + created.push(def.name) + } + + if (created.length === 0) { + console.log("Nenhuma fila criada. As filas padrão já existem.") + } else { + console.log(`Filas criadas: ${created.join(", ")}`) + } +} + +main().catch((error) => { + console.error("Falha ao garantir filas padrão", error) + process.exit(1) +}) diff --git a/referência/sistema-de-chamados-main/scripts/import-convex-to-prisma.mjs b/referência/sistema-de-chamados-main/scripts/import-convex-to-prisma.mjs new file mode 100644 index 0000000..d71584d --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/import-convex-to-prisma.mjs @@ -0,0 +1,425 @@ +import "dotenv/config" +import { PrismaClient } from "@prisma/client" +import { ConvexHttpClient } from "convex/browser" + +const prisma = new PrismaClient() + +const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas" +const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210" +const secret = process.env.CONVEX_SYNC_SECRET +const STAFF_ROSTER = [ + { email: "admin@sistema.dev", name: "Administrador", role: "ADMIN" }, + { email: "gabriel.oliveira@rever.com.br", name: "Gabriel Oliveira", role: "AGENT" }, + { email: "george.araujo@rever.com.br", name: "George Araujo", role: "AGENT" }, + { email: "hugo.soares@rever.com.br", name: "Hugo Soares", role: "AGENT" }, + { email: "julio@rever.com.br", name: "Julio Cesar", role: "AGENT" }, + { email: "lorena@rever.com.br", name: "Lorena Magalhães", role: "AGENT" }, + { email: "renan.pac@paulicon.com.br", name: "Rever", role: "AGENT" }, + { email: "suporte@rever.com.br", name: "Telão", role: "AGENT" }, + { email: "thiago.medeiros@rever.com.br", name: "Thiago Medeiros", role: "AGENT" }, + { email: "weslei@rever.com.br", name: "Weslei Magalhães", role: "AGENT" }, +] + +const rawDefaultAssigneeEmail = process.env.SYNC_DEFAULT_ASSIGNEE || "gabriel.oliveira@rever.com.br" + +if (!secret) { + console.error("CONVEX_SYNC_SECRET não configurado. Configure no .env.") + process.exit(1) +} + +const allowedRoles = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]) + +const client = new ConvexHttpClient(convexUrl) + +function normalizeEmail(email) { + if (!email) return null + return email.trim().toLowerCase() +} + +const defaultAssigneeEmail = normalizeEmail(rawDefaultAssigneeEmail) + +function toDate(value) { + if (!value && value !== 0) return null + return new Date(value) +} + +function slugify(value) { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .toLowerCase() +} + +const STATUS_MAP = { + NEW: "PENDING", + PENDING: "PENDING", + OPEN: "AWAITING_ATTENDANCE", + AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", + ON_HOLD: "PAUSED", + PAUSED: "PAUSED", + RESOLVED: "RESOLVED", + CLOSED: "RESOLVED", +} + +function normalizeStatus(status) { + if (!status) return "PENDING" + const key = String(status).toUpperCase() + return STATUS_MAP[key] ?? "PENDING" +} + +function serializeConvexSlaSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== "object") return null + const categoryIdRaw = snapshot.categoryId + let categoryId + if (typeof categoryIdRaw === "string") { + categoryId = categoryIdRaw + } else if (categoryIdRaw && typeof categoryIdRaw === "object" && "_id" in categoryIdRaw) { + categoryId = categoryIdRaw._id + } + + const pauseStatuses = + Array.isArray(snapshot.pauseStatuses) && snapshot.pauseStatuses.length > 0 + ? snapshot.pauseStatuses.filter((value) => typeof value === "string") + : undefined + + const normalized = { + categoryId, + categoryName: typeof snapshot.categoryName === "string" ? snapshot.categoryName : undefined, + priority: typeof snapshot.priority === "string" ? snapshot.priority : undefined, + responseTargetMinutes: typeof snapshot.responseTargetMinutes === "number" ? snapshot.responseTargetMinutes : undefined, + responseMode: typeof snapshot.responseMode === "string" ? snapshot.responseMode : undefined, + solutionTargetMinutes: typeof snapshot.solutionTargetMinutes === "number" ? snapshot.solutionTargetMinutes : undefined, + solutionMode: typeof snapshot.solutionMode === "string" ? snapshot.solutionMode : undefined, + alertThreshold: typeof snapshot.alertThreshold === "number" ? snapshot.alertThreshold : undefined, + pauseStatuses, + } + + const hasValues = Object.values(normalized).some((value) => value !== undefined) + return hasValues ? normalized : null +} + +async function upsertCompanies(snapshotCompanies) { + const map = new Map() + + for (const company of snapshotCompanies) { + const slug = company.slug || slugify(company.name) + const record = await prisma.company.upsert({ + where: { + tenantId_slug: { + tenantId, + slug, + }, + }, + update: { + name: company.name, + isAvulso: Boolean(company.isAvulso ?? false), + cnpj: company.cnpj ?? null, + domain: company.domain ?? null, + phone: company.phone ?? null, + description: company.description ?? null, + address: company.address ?? null, + }, + create: { + tenantId, + name: company.name, + slug, + isAvulso: Boolean(company.isAvulso ?? false), + cnpj: company.cnpj ?? null, + domain: company.domain ?? null, + phone: company.phone ?? null, + description: company.description ?? null, + address: company.address ?? null, + createdAt: toDate(company.createdAt) ?? new Date(), + updatedAt: toDate(company.updatedAt) ?? new Date(), + }, + }) + + map.set(slug, record.id) + } + + return map +} + +async function upsertUsers(snapshotUsers, companyMap) { + const map = new Map() + + for (const user of snapshotUsers) { + const normalizedEmail = normalizeEmail(user.email) + if (!normalizedEmail) continue + + const normalizedRole = (user.role ?? "MANAGER").toUpperCase() + const role = allowedRoles.has(normalizedRole) ? normalizedRole : "MANAGER" + const companyId = user.companySlug ? companyMap.get(user.companySlug) ?? null : null + + const record = await prisma.user.upsert({ + where: { email: normalizedEmail }, + update: { + name: user.name ?? normalizedEmail, + role, + tenantId, + avatarUrl: user.avatarUrl ?? null, + companyId, + }, + create: { + email: normalizedEmail, + name: user.name ?? normalizedEmail, + role, + tenantId, + avatarUrl: user.avatarUrl ?? null, + companyId, + }, + }) + + map.set(normalizedEmail, record.id) + } + + for (const staff of STAFF_ROSTER) { + const normalizedEmail = normalizeEmail(staff.email) + if (!normalizedEmail) continue + const record = await prisma.user.upsert({ + where: { email: normalizedEmail }, + update: { + name: staff.name, + role: staff.role, + tenantId, + companyId: null, + }, + create: { + email: normalizedEmail, + name: staff.name, + role: staff.role, + tenantId, + avatarUrl: null, + companyId: null, + }, + }) + map.set(normalizedEmail, record.id) + } + + const allowedStaffEmails = new Set(STAFF_ROSTER.map((staff) => normalizeEmail(staff.email)).filter(Boolean)) + + const removableStaff = await prisma.user.findMany({ + where: { + tenantId, + role: { in: ["ADMIN", "AGENT", "COLLABORATOR"] }, + email: { + notIn: Array.from(allowedStaffEmails), + }, + }, + }) + + const fallbackAssigneeId = defaultAssigneeEmail ? map.get(defaultAssigneeEmail) ?? null : null + + for (const staff of removableStaff) { + if (fallbackAssigneeId) { + await prisma.ticket.updateMany({ + where: { tenantId, assigneeId: staff.id }, + data: { assigneeId: fallbackAssigneeId }, + }) + await prisma.ticketComment.updateMany({ + where: { authorId: staff.id }, + data: { authorId: fallbackAssigneeId }, + }) + } + + await prisma.user.update({ + where: { id: staff.id }, + data: { + role: "MANAGER", + }, + }) + } + + return map +} + +async function upsertQueues(snapshotQueues) { + const map = new Map() + + for (const queue of snapshotQueues) { + if (!queue.slug) continue + const record = await prisma.queue.upsert({ + where: { + tenantId_slug: { + tenantId, + slug: queue.slug, + }, + }, + update: { + name: queue.name, + }, + create: { + tenantId, + name: queue.name, + slug: queue.slug, + }, + }) + + map.set(queue.slug, record.id) + } + + return map +} + +async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) { + let created = 0 + let updated = 0 + + const fallbackAssigneeId = defaultAssigneeEmail ? userMap.get(defaultAssigneeEmail) ?? null : null + + for (const ticket of snapshotTickets) { + if (!ticket.requesterEmail) continue + + const requesterId = userMap.get(normalizeEmail(ticket.requesterEmail)) + if (!requesterId) continue + + const queueId = ticket.queueSlug ? queueMap.get(ticket.queueSlug) ?? null : null + + let companyId = ticket.companySlug ? companyMap.get(ticket.companySlug) ?? null : null + if (!companyId && requesterId) { + const requester = await prisma.user.findUnique({ + where: { id: requesterId }, + select: { companyId: true }, + }) + companyId = requester?.companyId ?? null + } + + const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail) + const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null + + const slaSnapshot = serializeConvexSlaSnapshot(ticket.slaSnapshot) + + const existing = await prisma.ticket.findFirst({ + where: { + tenantId, + reference: ticket.reference, + }, + }) + + const data = { + subject: ticket.subject, + summary: ticket.summary ?? null, + status: normalizeStatus(ticket.status), + priority: (ticket.priority ?? "MEDIUM").toUpperCase(), + channel: (ticket.channel ?? "MANUAL").toUpperCase(), + queueId, + requesterId, + assigneeId, + dueAt: toDate(ticket.dueAt), + firstResponseAt: toDate(ticket.firstResponseAt), + resolvedAt: toDate(ticket.resolvedAt), + closedAt: toDate(ticket.closedAt), + createdAt: toDate(ticket.createdAt) ?? new Date(), + updatedAt: toDate(ticket.updatedAt) ?? new Date(), + companyId, + slaSnapshot: slaSnapshot ?? null, + slaResponseDueAt: toDate(ticket.slaResponseDueAt), + slaSolutionDueAt: toDate(ticket.slaSolutionDueAt), + slaResponseStatus: typeof ticket.slaResponseStatus === "string" ? ticket.slaResponseStatus : null, + slaSolutionStatus: typeof ticket.slaSolutionStatus === "string" ? ticket.slaSolutionStatus : null, + slaPausedAt: toDate(ticket.slaPausedAt), + slaPausedBy: typeof ticket.slaPausedBy === "string" ? ticket.slaPausedBy : null, + slaPausedMs: typeof ticket.slaPausedMs === "number" ? ticket.slaPausedMs : null, + } + + let ticketRecord + + if (existing) { + ticketRecord = await prisma.ticket.update({ + where: { id: existing.id }, + data, + }) + updated += 1 + } else { + ticketRecord = await prisma.ticket.create({ + data: { + tenantId, + reference: ticket.reference, + ...data, + }, + }) + created += 1 + } + + await prisma.ticketComment.deleteMany({ where: { ticketId: ticketRecord.id } }) + await prisma.ticketEvent.deleteMany({ where: { ticketId: ticketRecord.id } }) + + const commentsData = ticket.comments + .map((comment) => { + const authorId = comment.authorEmail ? userMap.get(normalizeEmail(comment.authorEmail)) : null + if (!authorId) { + return null + } + return { + ticketId: ticketRecord.id, + authorId, + visibility: (comment.visibility ?? "INTERNAL").toUpperCase(), + body: comment.body ?? "", + attachments: null, + createdAt: toDate(comment.createdAt) ?? new Date(), + updatedAt: toDate(comment.updatedAt) ?? new Date(), + } + }) + .filter(Boolean) + + if (commentsData.length > 0) { + await prisma.ticketComment.createMany({ data: commentsData }) + } + + const eventsData = ticket.events.map((event) => ({ + ticketId: ticketRecord.id, + type: event.type ?? "UNKNOWN", + payload: event.payload ?? {}, + createdAt: toDate(event.createdAt) ?? new Date(), + })) + + if (eventsData.length > 0) { + await prisma.ticketEvent.createMany({ data: eventsData }) + } + } + + return { created, updated } +} + +async function run() { + console.log("Baixando snapshot do Convex...") + const snapshot = await client.query("migrations:exportTenantSnapshot", { + secret, + tenantId, + }) + + console.log(`Empresas recebidas: ${snapshot.companies.length}`) + console.log(`Usuários recebidos: ${snapshot.users.length}`) + console.log(`Filas recebidas: ${snapshot.queues.length}`) + console.log(`Tickets recebidos: ${snapshot.tickets.length}`) + + console.log("Sincronizando empresas no Prisma...") + const companyMap = await upsertCompanies(snapshot.companies) + console.log(`Empresas ativas no mapa: ${companyMap.size}`) + + console.log("Sincronizando usuários no Prisma...") + const userMap = await upsertUsers(snapshot.users, companyMap) + console.log(`Usuários ativos no mapa: ${userMap.size}`) + + console.log("Sincronizando filas no Prisma...") + const queueMap = await upsertQueues(snapshot.queues) + console.log(`Filas ativas no mapa: ${queueMap.size}`) + + console.log("Sincronizando tickets no Prisma...") + const ticketStats = await upsertTickets(snapshot.tickets, userMap, queueMap, companyMap) + console.log(`Tickets criados: ${ticketStats.created}`) + console.log(`Tickets atualizados: ${ticketStats.updated}`) +} + +run() + .catch((error) => { + console.error("Falha ao importar dados do Convex para Prisma", error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/referência/sistema-de-chamados-main/scripts/prune-convex-companies.mjs b/referência/sistema-de-chamados-main/scripts/prune-convex-companies.mjs new file mode 100644 index 0000000..a3647bf --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/prune-convex-companies.mjs @@ -0,0 +1,54 @@ +import "dotenv/config" +import { PrismaClient } from "@prisma/client" +import { ConvexHttpClient } from "convex/browser" + +const prisma = new PrismaClient() + +const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas" +const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210" +const secret = process.env.CONVEX_SYNC_SECRET + +if (!secret) { + console.error("CONVEX_SYNC_SECRET não configurado. Configure-o para executar o prune.") + process.exit(1) +} + +async function main() { + const client = new ConvexHttpClient(convexUrl) + + const prismaCompanies = await prisma.company.findMany({ + where: { tenantId }, + select: { slug: true }, + }) + const validSlugs = new Set(prismaCompanies.map((company) => company.slug)) + + const snapshot = await client.query("migrations:exportTenantSnapshot", { + tenantId, + secret, + }) + + const extraCompanies = snapshot.companies.filter((company) => !validSlugs.has(company.slug)) + if (extraCompanies.length === 0) { + console.log("Nenhuma empresa extra encontrada no Convex.") + return + } + + console.log(`Removendo ${extraCompanies.length} empresa(s) do Convex: ${extraCompanies.map((c) => c.slug).join(", ")}`) + + for (const company of extraCompanies) { + await client.mutation("companies:removeBySlug", { + tenantId, + slug: company.slug, + }) + } + console.log("Concluído.") +} + +main() + .catch((error) => { + console.error("Falha ao remover empresas extras do Convex", error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/referência/sistema-de-chamados-main/scripts/reassign-legacy-assignees.mjs b/referência/sistema-de-chamados-main/scripts/reassign-legacy-assignees.mjs new file mode 100644 index 0000000..7f11f08 --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/reassign-legacy-assignees.mjs @@ -0,0 +1,88 @@ +import "dotenv/config" +import { ConvexHttpClient } from "convex/browser" + +const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL +const TENANT_ID = process.env.SEED_TENANT_ID ?? "tenant-atlas" + +if (!CONVEX_URL) { + console.error("NEXT_PUBLIC_CONVEX_URL não definido. Configure o endpoint do Convex e execute novamente.") + process.exit(1) +} + +const TARGET_NAMES = new Set(["Ana Souza", "Bruno Lima"]) +const REPLACEMENT = { + name: "Rever", + email: "renan.pac@paulicon.com.br", +} + +async function main() { + const client = new ConvexHttpClient(CONVEX_URL) + + const admin = await client.mutation("users:ensureUser", { + tenantId: TENANT_ID, + email: "admin@sistema.dev", + name: "Administrador", + role: "ADMIN", + }) + + if (!admin?._id) { + throw new Error("Não foi possível garantir o usuário administrador") + } + + const replacementUser = await client.mutation("users:ensureUser", { + tenantId: TENANT_ID, + email: REPLACEMENT.email, + name: REPLACEMENT.name, + role: "AGENT", + }) + + if (!replacementUser?._id) { + throw new Error("Não foi possível garantir o usuário Rever") + } + + const agents = await client.query("users:listAgents", { tenantId: TENANT_ID }) + const targets = agents.filter((agent) => TARGET_NAMES.has(agent.name)) + + if (targets.length === 0) { + console.log("Nenhum responsável legado encontrado. Nada a atualizar.") + } + + const targetIds = new Set(targets.map((agent) => agent._id)) + + const tickets = await client.query("tickets:list", { + tenantId: TENANT_ID, + viewerId: admin._id, + }) + + let reassignedCount = 0 + for (const ticket of tickets) { + if (ticket.assignee && targetIds.has(ticket.assignee.id)) { + await client.mutation("tickets:changeAssignee", { + ticketId: ticket.id, + assigneeId: replacementUser._id, + actorId: admin._id, + }) + reassignedCount += 1 + console.log(`Ticket ${ticket.reference} reatribuído para ${replacementUser.name}`) + } + } + + for (const agent of targets) { + try { + await client.mutation("users:deleteUser", { + userId: agent._id, + actorId: admin._id, + }) + console.log(`Usuário removido: ${agent.name}`) + } catch (error) { + console.error(`Falha ao remover ${agent.name}:`, error) + } + } + + console.log(`Total de tickets reatribuídos: ${reassignedCount}`) +} + +main().catch((error) => { + console.error("Erro ao reatribuir responsáveis legacy:", error) + process.exitCode = 1 +}) diff --git a/referência/sistema-de-chamados-main/scripts/remove-legacy-demo-users.mjs b/referência/sistema-de-chamados-main/scripts/remove-legacy-demo-users.mjs new file mode 100644 index 0000000..a24858b --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/remove-legacy-demo-users.mjs @@ -0,0 +1,49 @@ +import "dotenv/config" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +const EMAILS_TO_REMOVE = [ + "cliente.demo@sistema.dev", + "luciana.prado@omnisaude.com.br", + "ricardo.matos@omnisaude.com.br", + "aline.rezende@atlasengenharia.com.br", + "joao.ramos@atlasengenharia.com.br", + "fernanda.lima@omnisaude.com.br", + "mariana.andrade@atlasengenharia.com.br", + "renanzera@gmail.com", +].map((email) => email.toLowerCase()) + +async function deleteAuthUserByEmail(email) { + const authUser = await prisma.authUser.findUnique({ + where: { email }, + select: { id: true, email: true }, + }) + if (!authUser) { + console.log(`ℹ️ Usuário não encontrado, ignorando: ${email}`) + return + } + + await prisma.authSession.deleteMany({ where: { userId: authUser.id } }) + await prisma.authAccount.deleteMany({ where: { userId: authUser.id } }) + await prisma.authInvite.deleteMany({ where: { email } }) + await prisma.user.deleteMany({ where: { email } }) + + await prisma.authUser.delete({ where: { id: authUser.id } }) + console.log(`🧹 Removido: ${email}`) +} + +async function main() { + for (const email of EMAILS_TO_REMOVE) { + await deleteAuthUserByEmail(email) + } +} + +main() + .catch((error) => { + console.error("Falha ao remover usuários legado", error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/referência/sistema-de-chamados-main/scripts/seed-agents.mjs b/referência/sistema-de-chamados-main/scripts/seed-agents.mjs new file mode 100644 index 0000000..d29a8fa --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/seed-agents.mjs @@ -0,0 +1,139 @@ +import "dotenv/config" +import pkg from "@prisma/client" +import { hashPassword } from "better-auth/crypto" +import { ConvexHttpClient } from "convex/browser" + +const { PrismaClient } = pkg +const prisma = new PrismaClient() + +const USERS = [ + { name: "Administrador", email: "admin@sistema.dev", role: "admin" }, + { name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br", role: "agent" }, + { name: "George Araujo", email: "george.araujo@rever.com.br", role: "agent" }, + { name: "Hugo Soares", email: "hugo.soares@rever.com.br", role: "agent" }, + { name: "Julio Cesar", email: "julio@rever.com.br", role: "agent" }, + { name: "Lorena Magalhães", email: "lorena@rever.com.br", role: "agent" }, + { name: "Rever", email: "renan.pac@paulicon.com.br", role: "agent" }, + { name: "Telão", email: "suporte@rever.com.br", role: "agent" }, + { name: "Thiago Medeiros", email: "thiago.medeiros@rever.com.br", role: "agent" }, + { name: "Weslei Magalhães", email: "weslei@rever.com.br", role: "agent" }, +] + +const TENANT_ID = process.env.SEED_TENANT_ID ?? "tenant-atlas" +const DEFAULT_AGENT_PASSWORD = process.env.SEED_AGENT_PASSWORD ?? "agent123" +const DEFAULT_ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD ?? "admin123" +const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL + +async function syncConvexUsers(users) { + if (!CONVEX_URL) { + console.warn("NEXT_PUBLIC_CONVEX_URL não definido; sincronização com Convex ignorada.") + return + } + + const client = new ConvexHttpClient(CONVEX_URL) + for (const user of users) { + try { + await client.mutation("users:ensureUser", { + tenantId: TENANT_ID, + email: user.email, + name: user.name, + role: user.role.toUpperCase(), + }) + } catch (error) { + console.error(`Falha ao sincronizar usuário ${user.email} com Convex`, error) + } + } +} + +async function main() { + const emails = USERS.map((user) => user.email.toLowerCase()) + + const existing = await prisma.authUser.findMany({ + where: { + email: { + notIn: emails, + }, + }, + select: { id: true }, + }) + + if (existing.length > 0) { + const ids = existing.map((user) => user.id) + await prisma.authSession.deleteMany({ where: { userId: { in: ids } } }) + await prisma.authAccount.deleteMany({ where: { userId: { in: ids } } }) + await prisma.authUser.deleteMany({ where: { id: { in: ids } } }) + } + + const seededUsers = [] + + for (const definition of USERS) { + const email = definition.email.toLowerCase() + const role = definition.role ?? "agent" + const password = definition.password ?? (role === "admin" ? DEFAULT_ADMIN_PASSWORD : DEFAULT_AGENT_PASSWORD) + const hashedPassword = await hashPassword(password) + + const user = await prisma.authUser.upsert({ + where: { email }, + update: { + name: definition.name, + role, + tenantId: TENANT_ID, + emailVerified: true, + }, + create: { + email, + name: definition.name, + role, + tenantId: TENANT_ID, + emailVerified: true, + accounts: { + create: { + providerId: "credential", + accountId: email, + password: hashedPassword, + }, + }, + }, + include: { accounts: true }, + }) + + const credentialAccount = user.accounts.find( + (account) => account.providerId === "credential" && account.accountId === email, + ) + + if (credentialAccount) { + await prisma.authAccount.update({ + where: { id: credentialAccount.id }, + data: { password: hashedPassword }, + }) + } else { + await prisma.authAccount.create({ + data: { + userId: user.id, + providerId: "credential", + accountId: email, + password: hashedPassword, + }, + }) + } + + seededUsers.push({ id: user.id, name: definition.name, email, role }) + console.log(`✅ Usuário sincronizado: ${definition.name} <${email}> (${role})`) + } + + await syncConvexUsers(seededUsers) + + console.log("") + console.log(`Senha padrão agentes: ${DEFAULT_AGENT_PASSWORD}`) + console.log(`Senha padrão administrador: ${DEFAULT_ADMIN_PASSWORD}`) + console.log(`Total de usuários ativos: ${seededUsers.length}`) +} + +main() + .catch((error) => { + console.error("Erro ao processar agentes", error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/referência/sistema-de-chamados-main/scripts/seed-auth.mjs b/referência/sistema-de-chamados-main/scripts/seed-auth.mjs new file mode 100644 index 0000000..f5df9cc --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/seed-auth.mjs @@ -0,0 +1,174 @@ +import "dotenv/config" +import pkg from "@prisma/client" +import { hashPassword } from "better-auth/crypto" + +const { PrismaClient } = pkg +const prisma = new PrismaClient() + +// Em produção, evitar sobrescrever senhas a cada deploy. +// Por padrão, apenas GARANTE que o usuário e a conta existam (sem resetar senha). +const ensureOnly = (process.env.SEED_ENSURE_ONLY ?? "true").toLowerCase() === "true" + +const tenantId = process.env.SEED_USER_TENANT ?? "tenant-atlas" + +const singleUserFromEnv = process.env.SEED_USER_EMAIL + ? [{ + email: process.env.SEED_USER_EMAIL, + password: process.env.SEED_USER_PASSWORD ?? "admin123", + name: process.env.SEED_USER_NAME ?? "Administrador", + role: process.env.SEED_USER_ROLE ?? "admin", + tenantId, + }] + : null + +const defaultUsers = singleUserFromEnv ?? [ + { + email: "admin@sistema.dev", + password: "admin123", + name: "Administrador", + role: "admin", + tenantId, + }, + { + email: "gabriel.oliveira@rever.com.br", + password: "agent123", + name: "Gabriel Oliveira", + role: "agent", + tenantId, + }, + { + email: "george.araujo@rever.com.br", + password: "agent123", + name: "George Araujo", + role: "agent", + tenantId, + }, + { + email: "hugo.soares@rever.com.br", + password: "agent123", + name: "Hugo Soares", + role: "agent", + tenantId, + }, + { + email: "julio@rever.com.br", + password: "agent123", + name: "Julio Cesar", + role: "agent", + tenantId, + }, + { + email: "lorena@rever.com.br", + password: "agent123", + name: "Lorena Magalhães", + role: "agent", + tenantId, + }, + { + email: "renan.pac@paulicon.com.br", + password: "agent123", + name: "Rever", + role: "agent", + tenantId, + }, + { + email: "suporte@rever.com.br", + password: "agent123", + name: "Telão", + role: "agent", + tenantId, + }, + { + email: "thiago.medeiros@rever.com.br", + password: "agent123", + name: "Thiago Medeiros", + role: "agent", + tenantId, + }, + { + email: "weslei@rever.com.br", + password: "agent123", + name: "Weslei Magalhães", + role: "agent", + tenantId, + }, +] + +async function ensureCredentialAccount(userId, email, hashedPassword, updatePassword) { + const existing = await prisma.authAccount.findFirst({ + where: { userId, providerId: "credential", accountId: email }, + }) + if (existing) { + if (updatePassword) { + await prisma.authAccount.update({ + where: { id: existing.id }, + data: { password: hashedPassword }, + }) + } + return existing + } + return prisma.authAccount.create({ + data: { + userId, + providerId: "credential", + accountId: email, + password: hashedPassword, + }, + }) +} + +async function ensureAuthUser({ email, password, name, role, tenantId: userTenant }) { + const hashedPassword = await hashPassword(password) + + const existing = await prisma.authUser.findUnique({ where: { email } }) + if (!existing) { + const user = await prisma.authUser.create({ + data: { + email, + name, + role, + tenantId: userTenant, + accounts: { + create: { + providerId: "credential", + accountId: email, + password: hashedPassword, + }, + }, + }, + include: { accounts: true }, + }) + console.log(`✅ Usuario criado: ${user.email}`) + console.log(` ID: ${user.id}`) + console.log(` Role: ${user.role}`) + console.log(` Tenant: ${user.tenantId ?? "(nenhum)"}`) + console.log(` Senha provisoria: ${password}`) + return + } + + // Usuário já existe + if (!ensureOnly) { + await prisma.authUser.update({ + where: { id: existing.id }, + data: { name, role, tenantId: userTenant }, + }) + } + + await ensureCredentialAccount(existing.id, email, hashedPassword, !ensureOnly) + console.log(`✅ Usuario garantido${ensureOnly ? " (sem reset de senha)" : " (atualizado)"}: ${email}`) +} + +async function main() { + for (const user of defaultUsers) { + await ensureAuthUser(user) + } +} + +main() + .catch((error) => { + console.error("Erro ao criar usuario seed", error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/referência/sistema-de-chamados-main/scripts/seed-convex-demo.mjs b/referência/sistema-de-chamados-main/scripts/seed-convex-demo.mjs new file mode 100644 index 0000000..13dc2f1 --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/seed-convex-demo.mjs @@ -0,0 +1,19 @@ +import 'dotenv/config' +import { ConvexHttpClient } from 'convex/browser' +import { api } from '../convex/_generated/api.js' + +async function main() { + const url = process.env.NEXT_PUBLIC_CONVEX_URL || 'http://127.0.0.1:3210' + const client = new ConvexHttpClient(url) + console.log(`[seed] Using Convex at ${url}`) + try { + await client.mutation(api.seed.seedDemo, {}) + console.log('[seed] Convex demo data ensured (queues/users)') + } catch (err) { + console.error('[seed] Failed to seed Convex demo data:', err?.message || err) + process.exitCode = 1 + } +} + +main() + diff --git a/referência/sistema-de-chamados-main/scripts/start-web.sh b/referência/sistema-de-chamados-main/scripts/start-web.sh new file mode 100644 index 0000000..a8c2735 --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/start-web.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[start-web] Starting web service..." +echo "[start-web] Bun: $(bun --version || true)" + +cd /app + +export BUN_INSTALL_CACHE_DIR="${BUN_INSTALL_CACHE_DIR:-/tmp/bun-cache}" +mkdir -p "$BUN_INSTALL_CACHE_DIR" + +echo "[start-web] Using bun cache dir: $BUN_INSTALL_CACHE_DIR" + +echo "[start-web] Using APP_DIR=$(pwd)" +echo "[start-web] NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-}" +echo "[start-web] NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-}" + +# Bun keeps its store in node_modules/.bun by default; ensure it exists and is writable +mkdir -p node_modules/.bun >/dev/null 2>&1 || true + +# Prisma generate (idempotent) and apply DB migrations +echo "[start-web] prisma generate" +bun run prisma:generate + +echo "[start-web] prisma migrate deploy" +bunx prisma migrate deploy + +# Seed Better Auth users safely (ensure-only by default) +echo "[start-web] seeding Better Auth users (ensure-only)" +bun run auth:seed || true + +echo "[start-web] launching Next.js" +exec bun run start -- --port 3000 diff --git a/referência/sistema-de-chamados-main/scripts/sync-prisma-to-convex.mjs b/referência/sistema-de-chamados-main/scripts/sync-prisma-to-convex.mjs new file mode 100644 index 0000000..9dbbbd4 --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/sync-prisma-to-convex.mjs @@ -0,0 +1,187 @@ +import "dotenv/config" +import { PrismaClient } from "@prisma/client" +import { ConvexHttpClient } from "convex/browser" + +const prisma = new PrismaClient() + +function toMillis(date) { + return date instanceof Date ? date.getTime() : date ? new Date(date).getTime() : undefined +} + +function normalizeString(value, fallback = "") { + if (!value) return fallback + return value.trim() +} + +function slugify(value) { + return normalizeString(value) + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") || undefined +} + +function normalizePrismaSlaSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== "object") return undefined + const record = snapshot + const pauseStatuses = + Array.isArray(record.pauseStatuses) && record.pauseStatuses.length > 0 + ? record.pauseStatuses.filter((value) => typeof value === "string") + : undefined + + const normalized = { + categoryId: typeof record.categoryId === "string" ? record.categoryId : undefined, + categoryName: typeof record.categoryName === "string" ? record.categoryName : undefined, + priority: typeof record.priority === "string" ? record.priority : undefined, + responseTargetMinutes: typeof record.responseTargetMinutes === "number" ? record.responseTargetMinutes : undefined, + responseMode: typeof record.responseMode === "string" ? record.responseMode : undefined, + solutionTargetMinutes: typeof record.solutionTargetMinutes === "number" ? record.solutionTargetMinutes : undefined, + solutionMode: typeof record.solutionMode === "string" ? record.solutionMode : undefined, + alertThreshold: typeof record.alertThreshold === "number" ? record.alertThreshold : undefined, + pauseStatuses, + } + + return Object.values(normalized).some((value) => value !== undefined) ? normalized : undefined +} + +async function main() { + const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas" + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210" + const secret = process.env.CONVEX_SYNC_SECRET + + if (!secret) { + console.error("CONVEX_SYNC_SECRET não configurado. Configure no .env.") + process.exit(1) + } + + const [users, queues, tickets, companies] = await Promise.all([ + prisma.user.findMany({ + include: { + teams: { + include: { team: true }, + }, + company: true, + }, + }), + prisma.queue.findMany(), + prisma.ticket.findMany({ + include: { + requester: true, + assignee: true, + queue: true, + company: true, + comments: { + include: { + author: true, + }, + }, + events: true, + }, + orderBy: { createdAt: "asc" }, + }), + prisma.company.findMany(), + ]) + + const userSnapshot = users.map((user) => ({ + email: user.email, + name: normalizeString(user.name, user.email), + role: user.role, + avatarUrl: user.avatarUrl ?? undefined, + teams: user.teams + .map((membership) => membership.team?.name) + .filter((name) => Boolean(name) && typeof name === "string"), + companySlug: user.company?.slug ?? undefined, + })) + + const queueSnapshot = queues.map((queue) => ({ + name: normalizeString(queue.name, queue.slug ?? queue.id), + slug: queue.slug ? queue.slug : normalizeString(queue.name, queue.id).toLowerCase().replace(/\s+/g, "-"), + })) + + const referenceFallbackStart = 41000 + let referenceCounter = referenceFallbackStart + + const ticketSnapshot = tickets.map((ticket) => { + const reference = ticket.reference && ticket.reference > 0 ? ticket.reference : ++referenceCounter + const requesterEmail = ticket.requester?.email ?? userSnapshot[0]?.email ?? "unknown@example.com" + const assigneeEmail = ticket.assignee?.email ?? undefined + const queueSlug = ticket.queue?.slug ?? slugify(ticket.queue?.name) + const companySlug = ticket.company?.slug ?? ticket.requester?.company?.slug ?? undefined + + return { + reference, + subject: normalizeString(ticket.subject, `Ticket ${reference}`), + summary: ticket.summary ?? undefined, + status: ticket.status, + priority: ticket.priority, + channel: ticket.channel, + queueSlug: queueSlug ?? undefined, + requesterEmail, + assigneeEmail, + companySlug, + dueAt: toMillis(ticket.dueAt) ?? undefined, + firstResponseAt: toMillis(ticket.firstResponseAt) ?? undefined, + resolvedAt: toMillis(ticket.resolvedAt) ?? undefined, + closedAt: toMillis(ticket.closedAt) ?? undefined, + createdAt: toMillis(ticket.createdAt) ?? Date.now(), + updatedAt: toMillis(ticket.updatedAt) ?? Date.now(), + tags: Array.isArray(ticket.tags) ? ticket.tags : undefined, + slaSnapshot: normalizePrismaSlaSnapshot(ticket.slaSnapshot), + slaResponseDueAt: toMillis(ticket.slaResponseDueAt), + slaSolutionDueAt: toMillis(ticket.slaSolutionDueAt), + slaResponseStatus: ticket.slaResponseStatus ?? undefined, + slaSolutionStatus: ticket.slaSolutionStatus ?? undefined, + slaPausedAt: toMillis(ticket.slaPausedAt), + slaPausedBy: ticket.slaPausedBy ?? undefined, + slaPausedMs: typeof ticket.slaPausedMs === "number" ? ticket.slaPausedMs : undefined, + comments: ticket.comments.map((comment) => ({ + authorEmail: comment.author?.email ?? requesterEmail, + visibility: comment.visibility, + body: comment.body, + createdAt: toMillis(comment.createdAt) ?? Date.now(), + updatedAt: toMillis(comment.updatedAt) ?? Date.now(), + })), + events: ticket.events.map((event) => ({ + type: event.type, + payload: event.payload ?? {}, + createdAt: toMillis(event.createdAt) ?? Date.now(), + })), + } + }) + + const companySnapshot = companies.map((company) => ({ + slug: company.slug ?? slugify(company.name), + name: company.name, + cnpj: company.cnpj ?? undefined, + domain: company.domain ?? undefined, + phone: company.phone ?? undefined, + description: company.description ?? undefined, + address: company.address ?? undefined, + createdAt: toMillis(company.createdAt) ?? Date.now(), + updatedAt: toMillis(company.updatedAt) ?? Date.now(), + })) + + const client = new ConvexHttpClient(convexUrl) + + const result = await client.mutation("migrations:importPrismaSnapshot", { + secret, + snapshot: { + tenantId, + companies: companySnapshot, + users: userSnapshot, + queues: queueSnapshot, + tickets: ticketSnapshot, + }, + }) + + console.log("Sincronização concluída:", result) +} + +main() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/referência/sistema-de-chamados-main/scripts/test-windows-machine-info.ps1 b/referência/sistema-de-chamados-main/scripts/test-windows-machine-info.ps1 new file mode 100644 index 0000000..4e41f7a --- /dev/null +++ b/referência/sistema-de-chamados-main/scripts/test-windows-machine-info.ps1 @@ -0,0 +1,170 @@ +[CmdletBinding()] +param( + [switch]$Json +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-ActivationStatus { + $result = [ordered]@{ + LicenseStatus = $null + LicenseStatusText = $null + ProductName = $null + PartialProductKey = $null + IsActivated = $false + RawItemCount = 0 + Notes = @() + } + + try { + $cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $lsItems = Get-CimInstance -Query "SELECT Name, Description, LicenseStatus, PartialProductKey FROM SoftwareLicensingProduct WHERE PartialProductKey IS NOT NULL" -ErrorAction Stop | + Where-Object { $_.Name -like 'Windows*' -or $_.Description -like 'Windows*' } | + Sort-Object -Property LicenseStatus -Descending + + $result.RawItemCount = ($lsItems | Measure-Object).Count + $activated = $lsItems | Where-Object { $_.LicenseStatus -eq 1 } | Select-Object -First 1 + $primary = if ($activated) { $activated } else { $lsItems | Select-Object -First 1 } + + if ($primary) { + $result.LicenseStatus = $primary.LicenseStatus + $result.LicenseStatusText = switch ($primary.LicenseStatus) { + 0 { 'Unlicensed' } + 1 { 'Licensed' } + 2 { 'Out-Of-Box Grace Period' } + 3 { 'Out-Of-Tolerance Grace Period' } + 4 { 'Non-Genuine Grace Period' } + 5 { 'Notification' } + 6 { 'Extended Grace' } + Default { "Unknown ($($_))" } + } + $result.ProductName = $primary.Name + $result.PartialProductKey = $primary.PartialProductKey + $result.IsActivated = [bool]($primary.LicenseStatus -eq 1) + } else { + $result.Notes += 'Nenhum produto Windows com PartialProductKey encontrado.' + } + + if ($cv.InstallationType) { + $result.Notes += "InstallationType: $($cv.InstallationType)" + } + if ($os.Caption) { + $result.Notes += "Caption: $($os.Caption)" + } + } catch { + $result.Notes += "Erro ao consultar ativação: $($_.Exception.Message)" + } + + return $result +} + +function Get-DefenderStatus { + $result = [ordered]@{ + CommandAvailable = $false + AMRunningMode = $null + AntivirusEnabled = $null + RealTimeProtectionEnabled = $null + AntispywareEnabled = $null + Notes = @() + } + + $command = Get-Command -Name Get-MpComputerStatus -ErrorAction SilentlyContinue + if (-not $command) { + $result.Notes += 'Cmdlet Get-MpComputerStatus não está disponível (talvez a funcionalidade do Defender não esteja instalada).' + return $result + } + + $result.CommandAvailable = $true + + try { + $status = Get-MpComputerStatus | Select-Object AMRunningMode,AntivirusEnabled,RealTimeProtectionEnabled,AntispywareEnabled + if ($null -eq $status) { + $result.Notes += 'Get-MpComputerStatus retornou $null.' + return $result + } + + $result.AMRunningMode = $status.AMRunningMode + $result.AntivirusEnabled = $status.AntivirusEnabled + $result.RealTimeProtectionEnabled = $status.RealTimeProtectionEnabled + $result.AntispywareEnabled = $status.AntispywareEnabled + } catch { + $result.Notes += "Erro ao consultar Defender: $($_.Exception.Message)" + } + + return $result +} + +$payload = [ordered]@{ + Timestamp = (Get-Date).ToString('s') + ComputerName = $env:COMPUTERNAME + Activation = Get-ActivationStatus + Defender = Get-DefenderStatus +} + +$failures = @() + +if ($payload.Activation.IsActivated -isnot [bool]) { + $failures += 'Activation.IsActivated não é um booleano.' +} + +if ($payload.Activation.LicenseStatus -isnot [int] -and $payload.Activation.LicenseStatus -isnot [long]) { + $failures += 'Activation.LicenseStatus não é numérico.' +} + +if ($payload.Defender.CommandAvailable) { + if ($payload.Defender.RealTimeProtectionEnabled -isnot [bool]) { + $failures += 'Defender.RealTimeProtectionEnabled não é um booleano.' + } +} else { + $payload.Defender.Notes += 'Teste de Defender ignorado porque o cmdlet não está disponível.' +} + +if ($Json) { + $payload | ConvertTo-Json -Depth 6 +} else { + Write-Host "=== Diagnóstico de Ativação do Windows ===" + $activation = $payload.Activation + Write-Host ("IsActivated : {0}" -f $activation.IsActivated) + Write-Host ("LicenseStatus : {0}" -f $activation.LicenseStatus) + if ($activation.LicenseStatusText) { + Write-Host ("LicenseStatusText : {0}" -f $activation.LicenseStatusText) + } + if ($activation.ProductName) { + Write-Host ("ProductName : {0}" -f $activation.ProductName) + } + if ($activation.PartialProductKey) { + Write-Host ("PartialProductKey : {0}" -f $activation.PartialProductKey) + } + if ($activation.Notes.Count -gt 0) { + Write-Host "Notas:" + $activation.Notes | ForEach-Object { Write-Host " - $_" } + } + + Write-Host "" + Write-Host "=== Diagnóstico do Windows Defender ===" + $defender = $payload.Defender + Write-Host ("Cmdlet disponível : {0}" -f $defender.CommandAvailable) + Write-Host ("RealTimeProtection : {0}" -f $defender.RealTimeProtectionEnabled) + Write-Host ("AntivirusEnabled : {0}" -f $defender.AntivirusEnabled) + Write-Host ("AntispywareEnabled : {0}" -f $defender.AntispywareEnabled) + Write-Host ("AMRunningMode : {0}" -f $defender.AMRunningMode) + if ($defender.Notes.Count -gt 0) { + Write-Host "Notas:" + $defender.Notes | ForEach-Object { Write-Host " - $_" } + } + + if ($failures.Count -gt 0) { + Write-Host "" + Write-Host "Falhas encontradas:" + $failures | ForEach-Object { Write-Host " - $_" } + } else { + Write-Host "" + Write-Host "Status geral: OK" + } +} + +if ($failures.Count -gt 0) { + exit 1 +} diff --git a/referência/sistema-de-chamados-main/src/app/ConvexClientProvider.tsx b/referência/sistema-de-chamados-main/src/app/ConvexClientProvider.tsx new file mode 100644 index 0000000..4363aed --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/ConvexClientProvider.tsx @@ -0,0 +1,17 @@ +"use client"; + +import "@/lib/toast-patch"; + +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; + +const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + +const client = convexUrl ? new ConvexReactClient(convexUrl) : undefined; + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + if (!convexUrl) { + return <>{children}; + } + return {children}; +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/alerts/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/alerts/page.tsx new file mode 100644 index 0000000..8c8c8b6 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/alerts/page.tsx @@ -0,0 +1,20 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { AdminAlertsManager } from "@/components/admin/alerts/admin-alerts-manager" + +export default function AdminAlertsPage() { + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/channels/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/channels/page.tsx new file mode 100644 index 0000000..939c5a1 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/channels/page.tsx @@ -0,0 +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 ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/clients/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/clients/page.tsx new file mode 100644 index 0000000..486bdbb --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/clients/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation" + +export const runtime = "nodejs" + +export default function AdminClientsRedirect() { + redirect("/admin/users") +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/companies/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/companies/page.tsx new file mode 100644 index 0000000..d8cca43 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/companies/page.tsx @@ -0,0 +1,26 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { requireStaffSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { AdminCompaniesManager } from "@/components/admin/companies/admin-companies-manager" +import { fetchCompaniesByTenant, normalizeCompany } from "@/server/company-service" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export default async function AdminCompaniesPage() { + const session = await requireStaffSession() + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + const companies = (await fetchCompaniesByTenant(tenantId)).map(normalizeCompany) + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/devices/[id]/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/devices/[id]/page.tsx new file mode 100644 index 0000000..61511ba --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/devices/[id]/page.tsx @@ -0,0 +1,20 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { AdminDeviceDetailsClient } from "@/components/admin/devices/admin-device-details.client" +import { DeviceBreadcrumbs } from "@/components/admin/devices/device-breadcrumbs.client" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export default async function AdminDeviceDetailsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + return ( + }> +
+ + +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/devices/[id]/tickets/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/devices/[id]/tickets/page.tsx new file mode 100644 index 0000000..3410d9b --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/devices/[id]/tickets/page.tsx @@ -0,0 +1,26 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { DeviceBreadcrumbs } from "@/components/admin/devices/device-breadcrumbs.client" +import { DeviceTicketsHistoryClient } from "@/components/admin/devices/device-tickets-history.client" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export default async function AdminDeviceTicketsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + + return ( + }> +
+ + +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/devices/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/devices/page.tsx new file mode 100644 index 0000000..403e6fc --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/devices/page.tsx @@ -0,0 +1,29 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview" +import { DEFAULT_TENANT_ID } from "@/lib/constants" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export default async function AdminDevicesPage({ + searchParams, +}: { searchParams: Promise> }) { + const params = await searchParams + const companyParam = params.company + const company = typeof companyParam === "string" ? companyParam : undefined + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/fields/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/fields/page.tsx new file mode 100644 index 0000000..6666ace --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/fields/page.tsx @@ -0,0 +1,26 @@ +import { CategoriesManager } from "@/components/admin/categories/categories-manager" +import { FieldsManager } from "@/components/admin/fields/fields-manager" +import { TicketFormTemplatesManager } from "@/components/admin/fields/ticket-form-templates-manager" +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" + +export const dynamic = "force-dynamic" + +export default function AdminFieldsPage() { + return ( + + } + > +
+ + + +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/layout.tsx b/referência/sistema-de-chamados-main/src/app/admin/layout.tsx new file mode 100644 index 0000000..93d629f --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/layout.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from "react" +import { redirect } from "next/navigation" + +import { requireAuthenticatedSession } from "@/lib/auth-server" +import { isStaff } from "@/lib/authz" + +export const dynamic = "force-dynamic" +export const runtime = "nodejs" + +export default async function AdminLayout({ children }: { children: ReactNode }) { + const session = await requireAuthenticatedSession() + const role = session.user.role ?? "agent" + if (!isStaff(role)) { + // agentes (staff) podem acessar; visitantes sem papel de staff caem no portal. + redirect("/portal") + } + return <>{children} +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/page.tsx new file mode 100644 index 0000000..af95f90 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/page.tsx @@ -0,0 +1,155 @@ +import { Prisma } from "@prisma/client" + +import { AdminUsersManager } from "@/components/admin/admin-users-manager" +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { ROLE_OPTIONS, normalizeRole, type RoleOption } from "@/lib/authz" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { prisma } from "@/lib/prisma" +import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils" +import { getServerSession } from "@/lib/auth-server" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +function isMissingAuthTableError(error: unknown, table: string): boolean { + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) { + return false + } + if (error.code !== "P2021" && error.code !== "P2023") { + return false + } + const target = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "" + return target.includes(table.toLowerCase()) +} + +async function loadUsers() { + try { + const users = await prisma.authUser.findMany({ + orderBy: { createdAt: "desc" }, + select: { + id: true, + email: true, + name: true, + role: true, + tenantId: true, + machinePersona: true, + createdAt: true, + updatedAt: true, + }, + }) + + if (users.length === 0) { + return [] + } + + const domainUsers = await prisma.user.findMany({ + select: { + email: true, + companyId: true, + company: { + select: { + id: true, + name: true, + }, + }, + }, + }) + + const domainByEmail = new Map( + domainUsers.map( + (user: (typeof domainUsers)[number]): [string, (typeof domainUsers)[number]] => [ + user.email.toLowerCase(), + user, + ] + ) + ) + + return users.map((user: (typeof users)[number]) => { + const domain = domainByEmail.get(user.email.toLowerCase()) + const normalizedRole = (normalizeRole(user.role) ?? "agent") as RoleOption + return { + id: user.id, + email: user.email, + name: user.name ?? "", + role: normalizedRole, + tenantId: user.tenantId ?? DEFAULT_TENANT_ID, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt?.toISOString() ?? null, + companyId: domain?.companyId ?? null, + companyName: domain?.company?.name ?? null, + machinePersona: user.machinePersona ?? null, + } + }) + } catch (error) { + if (isMissingAuthTableError(error, "AuthUser")) { + console.warn("[admin] auth tables missing; returning empty user list") + return [] + } + throw error + } +} + +async function loadInvites(): Promise { + try { + const invites = await prisma.authInvite.findMany({ + orderBy: { createdAt: "desc" }, + include: { + events: { + orderBy: { createdAt: "asc" }, + }, + }, + }) + + const now = new Date() + const cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + return invites + .map((invite) => normalizeInvite(invite, now)) + .filter((invite: NormalizedInvite) => { + if (invite.status !== "revoked") return true + if (!invite.revokedAt) return true + return new Date(invite.revokedAt) > cutoff + }) + } catch (error) { + if (isMissingAuthTableError(error, "AuthInvite")) { + console.warn("[admin] auth invite tables missing; returning empty invite list") + return [] + } + throw error + } +} + +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 + }) + const session = await getServerSession() + const viewerRole = session?.user.role ?? "agent" + + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/slas/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/slas/page.tsx new file mode 100644 index 0000000..abe0902 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/slas/page.tsx @@ -0,0 +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 ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/teams/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/teams/page.tsx new file mode 100644 index 0000000..89672ad --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/teams/page.tsx @@ -0,0 +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 ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/admin/users/page.tsx b/referência/sistema-de-chamados-main/src/app/admin/users/page.tsx new file mode 100644 index 0000000..5065ca4 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/admin/users/page.tsx @@ -0,0 +1,110 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { prisma } from "@/lib/prisma" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { requireStaffSession } from "@/lib/auth-server" +import { AdminUsersWorkspace, type AdminAccount } from "@/components/admin/users/admin-users-workspace" +import { fetchCompaniesByTenant, normalizeCompany } from "@/server/company-service" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export default async function AdminUsersPage() { + const session = await requireStaffSession() + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + const users = await prisma.user.findMany({ + where: { + tenantId, + role: { in: ["MANAGER", "COLLABORATOR"] }, + }, + include: { + company: { + select: { + id: true, + name: true, + }, + }, + manager: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }) + + const emails = users.map((user: (typeof users)[number]) => user.email) + const authUsers = await prisma.authUser.findMany({ + where: { email: { in: emails } }, + select: { id: true, email: true, updatedAt: true, createdAt: true }, + }) + + const sessions = await prisma.authSession.findMany({ + where: { + userId: { + in: authUsers.map((auth: (typeof authUsers)[number]) => auth.id), + }, + }, + orderBy: { updatedAt: "desc" }, + select: { userId: true, updatedAt: true }, + }) + + const sessionByUserId = new Map() + for (const sessionRow of sessions) { + if (!sessionByUserId.has(sessionRow.userId)) { + sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt) + } + } + + const authByEmail = new Map() + for (const authUser of authUsers) { + authByEmail.set(authUser.email.toLowerCase(), { + id: authUser.id, + updatedAt: authUser.updatedAt, + createdAt: authUser.createdAt, + }) + } + + const accounts: AdminAccount[] = users.map((user: (typeof users)[number]) => { + const auth = authByEmail.get(user.email.toLowerCase()) + const lastSeenAt = auth ? sessionByUserId.get(auth.id) ?? auth.updatedAt : null + return { + id: user.id, + email: user.email, + name: user.name ?? user.email, + role: user.role === "MANAGER" ? "MANAGER" : "COLLABORATOR", + companyId: user.companyId ?? null, + companyName: user.company?.name ?? null, + jobTitle: user.jobTitle ?? null, + managerId: user.managerId ?? null, + managerName: user.manager?.name ?? null, + managerEmail: user.manager?.email ?? null, + tenantId: user.tenantId, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + authUserId: auth?.id ?? null, + lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null, + } + }) + + const companiesRaw = await fetchCompaniesByTenant(tenantId) + const companies = companiesRaw.map(normalizeCompany) + + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/agenda/agenda-page-client.tsx b/referência/sistema-de-chamados-main/src/app/agenda/agenda-page-client.tsx new file mode 100644 index 0000000..f7e20be --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/agenda/agenda-page-client.tsx @@ -0,0 +1,121 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useQuery } from "convex/react" +import { format } from "date-fns" +import { ptBR } from "date-fns/locale/pt-BR" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { mapTicketsFromServerList } from "@/lib/mappers/ticket" +import type { Ticket } from "@/lib/schemas/ticket" +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Button } from "@/components/ui/button" +import { CalendarPlus } from "lucide-react" +import { useAuth } from "@/lib/auth-client" +import { AgendaFilters, AgendaFilterState, AgendaPeriod, defaultAgendaFilters } from "@/components/agenda/agenda-filters" +import { AgendaSummaryView } from "@/components/agenda/agenda-summary-view" +import { AgendaCalendarView } from "@/components/agenda/agenda-calendar-view" +import { buildAgendaDataset, type AgendaDataset } from "@/lib/agenda-utils" + +export function AgendaPageClient() { + const [activeTab, setActiveTab] = useState<"summary" | "calendar">("summary") + const [filters, setFilters] = useState(defaultAgendaFilters) + + const { convexUserId, session } = useAuth() + const userId = convexUserId as Id<"users"> | null + const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID + + const ticketsArgs = userId + ? { + tenantId, + viewerId: userId, + status: undefined, + priority: filters.priorities.length ? filters.priorities : undefined, + queueId: undefined, + channel: undefined, + assigneeId: filters.onlyMyTickets ? userId : undefined, + search: undefined, + } + : "skip" + + const ticketsRaw = useQuery(api.tickets.list, ticketsArgs) + const mappedTickets = useMemo(() => { + if (!Array.isArray(ticketsRaw)) return null + return mapTicketsFromServerList(ticketsRaw as unknown[]) + }, [ticketsRaw]) + + const [cachedTickets, setCachedTickets] = useState([]) + + useEffect(() => { + if (mappedTickets) { + setCachedTickets(mappedTickets) + } + }, [mappedTickets]) + + const effectiveTickets = mappedTickets ?? cachedTickets + const isInitialLoading = !mappedTickets && cachedTickets.length === 0 && ticketsArgs !== "skip" + + const dataset: AgendaDataset = useMemo( + () => buildAgendaDataset(effectiveTickets, filters), + [effectiveTickets, filters] + ) + + const greeting = getGreetingMessage() + const firstName = session?.user?.name?.split(" ")[0] ?? session?.user?.email?.split("@")[0] ?? "equipe" + const rangeDescription = formatRangeDescription(filters.period, dataset.range) + const headerLead = `${greeting}, ${firstName}! ${rangeDescription}` + + return ( + + + Em breve: novo compromisso + + } + /> + } + > +
+ + setActiveTab(value as typeof activeTab)}> + + Resumo + Calendário + + + + + + + + +
+
+ ) +} + +function getGreetingMessage(date: Date = new Date()) { + const hour = date.getHours() + if (hour < 12) return "Bom dia" + if (hour < 18) return "Boa tarde" + return "Boa noite" +} + +function formatRangeDescription(period: AgendaPeriod, range: { start: Date; end: Date }) { + if (period === "today") { + return `Hoje é ${format(range.start, "eeee, d 'de' MMMM", { locale: ptBR })}` + } + if (period === "week") { + return `Semana de ${format(range.start, "d MMM", { locale: ptBR })} a ${format(range.end, "d MMM", { locale: ptBR })}` + } + return `Mês de ${format(range.start, "MMMM 'de' yyyy", { locale: ptBR })}` +} diff --git a/referência/sistema-de-chamados-main/src/app/agenda/page.tsx b/referência/sistema-de-chamados-main/src/app/agenda/page.tsx new file mode 100644 index 0000000..8fd57d3 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/agenda/page.tsx @@ -0,0 +1,9 @@ +import { requireAuthenticatedSession } from "@/lib/auth-server" + +import { AgendaPageClient } from "./agenda-page-client" + +export default async function AgendaPage() { + await requireAuthenticatedSession() + return +} + diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/alerts/hours-usage/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/alerts/hours-usage/route.ts new file mode 100644 index 0000000..f69fed3 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/alerts/hours-usage/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { assertAdminSession } from "@/lib/auth-server" +import { env } from "@/lib/env" +import { prisma } from "@/lib/prisma" +import { sendSmtpMail } from "@/server/email-smtp" + +export const runtime = "nodejs" + +function fmtHours(ms: number) { + const hours = ms / 3600000 + if (hours > 0 && hours < 1) { + const mins = Math.round(hours * 60) + return `${mins} min` + } + return `${hours.toFixed(2)} h` +} + +export async function POST(request: Request) { + const session = await assertAdminSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + if (!env.SMTP) return NextResponse.json({ error: "SMTP não configurado" }, { status: 500 }) + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? "30d" + const threshold = Number(searchParams.get("threshold") ?? 90) + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? "tenant-atlas" + + // Ensure user exists in Convex to obtain a typed viewerId + let viewerId: Id<"users"> | null = null + try { + const ensured = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = (ensured?._id ?? null) as Id<"users"> | null + } catch (error) { + console.error("Failed to synchronize user with Convex for alerts", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + if (!viewerId) return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + + const report = await client.query(api.reports.hoursByClient, { + tenantId, + viewerId, + range, + }) + + type HoursByClientItem = { + companyId: Id<"companies"> + name: string + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth: number | null + } + const items = (report.items ?? []) as HoursByClientItem[] + const alerts = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= threshold) + + type ManagerUser = { email: string; name: string | null } + + for (const item of alerts) { + // Find managers of the company in Prisma + const managers: ManagerUser[] = await prisma.user.findMany({ + where: { + tenantId, + companyId: item.companyId, + role: "MANAGER", + }, + select: { email: true, name: true }, + }) + if (managers.length === 0) continue + + const subject = `Alerta: uso de horas em ${item.name} acima de ${threshold}%` + const body = ` +

Olá,

+

O uso de horas contratadas para ${item.name} atingiu ${(((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100).toFixed(1)}%.

+
    +
  • Horas internas: ${fmtHours(item.internalMs)}
  • +
  • Horas externas: ${fmtHours(item.externalMs)}
  • +
  • Total: ${fmtHours(item.totalMs)}
  • +
  • Contratadas/mês: ${item.contractedHoursPerMonth ?? "—"}
  • +
+

Reveja a alocação da equipe e, se necessário, ajuste o atendimento.

+ ` + let delivered = 0 + for (const m of managers) { + try { + await sendSmtpMail( + { + host: env.SMTP!.host, + port: env.SMTP!.port, + username: env.SMTP!.username, + password: env.SMTP!.password, + from: env.SMTP!.from!, + }, + m.email, + subject, + body + ) + delivered += 1 + } catch (error) { + console.error("Failed to send alert to", m.email, error) + } + } + try { + await client.mutation(api.alerts.log, { + tenantId, + companyId: item.companyId, + companyName: item.name, + usagePct: (((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100), + threshold, + range, + recipients: managers.map((m) => m.email), + deliveredCount: delivered, + }) + } catch (error) { + console.error("Failed to log alert in Convex", error) + } + } + + return NextResponse.json({ sent: alerts.length }) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/clients/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/clients/route.ts new file mode 100644 index 0000000..e50d606 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/clients/route.ts @@ -0,0 +1,2 @@ +export const runtime = "nodejs" +export { GET, DELETE } from "../users/route" diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/companies/[id]/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/companies/[id]/route.ts new file mode 100644 index 0000000..6c1c20f --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/companies/[id]/route.ts @@ -0,0 +1,180 @@ +import { NextResponse } from "next/server" +import { ZodError } from "zod" +import { Prisma } from "@prisma/client" +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library" + +import { prisma } from "@/lib/prisma" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" +import { removeConvexCompany, syncConvexCompany } from "@/server/companies-sync" +import { + buildCompanyData, + fetchCompanyById, + formatZodError, + normalizeCompany, + sanitizeCompanyInput, +} from "@/server/company-service" +import type { CompanyFormValues } from "@/lib/schemas/company" + +export const runtime = "nodejs" + +function mergePayload(base: CompanyFormValues, updates: Record): Record { + const merged: Record = { ...base, ...updates } + + if (!("businessHours" in updates)) merged.businessHours = base.businessHours + if (!("communicationChannels" in updates)) merged.communicationChannels = base.communicationChannels + if (!("clientDomains" in updates)) merged.clientDomains = base.clientDomains + if (!("fiscalAddress" in updates)) merged.fiscalAddress = base.fiscalAddress + if (!("regulatedEnvironments" in updates)) merged.regulatedEnvironments = base.regulatedEnvironments + if (!("contacts" in updates)) merged.contacts = base.contacts + if (!("locations" in updates)) merged.locations = base.locations + if (!("contracts" in updates)) merged.contracts = base.contracts + if (!("sla" in updates)) merged.sla = base.sla + if (!("tags" in updates)) merged.tags = base.tags + if (!("customFields" in updates)) merged.customFields = base.customFields + + if ("privacyPolicy" in updates) { + const incoming = updates.privacyPolicy + if (incoming && typeof incoming === "object") { + merged.privacyPolicy = { + ...base.privacyPolicy, + ...incoming, + } + } + } else { + merged.privacyPolicy = base.privacyPolicy + } + + merged.tenantId = base.tenantId + return merged +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await assertStaffSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem editar empresas" }, { status: 403 }) + } + + const { id } = await params + + try { + const existing = await fetchCompanyById(id) + if (!existing) { + return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 }) + } + + if (existing.tenantId !== (session.user.tenantId ?? existing.tenantId)) { + return NextResponse.json({ error: "Acesso negado" }, { status: 403 }) + } + + const rawBody = (await request.json()) as Record + const normalized = normalizeCompany(existing) + const { + id: _ignoreId, + provisioningCode: _ignoreCode, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...baseForm + } = normalized + void _ignoreId + void _ignoreCode + void _createdAt + void _updatedAt + + const mergedInput = mergePayload(baseForm, rawBody) + const form = sanitizeCompanyInput(mergedInput, existing.tenantId) + const createData = buildCompanyData(form, existing.tenantId) + const { tenantId: _omitTenant, ...updateData } = createData + void _omitTenant + + const company = await prisma.company.update({ + where: { id }, + data: updateData, + }) + + if (company.provisioningCode) { + const synced = await syncConvexCompany({ + tenantId: company.tenantId, + slug: company.slug, + name: company.name, + provisioningCode: company.provisioningCode, + }) + if (!synced) { + console.warn("[admin.companies] Convex não configurado; atualização aplicada apenas no Prisma.") + } + } + + return NextResponse.json({ company: normalizeCompany(company) }) + } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json({ error: "Dados inválidos", issues: formatZodError(error) }, { status: 422 }) + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json({ error: "Já existe uma empresa com este slug." }, { status: 409 }) + } + console.error("Failed to update company", error) + return NextResponse.json({ error: "Falha ao atualizar empresa" }, { status: 500 }) + } +} + +export async function DELETE( + _: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await assertStaffSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem excluir empresas" }, { status: 403 }) + } + const { id } = await params + + const company = await fetchCompanyById(id) + + if (!company) { + return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 }) + } + + if (company.tenantId !== (session.user.tenantId ?? company.tenantId)) { + return NextResponse.json({ error: "Acesso negado" }, { status: 403 }) + } + + try { + const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const users = await tx.user.updateMany({ + where: { companyId: company.id, tenantId: company.tenantId }, + data: { companyId: null }, + }) + const tickets = await tx.ticket.updateMany({ + where: { companyId: company.id, tenantId: company.tenantId }, + data: { companyId: null }, + }) + await tx.company.delete({ where: { id: company.id } }) + return { detachedUsers: users.count, detachedTickets: tickets.count } + }) + + if (company.slug) { + const removed = await removeConvexCompany({ + tenantId: company.tenantId, + slug: company.slug, + }) + if (!removed) { + console.warn("[admin.companies] Convex não configurado; empresa removida apenas no Prisma.") + } + } + + return NextResponse.json({ ok: true, ...result }) + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2003") { + return NextResponse.json( + { error: "Não é possível remover esta empresa pois existem registros vinculados." }, + { status: 409 } + ) + } + console.error("Failed to delete company", error) + return NextResponse.json({ error: "Falha ao excluir empresa" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/companies/last-alerts/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/companies/last-alerts/route.ts new file mode 100644 index 0000000..390e394 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/companies/last-alerts/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import { env } from "@/lib/env" +import { assertAdminSession } from "@/lib/auth-server" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const session = await assertAdminSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + const client = new ConvexHttpClient(convexUrl) + + const { searchParams } = new URL(request.url) + const slugsParam = searchParams.get("slugs") + if (!slugsParam) return NextResponse.json({ items: {} }) + const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean) + + const tenantId = session.user.tenantId ?? "tenant-atlas" + try { + const result = (await client.query(api.alerts.lastForCompaniesBySlugs, { tenantId, slugs })) as Record + return NextResponse.json({ items: result }) + } catch (error) { + console.error("Failed to fetch last alerts by slugs", error) + return NextResponse.json({ items: {} }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/companies/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/companies/route.ts new file mode 100644 index 0000000..b9b7a46 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/companies/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server" +import { randomBytes } from "crypto" +import { ZodError } from "zod" +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library" + +import { prisma } from "@/lib/prisma" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" +import { syncConvexCompany } from "@/server/companies-sync" +import { + buildCompanyData, + fetchCompaniesByTenant, + formatZodError, + normalizeCompany, + sanitizeCompanyInput, +} from "@/server/company-service" + +export const runtime = "nodejs" + +const DEFAULT_TENANT_ID = "tenant-atlas" + +export async function GET() { + const session = await assertStaffSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + const companies = await fetchCompaniesByTenant(tenantId) + return NextResponse.json({ companies: companies.map(normalizeCompany) }) +} + +export async function POST(request: Request) { + const session = await assertStaffSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem criar empresas" }, { status: 403 }) + } + + try { + const rawBody = await request.json() + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + const form = sanitizeCompanyInput(rawBody, tenantId) + const provisioningCode = randomBytes(32).toString("hex") + + const createData = buildCompanyData(form, tenantId) + + const company = await prisma.company.create({ + data: { + ...createData, + provisioningCode, + }, + }) + + if (company.provisioningCode) { + const synced = await syncConvexCompany({ + tenantId: company.tenantId, + slug: company.slug, + name: company.name, + provisioningCode: company.provisioningCode, + }) + if (!synced) { + console.warn("[admin.companies] Convex não configurado; empresa criada apenas no Prisma.") + } + } + + return NextResponse.json({ company: normalizeCompany(company) }) + } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json({ error: "Dados inválidos", issues: formatZodError(error) }, { status: 422 }) + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { error: "Já existe uma empresa com este slug ou código de provisionamento." }, + { status: 409 } + ) + } + console.error("Failed to create company", error) + return NextResponse.json({ error: "Falha ao criar empresa" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/[id]/details/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/[id]/details/route.ts new file mode 100644 index 0000000..52cfa54 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/[id]/details/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server" +import type { Id } from "@/convex/_generated/dataModel" +import { api } from "@/convex/_generated/api" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" + +export const dynamic = "force-dynamic" + +export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const client = createConvexClient() + const { id } = await ctx.params + const machineId = id as Id<"machines"> + const data = (await client.query(api.devices.getById, { id: machineId, includeMetadata: true })) as unknown + if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json(data, { status: 200 }) + } catch (err) { + if (err instanceof ConvexConfigurationError) { + console.error("[api] admin/machines/[id]/details configuration error", err) + return NextResponse.json({ error: err.message }, { status: 500 }) + } + console.error("[api] admin/machines/[id]/details error", err) + const message = err instanceof Error && err.message ? err.message : "Internal error" + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/[id]/inventory.xlsx/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/[id]/inventory.xlsx/route.ts new file mode 100644 index 0000000..f27e369 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/[id]/inventory.xlsx/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ + id: string + }> +} + +function sanitizeFilename(hostname: string, fallback: string): string { + const safe = hostname.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase() + return safe || fallback +} + +export async function GET(_request: Request, context: RouteContext) { + const session = await assertAuthenticatedSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const { id } = await context.params + const machineId = id as Id<"machines"> + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + try { + const machine = (await client.query(api.devices.getById, { + id: machineId, + includeMetadata: true, + })) as MachineInventoryRecord | null + + if (!machine || machine.tenantId !== tenantId) { + return NextResponse.json({ error: "Dispositivo não encontrada" }, { status: 404 }) + } + + const workbook = buildMachinesInventoryWorkbook([machine], { + tenantId, + generatedBy: session.user.name ?? session.user.email, + companyFilterLabel: machine.companyName ?? machine.companySlug ?? null, + generatedAt: new Date(), + }) + + const hostnameSafe = sanitizeFilename(machine.hostname, "machine") + const body = new Uint8Array(workbook) + + return new NextResponse(body, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="machine-inventory-${hostnameSafe}.xlsx"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to export machine inventory", error) + return NextResponse.json({ error: "Falha ao gerar planilha da dispositivo" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/access/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/access/route.ts new file mode 100644 index 0000000..32d100b --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/access/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { assertStaffSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" + +export const runtime = "nodejs" + +const schema = z.object({ + machineId: z.string().min(1), + persona: z.enum(["collaborator", "manager"]), + email: z.string().email(), + name: z.string().optional(), +}) + +export async function POST(request: Request) { + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + let parsed: z.infer + try { + const body = await request.json() + parsed = schema.parse(body) + } catch { + return NextResponse.json({ error: "Payload inválido" }, { status: 400 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const client = new ConvexHttpClient(convexUrl) + + try { + const machine = (await client.query(api.devices.getContext, { + machineId: parsed.machineId as Id<"machines">, + })) as { + id: string + tenantId: string + companyId: string | null + } | null + + if (!machine) { + return NextResponse.json({ error: "Dispositivo não encontrada" }, { status: 404 }) + } + + const tenantId = machine.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID + + const ensuredUser = (await client.mutation(api.users.ensureUser, { + tenantId, + email: parsed.email, + name: parsed.name ?? parsed.email, + avatarUrl: undefined, + role: parsed.persona.toUpperCase(), + companyId: machine.companyId ? (machine.companyId as Id<"companies">) : undefined, + })) as { _id?: Id<"users"> } | null + + await client.mutation(api.devices.updatePersona, { + machineId: parsed.machineId as Id<"machines">, + persona: parsed.persona, + assignedUserId: ensuredUser?._id, + assignedUserEmail: parsed.email, + assignedUserName: parsed.name ?? undefined, + assignedUserRole: parsed.persona === "manager" ? "MANAGER" : "COLLABORATOR", + }) + + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("[machines.access]", error) + return NextResponse.json({ error: "Falha ao atualizar acesso da dispositivo" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/delete/route.test.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/delete/route.test.ts new file mode 100644 index 0000000..5f62a1d --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/delete/route.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const mutationMock = vi.fn() +const deleteManyMock = vi.fn() +const assertAuthenticatedSession = vi.fn() + +vi.mock("convex/browser", () => { + const ConvexHttpClient = vi.fn(function ConvexHttpClientMock() { + return { + mutation: mutationMock, + } + }) + + return { + ConvexHttpClient, + } +}) + +vi.mock("@/lib/prisma", () => ({ + prisma: { + authUser: { + deleteMany: deleteManyMock, + }, + }, +})) + +vi.mock("@/lib/auth-server", () => ({ + assertAuthenticatedSession: assertAuthenticatedSession, +})) + +describe("POST /api/admin/devices/delete", () => { + const originalEnv = process.env.NEXT_PUBLIC_CONVEX_URL + + let restoreConsole: (() => void) | undefined + + beforeEach(() => { + process.env.NEXT_PUBLIC_CONVEX_URL = "https://convex.example" + mutationMock.mockReset() + deleteManyMock.mockReset() + assertAuthenticatedSession.mockReset() + mutationMock.mockImplementation(async (_ctx, payload) => { + if (payload && typeof payload === "object" && "machineId" in payload) { + return { ok: true } + } + return { _id: "user_123" } + }) + assertAuthenticatedSession.mockResolvedValue({ + user: { + email: "admin@example.com", + name: "Admin", + role: "ADMIN", + tenantId: "tenant-1", + avatarUrl: null, + }, + }) + const consoleSpy = vi.spyOn(console, "error").mockImplementation(function noop() {}) + restoreConsole = () => consoleSpy.mockRestore() + }) + + afterEach(() => { + process.env.NEXT_PUBLIC_CONVEX_URL = originalEnv + restoreConsole?.() + }) + + it("returns ok when the machine removal succeeds", async () => { + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/admin/devices/delete", { + method: "POST", + body: JSON.stringify({ machineId: "jn_machine" }), + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ ok: true, machineMissing: false }) + expect(deleteManyMock).toHaveBeenCalledWith({ where: { email: "machine-jn_machine@machines.local" } }) + expect(mutationMock).toHaveBeenNthCalledWith(1, expect.anything(), expect.objectContaining({ email: "admin@example.com" })) + expect(mutationMock).toHaveBeenNthCalledWith(2, expect.anything(), expect.objectContaining({ machineId: "jn_machine" })) + }) + + it("still succeeds when the Convex machine is already missing", async () => { + mutationMock.mockImplementation(async (_ctx, payload) => { + if (payload && typeof payload === "object" && "machineId" in payload) { + throw new Error("Dispositivo não encontrada") + } + return { _id: "user_123" } + }) + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/admin/devices/delete", { + method: "POST", + body: JSON.stringify({ machineId: "jn_machine" }), + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ ok: true, machineMissing: true }) + expect(deleteManyMock).toHaveBeenCalledWith({ where: { email: "machine-jn_machine@machines.local" } }) + }) + + it("returns an error for other Convex failures", async () => { + mutationMock.mockImplementation(async (_ctx, payload) => { + if (payload && typeof payload === "object" && "machineId" in payload) { + throw new Error("timeout error") + } + return { _id: "user_123" } + }) + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/admin/devices/delete", { + method: "POST", + body: JSON.stringify({ machineId: "jn_machine" }), + }) + ) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: "Falha ao remover dispositivo no Convex" }) + expect(deleteManyMock).not.toHaveBeenCalled() + }) +}) diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/delete/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/delete/route.ts new file mode 100644 index 0000000..f05a30f --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/delete/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { prisma } from "@/lib/prisma" + +export const runtime = "nodejs" + +const schema = z.object({ + machineId: z.string().min(1), +}) + +export async function POST(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const payload = await request.json().catch(() => null) + const parsed = schema.safeParse(payload) + if (!parsed.success) { + return NextResponse.json({ error: "Payload inválido", details: parsed.error.flatten() }, { status: 400 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + try { + const convex = new ConvexHttpClient(convexUrl) + const ensured = await convex.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + + const actorId = ensured?._id as Id<"users"> | undefined + if (!actorId) { + return NextResponse.json({ error: "Falha ao identificar o administrador" }, { status: 500 }) + } + + let machineMissing = false + try { + await convex.mutation(api.devices.remove, { + machineId: parsed.data.machineId as Id<"machines">, + actorId, + }) + } catch (error) { + const message = error instanceof Error ? error.message : "" + if (message.includes("Dispositivo não encontrada")) { + machineMissing = true + } else { + console.error("[machines.delete] Convex failure", error) + return NextResponse.json({ error: "Falha ao remover dispositivo no Convex" }, { status: 500 }) + } + } + + const machineEmail = `machine-${parsed.data.machineId}@machines.local` + await prisma.authUser.deleteMany({ where: { email: machineEmail } }) + + return NextResponse.json({ ok: true, machineMissing }) + } catch (error) { + console.error("[machines.delete] Falha ao excluir", error) + return NextResponse.json({ error: "Falha ao excluir dispositivo" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/links/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/links/route.ts new file mode 100644 index 0000000..ef892cd --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/links/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { assertStaffSession } from "@/lib/auth-server" + +export const runtime = "nodejs" + +const addSchema = z.object({ machineId: z.string().min(1), email: z.string().email() }) +const removeSchema = z.object({ machineId: z.string().min(1), userId: z.string().min(1) }) + +export async function POST(request: Request) { + const session = await assertStaffSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + let parsed: z.infer + try { + parsed = addSchema.parse(await request.json()) + } catch { + return NextResponse.json({ error: "Payload inválido" }, { status: 400 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + const client = new ConvexHttpClient(convexUrl) + + try { + await client.mutation(api.devices.linkUser, { + machineId: parsed.machineId as Id<"machines">, + email: parsed.email, + }) + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("[machines.links.add]", error) + return NextResponse.json({ error: "Falha ao vincular usuário" }, { status: 500 }) + } +} + +export async function DELETE(request: Request) { + const session = await assertStaffSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const url = new URL(request.url) + const machineId = url.searchParams.get("machineId") + const userId = url.searchParams.get("userId") + const parsed = removeSchema.safeParse({ machineId, userId }) + if (!parsed.success) return NextResponse.json({ error: "Parâmetros inválidos" }, { status: 400 }) + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + const client = new ConvexHttpClient(convexUrl) + + try { + await client.mutation(api.devices.unlinkUser, { + machineId: parsed.data.machineId as Id<"machines">, + userId: parsed.data.userId as Id<"users">, + }) + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("[machines.links.remove]", error) + return NextResponse.json({ error: "Falha ao desvincular usuário" }, { status: 500 }) + } +} + diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/remote-access/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/remote-access/route.ts new file mode 100644 index 0000000..cf7f169 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/remote-access/route.ts @@ -0,0 +1,146 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { assertStaffSession } from "@/lib/auth-server" +import type { Id } from "@/convex/_generated/dataModel" +import { api } from "@/convex/_generated/api" + +export const runtime = "nodejs" + +const schema = z.object({ + machineId: z.string().min(1), + provider: z.string().optional(), + identifier: z.string().optional(), + url: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + notes: z.string().optional(), + entryId: z.string().optional(), + action: z.enum(["save", "upsert", "clear", "delete", "remove"]).optional(), +}) + +export async function POST(request: Request) { + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + let parsed: z.infer + try { + const body = await request.json() + parsed = schema.parse(body) + } catch { + return NextResponse.json({ error: "Payload inválido" }, { status: 400 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const client = new ConvexHttpClient(convexUrl) + + try { + const tenantId = + session.user.tenantId ?? + process.env.SYNC_TENANT_ID ?? + process.env.SEED_USER_TENANT ?? + "tenant-atlas" + const ensured = (await client.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + })) as { _id?: Id<"users"> } | null + + const actorId = ensured?._id as Id<"users"> | undefined + if (!actorId) { + return NextResponse.json( + { error: "Usuário não encontrado no Convex para executar esta ação." }, + { status: 403 } + ) + } + + const actionRaw = (parsed.action ?? "save").toLowerCase() + const normalizedAction = + actionRaw === "clear" + ? "clear" + : actionRaw === "delete" || actionRaw === "remove" + ? "delete" + : "upsert" + + const provider = (parsed.provider ?? "").trim() + const identifier = (parsed.identifier ?? "").trim() + const notes = (parsed.notes ?? "").trim() + + let normalizedUrl: string | undefined + if (normalizedAction === "upsert") { + if (!provider || !identifier) { + return NextResponse.json({ error: "Informe o provedor e o identificador do acesso remoto." }, { status: 400 }) + } + const rawUrl = (parsed.url ?? "").trim() + if (rawUrl.length > 0) { + const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}` + try { + new URL(candidate) + normalizedUrl = candidate + } catch { + return NextResponse.json({ error: "URL inválida. Informe um endereço iniciado com http:// ou https://." }, { status: 422 }) + } + } + } + + const mutationArgs: Record = { + machineId: parsed.machineId as Id<"machines">, + actorId, + action: normalizedAction, + } + + if (parsed.entryId) { + mutationArgs.entryId = parsed.entryId + } + + if (normalizedAction === "clear") { + mutationArgs.clear = true + } else { + if (provider) mutationArgs.provider = provider + if (identifier) mutationArgs.identifier = identifier + if (normalizedUrl !== undefined) mutationArgs.url = normalizedUrl + mutationArgs.username = (parsed.username ?? "").trim() + mutationArgs.password = (parsed.password ?? "").trim() + if (notes.length) mutationArgs.notes = notes + } + + const result = (await (client as unknown as { mutation: (name: string, args: Record) => Promise }).mutation( + "machines:updateRemoteAccess", + mutationArgs + )) as { remoteAccess?: unknown } | null + + return NextResponse.json({ ok: true, remoteAccess: result?.remoteAccess ?? null }) + } catch (error) { + console.error("[machines.remote-access]", error) + const detail = error instanceof Error ? error.message : null + const isOutdatedConvex = + typeof detail === "string" && detail.includes("extra field `action`") + + if (isOutdatedConvex) { + return NextResponse.json( + { + error: "Backend do Convex desatualizado", + detail: "Recompile/deploy as funções Convex (ex.: `bun run convex:dev:bun` em desenvolvimento ou `bun x convex deploy`) para aplicar o suporte a múltiplos acessos remotos.", + }, + { status: 409 } + ) + } + + return NextResponse.json( + { + error: "Falha ao atualizar acesso remoto", + ...(process.env.NODE_ENV !== "production" && detail ? { detail } : {}), + }, + { status: 500 } + ) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/rename/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/rename/route.ts new file mode 100644 index 0000000..9fd7250 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/rename/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { api } from "@/convex/_generated/api" + +export const runtime = "nodejs" + +const schema = z.object({ + machineId: z.string().min(1), + hostname: z.string().min(2), +}) + +export async function POST(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const payload = await request.json().catch(() => null) + const parsed = schema.safeParse(payload) + if (!parsed.success) { + return NextResponse.json({ error: "Payload inválido", details: parsed.error.flatten() }, { status: 400 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + try { + // Garante usuário no Convex e obtém seu Id + const convex = new ConvexHttpClient(convexUrl) + const ensured = await convex.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + const actorId = ensured?._id + if (!actorId) { + return NextResponse.json({ error: "Falha ao obter ID do usuário no Convex" }, { status: 500 }) + } + + // Chamada por string reference (evita depender do tipo gerado imediatamente) + const client = convex as unknown as { mutation: (name: string, args: unknown) => Promise } + await client.mutation("machines:rename", { + machineId: parsed.data.machineId, + actorId, + tenantId, + hostname: parsed.data.hostname, + }) + + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("[machines.rename] Falha ao renomear", error) + return NextResponse.json({ error: "Falha ao renomear dispositivo" }, { status: 500 }) + } +} + diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/reset-agent/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/reset-agent/route.ts new file mode 100644 index 0000000..cbe4c28 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/reset-agent/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { api } from "@/convex/_generated/api" + +export const runtime = "nodejs" + +const schema = z.object({ + machineId: z.string().min(1), +}) + +export async function POST(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const payload = await request.json().catch(() => null) + const parsed = schema.safeParse(payload) + if (!parsed.success) { + return NextResponse.json({ error: "Payload inválido", details: parsed.error.flatten() }, { status: 400 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + try { + const convex = new ConvexHttpClient(convexUrl) + const ensured = await convex.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + const actorId = ensured?._id + if (!actorId) { + return NextResponse.json({ error: "Falha ao obter ID do usuário no Convex" }, { status: 500 }) + } + + const client = convex as unknown as { mutation: (name: string, args: unknown) => Promise } + const result = (await client.mutation("machines:resetAgent", { + machineId: parsed.data.machineId, + actorId, + })) as { revoked?: number } | null + + return NextResponse.json({ ok: true, revoked: result?.revoked ?? 0 }) + } catch (error) { + console.error("[machines.resetAgent] Falha ao resetar agente", error) + return NextResponse.json({ error: "Falha ao resetar agente da dispositivo" }, { status: 500 }) + } +} + diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/devices/toggle-active/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/devices/toggle-active/route.ts new file mode 100644 index 0000000..b77e6bf --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/devices/toggle-active/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { api } from "@/convex/_generated/api" + +export const runtime = "nodejs" + +const schema = z.object({ + machineId: z.string().min(1), + active: z.boolean(), +}) + +export async function POST(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const payload = await request.json().catch(() => null) + const parsed = schema.safeParse(payload) + if (!parsed.success) { + return NextResponse.json({ error: "Payload inválido", details: parsed.error.flatten() }, { status: 400 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + try { + const convex = new ConvexHttpClient(convexUrl) + const ensured = await convex.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + const actorId = ensured?._id + if (!actorId) { + return NextResponse.json({ error: "Falha ao obter ID do usuário no Convex" }, { status: 500 }) + } + + const client = convex as unknown as { mutation: (name: string, args: unknown) => Promise } + await client.mutation("machines:toggleActive", { + machineId: parsed.data.machineId, + actorId, + active: parsed.data.active, + }) + + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("[machines.toggleActive] Falha ao atualizar status", error) + return NextResponse.json({ error: "Falha ao atualizar status da dispositivo" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/invites/[id]/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/invites/[id]/route.ts new file mode 100644 index 0000000..e1c9bcd --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/invites/[id]/route.ts @@ -0,0 +1,139 @@ +import { NextResponse } from "next/server" +import { Prisma } from "@prisma/client" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" +import { env } from "@/lib/env" +import { prisma } from "@/lib/prisma" +import { canReactivateInvite } from "@/lib/invite-policies" +import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils" + +type InviteAction = "revoke" | "reactivate" + +type InvitePayload = { + action?: InviteAction + 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, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const body = (await request.json().catch(() => null)) as Partial | null + const action: InviteAction = body?.action === "reactivate" ? "reactivate" : "revoke" + const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null + + const invite = await prisma.authInvite.findUnique({ + where: { id }, + include: { events: { orderBy: { createdAt: "asc" } } }, + }) + + if (!invite) { + return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 }) + } + + const inviteRole = invite.role?.toLowerCase?.() + if (!isAdmin(session.user.role) && inviteRole && ["admin", "agent"].includes(inviteRole)) { + return NextResponse.json({ error: "Permissão insuficiente para alterar convites de administradores ou agentes" }, { status: 403 }) + } + + const now = new Date() + const status = computeInviteStatus(invite, now) + + if (status === "accepted") { + return NextResponse.json({ error: "Convite já aceito" }, { status: 400 }) + } + + if (action === "reactivate") { + if (status !== "revoked") { + return NextResponse.json({ error: "Convite não está revogado" }, { status: 400 }) + } + if (!invite.revokedAt) { + return NextResponse.json({ error: "Convite revogado sem data. Não é possível reativar." }, { status: 400 }) + } + if (!canReactivateInvite({ status, revokedAt: invite.revokedAt }, now)) { + return NextResponse.json({ error: "Este convite foi revogado há mais de 7 dias" }, { status: 400 }) + } + + const updated = await prisma.authInvite.update({ + where: { id: invite.id }, + data: { + status: "pending", + revokedAt: null, + revokedById: null, + revokedReason: null, + }, + }) + + const event = await prisma.authInviteEvent.create({ + data: { + inviteId: invite.id, + type: "reactivated", + payload: Prisma.JsonNull, + actorId: session.user.id ?? null, + }, + }) + + const normalized = normalizeInvite({ ...updated, events: [...invite.events, event] }, now) + await syncInvite(normalized) + return NextResponse.json({ invite: normalized }) + } + + 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 } : Prisma.JsonNull, + actorId: session.user.id ?? null, + }, + }) + + const normalized = normalizeInvite({ ...updated, events: [...invite.events, event] }, now) + await syncInvite(normalized) + + return NextResponse.json({ invite: normalized }) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/invites/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/invites/route.ts new file mode 100644 index 0000000..b395387 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/invites/route.ts @@ -0,0 +1,216 @@ +import { NextResponse } from "next/server" +import { randomBytes } from "crypto" + +import { Prisma } from "@prisma/client" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import { assertStaffSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { ROLE_OPTIONS, type RoleOption, isAdmin } 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 toJsonPayload(payload: unknown): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput { + if (payload === null || payload === undefined) { + return Prisma.JsonNull + } + return payload as Prisma.InputJsonValue +} + +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: toJsonPayload(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 assertStaffSession() + 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 assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const body = (await request.json().catch(() => null)) as Partial | 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 isSessionAdmin = isAdmin(session.user.role) + if (!isSessionAdmin && !["manager", "collaborator"].includes(role)) { + return NextResponse.json({ error: "Agentes só podem convidar gestores ou colaboradores" }, { status: 403 }) + } + 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 }) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/users/[id]/reset-password/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/users/[id]/reset-password/route.ts new file mode 100644 index 0000000..e5ab37a --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/users/[id]/reset-password/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server" + +import { hashPassword } from "better-auth/crypto" + +import { prisma } from "@/lib/prisma" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" + +function generatePassword(length = 12) { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + let result = "" + for (let index = 0; index < length; index += 1) { + const randomIndex = Math.floor(Math.random() * alphabet.length) + result += alphabet[randomIndex] + } + return result +} + +export const runtime = "nodejs" + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + const sessionIsAdmin = isAdmin(session.user.role) + + const user = await prisma.authUser.findUnique({ + where: { id }, + select: { id: true, role: true }, + }) + + if (!user) { + return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 }) + } + + const targetRole = (user.role ?? "").toLowerCase() + if (!sessionIsAdmin && (targetRole === "admin" || targetRole === "agent")) { + return NextResponse.json({ error: "Você não pode redefinir a senha desse usuário" }, { status: 403 }) + } + + if (targetRole === "machine") { + return NextResponse.json({ error: "Contas de dispositivo não possuem senha web" }, { status: 400 }) + } + + const body = (await request.json().catch(() => null)) as { password?: string } | null + const temporaryPassword = body?.password?.trim() || generatePassword() + const hashedPassword = await hashPassword(temporaryPassword) + + const credentialAccount = await prisma.authAccount.findFirst({ + where: { userId: user.id, providerId: "credential" }, + }) + + if (credentialAccount) { + await prisma.authAccount.update({ where: { id: credentialAccount.id }, data: { password: hashedPassword } }) + } else { + // se a conta não existir, cria automaticamente + const authUser = await prisma.authUser.findUnique({ where: { id: user.id } }) + if (!authUser) { + return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 }) + } + await prisma.authAccount.create({ + data: { + userId: user.id, + providerId: "credential", + accountId: authUser.email, + password: hashedPassword, + }, + }) + } + + return NextResponse.json({ temporaryPassword }) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/users/[id]/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..1ebceaf --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/users/[id]/route.ts @@ -0,0 +1,405 @@ +import { NextResponse } from "next/server" +import type { Id } from "@/convex/_generated/dataModel" +import type { Prisma, UserRole } from "@prisma/client" +import { api } from "@/convex/_generated/api" +import { ConvexHttpClient } from "convex/browser" +import { prisma } from "@/lib/prisma" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { assertStaffSession } from "@/lib/auth-server" +import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz" + +function normalizeRole(input: string | null | undefined): RoleOption { + const candidate = (input ?? "agent").toLowerCase() as RoleOption + return ((ROLE_OPTIONS as readonly string[]).includes(candidate) ? candidate : "agent") as RoleOption +} + +const USER_ROLE_OPTIONS: ReadonlyArray = ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"] + +function mapToUserRole(role: RoleOption): UserRole { + const candidate = role.toUpperCase() as UserRole + return USER_ROLE_OPTIONS.includes(candidate) ? candidate : "AGENT" +} + +function canManageRole(role: string | null | undefined) { + const normalized = (role ?? "").toLowerCase() + return normalized !== "admin" && normalized !== "agent" +} + +export const runtime = "nodejs" + +export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const user = await prisma.authUser.findUnique({ + where: { id }, + select: { + id: true, + email: true, + name: true, + role: true, + tenantId: true, + machinePersona: true, + createdAt: true, + updatedAt: true, + }, + }) + + if (!user) { + return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 }) + } + + const domain = await prisma.user.findUnique({ + where: { email: user.email }, + select: { + companyId: true, + company: { select: { name: true } }, + jobTitle: true, + managerId: true, + manager: { select: { id: true, name: true, email: true } }, + }, + }) + + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + name: user.name ?? "", + role: normalizeRole(user.role), + tenantId: user.tenantId ?? DEFAULT_TENANT_ID, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt?.toISOString() ?? null, + companyId: domain?.companyId ?? null, + companyName: domain?.company?.name ?? null, + machinePersona: user.machinePersona ?? null, + jobTitle: domain?.jobTitle ?? null, + managerId: domain?.managerId ?? null, + managerName: domain?.manager?.name ?? null, + managerEmail: domain?.manager?.email ?? null, + }, + }) +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + const sessionIsAdmin = isAdmin(session.user.role) + + const payload = (await request.json().catch(() => null)) as { + name?: string + email?: string + role?: RoleOption + tenantId?: string + companyId?: string | null + jobTitle?: string | null + managerId?: string | null + } | null + + if (!payload || typeof payload !== "object") { + return NextResponse.json({ error: "Payload inválido" }, { status: 400 }) + } + + const user = await prisma.authUser.findUnique({ where: { id } }) + if (!user) { + return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 }) + } + + const nextName = payload.name?.trim() ?? user.name ?? "" + const nextEmail = (payload.email ?? user.email).trim().toLowerCase() + const nextRole = normalizeRole(payload.role ?? user.role) + const nextTenant = (payload.tenantId ?? user.tenantId ?? DEFAULT_TENANT_ID).trim() || DEFAULT_TENANT_ID + const companyId = payload.companyId ? payload.companyId : null + const hasJobTitleField = Object.prototype.hasOwnProperty.call(payload, "jobTitle") + let jobTitle: string | null | undefined + if (hasJobTitleField) { + if (typeof payload.jobTitle === "string") { + const trimmed = payload.jobTitle.trim() + jobTitle = trimmed.length > 0 ? trimmed : null + } else { + jobTitle = null + } + } + const hasManagerField = Object.prototype.hasOwnProperty.call(payload, "managerId") + let managerIdValue: string | null | undefined + if (hasManagerField) { + if (typeof payload.managerId === "string") { + const trimmed = payload.managerId.trim() + managerIdValue = trimmed.length > 0 ? trimmed : null + } else { + managerIdValue = null + } + } + let managerRecord: { id: string; email: string; tenantId: string; name: string } | null = null + if (managerIdValue && managerIdValue !== null) { + managerRecord = await prisma.user.findUnique({ + where: { id: managerIdValue }, + select: { id: true, email: true, tenantId: true, name: true }, + }) + if (!managerRecord) { + return NextResponse.json({ error: "Gestor informado não foi encontrado." }, { status: 400 }) + } + if (managerRecord.tenantId !== nextTenant) { + return NextResponse.json({ error: "Gestor pertence a outro cliente." }, { status: 400 }) + } + } + + if (!nextEmail || !nextEmail.includes("@")) { + return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 }) + } + + if (!sessionIsAdmin && !canManageRole(user.role)) { + return NextResponse.json({ error: "Você não pode editar esse usuário" }, { status: 403 }) + } + + if ((user.role ?? "").toLowerCase() === "machine") { + return NextResponse.json({ error: "Ajustes de dispositivos devem ser feitos em Admin ▸ Dispositivos" }, { status: 400 }) + } + + if (!sessionIsAdmin && !canManageRole(nextRole)) { + return NextResponse.json({ error: "Papel inválido para este perfil" }, { status: 403 }) + } + + if (nextEmail !== user.email) { + const conflict = await prisma.authUser.findUnique({ where: { email: nextEmail } }) + if (conflict && conflict.id !== user.id) { + return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 }) + } + } + + const updated = await prisma.authUser.update({ + where: { id: user.id }, + data: { + name: nextName, + email: nextEmail, + role: nextRole, + tenantId: nextTenant, + }, + }) + + if (nextEmail !== user.email) { + const credentialAccount = await prisma.authAccount.findFirst({ + where: { userId: user.id, providerId: "credential" }, + }) + if (credentialAccount) { + await prisma.authAccount.update({ where: { id: credentialAccount.id }, data: { accountId: nextEmail } }) + } + } + + const previousEmail = user.email + const domainUser = await prisma.user.findUnique({ where: { email: previousEmail } }) + const companyData = companyId + ? await prisma.company.findUnique({ where: { id: companyId } }) + : null + + if (companyId && !companyData) { + return NextResponse.json({ error: "Empresa não encontrada" }, { status: 400 }) + } + + if (domainUser && managerRecord && managerRecord.id === domainUser.id) { + return NextResponse.json({ error: "Um usuário não pode ser gestor de si mesmo." }, { status: 400 }) + } + + if (domainUser) { + const updateData: Prisma.UserUncheckedUpdateInput = { + email: nextEmail, + name: nextName || domainUser.name, + role: mapToUserRole(nextRole), + tenantId: nextTenant, + companyId: companyId ?? null, + } + if (hasJobTitleField) { + updateData.jobTitle = jobTitle ?? null + } + if (hasManagerField) { + updateData.managerId = managerRecord?.id ?? null + } + await prisma.user.update({ + where: { id: domainUser.id }, + data: updateData, + }) + } else { + const upsertUpdate: Prisma.UserUncheckedUpdateInput = { + name: nextName || nextEmail, + role: mapToUserRole(nextRole), + tenantId: nextTenant, + companyId: companyId ?? null, + } + if (hasJobTitleField) { + upsertUpdate.jobTitle = jobTitle ?? null + } + if (hasManagerField) { + upsertUpdate.managerId = managerRecord?.id ?? null + } + const upsertCreate: Prisma.UserUncheckedCreateInput = { + email: nextEmail, + name: nextName || nextEmail, + role: mapToUserRole(nextRole), + tenantId: nextTenant, + companyId: companyId ?? null, + } + if (hasJobTitleField) { + upsertCreate.jobTitle = jobTitle ?? null + } + if (hasManagerField) { + upsertCreate.managerId = managerRecord?.id ?? null + } + await prisma.user.upsert({ + where: { email: nextEmail }, + update: upsertUpdate, + create: upsertCreate, + }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (convexUrl) { + try { + const convex = new ConvexHttpClient(convexUrl) + let managerConvexId: Id<"users"> | undefined + if (hasManagerField && managerRecord?.email) { + try { + const managerUser = await convex.query(api.users.findByEmail, { + tenantId: nextTenant, + email: managerRecord.email, + }) + if (managerUser?._id) { + managerConvexId = managerUser._id as Id<"users"> + } + } catch (error) { + console.warn("Falha ao localizar gestor no Convex", error) + } + } + const ensurePayload: { + tenantId: string + email: string + name: string + avatarUrl?: string + role: string + companyId?: Id<"companies"> + jobTitle?: string | undefined + managerId?: Id<"users"> + } = { + tenantId: nextTenant, + email: nextEmail, + name: nextName || nextEmail, + avatarUrl: updated.avatarUrl ?? undefined, + role: nextRole.toUpperCase(), + } + if (companyId) { + ensurePayload.companyId = companyId as Id<"companies"> + } + if (hasJobTitleField) { + ensurePayload.jobTitle = jobTitle ?? undefined + } + if (hasManagerField) { + ensurePayload.managerId = managerConvexId + } + await convex.mutation(api.users.ensureUser, ensurePayload) + } catch (error) { + console.warn("Falha ao sincronizar usuário no Convex", error) + } + } + + const updatedDomain = await prisma.user.findUnique({ + where: { email: nextEmail }, + select: { + jobTitle: true, + managerId: true, + manager: { select: { name: true, email: true } }, + }, + }) + + return NextResponse.json({ + user: { + id: updated.id, + email: nextEmail, + name: nextName, + role: nextRole, + tenantId: nextTenant, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt?.toISOString() ?? null, + companyId, + companyName: companyData?.name ?? null, + machinePersona: updated.machinePersona ?? null, + jobTitle: updatedDomain?.jobTitle ?? null, + managerId: updatedDomain?.managerId ?? null, + managerName: updatedDomain?.manager?.name ?? null, + managerEmail: updatedDomain?.manager?.email ?? null, + }, + }) +} + +export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + const sessionIsAdmin = isAdmin(session.user.role) + + const target = await prisma.authUser.findUnique({ + where: { id }, + select: { id: true, email: true, role: true, tenantId: true }, + }) + + if (!target) { + return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 }) + } + + if (!sessionIsAdmin && !canManageRole(target.role)) { + return NextResponse.json({ error: "Você não pode remover esse usuário" }, { status: 403 }) + } + + if (target.role === "machine") { + return NextResponse.json({ error: "Os agentes de dispositivo devem ser removidos via módulo de dispositivos." }, { status: 400 }) + } + + if (target.email === session.user.email) { + return NextResponse.json({ error: "Você não pode remover o usuário atualmente autenticado." }, { status: 400 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + const tenantId = target.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID + + if (convexUrl) { + try { + const convex = new ConvexHttpClient(convexUrl) + const ensured = await convex.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + + const actorId = ensured?._id + if (!actorId) { + throw new Error("Falha ao identificar o administrador no Convex") + } + + const convexUser = await convex.query(api.users.findByEmail, { + tenantId, + email: target.email, + }) + + if (convexUser?._id) { + await convex.mutation(api.users.deleteUser, { + userId: convexUser._id, + actorId, + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : "Falha ao remover usuário na base de dados" + return NextResponse.json({ error: message }, { status: 400 }) + } + } + + await prisma.authUser.delete({ where: { id: target.id } }) + + return NextResponse.json({ ok: true }) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/users/assign-company/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/users/assign-company/route.ts new file mode 100644 index 0000000..47f1a65 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/users/assign-company/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server" +import { randomBytes } from "crypto" +import { ConvexHttpClient } from "convex/browser" + +import { assertAdminSession } from "@/lib/auth-server" +import { prisma } from "@/lib/prisma" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import type { Id } from "@/convex/_generated/dataModel" +import { api } from "@/convex/_generated/api" + +export const runtime = "nodejs" + +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 { email?: string; companyId?: string } + const email = body?.email?.trim().toLowerCase() + const companyId = body?.companyId + if (!email || !companyId) { + return NextResponse.json({ error: "Informe e-mail e empresa" }, { status: 400 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + const client = new ConvexHttpClient(convexUrl) + + try { + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + const company = await prisma.company.findUnique({ + where: { id: companyId }, + select: { id: true, tenantId: true, slug: true, name: true, provisioningCode: true }, + }) + + if (!company || company.tenantId !== tenantId) { + return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 }) + } + + let provisioningCode = company.provisioningCode + if (!provisioningCode) { + provisioningCode = randomBytes(16).toString("hex") + await prisma.company.update({ where: { id: company.id }, data: { provisioningCode } }) + } + + const ensured = await client.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email ?? "admin@sistema.dev", + name: session.user.name ?? session.user.email ?? "Administrador", + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role?.toUpperCase?.(), + }) + + if (!ensured?._id) { + throw new Error("Não foi possível identificar o ator no Convex") + } + + const ensuredCompany = await client.mutation(api.companies.ensureProvisioned, { + tenantId, + slug: company.slug ?? `company-${company.id}`, + name: company.name, + provisioningCode, + }) + + if (!ensuredCompany?.id) { + throw new Error("Falha ao sincronizar empresa no Convex") + } + + await client.mutation(api.users.assignCompany, { + tenantId, + email, + companyId: ensuredCompany.id as Id<"companies">, + actorId: ensured._id, + }) + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("Failed to assign company", error) + return NextResponse.json({ error: "Falha ao vincular usuário" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/users/cleanup/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/users/cleanup/route.ts new file mode 100644 index 0000000..64d173c --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/users/cleanup/route.ts @@ -0,0 +1,160 @@ +import { NextResponse } from "next/server" + +import type { Id } from "@/convex/_generated/dataModel" +import { api } from "@/convex/_generated/api" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { createConvexClient } from "@/server/convex-client" + +const DEFAULT_KEEP_EMAILS = ["renan.pac@paulicon.com.br"] + +type CleanupSummary = { + removedPortalUserIds: string[] + removedPortalEmails: string[] + removedAuthUserIds: string[] + removedConvexUserIds: string[] + removedTicketIds: string[] + convictTicketsDeleted: number + keepEmails: string[] +} + +export const runtime = "nodejs" + +export async function POST(request: Request) { + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem remover dados." }, { status: 403 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + const sessionEmail = session.user.email?.toLowerCase() + if (!sessionEmail) { + return NextResponse.json({ error: "Administrador sem e-mail associado." }, { status: 400 }) + } + + const body = await request.json().catch(() => ({})) as { keepEmails?: string[] } + const keepEmailsInput = Array.isArray(body.keepEmails) ? body.keepEmails : [] + const keepEmailsSet = new Set() + DEFAULT_KEEP_EMAILS.forEach((email) => keepEmailsSet.add(email.toLowerCase())) + keepEmailsInput + .map((email) => (typeof email === "string" ? email.trim().toLowerCase() : "")) + .filter((email) => email.length > 0) + .forEach((email) => keepEmailsSet.add(email)) + + keepEmailsSet.add(sessionEmail) + const viewerEmail = sessionEmail + + const portalUsers = await prisma.user.findMany({ + where: { tenantId }, + select: { id: true, email: true }, + }) + + const portalToRemove = portalUsers.filter((user) => !keepEmailsSet.has(user.email.toLowerCase())) + const portalIdsToRemove = portalToRemove.map((user) => user.id) + const portalEmailsToRemove = portalToRemove.map((user) => user.email.toLowerCase()) + + const responseSummary: CleanupSummary = { + removedPortalUserIds: portalIdsToRemove, + removedPortalEmails: portalEmailsToRemove, + removedAuthUserIds: [], + removedConvexUserIds: [], + removedTicketIds: [], + convictTicketsDeleted: 0, + keepEmails: Array.from(keepEmailsSet), + } + + if (portalIdsToRemove.length > 0) { + const ticketsToRemove = await prisma.ticket.findMany({ + where: { + tenantId, + OR: [{ requesterId: { in: portalIdsToRemove } }, { assigneeId: { in: portalIdsToRemove } }], + }, + select: { id: true }, + }) + responseSummary.removedTicketIds = ticketsToRemove.map((ticket) => ticket.id) + + await prisma.$transaction(async (tx) => { + if (ticketsToRemove.length > 0) { + await tx.ticket.deleteMany({ + where: { id: { in: ticketsToRemove.map((ticket) => ticket.id) } }, + }) + } + + if (portalEmailsToRemove.length > 0) { + const authUsers = await tx.authUser.findMany({ + where: { email: { in: portalEmailsToRemove } }, + select: { id: true }, + }) + if (authUsers.length > 0) { + const authIds = authUsers.map((item) => item.id) + responseSummary.removedAuthUserIds = authIds + await tx.authSession.deleteMany({ where: { userId: { in: authIds } } }) + await tx.authAccount.deleteMany({ where: { userId: { in: authIds } } }) + await tx.authUser.deleteMany({ where: { id: { in: authIds } } }) + } + } + + await tx.user.deleteMany({ + where: { id: { in: portalIdsToRemove } }, + }) + }) + } + + try { + const client = createConvexClient() + const actor = + (await client.query(api.users.findByEmail, { tenantId, email: sessionEmail })) ?? + (await client.mutation(api.users.ensureUser, { + tenantId, + email: sessionEmail, + name: session.user.name ?? sessionEmail, + })) + + const actorId = actor?._id as Id<"users"> | undefined + if (actorId) { + const convexCustomers = await client.query(api.users.listCustomers, { + tenantId, + viewerId: actorId, + }) + + const convexToRemove = convexCustomers.filter( + (customer) => !keepEmailsSet.has(customer.email.toLowerCase()), + ) + + const removedConvexIds: Id<"users">[] = [] + for (const customer of convexToRemove) { + const userId = customer.id as Id<"users"> + try { + await client.mutation(api.users.deleteUser, { userId, actorId }) + removedConvexIds.push(userId) + } catch (error) { + console.error("[users.cleanup] Falha ao remover usuário do Convex", customer.email, error) + } + } + responseSummary.removedConvexUserIds = removedConvexIds.map((id) => id as unknown as string) + + if (removedConvexIds.length > 0) { + try { + const result = await client.mutation(api.tickets.purgeTicketsForUsers, { + tenantId, + actorId, + userIds: removedConvexIds, + }) + responseSummary.convictTicketsDeleted = typeof result?.deleted === "number" ? result.deleted : 0 + } catch (error) { + console.error("[users.cleanup] Falha ao remover tickets no Convex", error) + } + } + } + } catch (error) { + console.error("[users.cleanup] Convex indisponível", error) + } + + return NextResponse.json(responseSummary) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/admin/users/route.ts b/referência/sistema-de-chamados-main/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..48c63da --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/admin/users/route.ts @@ -0,0 +1,334 @@ +import { NextResponse } from "next/server" + +import { hashPassword } from "better-auth/crypto" +import { ConvexHttpClient } from "convex/browser" +import type { UserRole } from "@prisma/client" +import type { Id } from "@/convex/_generated/dataModel" + +import { prisma } from "@/lib/prisma" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { assertAdminSession, assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" +import { api } from "@/convex/_generated/api" + +export const runtime = "nodejs" + +const ALLOWED_ROLES = ["MANAGER", "COLLABORATOR"] as const + +type AllowedRole = (typeof ALLOWED_ROLES)[number] + +function normalizeRole(role?: string | null): AllowedRole { + const normalized = (role ?? "COLLABORATOR").toUpperCase() + return ALLOWED_ROLES.includes(normalized as AllowedRole) ? (normalized as AllowedRole) : "COLLABORATOR" +} + +function generatePassword(length = 12) { + const charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%&*?" + let password = "" + const array = new Uint32Array(length) + crypto.getRandomValues(array) + for (let index = 0; index < length; index += 1) { + password += charset[array[index] % charset.length] + } + return password +} + +export async function GET() { + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + const users = await prisma.user.findMany({ + where: { + tenantId, + role: { in: [...ALLOWED_ROLES] }, + }, + include: { + company: { + select: { + id: true, + name: true, + }, + }, + manager: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }) + + const emails = users.map((user) => user.email) + const authUsers = await prisma.authUser.findMany({ + where: { email: { in: emails } }, + select: { + id: true, + email: true, + updatedAt: true, + createdAt: true, + }, + }) + + const sessions = await prisma.authSession.findMany({ + where: { userId: { in: authUsers.map((authUser) => authUser.id) } }, + orderBy: { updatedAt: "desc" }, + select: { + userId: true, + updatedAt: true, + }, + }) + + const sessionByUserId = new Map() + for (const sessionRow of sessions) { + if (!sessionByUserId.has(sessionRow.userId)) { + sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt) + } + } + + const authByEmail = new Map() + for (const authUser of authUsers) { + authByEmail.set(authUser.email.toLowerCase(), { + id: authUser.id, + updatedAt: authUser.updatedAt, + createdAt: authUser.createdAt, + }) + } + + const items = users.map((user) => { + const auth = authByEmail.get(user.email.toLowerCase()) + const lastSeenAt = auth ? sessionByUserId.get(auth.id) ?? auth.updatedAt : null + return { + id: user.id, + email: user.email, + name: user.name, + role: normalizeRole(user.role), + companyId: user.companyId, + companyName: user.company?.name ?? null, + jobTitle: user.jobTitle ?? null, + managerId: user.managerId, + managerName: user.manager?.name ?? null, + managerEmail: user.manager?.email ?? null, + tenantId: user.tenantId, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + authUserId: auth?.id ?? null, + lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null, + } + }) + + return NextResponse.json({ items }) +} + +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 { + name?: string + email?: string + role?: string + tenantId?: string + jobTitle?: string | null + managerId?: string | null + } | null + + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Payload inválido" }, { status: 400 }) + } + + const name = body.name?.trim() ?? "" + const email = body.email?.trim().toLowerCase() ?? "" + const tenantId = (body.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID).trim() || DEFAULT_TENANT_ID + + if (!name) { + return NextResponse.json({ error: "Informe o nome do usuário" }, { status: 400 }) + } + if (!email || !email.includes("@")) { + return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 }) + } + + const rawJobTitle = typeof body.jobTitle === "string" ? body.jobTitle.trim() : null + const jobTitle = rawJobTitle ? rawJobTitle : null + const rawManagerId = typeof body.managerId === "string" ? body.managerId.trim() : "" + const managerId = rawManagerId.length > 0 ? rawManagerId : null + + let managerRecord: { id: string; email: string; tenantId: string; name: string } | null = null + if (managerId) { + managerRecord = await prisma.user.findUnique({ + where: { id: managerId }, + select: { id: true, email: true, tenantId: true, name: true }, + }) + if (!managerRecord) { + return NextResponse.json({ error: "Gestor informado não foi encontrado." }, { status: 400 }) + } + if (managerRecord.tenantId !== tenantId) { + return NextResponse.json({ error: "Gestor pertence a outro cliente." }, { status: 400 }) + } + } + + const normalizedRole = normalizeRole(body.role) + const authRole = normalizedRole.toLowerCase() + const userRole = normalizedRole as UserRole + + const existingAuth = await prisma.authUser.findUnique({ where: { email } }) + if (existingAuth) { + return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 }) + } + + const temporaryPassword = generatePassword() + const hashedPassword = await hashPassword(temporaryPassword) + + const [authUser, domainUser] = await prisma.$transaction(async (tx) => { + const createdAuthUser = await tx.authUser.create({ + data: { + email, + name, + role: authRole, + tenantId, + accounts: { + create: { + providerId: "credential", + accountId: email, + password: hashedPassword, + }, + }, + }, + }) + + const createdDomainUser = await tx.user.upsert({ + where: { email }, + update: { + name, + role: userRole, + tenantId, + jobTitle, + managerId: managerRecord?.id ?? null, + }, + create: { + name, + email, + role: userRole, + tenantId, + jobTitle, + managerId: managerRecord?.id ?? null, + }, + }) + + return [createdAuthUser, createdDomainUser] as const + }) + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (convexUrl) { + try { + const convex = new ConvexHttpClient(convexUrl) + let managerConvexId: Id<"users"> | undefined + if (managerRecord?.email) { + try { + const convexManager = await convex.query(api.users.findByEmail, { + tenantId, + email: managerRecord.email, + }) + if (convexManager?._id) { + managerConvexId = convexManager._id as Id<"users"> + } + } catch (error) { + console.warn("[admin/users] Falha ao localizar gestor no Convex", error) + } + } + await convex.mutation(api.users.ensureUser, { + tenantId, + email, + name, + avatarUrl: authUser.avatarUrl ?? undefined, + role: userRole, + jobTitle: jobTitle ?? undefined, + managerId: managerConvexId, + }) + } catch (error) { + console.error("[admin/users] ensureUser failed", error) + } + } + + return NextResponse.json({ + user: { + id: domainUser.id, + authUserId: authUser.id, + email: domainUser.email, + name: domainUser.name, + role: authRole, + tenantId: domainUser.tenantId, + createdAt: domainUser.createdAt.toISOString(), + jobTitle, + managerId: managerRecord?.id ?? null, + managerName: managerRecord?.name ?? null, + managerEmail: managerRecord?.email ?? null, + }, + temporaryPassword, + }) +} + +export async function DELETE(request: Request) { + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem excluir usuários." }, { status: 403 }) + } + + const json = await request.json().catch(() => null) + const ids = Array.isArray(json?.ids) ? (json.ids as string[]) : [] + if (ids.length === 0) { + return NextResponse.json({ error: "Nenhum usuário selecionado." }, { status: 400 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + const users = await prisma.user.findMany({ + where: { + id: { in: ids }, + tenantId, + role: { in: [...ALLOWED_ROLES] }, + }, + select: { + id: true, + email: true, + }, + }) + + if (users.length === 0) { + return NextResponse.json({ deletedIds: [] }) + } + + const emails = users.map((user) => user.email.toLowerCase()) + const authUsers = await prisma.authUser.findMany({ + where: { + email: { in: emails }, + }, + select: { + id: true, + }, + }) + + const authUserIds = authUsers.map((authUser) => authUser.id) + + await prisma.$transaction(async (tx) => { + if (authUserIds.length > 0) { + await tx.authSession.deleteMany({ where: { userId: { in: authUserIds } } }) + await tx.authAccount.deleteMany({ where: { userId: { in: authUserIds } } }) + await tx.authUser.deleteMany({ where: { id: { in: authUserIds } } }) + } + await tx.user.deleteMany({ where: { id: { in: users.map((user) => user.id) } } }) + }) + + return NextResponse.json({ deletedIds: users.map((user) => user.id) }) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/auth/[...all]/route.ts b/referência/sistema-de-chamados-main/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..440620d --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,5 @@ +import { toNextJsHandler } from "better-auth/next-js" + +import { auth } from "@/lib/auth" + +export const { GET, POST } = toNextJsHandler(auth.handler) diff --git a/referência/sistema-de-chamados-main/src/app/api/auth/get-session/route.ts b/referência/sistema-de-chamados-main/src/app/api/auth/get-session/route.ts new file mode 100644 index 0000000..3936d52 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/auth/get-session/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server" + +import { auth } from "@/lib/auth" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const result = await auth.api.getSession({ headers: request.headers, request, asResponse: true }) + + if (!result) { + return NextResponse.json({ user: null }, { status: 200 }) + } + + const body = await result.json() + const response = NextResponse.json(body, { + status: result.status, + }) + + const headersWithGetSetCookie = result.headers as Headers & { getSetCookie?: () => string[] | undefined } + let setCookieHeaders = + typeof headersWithGetSetCookie.getSetCookie === "function" + ? headersWithGetSetCookie.getSetCookie() ?? [] + : [] + + if (setCookieHeaders.length === 0) { + const single = result.headers.get("set-cookie") + if (single) { + setCookieHeaders = [single] + } + } + + for (const cookie of setCookieHeaders) { + response.headers.append("set-cookie", cookie) + } + + return response +} diff --git a/referência/sistema-de-chamados-main/src/app/api/dashboards/[id]/export/[format]/route.ts b/referência/sistema-de-chamados-main/src/app/api/dashboards/[id]/export/[format]/route.ts new file mode 100644 index 0000000..264aaeb --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/dashboards/[id]/export/[format]/route.ts @@ -0,0 +1,180 @@ +import { NextRequest, NextResponse } from "next/server" +import type { Browser } from "playwright" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { createConvexClient } from "@/server/convex-client" + +export const runtime = "nodejs" + +const WAIT_SELECTOR_DEFAULT = "[data-dashboard-ready='true']" +const DEFAULT_VIEWPORT_WIDTH = 1600 +const DEFAULT_VIEWPORT_HEIGHT = 900 + +function slugifyFilename(raw: string, extension: "pdf" | "png") { + const base = raw + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-zA-Z0-9-_]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() + const safe = base.length > 0 ? base : "dashboard" + return `${safe}.${extension}` +} + +type ExportFormat = "pdf" | "png" + +function isSupportedFormat(value: string): value is ExportFormat { + return value === "pdf" || value === "png" +} + +function buildDisposition(filename: string) { + const encoded = encodeURIComponent(filename) + return `attachment; filename="${filename}"; filename*=UTF-8''${encoded}` +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string; format: string }> }, +) { + const { id, format } = await context.params + const formatParam = format?.toLowerCase() + if (!isSupportedFormat(formatParam)) { + return NextResponse.json({ error: "Formato não suportado" }, { status: 400 }) + } + + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convex = createConvexClient() + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let ensuredUser: { _id: Id<"users"> } | null = null + try { + ensuredUser = await convex.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + } catch (error) { + console.error("[dashboards.export] Falha ao garantir usuário no Convex", error) + return NextResponse.json({ error: "Não foi possível preparar a exportação" }, { status: 500 }) + } + + const viewerId = ensuredUser?._id + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + let detail: { dashboard: { name?: string | null; readySelector?: string | null } | null } | null = null + try { + detail = await convex.query(api.dashboards.get, { + tenantId, + viewerId, + dashboardId: id as Id<"dashboards">, + }) + } catch (error) { + console.error("[dashboards.export] Falha ao obter dashboard", error, { tenantId, dashboardId: id }) + return NextResponse.json({ error: "Não foi possível carregar o dashboard" }, { status: 500 }) + } + + if (!detail?.dashboard) { + return NextResponse.json({ error: "Dashboard não encontrado" }, { status: 404 }) + } + + const requestUrl = request.nextUrl + const printUrl = new URL(`/dashboards/${id}/print`, requestUrl.origin).toString() + const waitForSelector = detail.dashboard.readySelector ?? WAIT_SELECTOR_DEFAULT + const width = Number(requestUrl.searchParams.get("width") ?? DEFAULT_VIEWPORT_WIDTH) || DEFAULT_VIEWPORT_WIDTH + const height = Number(requestUrl.searchParams.get("height") ?? DEFAULT_VIEWPORT_HEIGHT) || DEFAULT_VIEWPORT_HEIGHT + let browser: Browser | null = null + + try { + const { chromium } = await import("playwright") + try { + browser = await chromium.launch({ headless: true }) + } catch (launchError) { + const message = (launchError as Error)?.message ?? String(launchError) + if (/Executable doesn't exist|Please run the following command to download new browsers/i.test(message)) { + return NextResponse.json( + { + error: "Playwright browsers not installed", + hint: "Execute: npx playwright install", + details: message, + }, + { status: 501 }, + ) + } + throw launchError + } + + const page = await browser.newPage({ viewport: { width, height } }) + await page.goto(printUrl, { waitUntil: "networkidle" }) + if (waitForSelector) { + try { + await page.waitForSelector(waitForSelector, { timeout: 15000 }) + } catch (error) { + console.warn("[dashboards.export] Tempo excedido aguardando seletor", waitForSelector, error) + } + } + + await page.emulateMedia({ media: "screen" }).catch(() => undefined) + + if (formatParam === "pdf") { + const scrollHeight = await page.evaluate(() => + Math.max( + document.documentElement?.scrollHeight ?? 0, + document.body?.scrollHeight ?? 0, + window.innerHeight, + ), + ) + const pdfBuffer = await page.pdf({ + width: `${width}px`, + height: `${Math.max(scrollHeight, height)}px`, + printBackground: true, + margin: { + top: "12px", + bottom: "12px", + left: "18px", + right: "18px", + }, + }) + const bytes = new Uint8Array(pdfBuffer) + const filename = slugifyFilename(detail.dashboard.name ?? "dashboard", "pdf") + return new NextResponse(bytes, { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": buildDisposition(filename), + "Cache-Control": "no-store", + }, + }) + } + + const screenshot = await page.screenshot({ type: "png", fullPage: true }) + const bytes = new Uint8Array(screenshot) + const filename = slugifyFilename(detail.dashboard.name ?? "dashboard", "png") + return new NextResponse(bytes, { + status: 200, + headers: { + "Content-Type": "image/png", + "Content-Disposition": buildDisposition(filename), + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("[dashboards.export] Falha ao exportar dashboard", error) + return NextResponse.json({ error: "Falha ao exportar o dashboard" }, { status: 500 }) + } finally { + if (browser) { + await browser.close().catch(() => undefined) + } + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/integrations/fleet/hosts/route.ts b/referência/sistema-de-chamados-main/src/app/api/integrations/fleet/hosts/route.ts new file mode 100644 index 0000000..732cbeb --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/integrations/fleet/hosts/route.ts @@ -0,0 +1,181 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import { env } from "@/lib/env" + +const fleetHostSchema = z.object({ + host: z + .object({ + id: z.number().optional(), + hostname: z.string().optional(), + display_name: z.string().optional(), + platform: z.string().optional(), + os_version: z.string().optional(), + hardware_model: z.string().optional(), + hardware_serial: z.string().optional(), + hardware_uuid: z.string().optional(), + uuid: z.string().optional(), + device_id: z.string().optional(), + primary_ip: z.string().optional(), + public_ip: z.string().optional(), + primary_mac: z.string().optional(), + macs: z.string().optional(), + serial_number: z.string().optional(), + memory: z.number().optional(), + cpu_type: z.string().optional(), + cpu_physical_cores: z.number().optional(), + cpu_logical_cores: z.number().optional(), + hardware_vendor: z.string().optional(), + computer_name: z.string().optional(), + detail_updated_at: z.string().optional(), + platform_like: z.string().optional(), + osquery_version: z.string().optional(), + team_id: z.number().optional(), + software: z + .array( + z.object({ + name: z.string().optional(), + version: z.string().optional(), + source: z.string().optional(), + }) + ) + .optional(), + labels: z + .array( + z.object({ + id: z.number(), + name: z.string(), + }) + ) + .optional(), + }) + .transform((value) => value ?? {}), +}) + +function extractMacs(host: z.infer["host"]) { + const macs = new Set() + const append = (input?: string | null) => { + if (!input) return + input + .split(/[\s,]+/) + .map((mac) => mac.trim()) + .filter(Boolean) + .forEach((mac) => macs.add(mac)) + } + append(host.primary_mac) + append(host.macs) + return Array.from(macs) +} + +function extractSerials(host: z.infer["host"]) { + return [ + host.hardware_serial, + host.hardware_uuid, + host.uuid, + host.serial_number, + host.device_id, + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)) +} + +export async function POST(request: Request) { + const fleetSecret = env.FLEET_SYNC_SECRET ?? env.MACHINE_PROVISIONING_SECRET + if (!fleetSecret) { + return NextResponse.json({ error: "Sincronização Fleet não configurada" }, { status: 500 }) + } + + const providedSecret = request.headers.get("x-fleet-secret") ?? request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") + if (!providedSecret || providedSecret !== fleetSecret) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + let parsed + try { + const raw = await request.json() + parsed = fleetHostSchema.parse(raw) + } catch (error) { + return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 }) + } + + const host = parsed.host + const hostname = host.hostname ?? host.computer_name ?? host.display_name + if (!hostname) { + return NextResponse.json({ error: "Host sem hostname válido" }, { status: 400 }) + } + + const macAddresses = extractMacs(host) + const serialNumbers = extractSerials(host) + + if (macAddresses.length === 0 && serialNumbers.length === 0) { + return NextResponse.json({ error: "Host sem identificadores de hardware (MAC ou serial)" }, { status: 400 }) + } + + const osInfo = { + name: host.os_version ?? host.platform ?? "desconhecido", + version: host.os_version, + architecture: host.platform_like, + } + + const inventory = { + fleet: { + id: host.id, + teamId: host.team_id, + detailUpdatedAt: host.detail_updated_at, + osqueryVersion: host.osquery_version, + }, + hardware: { + vendor: host.hardware_vendor, + model: host.hardware_model, + serial: host.hardware_serial ?? host.serial_number, + cpuType: host.cpu_type, + physicalCores: host.cpu_physical_cores, + logicalCores: host.cpu_logical_cores, + memoryBytes: host.memory, + }, + network: { + primaryIp: host.primary_ip, + publicIp: host.public_ip, + macAddresses, + }, + labels: host.labels, + software: host.software?.slice(0, 50).map((item) => ({ + name: item.name, + version: item.version, + source: item.source, + })), + } + + const metrics = { + memoryBytes: host.memory, + cpuPhysicalCores: host.cpu_physical_cores, + cpuLogicalCores: host.cpu_logical_cores, + } + + const client = new ConvexHttpClient(convexUrl) + + try { + const result = await client.mutation(api.devices.upsertInventory, { + provisioningCode: fleetSecret, + hostname, + os: osInfo, + macAddresses, + serialNumbers, + inventory, + metrics, + registeredBy: "fleet", + }) + + return NextResponse.json({ ok: true, machineId: result.machineId, status: result.status }) + } catch (error) { + console.error("[fleet.hosts] Falha ao sincronizar inventário", error) + return NextResponse.json({ error: "Falha ao sincronizar inventário" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/invites/[token]/route.ts b/referência/sistema-de-chamados-main/src/app/api/invites/[token]/route.ts new file mode 100644 index 0000000..89209b7 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/invites/[token]/route.ts @@ -0,0 +1,198 @@ +import { NextResponse } from "next/server" + +import { Prisma } from "@prisma/client" +import { hashPassword } from "better-auth/crypto" +import { ConvexHttpClient } from "convex/browser" + +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, context: { params: Promise<{ token: string }> }) { + const { token } = await context.params + const invite = await prisma.authInvite.findUnique({ + where: { 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: Prisma.JsonNull, + 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, context: { params: Promise<{ token: string }> }) { + const { token } = await context.params + const payload = (await request.json().catch(() => null)) as Partial | 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 }, + 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: Prisma.JsonNull, + 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 }) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/companies/route.ts b/referência/sistema-de-chamados-main/src/app/api/machines/companies/route.ts new file mode 100644 index 0000000..c21252f --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/companies/route.ts @@ -0,0 +1,228 @@ +import { randomBytes } from "crypto" +import { Prisma } from "@prisma/client" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { env } from "@/lib/env" +import { normalizeSlug, slugify } from "@/lib/slug" +import { prisma } from "@/lib/prisma" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { ConvexConfigurationError } from "@/server/convex-client" +import { syncConvexCompany } from "@/server/companies-sync" +import { safeCompanyFindMany } from "@/server/company-service" + +export const runtime = "nodejs" + +const CORS_METHODS = "GET, POST, OPTIONS" + +function extractSecret(request: Request, url: URL): string | null { + const header = + request.headers.get("x-machine-secret") ?? + request.headers.get("x-machine-provisioning-secret") ?? + request.headers.get("x-provisioning-secret") + if (header && header.trim()) return header.trim() + + const auth = request.headers.get("authorization") + if (auth && auth.toLowerCase().startsWith("bearer ")) { + const token = auth.slice(7).trim() + if (token) return token + } + + const querySecret = url.searchParams.get("provisioningSecret") + if (querySecret && querySecret.trim()) return querySecret.trim() + + return null +} + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + +export async function GET(request: Request) { + const url = new URL(request.url) + const origin = request.headers.get("origin") + const secret = extractSecret(request, url) + const expectedSecret = env.MACHINE_PROVISIONING_SECRET + if (!expectedSecret) { + return jsonWithCors({ error: "Provisionamento não configurado" }, 500, origin, CORS_METHODS) + } + if (!secret || secret !== expectedSecret) { + return jsonWithCors({ error: "Não autorizado" }, 401, origin, CORS_METHODS) + } + + const tenantIdRaw = url.searchParams.get("tenantId") ?? "" + const tenantId = tenantIdRaw.trim() || DEFAULT_TENANT_ID + const search = url.searchParams.get("search")?.trim() ?? "" + + try { + const slugSearch = search ? normalizeSlug(search) ?? slugify(search) : null + const orFilters: Prisma.CompanyWhereInput[] = [] + if (search) { + orFilters.push({ + name: { + contains: search, + mode: Prisma.QueryMode.insensitive, + } as unknown as Prisma.StringFilter<"Company">, + }) + if (slugSearch) { + orFilters.push({ + slug: { + contains: slugSearch, + mode: Prisma.QueryMode.insensitive, + } as unknown as Prisma.StringFilter<"Company">, + }) + } + } + const where: Prisma.CompanyWhereInput = { + tenantId, + ...(orFilters.length > 0 ? { OR: orFilters } : {}), + } + + const companies = await safeCompanyFindMany({ + where, + orderBy: { name: "asc" }, + take: 20, + }) + + return jsonWithCors( + { + companies: companies.map((company) => ({ + id: company.id, + tenantId: company.tenantId, + name: company.name, + slug: company.slug, + })), + }, + 200, + origin, + CORS_METHODS + ) + } catch (error) { + console.error("[machines.companies] Falha ao listar empresas", error) + return jsonWithCors({ error: "Falha ao buscar empresas" }, 500, origin, CORS_METHODS) + } +} + +export async function POST(request: Request) { + const url = new URL(request.url) + const origin = request.headers.get("origin") + const secret = extractSecret(request, url) + const expectedSecret = env.MACHINE_PROVISIONING_SECRET + if (!expectedSecret) { + return jsonWithCors({ error: "Provisionamento não configurado" }, 500, origin, CORS_METHODS) + } + if (!secret || secret !== expectedSecret) { + return jsonWithCors({ error: "Não autorizado" }, 401, origin, CORS_METHODS) + } + + let payload: Partial<{ tenantId?: string; name?: string; slug?: string }> + try { + payload = (await request.json()) as Partial<{ tenantId?: string; name?: string; slug?: string }> + } catch (error) { + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + origin, + CORS_METHODS + ) + } + + const tenantId = payload?.tenantId?.trim() || DEFAULT_TENANT_ID + const name = payload?.name?.trim() ?? "" + const normalizedSlug = normalizeSlug(payload?.slug ?? name) + if (!name) { + return jsonWithCors({ error: "Informe o nome da empresa" }, 400, origin, CORS_METHODS) + } + if (!normalizedSlug) { + return jsonWithCors({ error: "Não foi possível gerar um slug para a empresa" }, 400, origin, CORS_METHODS) + } + + try { + const existing = await prisma.company.findFirst({ + where: { tenantId, slug: normalizedSlug }, + }) + + const provisioningCode = existing?.provisioningCode ?? randomBytes(32).toString("hex") + + const company = + existing ?? + (await prisma.company.create({ + data: { + tenantId, + name, + slug: normalizedSlug, + provisioningCode, + }, + })) + + try { + const synced = await syncConvexCompany({ + tenantId, + slug: company.slug, + name: company.name, + provisioningCode: company.provisioningCode, + }) + if (!synced) { + throw new ConvexConfigurationError() + } + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } + + return jsonWithCors( + { + company: { + id: company.id, + tenantId: company.tenantId, + name: company.name, + slug: company.slug, + created: existing ? false : true, + }, + }, + existing ? 200 : 201, + origin, + CORS_METHODS + ) + } catch (error) { + const prismaError = error as { code?: string } + if (prismaError?.code === "P2002") { + try { + const fallback = await prisma.company.findFirst({ where: { tenantId, slug: normalizedSlug } }) + if (fallback) { + try { + await syncConvexCompany({ + tenantId, + slug: fallback.slug, + name: fallback.name, + provisioningCode: fallback.provisioningCode, + }) + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } + return jsonWithCors( + { + company: { + id: fallback.id, + tenantId: fallback.tenantId, + name: fallback.name, + slug: fallback.slug, + created: false, + }, + }, + 200, + origin, + CORS_METHODS + ) + } + } catch (lookupError) { + console.error("[machines.companies] Falha ao recuperar empresa após conflito", lookupError) + } + } + console.error("[machines.companies] Falha ao criar empresa", error) + return jsonWithCors({ error: "Falha ao criar ou recuperar empresa" }, 500, origin, CORS_METHODS) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/heartbeat/route.test.ts b/referência/sistema-de-chamados-main/src/app/api/machines/heartbeat/route.test.ts new file mode 100644 index 0000000..03aa7c3 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/heartbeat/route.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, beforeEach, vi } from "vitest" + +import { api } from "@/convex/_generated/api" + +const mutationMock = vi.fn() + +vi.mock("@/server/convex-client", () => ({ + createConvexClient: () => ({ + mutation: mutationMock, + }), + ConvexConfigurationError: class extends Error {}, +})) + +describe("POST /api/machines/heartbeat", () => { + beforeEach(() => { + mutationMock.mockReset() + }) + + it("accepts a valid payload and forwards it to Convex", async () => { + const payload = { + machineToken: "token-123", + status: "online", + metrics: { cpu: 42 }, + } + mutationMock.mockResolvedValue({ ok: true }) + + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/heartbeat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ ok: true }) + expect(mutationMock).toHaveBeenCalledWith(api.devices.heartbeat, payload) + }) + + it("rejects an invalid payload", async () => { + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/heartbeat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toHaveProperty("error", "Payload inválido") + expect(mutationMock).not.toHaveBeenCalled() + }) +}) + diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/heartbeat/route.ts b/referência/sistema-de-chamados-main/src/app/api/machines/heartbeat/route.ts new file mode 100644 index 0000000..cb520ee --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/heartbeat/route.ts @@ -0,0 +1,66 @@ +import { z } from "zod" + +import { api } from "@/convex/_generated/api" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" + +const heartbeatSchema = z.object({ + machineToken: z.string().min(1), + status: z.string().optional(), + hostname: z.string().optional(), + os: z + .object({ + name: z.string(), + version: z.string().optional(), + architecture: z.string().optional(), + }) + .optional(), + metrics: z.record(z.string(), z.unknown()).optional(), + inventory: z.record(z.string(), z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + +export async function POST(request: Request) { + const origin = request.headers.get("origin") + if (request.method !== "POST") { + return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS) + } + + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } + + let payload + try { + const raw = await request.json() + payload = heartbeatSchema.parse(raw) + } catch (error) { + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + origin, + CORS_METHODS + ) + } + + try { + const response = await client.mutation(api.devices.heartbeat, payload) + return jsonWithCors(response, 200, origin, CORS_METHODS) + } catch (error) { + console.error("[machines.heartbeat] Falha ao registrar heartbeat", error) + const details = error instanceof Error ? error.message : String(error) + return jsonWithCors({ error: "Falha ao registrar heartbeat", details }, 500, origin, CORS_METHODS) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/inventory/route.test.ts b/referência/sistema-de-chamados-main/src/app/api/machines/inventory/route.test.ts new file mode 100644 index 0000000..42dc562 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/inventory/route.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, beforeEach, vi } from "vitest" + +import { api } from "@/convex/_generated/api" + +const mutationMock = vi.fn() + +vi.mock("@/server/convex-client", () => ({ + createConvexClient: () => ({ + mutation: mutationMock, + }), + ConvexConfigurationError: class extends Error {}, +})) + +describe("POST /api/machines/inventory", () => { + beforeEach(() => { + mutationMock.mockReset() + }) + + it("accepts the token mode payload", async () => { + const payload = { + machineToken: "token-123", + hostname: "machine", + metrics: { cpu: 50 }, + } + mutationMock.mockResolvedValue({ ok: true, machineId: "machine-123" }) + + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/inventory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + ) + + expect(response.status).toBe(200) + expect(mutationMock).toHaveBeenCalledWith( + api.devices.heartbeat, + expect.objectContaining({ + machineToken: "token-123", + hostname: "machine", + metrics: { cpu: 50 }, + }) + ) + const body = await response.json() + expect(body).toEqual({ ok: true, machineId: "machine-123" }) + }) + + it("accepts the provisioning mode payload", async () => { + const payload = { + provisioningCode: "a".repeat(32), + hostname: "machine", + os: { name: "Linux" }, + macAddresses: ["00:11:22:33"], + serialNumbers: [], + } + mutationMock.mockResolvedValue({ ok: true, status: "updated", machineId: "machine-987" }) + + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/inventory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + ) + + expect(response.status).toBe(200) + expect(mutationMock).toHaveBeenCalledWith( + api.devices.upsertInventory, + expect.objectContaining({ + provisioningCode: "a".repeat(32), + hostname: "machine", + os: { name: "Linux" }, + macAddresses: ["00:11:22:33"], + serialNumbers: [], + registeredBy: "agent:inventory", + }) + ) + const body = await response.json() + expect(body).toEqual({ ok: true, machineId: "machine-987", status: "updated" }) + }) + + it("rejects unknown payloads", async () => { + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/inventory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hostname: "machine" }), + }) + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: "Formato de payload não suportado" }) + expect(mutationMock).not.toHaveBeenCalled() + }) +}) diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/inventory/route.ts b/referência/sistema-de-chamados-main/src/app/api/machines/inventory/route.ts new file mode 100644 index 0000000..2ae6fb1 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/inventory/route.ts @@ -0,0 +1,109 @@ +import { z } from "zod" + +import { api } from "@/convex/_generated/api" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" + +const tokenModeSchema = z.object({ + machineToken: z.string().min(1), + hostname: z.string().optional(), + os: z + .object({ + name: z.string(), + version: z.string().optional(), + architecture: z.string().optional(), + }) + .optional(), + metrics: z.record(z.string(), z.unknown()).optional(), + inventory: z.record(z.string(), z.unknown()).optional(), +}) + +const provisioningModeSchema = z.object({ + provisioningCode: z.string().min(32), + hostname: z.string().min(1), + os: z.object({ + name: z.string().min(1), + version: z.string().optional(), + architecture: z.string().optional(), + }), + macAddresses: z.array(z.string()).default([]), + serialNumbers: z.array(z.string()).default([]), + inventory: z.record(z.string(), z.unknown()).optional(), + metrics: z.record(z.string(), z.unknown()).optional(), + registeredBy: z.string().optional(), +}) + +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + +export async function POST(request: Request) { + const origin = request.headers.get("origin") + + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } + + let raw: unknown + try { + raw = await request.json() + } catch (error) { + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + origin, + CORS_METHODS + ) + } + + // Modo A: com token da dispositivo (usa heartbeat para juntar inventário) + const tokenParsed = tokenModeSchema.safeParse(raw) + if (tokenParsed.success) { + try { + const result = await client.mutation(api.devices.heartbeat, { + machineToken: tokenParsed.data.machineToken, + hostname: tokenParsed.data.hostname, + os: tokenParsed.data.os, + metrics: tokenParsed.data.metrics, + inventory: tokenParsed.data.inventory, + }) + return jsonWithCors({ ok: true, machineId: result.machineId, expiresAt: result.expiresAt }, 200, origin, CORS_METHODS) + } catch (error) { + console.error("[machines.inventory:token] Falha ao atualizar inventário", error) + const details = error instanceof Error ? error.message : String(error) + return jsonWithCors({ error: "Falha ao atualizar inventário", details }, 500, origin, CORS_METHODS) + } + } + + // Modo B: com segredo de provisionamento (upsert sem token) + const provParsed = provisioningModeSchema.safeParse(raw) + if (provParsed.success) { + try { + const result = await client.mutation(api.devices.upsertInventory, { + provisioningCode: provParsed.data.provisioningCode.trim().toLowerCase(), + hostname: provParsed.data.hostname, + os: provParsed.data.os, + macAddresses: provParsed.data.macAddresses, + serialNumbers: provParsed.data.serialNumbers, + inventory: provParsed.data.inventory, + metrics: provParsed.data.metrics, + registeredBy: provParsed.data.registeredBy ?? "agent:inventory", + }) + return jsonWithCors({ ok: true, machineId: result.machineId, status: result.status }, 200, origin, CORS_METHODS) + } catch (error) { + console.error("[machines.inventory:prov] Falha ao fazer upsert de inventário", error) + const details = error instanceof Error ? error.message : String(error) + return jsonWithCors({ error: "Falha ao fazer upsert de inventário", details }, 500, origin, CORS_METHODS) + } + } + + return jsonWithCors({ error: "Formato de payload não suportado" }, 400, origin, CORS_METHODS) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/provisioning/route.ts b/referência/sistema-de-chamados-main/src/app/api/machines/provisioning/route.ts new file mode 100644 index 0000000..c50ece0 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/provisioning/route.ts @@ -0,0 +1,93 @@ +import { api } from "@/convex/_generated/api" +import { prisma } from "@/lib/prisma" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" + +export const runtime = "nodejs" + +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + +export async function POST(request: Request) { + const origin = request.headers.get("origin") + let payload: unknown + try { + payload = await request.json() + } catch (error) { + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + origin, + CORS_METHODS + ) + } + + const provisioningCodeRaw = + payload && typeof payload === "object" && "provisioningCode" in payload + ? (payload as { provisioningCode?: unknown }).provisioningCode + : null + + const provisioningCode = + typeof provisioningCodeRaw === "string" ? provisioningCodeRaw.trim().toLowerCase() : "" + if (!provisioningCode) { + return jsonWithCors({ error: "Informe o código de provisionamento" }, 400, origin, CORS_METHODS) + } + + try { + const company = await prisma.company.findFirst({ + where: { provisioningCode }, + select: { + id: true, + tenantId: true, + name: true, + slug: true, + provisioningCode: true, + }, + }) + + if (!company) { + return jsonWithCors({ error: "Código de provisionamento inválido" }, 404, origin, CORS_METHODS) + } + + try { + const client = createConvexClient() + await client.mutation(api.companies.ensureProvisioned, { + tenantId: company.tenantId, + slug: company.slug, + name: company.name, + provisioningCode: company.provisioningCode, + }) + } catch (error) { + if (error instanceof ConvexConfigurationError) { + console.warn("[machines.provisioning] Convex não configurado; ignorando sincronização de empresa.") + } else { + console.error("[machines.provisioning] Falha ao sincronizar empresa no Convex", error) + } + } + + return jsonWithCors( + { + company: { + id: company.id, + tenantId: company.tenantId, + name: company.name, + slug: company.slug, + }, + }, + 200, + origin, + CORS_METHODS + ) + } catch (error) { + console.error("[machines.provisioning] Falha ao validar código", error) + return jsonWithCors( + { error: "Falha ao validar código de provisionamento" }, + 500, + origin, + CORS_METHODS + ) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/register/route.ts b/referência/sistema-de-chamados-main/src/app/api/machines/register/route.ts new file mode 100644 index 0000000..a11a10b --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/register/route.ts @@ -0,0 +1,217 @@ +import { z } from "zod" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { ensureCollaboratorAccount, ensureMachineAccount } from "@/server/machines-auth" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { prisma } from "@/lib/prisma" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" + +const registerSchema = z + .object({ + provisioningCode: z.string().min(32), + hostname: z.string().min(1), + os: z.object({ + name: z.string().min(1), + version: z.string().optional(), + architecture: z.string().optional(), + }), + macAddresses: z.array(z.string()).default([]), + serialNumbers: z.array(z.string()).default([]), + metadata: z.record(z.string(), z.unknown()).optional(), + registeredBy: z.string().optional(), + accessRole: z.enum(["collaborator", "manager"]).optional(), + collaborator: z + .object({ + email: z.string().email(), + name: z.string().min(1, "Informe o nome do colaborador/gestor"), + }) + .optional(), + }) + .refine( + (data) => (data.macAddresses && data.macAddresses.length > 0) || (data.serialNumbers && data.serialNumbers.length > 0), + { message: "Informe ao menos um MAC address ou número de série" } + ) + +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + +export async function POST(request: Request) { + const origin = request.headers.get("origin") + if (request.method !== "POST") { + return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS) + } + + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } + + let payload + try { + const raw = await request.json() + payload = registerSchema.parse(raw) + } catch (error) { + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + origin, + CORS_METHODS + ) + } + + try { + const provisioningCode = payload.provisioningCode.trim().toLowerCase() + const companyRecord = await prisma.company.findFirst({ + where: { provisioningCode }, + select: { id: true, tenantId: true, name: true, slug: true, provisioningCode: true }, + }) + + if (!companyRecord) { + return jsonWithCors( + { error: "Código de provisionamento inválido" }, + 404, + origin, + CORS_METHODS + ) + } + + const tenantId: string = companyRecord.tenantId ?? DEFAULT_TENANT_ID + const persona = payload.accessRole ?? undefined + const collaborator = payload.collaborator ?? null + + if (persona && !collaborator) { + return jsonWithCors( + { error: "Informe os dados do colaborador/gestor ao definir o perfil de acesso." }, + 400, + origin, + CORS_METHODS + ) + } + + let metadataPayload: Record | undefined = payload.metadata + ? { ...(payload.metadata as Record) } + : undefined + if (collaborator) { + const collaboratorMeta = { + email: collaborator.email, + name: collaborator.name ?? null, + role: persona ?? "collaborator", + } + metadataPayload = { + ...(metadataPayload ?? {}), + collaborator: collaboratorMeta, + } + } + + await client.mutation(api.companies.ensureProvisioned, { + tenantId, + slug: companyRecord.slug, + name: companyRecord.name, + provisioningCode: companyRecord.provisioningCode, + }) + + const registration = await client.mutation(api.devices.register, { + provisioningCode, + hostname: payload.hostname, + os: payload.os, + macAddresses: payload.macAddresses, + serialNumbers: payload.serialNumbers, + metadata: metadataPayload, + registeredBy: payload.registeredBy, + }) + + const account = await ensureMachineAccount({ + machineId: registration.machineId, + tenantId: registration.tenantId ?? DEFAULT_TENANT_ID, + hostname: payload.hostname, + machineToken: registration.machineToken, + persona, + }) + + await client.mutation(api.devices.linkAuthAccount, { + machineId: registration.machineId as Id<"machines">, + authUserId: account.authUserId, + authEmail: account.authEmail, + }) + + let assignedUserId: Id<"users"> | undefined + if (collaborator) { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + email: collaborator.email, + name: collaborator.name ?? collaborator.email, + avatarUrl: undefined, + role: persona?.toUpperCase(), + companyId: registration.companyId ? (registration.companyId as Id<"companies">) : undefined, + }) + + await ensureCollaboratorAccount({ + email: collaborator.email, + name: collaborator.name ?? collaborator.email, + tenantId, + companyId: companyRecord.id, + role: persona === "manager" ? "MANAGER" : "COLLABORATOR", + }) + + if (persona) { + assignedUserId = ensuredUser?._id + await client.mutation(api.devices.updatePersona, { + machineId: registration.machineId as Id<"machines">, + persona, + ...(assignedUserId ? { assignedUserId } : {}), + assignedUserEmail: collaborator.email, + assignedUserName: collaborator.name ?? undefined, + assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR", + }) + } else { + await client.mutation(api.devices.updatePersona, { + machineId: registration.machineId as Id<"machines">, + persona: "", + }) + } + } else { + await client.mutation(api.devices.updatePersona, { + machineId: registration.machineId as Id<"machines">, + persona: "", + }) + } + + return jsonWithCors( + { + machineId: registration.machineId, + tenantId: registration.tenantId, + companyId: registration.companyId, + companySlug: registration.companySlug, + machineToken: registration.machineToken, + machineEmail: account.authEmail, + expiresAt: registration.expiresAt, + persona: persona ?? null, + assignedUserId: assignedUserId ?? null, + collaborator: collaborator ?? null, + }, + { status: 201 }, + origin, + CORS_METHODS + ) + } catch (error) { + console.error("[machines.register] Falha no provisionamento", error) + const details = error instanceof Error ? error.message : String(error) + const msg = details.toLowerCase() + const isInvalidCode = msg.includes("código de provisionamento inválido") + const isCompanyNotFound = msg.includes("empresa não encontrada") + const isConvexError = msg.includes("convexerror") + const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500 + const payload = { error: "Falha ao provisionar dispositivo", details } + return jsonWithCors(payload, status, origin, CORS_METHODS) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/session/route.test.ts b/referência/sistema-de-chamados-main/src/app/api/machines/session/route.test.ts new file mode 100644 index 0000000..35b814a --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/session/route.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, beforeEach, vi } from "vitest" +import { NextRequest } from "next/server" + +import { MACHINE_CTX_COOKIE, serializeMachineCookie } from "@/server/machines/context" + +const mockAssertSession = vi.fn() +vi.mock("@/lib/auth-server", () => ({ + assertAuthenticatedSession: mockAssertSession, +})) + +const mockCreateConvexClient = vi.fn() +vi.mock("@/server/convex-client", () => { + class ConvexConfigurationError extends Error {} + return { + createConvexClient: mockCreateConvexClient, + ConvexConfigurationError, + } +}) + +const mockCookieStore = { + get: vi.fn<() => unknown>(), +} + +vi.mock("next/headers", () => ({ + cookies: vi.fn(async () => mockCookieStore), +})) + +describe("GET /api/machines/session", () => { + beforeEach(() => { + vi.clearAllMocks() + mockCookieStore.get.mockReturnValue(undefined) + mockCreateConvexClient.mockReturnValue({ + query: vi.fn(), + mutation: vi.fn(), + }) + }) + + it("returns 403 when the current session is not a machine", async () => { + mockAssertSession.mockResolvedValue({ user: { role: "admin" } }) + + const { GET } = await import("./route") + const response = await GET(new NextRequest("https://example.com/api/machines/session")) + + expect(response.status).toBe(403) + const payload = await response.json() + expect(payload).toEqual({ error: "Sessão de dispositivo não encontrada." }) + expect(mockCreateConvexClient).not.toHaveBeenCalled() + }) + + it("returns machine context and sets cookie when lookup succeeds", async () => { + mockAssertSession.mockResolvedValue({ + user: { role: "machine", email: "device@example.com" }, + }) + mockCookieStore.get.mockReturnValueOnce(undefined) + + const sampleContext = { + id: "machine-123", + tenantId: "tenant-1", + companyId: "company-1", + companySlug: "acme", + persona: "manager", + assignedUserId: "user-789", + assignedUserEmail: "manager@acme.com", + assignedUserName: "Manager Doe", + assignedUserRole: "MANAGER", + metadata: null, + authEmail: "device@example.com", + } + + let call = 0 + const queryMock = vi.fn(async (_route: unknown, args: unknown) => { + call += 1 + if (call === 1) { + expect(args).toEqual({ authEmail: "device@example.com" }) + return { id: "machine-123" } + } + if (call === 2) { + expect(args).toEqual({ machineId: "machine-123" }) + return sampleContext + } + return null + }) + + mockCreateConvexClient.mockReturnValue({ + query: queryMock, + mutation: vi.fn(), + }) + + const { GET } = await import("./route") + const response = await GET(new NextRequest("https://example.com/api/machines/session")) + + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload.machine.id).toBe(sampleContext.id) + expect(payload.machine.assignedUserEmail).toBe(sampleContext.assignedUserEmail) + + const cookie = response.cookies.get(MACHINE_CTX_COOKIE) + expect(cookie?.value).toBe(serializeMachineCookie({ + machineId: sampleContext.id, + persona: sampleContext.persona, + assignedUserId: sampleContext.assignedUserId, + assignedUserEmail: sampleContext.assignedUserEmail, + assignedUserName: sampleContext.assignedUserName, + assignedUserRole: sampleContext.assignedUserRole, + })) + }) +}) diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/session/route.ts b/referência/sistema-de-chamados-main/src/app/api/machines/session/route.ts new file mode 100644 index 0000000..e7a2a39 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/session/route.ts @@ -0,0 +1,178 @@ +import { NextRequest, NextResponse } from "next/server" +import { cookies } from "next/headers" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { + MACHINE_CTX_COOKIE, + extractCollaboratorFromMetadata, + parseMachineCookie, + serializeMachineCookie, + type CollaboratorMetadata, + type MachineContextCookiePayload, +} from "@/server/machines/context" + +// Força runtime Node.js para leitura consistente de cookies de sessão +export const runtime = "nodejs" + +export async function GET(request: NextRequest) { + const session = await assertAuthenticatedSession() + if (!session || session.user?.role !== "machine") { + return NextResponse.json({ error: "Sessão de dispositivo não encontrada." }, { status: 403 }) + } + + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + throw error + } + + const cookieStore = await cookies() + const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value ?? null + + const decoded = parseMachineCookie(cookieValue) + let machineId: Id<"machines"> | null = decoded?.machineId ? (decoded.machineId as Id<"machines">) : null + + if (!machineId) { + try { + const lookup = (await client.query(api.devices.findByAuthEmail, { + authEmail: session.user.email.toLowerCase(), + })) as { id: string } | null + + if (!lookup?.id) { + return NextResponse.json({ error: "Dispositivo não vinculada à sessão atual." }, { status: 404 }) + } + + machineId = lookup.id as Id<"machines"> + } catch (error) { + console.error("[machines.session] Falha ao localizar dispositivo por e-mail", error) + return NextResponse.json({ error: "Não foi possível localizar a dispositivo." }, { status: 500 }) + } + } + + try { + let context = (await client.query(api.devices.getContext, { + machineId, + })) as { + id: string + tenantId: string + companyId: string | null + companySlug: string | null + persona: string | null + assignedUserId: string | null + assignedUserEmail: string | null + assignedUserName: string | null + assignedUserRole: string | null + metadata: Record | null + authEmail: string | null + } + + const metadataCollaborator: CollaboratorMetadata | null = extractCollaboratorFromMetadata(context.metadata) + + let ensuredAssignedUserId = context.assignedUserId + let ensuredAssignedUserEmail = context.assignedUserEmail ?? metadataCollaborator?.email ?? null + let ensuredAssignedUserName = context.assignedUserName ?? metadataCollaborator?.name ?? null + let ensuredAssignedUserRole = + context.assignedUserRole ?? + (metadataCollaborator?.role ? metadataCollaborator.role.toUpperCase() : null) + let ensuredPersona = context.persona ?? metadataCollaborator?.role ?? null + + if (!ensuredAssignedUserId && ensuredAssignedUserEmail) { + try { + const personaRoleCandidate = ensuredPersona ?? ensuredAssignedUserRole ?? "collaborator" + const personaRole = personaRoleCandidate.toString().toLowerCase() + const normalizedPersona = personaRole === "manager" ? "manager" : "collaborator" + const assignedRole = normalizedPersona === "manager" ? "MANAGER" : "COLLABORATOR" + + const ensuredUser = (await client.mutation(api.users.ensureUser, { + tenantId: context.tenantId ?? DEFAULT_TENANT_ID, + email: ensuredAssignedUserEmail, + name: ensuredAssignedUserName ?? ensuredAssignedUserEmail, + role: normalizedPersona.toUpperCase(), + companyId: context.companyId ? (context.companyId as Id<"companies">) : undefined, + })) as { + _id?: Id<"users"> + name?: string | null + role?: string | null + } | null + + if (ensuredUser?._id) { + ensuredAssignedUserId = ensuredUser._id as string + ensuredAssignedUserName = ensuredUser.name ?? ensuredAssignedUserName ?? ensuredAssignedUserEmail + ensuredAssignedUserRole = ensuredUser.role ?? ensuredAssignedUserRole ?? assignedRole + ensuredPersona = normalizedPersona + + await client.mutation(api.devices.updatePersona, { + machineId: machineId as Id<"machines">, + persona: normalizedPersona, + assignedUserId: ensuredUser._id as Id<"users">, + assignedUserEmail: ensuredAssignedUserEmail, + assignedUserName: ensuredAssignedUserName ?? undefined, + assignedUserRole: (ensuredAssignedUserRole ?? assignedRole).toUpperCase(), + }) + + context = (await client.query(api.devices.getContext, { + machineId, + })) as typeof context + + ensuredAssignedUserId = context.assignedUserId ?? ensuredAssignedUserId + ensuredAssignedUserEmail = context.assignedUserEmail ?? ensuredAssignedUserEmail + ensuredAssignedUserName = context.assignedUserName ?? ensuredAssignedUserName + ensuredAssignedUserRole = context.assignedUserRole ?? ensuredAssignedUserRole + ensuredPersona = context.persona ?? ensuredPersona + } + } catch (error) { + console.error("[machines.session] Falha ao garantir usuário vinculado", error) + } + } + + const resolvedPersona = + context.persona ?? + ensuredPersona ?? + (ensuredAssignedUserRole ? ensuredAssignedUserRole.toLowerCase() : null) + + const responsePayload: MachineContextCookiePayload = { + machineId: context.id, + persona: resolvedPersona ?? null, + assignedUserId: ensuredAssignedUserId ?? null, + assignedUserEmail: ensuredAssignedUserEmail ?? null, + assignedUserName: ensuredAssignedUserName ?? null, + assignedUserRole: ensuredAssignedUserRole ?? null, + } + + const response = NextResponse.json({ + machine: { + ...context, + persona: resolvedPersona, + assignedUserEmail: ensuredAssignedUserEmail ?? null, + assignedUserId: ensuredAssignedUserId ?? null, + assignedUserName: ensuredAssignedUserName ?? null, + assignedUserRole: ensuredAssignedUserRole ?? null, + }, + cookie: responsePayload, + }) + + const isSecure = request.nextUrl.protocol === "https:" + response.cookies.set({ + name: MACHINE_CTX_COOKIE, + value: serializeMachineCookie(responsePayload), + httpOnly: true, + sameSite: "lax", + secure: isSecure, + path: "/", + maxAge: 60 * 60 * 24 * 30, + }) + + return response + } catch (error) { + console.error("[machines.session] Falha ao obter contexto da dispositivo", error) + return NextResponse.json({ error: "Falha ao obter contexto da dispositivo." }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/machines/sessions/route.ts b/referência/sistema-de-chamados-main/src/app/api/machines/sessions/route.ts new file mode 100644 index 0000000..a7c19c7 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/machines/sessions/route.ts @@ -0,0 +1,139 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { createMachineSession, MachineInactiveError } from "@/server/machines-session" +import { applyCorsHeaders, createCorsPreflight, jsonWithCors } from "@/server/cors" +import { + MACHINE_CTX_COOKIE, + serializeMachineCookie, + type MachineContextCookiePayload, +} from "@/server/machines/context" + +const sessionSchema = z.object({ + machineToken: z.string().min(1), + rememberMe: z.boolean().optional(), +}) + +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + +export async function POST(request: Request) { + const origin = request.headers.get("origin") + if (request.method !== "POST") { + return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS) + } + + let payload + try { + const raw = await request.json() + payload = sessionSchema.parse(raw) + } catch (error) { + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + request.headers.get("origin"), + CORS_METHODS + ) + } + + try { + const session = await createMachineSession(payload.machineToken, payload.rememberMe ?? true) + const response = NextResponse.json( + { + ok: true, + machine: session.machine, + session: session.response, + }, + { status: 200 } + ) + + // Propaga cookies de sessão do Better Auth com segurança. + // Em alguns ambientes, múltiplos Set-Cookie são colapsados; tentamos cobrir ambos. + const headersAny = session.headers as unknown as { getSetCookie?: () => string[] } + const setCookies: string[] = [] + try { + if (typeof headersAny?.getSetCookie === "function") { + setCookies.push(...(headersAny.getSetCookie() ?? [])) + } else { + const single = session.headers.get("set-cookie") + if (single) setCookies.push(single) + } + } catch { + const single = session.headers.get("set-cookie") + if (single) setCookies.push(single) + } + + // Converte os Set-Cookie recebidos em cookies do Next (maior compatibilidade) + const toPairs = (raw: string) => { + const [nameValue, ...attrs] = raw.split(/;\s*/) + const [name, ...v] = nameValue.split("=") + const value = v.join("=") + const record: Record = { name, value } + for (const attr of attrs) { + const [k, ...vv] = attr.split("=") + const key = k.toLowerCase() + const val = vv.join("=") + if (!val && (key === "httponly" || key === "secure")) { + record[key] = true + } else if (val) { + record[key] = val + } + } + return record + } + for (const raw of setCookies) { + const rec = toPairs(raw) + const name = String(rec.name) + const value = String(rec.value) + const options: Parameters[2] = { + httpOnly: Boolean(rec["httponly"]) || /httponly/i.test(raw), + secure: /;\s*secure/i.test(raw), + path: typeof rec["path"] === "string" ? (rec["path"] as string) : "/", + } + if (typeof rec["samesite"] === "string") { + const s = String(rec["samesite"]).toLowerCase() as "lax" | "strict" | "none" + options.sameSite = s + } + if (typeof rec["domain"] === "string") options.domain = rec["domain"] as string + if (typeof rec["expires"] === "string") options.expires = new Date(rec["expires"] as string) + if (typeof rec["max-age"] === "string") options.maxAge = Number(rec["max-age"]) + response.cookies.set(name, value, options) + } + + const machineCookiePayload: MachineContextCookiePayload = { + machineId: session.machine.id, + persona: session.machine.persona ?? null, + assignedUserId: session.machine.assignedUserId ?? null, + assignedUserEmail: session.machine.assignedUserEmail ?? null, + assignedUserName: session.machine.assignedUserName ?? null, + assignedUserRole: session.machine.assignedUserRole ?? null, + } + const isSecure = new URL(request.url).protocol === "https:" + response.cookies.set({ + name: MACHINE_CTX_COOKIE, + value: serializeMachineCookie(machineCookiePayload), + httpOnly: true, + sameSite: "lax", + secure: isSecure, + path: "/", + maxAge: 60 * 60 * 24 * 30, + }) + + applyCorsHeaders(response, request.headers.get("origin"), CORS_METHODS) + + return response + } catch (error) { + if (error instanceof MachineInactiveError) { + return jsonWithCors( + { error: "Dispositivo desativada. Entre em contato com o suporte da Rever para reativar o acesso." }, + 423, + origin, + CORS_METHODS + ) + } + console.error("[machines.sessions] Falha ao criar sessão", error) + return jsonWithCors({ error: "Falha ao autenticar dispositivo" }, 500, origin, CORS_METHODS) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/portal/profile/route.ts b/referência/sistema-de-chamados-main/src/app/api/portal/profile/route.ts new file mode 100644 index 0000000..3e554a9 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/portal/profile/route.ts @@ -0,0 +1,185 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { hashPassword } from "better-auth/crypto" +import { ConvexHttpClient } from "convex/browser" + +import { prisma } from "@/lib/prisma" +import { requireAuthenticatedSession } from "@/lib/auth-server" +import { env } from "@/lib/env" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { api } from "@/convex/_generated/api" +import { ensureCollaboratorAccount } from "@/server/machines-auth" + +const updateSchema = z.object({ + email: z.string().email().optional(), + password: z + .object({ + newPassword: z.string().min(8, "A nova senha deve ter pelo menos 8 caracteres."), + confirmPassword: z.string().min(8), + }) + .optional(), +}) + +export async function PATCH(request: Request) { + const session = await requireAuthenticatedSession() + const normalizedRole = (session.user.role ?? "").toLowerCase() + const persona = (session.user.machinePersona ?? "").toLowerCase() + const allowedRoles = new Set(["collaborator", "manager", "admin", "agent"]) + const isMachinePersonaAllowed = normalizedRole === "machine" && (persona === "collaborator" || persona === "manager") + if (!allowedRoles.has(normalizedRole) && !isMachinePersonaAllowed) { + return NextResponse.json({ error: "Acesso não autorizado" }, { status: 403 }) + } + const effectiveRole = + normalizedRole === "admin" + ? "ADMIN" + : normalizedRole === "agent" + ? "AGENT" + : normalizedRole === "manager" + ? "MANAGER" + : normalizedRole === "collaborator" + ? "COLLABORATOR" + : persona === "manager" + ? "MANAGER" + : "COLLABORATOR" + + let payload: unknown + try { + payload = await request.json() + } catch { + return NextResponse.json({ error: "Payload inválido" }, { status: 400 }) + } + + const parsed = updateSchema.safeParse(payload) + if (!parsed.success) { + return NextResponse.json({ error: "Dados inválidos", details: parsed.error.flatten() }, { status: 400 }) + } + + const { email: emailInput, password } = parsed.data + const currentEmail = session.user.email.trim().toLowerCase() + const authUserId = session.user.id + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let newEmail = emailInput?.trim().toLowerCase() + if (newEmail && newEmail === currentEmail) { + newEmail = undefined + } + + if (password && password.newPassword !== password.confirmPassword) { + return NextResponse.json({ error: "As senhas informadas não conferem." }, { status: 400 }) + } + + if (newEmail) { + const existingEmail = await prisma.authUser.findUnique({ where: { email: newEmail } }) + if (existingEmail && existingEmail.id !== authUserId) { + return NextResponse.json({ error: "Já existe um usuário com este e-mail." }, { status: 409 }) + } + } + + const domainUser = await prisma.user.findUnique({ where: { email: currentEmail } }) + const companyId = domainUser?.companyId ?? null + const name = session.user.name ?? currentEmail + + await prisma.$transaction(async (tx) => { + if (newEmail) { + await tx.authUser.update({ + where: { id: authUserId }, + data: { email: newEmail }, + }) + + const existingAccount = await tx.authAccount.findUnique({ + where: { + providerId_accountId: { + providerId: "credential", + accountId: currentEmail, + }, + }, + }) + + if (existingAccount) { + await tx.authAccount.update({ + where: { id: existingAccount.id }, + data: { accountId: newEmail }, + }) + } else { + await tx.authAccount.create({ + data: { + providerId: "credential", + accountId: newEmail, + userId: authUserId, + password: null, + }, + }) + } + + await tx.user.updateMany({ + where: { email: currentEmail }, + data: { email: newEmail }, + }) + } + + if (password) { + const hashed = await hashPassword(password.newPassword) + await tx.authAccount.upsert({ + where: { + providerId_accountId: { + providerId: "credential", + accountId: newEmail ?? currentEmail, + }, + }, + update: { + password: hashed, + userId: authUserId, + }, + create: { + providerId: "credential", + accountId: newEmail ?? currentEmail, + userId: authUserId, + password: hashed, + }, + }) + } + }) + + const effectiveEmail = newEmail ?? currentEmail + + await prisma.user.upsert({ + where: { email: effectiveEmail }, + update: { + name, + tenantId, + role: effectiveRole, + companyId: companyId ?? undefined, + }, + create: { + email: effectiveEmail, + name, + tenantId, + role: effectiveRole, + companyId: companyId ?? undefined, + }, + }) + + await ensureCollaboratorAccount({ + email: effectiveEmail, + name, + tenantId, + companyId, + role: effectiveRole, + }) + + if (env.NEXT_PUBLIC_CONVEX_URL) { + try { + const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL) + await client.mutation(api.users.ensureUser, { + tenantId, + email: effectiveEmail, + name, + role: effectiveRole, + }) + } catch (error) { + console.warn("[portal.profile] Falha ao sincronizar usuário no Convex", error) + } + } + + return NextResponse.json({ ok: true, email: effectiveEmail }) +} diff --git a/referência/sistema-de-chamados-main/src/app/api/reports/backlog.xlsx/route.ts b/referência/sistema-de-chamados-main/src/app/api/reports/backlog.xlsx/route.ts new file mode 100644 index 0000000..3b5d98e --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/reports/backlog.xlsx/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { buildXlsxWorkbook } from "@/lib/xlsx" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for backlog export", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined + const companyId = searchParams.get("companyId") ?? undefined + const report = await client.query(api.reports.backlogOverview, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + companyId: companyId as unknown as Id<"companies">, + }) + + const summaryRows: Array> = [ + ["Relatório", "Backlog"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"], + ] + if (companyId) { + summaryRows.push(["EmpresaId", companyId]) + } + summaryRows.push(["Chamados em aberto", report.totalOpen]) + + const distributionRows: Array> = [] + + const STATUS_PT: Record = { + PENDING: "Pendentes", + AWAITING_ATTENDANCE: "Em andamento", + PAUSED: "Pausados", + RESOLVED: "Resolvidos", + } + for (const [status, total] of Object.entries(report.statusCounts)) { + distributionRows.push(["Status", STATUS_PT[status] ?? status, total]) + } + + const PRIORITY_PT: Record = { + LOW: "Baixa", + MEDIUM: "Média", + HIGH: "Alta", + URGENT: "Crítica", + } + for (const [priority, total] of Object.entries(report.priorityCounts)) { + distributionRows.push(["Prioridade", PRIORITY_PT[priority] ?? priority, total]) + } + + for (const q of report.queueCounts) { + distributionRows.push(["Fila", q.name || q.id, q.total]) + } + + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Item", "Valor"], + rows: summaryRows, + }, + { + name: "Distribuições", + headers: ["Categoria", "Chave", "Total"], + rows: distributionRows, + }, + ]) + + const body = new Uint8Array(workbook) + + return new NextResponse(body, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="backlog-${tenantId}-${report.rangeDays ?? 'all'}d.xlsx"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate backlog export", error) + return NextResponse.json({ error: "Falha ao gerar planilha do backlog" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/reports/csat.xlsx/route.ts b/referência/sistema-de-chamados-main/src/app/api/reports/csat.xlsx/route.ts new file mode 100644 index 0000000..0ca8672 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/reports/csat.xlsx/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { buildXlsxWorkbook } from "@/lib/xlsx" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined + const companyId = searchParams.get("companyId") ?? undefined + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for CSAT export", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const report = await client.query(api.reports.csatOverview, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + companyId: companyId as unknown as Id<"companies">, + }) + + const summaryRows: Array> = [ + ["Relatório", "CSAT"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : range ?? "90d"], + ] + if (companyId) { + summaryRows.push(["EmpresaId", companyId]) + } + summaryRows.push(["CSAT médio", report.averageScore ?? "—"]) + summaryRows.push(["Total de respostas", report.totalSurveys ?? 0]) + + const distributionRows: Array> = (report.distribution ?? []).map((entry) => [ + entry.score, + entry.total, + ]) + + const recentRows: Array> = (report.recent ?? []).map((item) => [ + `#${item.reference}`, + item.score, + new Date(item.receivedAt).toISOString(), + ]) + + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Métrica", "Valor"], + rows: summaryRows, + }, + { + name: "Distribuição", + headers: ["Nota", "Total"], + rows: distributionRows.length > 0 ? distributionRows : [["—", 0]], + }, + { + name: "Respostas recentes", + headers: ["Ticket", "Nota", "Recebido em"], + rows: recentRows.length > 0 ? recentRows : [["—", "—", "—"]], + }, + ]) + + const body = new Uint8Array(workbook) + + return new NextResponse(body, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="csat-${tenantId}-${report.rangeDays ?? '90'}d.xlsx"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate CSAT export", error) + return NextResponse.json({ error: "Falha ao gerar planilha de CSAT" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/reports/hours-by-client.xlsx/route.ts b/referência/sistema-de-chamados-main/src/app/api/reports/hours-by-client.xlsx/route.ts new file mode 100644 index 0000000..68b1683 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/reports/hours-by-client.xlsx/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { buildXlsxWorkbook } from "@/lib/xlsx" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined + const q = searchParams.get("q")?.toLowerCase().trim() ?? "" + const companyId = searchParams.get("companyId") ?? "" + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch { + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + if (!viewerId) return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + + try { + const report = await client.query(api.reports.hoursByClient, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + }) + + const summaryRows: Array> = [ + ["Relatório", "Horas por cliente"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : range ?? "90d"], + ] + if (q) summaryRows.push(["Filtro", q]) + if (companyId) summaryRows.push(["EmpresaId", companyId]) + summaryRows.push(["Total de clientes", (report.items as Array).length]) + + type Item = { companyId: string; name: string; isAvulso: boolean; internalMs: number; externalMs: number; totalMs: number; contractedHoursPerMonth: number | null } + let items = (report.items as Item[]) + if (companyId) items = items.filter((i) => String(i.companyId) === companyId) + if (q) items = items.filter((i) => i.name.toLowerCase().includes(q)) + + const dataRows = items.map((item) => { + const internalHours = item.internalMs / 3600000 + const externalHours = item.externalMs / 3600000 + const totalHours = item.totalMs / 3600000 + const contracted = item.contractedHoursPerMonth + const usagePct = contracted ? (totalHours / contracted) * 100 : null + return [ + item.name, + item.isAvulso ? "Sim" : "Não", + Number(internalHours.toFixed(2)), + Number(externalHours.toFixed(2)), + Number(totalHours.toFixed(2)), + contracted ?? null, + usagePct !== null ? Number(usagePct.toFixed(1)) : null, + ] + }) + + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Item", "Valor"], + rows: summaryRows, + }, + { + name: "Clientes", + headers: ["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"], + rows: dataRows.length > 0 ? dataRows : [["—", "—", 0, 0, 0, null, null]], + }, + ]) + + const body = new Uint8Array(workbook) + + return new NextResponse(body, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d${companyId ? `-${companyId}` : ''}${q ? `-${encodeURIComponent(q)}` : ''}.xlsx"`, + "Cache-Control": "no-store", + }, + }) + } catch { + return NextResponse.json({ error: "Falha ao gerar planilha de horas por cliente" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/reports/machines-inventory.xlsx/route.ts b/referência/sistema-de-chamados-main/src/app/api/reports/machines-inventory.xlsx/route.ts new file mode 100644 index 0000000..1feda15 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/reports/machines-inventory.xlsx/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export" +import type { DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const companyId = searchParams.get("companyId") ?? undefined + const machineIdParams = searchParams.getAll("machineId").filter(Boolean) + const machineIdFilter = machineIdParams.length > 0 ? new Set(machineIdParams) : null + const columnsParam = searchParams.get("columns") + let columnConfig: DeviceInventoryColumnConfig[] | undefined + if (columnsParam) { + try { + const parsed = JSON.parse(columnsParam) + if (Array.isArray(parsed)) { + columnConfig = parsed + .map((item) => { + if (typeof item === "string") { + return { key: item } + } + if (item && typeof item === "object" && typeof item.key === "string") { + return { + key: item.key, + label: typeof item.label === "string" && item.label.length > 0 ? item.label : undefined, + } + } + return null + }) + .filter((item): item is DeviceInventoryColumnConfig => item !== null) + } + } catch (error) { + console.warn("Invalid columns parameter for machines export", error) + } + } + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for machines export", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const machines = (await client.query(api.devices.listByTenant, { + tenantId, + includeMetadata: true, + })) as MachineInventoryRecord[] + + const filtered = machines.filter((machine) => { + if (companyId) { + const companyMatches = String(machine.companyId ?? "") === companyId || (machine.companySlug ?? "") === companyId + if (!companyMatches) { + return false + } + } + if (machineIdFilter && !machineIdFilter.has(String(machine.id))) { + return false + } + return true + }) + const companyFilterLabel = (() => { + if (!companyId) return null + const matchById = filtered.find((machine) => machine.companyId && String(machine.companyId) === companyId) + if (matchById?.companyName) return matchById.companyName + const matchBySlug = filtered.find((machine) => machine.companySlug === companyId) + if (matchBySlug?.companyName) return matchBySlug.companyName + return companyId + })() + + const workbook = buildMachinesInventoryWorkbook(filtered, { + tenantId, + generatedBy: session.user.name ?? session.user.email, + companyFilterLabel, + generatedAt: new Date(), + columns: columnConfig, + }) + + const body = new Uint8Array(workbook) + + return new NextResponse(body, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="machines-inventory-${tenantId}${companyId ? `-${companyId}` : ""}.xlsx"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate machines inventory export", error) + return NextResponse.json({ error: "Falha ao gerar planilha de inventário" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/reports/sla.xlsx/route.ts b/referência/sistema-de-chamados-main/src/app/api/reports/sla.xlsx/route.ts new file mode 100644 index 0000000..ebbf2c3 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/reports/sla.xlsx/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { buildXlsxWorkbook } from "@/lib/xlsx" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined + const companyId = searchParams.get("companyId") ?? undefined + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for SLA export", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const report = await client.query(api.reports.slaOverview, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + companyId: companyId as unknown as Id<"companies">, + }) + + const summaryRows: Array> = [ + ["Relatório", "Produtividade"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")], + ] + if (companyId) { + summaryRows.push(["EmpresaId", companyId]) + } + summaryRows.push(["Tickets totais", report.totals.total]) + summaryRows.push(["Tickets abertos", report.totals.open]) + summaryRows.push(["Tickets resolvidos", report.totals.resolved]) + summaryRows.push(["Atrasados (SLA)", report.totals.overdue]) + summaryRows.push(["Tempo médio de 1ª resposta (min)", report.response.averageFirstResponseMinutes ?? "—"]) + summaryRows.push(["Respostas registradas", report.response.responsesRegistered ?? 0]) + summaryRows.push(["Tempo médio de resolução (min)", report.resolution.averageResolutionMinutes ?? "—"]) + summaryRows.push(["Tickets resolvidos (amostra)", report.resolution.resolvedCount ?? 0]) + + const queueRows: Array> = [] + for (const queue of report.queueBreakdown ?? []) { + queueRows.push([queue.name || queue.id, queue.open]) + } + + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Indicador", "Valor"], + rows: summaryRows, + }, + { + name: "Filas", + headers: ["Fila", "Chamados abertos"], + rows: queueRows.length > 0 ? queueRows : [["—", 0]], + }, + ]) + + const daysLabel = (() => { + const raw = (range ?? "90d").replace("d", "") + return /^(7|30|90)$/.test(raw) ? `${raw}d` : "all" + })() + const body = new Uint8Array(workbook) + + return new NextResponse(body, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="sla-${tenantId}-${daysLabel}.xlsx"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate SLA export", error) + return NextResponse.json({ error: "Falha ao gerar planilha de SLA" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/reports/tickets-by-channel.xlsx/route.ts b/referência/sistema-de-chamados-main/src/app/api/reports/tickets-by-channel.xlsx/route.ts new file mode 100644 index 0000000..33fc1db --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/reports/tickets-by-channel.xlsx/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { buildXlsxWorkbook } from "@/lib/xlsx" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined // "7d" | "30d" | undefined(=90d) + const companyId = searchParams.get("companyId") ?? undefined + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for channel export", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const report = await client.query(api.reports.ticketsByChannel, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + companyId: companyId as unknown as Id<"companies">, + }) + + const channels = report.channels + const CHANNEL_PT: Record = { + EMAIL: "E-mail", + PHONE: "Telefone", + CHAT: "Chat", + WHATSAPP: "WhatsApp", + API: "API", + MANUAL: "Manual", + WEB: "Portal", + PORTAL: "Portal", + } + const summaryRows: Array> = [ + ["Relatório", "Tickets por canal"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : range ?? "90d"], + ] + if (companyId) summaryRows.push(["EmpresaId", companyId]) + summaryRows.push(["Total de linhas", report.points.length]) + + const header = ["Data", ...channels.map((ch) => CHANNEL_PT[ch] ?? ch)] + const dataRows: Array> = report.points.map((point) => { + const values = channels.map((ch) => point.values[ch] ?? 0) + return [point.date, ...values] + }) + + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Item", "Valor"], + rows: summaryRows, + }, + { + name: "Distribuição", + headers: header, + rows: dataRows.length > 0 ? dataRows : [[new Date().toISOString().slice(0, 10), ...channels.map(() => 0)]], + }, + ]) + + const body = new Uint8Array(workbook) + + return new NextResponse(body, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}${companyId ? `-${companyId}` : ''}.xlsx"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate tickets-by-channel export", error) + return NextResponse.json({ error: "Falha ao gerar planilha de tickets por canal" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/tickets/[id]/export/pdf/route.ts b/referência/sistema-de-chamados-main/src/app/api/tickets/[id]/export/pdf/route.ts new file mode 100644 index 0000000..a774af0 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/tickets/[id]/export/pdf/route.ts @@ -0,0 +1,95 @@ +import path from "path" +import fs from "fs" +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { renderTicketPdfBuffer } from "@/server/pdf/ticket-pdf-template" + +export const runtime = "nodejs" + +async function readLogoAsDataUrl() { + const logoPath = path.join(process.cwd(), "public", "logo-raven.png") + try { + const buffer = await fs.promises.readFile(logoPath) + const base64 = buffer.toString("base64") + return `data:image/png;base64,${base64}` + } catch (error) { + console.warn("[tickets.export.pdf] Logo não encontrado, seguindo sem imagem", error) + return null + } +} + +export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { + const { id: ticketId } = await context.params + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("[tickets.export.pdf] Falha ao sincronizar usuário no Convex", error) + return NextResponse.json({ error: "Não foi possível preparar a exportação" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + let ticketRaw: unknown + try { + ticketRaw = await client.query(api.tickets.getById, { + tenantId, + id: ticketId as unknown as Id<"tickets">, + viewerId: viewerId as unknown as Id<"users">, + }) + } catch (error) { + console.error("[tickets.export.pdf] Falha ao carregar ticket", error, { tenantId, ticketId, viewerId }) + return NextResponse.json({ error: "Não foi possível carregar o ticket" }, { status: 500 }) + } + + if (!ticketRaw) { + return NextResponse.json({ error: "Ticket não encontrado" }, { status: 404 }) + } + + const ticket = mapTicketWithDetailsFromServer(ticketRaw) + + const logoDataUrl = await readLogoAsDataUrl() + try { + const pdfBuffer = await renderTicketPdfBuffer({ ticket, logoDataUrl }) + const payload = pdfBuffer instanceof Uint8Array ? pdfBuffer : new Uint8Array(pdfBuffer) + return new NextResponse(payload as unknown as BodyInit, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("[tickets.export.pdf] Falha ao renderizar PDF", error) + return NextResponse.json({ error: "Não foi possível gerar o PDF" }, { status: 500 }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/api/tickets/mentions/route.ts b/referência/sistema-de-chamados-main/src/app/api/tickets/mentions/route.ts new file mode 100644 index 0000000..3285a92 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/api/tickets/mentions/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" + +export const runtime = "nodejs" + +const MAX_RESULTS = 10 + +type TicketRecord = { + id: string | number + reference: string | number + subject?: string | null + status?: string | null + priority?: string | null + requester?: { name?: string | null } | null + assignee?: { name?: string | null } | null + company?: { name?: string | null } | null + updatedAt?: number | null +} + +function isTicketRecord(value: unknown): value is TicketRecord { + if (!value || typeof value !== "object") { + return false + } + const record = value as Record + const hasValidId = typeof record.id === "string" || typeof record.id === "number" + const hasValidReference = typeof record.reference === "string" || typeof record.reference === "number" + return hasValidId && hasValidReference +} + +function normalizeRole(role?: string | null) { + return (role ?? "").toLowerCase() +} + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ items: [] }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ items: [] }, { status: 500 }) + } + + const normalizedRole = normalizeRole(session.user.role) + const isAgentOrAdmin = normalizedRole === "admin" || normalizedRole === "agent" + const canLinkOwnTickets = normalizedRole === "collaborator" + if (!isAgentOrAdmin && !canLinkOwnTickets) { + return NextResponse.json({ items: [] }, { status: 403 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + const url = new URL(request.url) + const rawQuery = url.searchParams.get("q") ?? "" + const query = rawQuery.trim() + + const client = new ConvexHttpClient(convexUrl) + + // Garantir que o usuário exista no Convex para obter viewerId + let viewerId: string | null = null + try { + const ensured = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensured?._id ?? null + } catch (error) { + console.error("[mentions] ensureUser failed", error) + return NextResponse.json({ items: [] }, { status: 500 }) + } + if (!viewerId) { + return NextResponse.json({ items: [] }, { status: 403 }) + } + + // Pesquisar pelos tickets visíveis ao viewer (assunto, resumo ou #referência) + let tickets: TicketRecord[] = [] + try { + const res = await client.query(api.tickets.list, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + search: query, + limit: 40, + }) + tickets = Array.isArray(res) ? res.filter(isTicketRecord) : [] + } catch (error) { + console.error("[mentions] tickets.list failed", error) + return NextResponse.json({ items: [] }, { status: 500 }) + } + + const basePath = isAgentOrAdmin ? "/tickets" : "/portal/tickets" + const items = tickets.slice(0, MAX_RESULTS).map((t) => ({ + id: String(t.id), + reference: Number(t.reference), + subject: String(t.subject ?? ""), + status: String(t.status ?? "PENDING"), + priority: String(t.priority ?? "MEDIUM"), + requesterName: t.requester?.name ?? null, + assigneeName: t.assignee?.name ?? null, + companyName: t.company?.name ?? null, + url: `${basePath}/${String(t.id)}`, + updatedAt: t.updatedAt ? new Date(t.updatedAt).toISOString() : new Date().toISOString(), + })) + + return NextResponse.json({ items }) +} diff --git a/referência/sistema-de-chamados-main/src/app/dashboard/page.tsx b/referência/sistema-de-chamados-main/src/app/dashboard/page.tsx new file mode 100644 index 0000000..c4800cb --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/dashboard/page.tsx @@ -0,0 +1,38 @@ +import { AppShell } from "@/components/app-shell" +import { SectionCards } from "@/components/section-cards" +import { SiteHeader } from "@/components/site-header" +import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel" +import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" +import { ChartOpenedResolved } from "@/components/charts/chart-opened-resolved" +import { ChartOpenByPriority } from "@/components/charts/chart-open-priority" +import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export default async function Dashboard() { + // Garante redirecionamento para /login quando sem sessão (aba anônima, etc.) + await requireAuthenticatedSession() + return ( + } + /> + } + > + +
+
+ + +
+ +
+
+ +
+
+ ) +} + diff --git a/referência/sistema-de-chamados-main/src/app/dashboards/[id]/page.tsx b/referência/sistema-de-chamados-main/src/app/dashboards/[id]/page.tsx new file mode 100644 index 0000000..89dd1bd --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/dashboards/[id]/page.tsx @@ -0,0 +1,43 @@ +import { AppShell } from "@/components/app-shell" +import { DashboardBuilder } from "@/components/dashboards/dashboard-builder" +import { SiteHeader } from "@/components/site-header" +import { requireStaffSession } from "@/lib/auth-server" + +type PageProps = { + params?: Promise<{ id: string }> + searchParams?: Promise<{ [key: string]: string | string[] | undefined }> +} + +export const dynamic = "force-dynamic" + +export default async function DashboardDetailPage({ params, searchParams }: PageProps) { + if (!params) { + throw new Error("Dashboard id not provided") + } + + const { id } = await params + const resolvedSearchParams = + (searchParams ? await searchParams : {}) as Record + + await requireStaffSession() + const tvMode = resolvedSearchParams?.tv === "1" + + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/dashboards/[id]/print/page.tsx b/referência/sistema-de-chamados-main/src/app/dashboards/[id]/print/page.tsx new file mode 100644 index 0000000..e408b09 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/dashboards/[id]/print/page.tsx @@ -0,0 +1,23 @@ +import { DashboardBuilder } from "@/components/dashboards/dashboard-builder" +import { requireStaffSession } from "@/lib/auth-server" + +type PageProps = { + params?: Promise<{ id: string }> +} + +export const dynamic = "force-dynamic" + +export default async function DashboardPrintPage({ params }: PageProps) { + if (!params) { + throw new Error("Dashboard id not provided") + } + + const { id } = await params + + await requireStaffSession() + return ( +
+ +
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/dashboards/page.tsx b/referência/sistema-de-chamados-main/src/app/dashboards/page.tsx new file mode 100644 index 0000000..fdec6e9 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/dashboards/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell" +import { DashboardListView } from "@/components/dashboards/dashboard-list" +import { SiteHeader } from "@/components/site-header" +import { requireStaffSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function DashboardsPage() { + await requireStaffSession() + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/dev/seed/page.tsx b/referência/sistema-de-chamados-main/src/app/dev/seed/page.tsx new file mode 100644 index 0000000..168e314 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/dev/seed/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "convex/react"; +import { api } from "@/convex/_generated/api"; + +export default function SeedPage() { + const seed = useMutation(api.seed.seedDemo); + const [done, setDone] = useState(false); + return ( +
+
+

Popular dados de demonstração

+ + {done &&

Ok! Abra a página de Tickets.

} +
+
+ ); +} diff --git a/referência/sistema-de-chamados-main/src/app/favicon.ico b/referência/sistema-de-chamados-main/src/app/favicon.ico new file mode 100644 index 0000000..b56f21d Binary files /dev/null and b/referência/sistema-de-chamados-main/src/app/favicon.ico differ diff --git a/referência/sistema-de-chamados-main/src/app/globals.css b/referência/sistema-de-chamados-main/src/app/globals.css new file mode 100644 index 0000000..9677c57 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/globals.css @@ -0,0 +1,266 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@font-face { + font-family: "InterVariable"; + src: url("/fonts/Inter-VariableFont_opsz,wght.ttf") format("truetype"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "InterVariable"; + src: url("/fonts/Inter-Italic-VariableFont_opsz,wght.ttf") format("truetype"); + font-weight: 100 900; + font-style: italic; + font-display: swap; +} + +/* JetBrains Mono agora depende das fontes do sistema para evitar carregar um arquivo corrompido */ + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-destructive-foreground: var(--destructive-foreground); + --font-geist-mono: var(----font-geist-mono); + --font-geist-sans: var(----font-geist-sans); + --radius: var(----radius); +} + +:root { + --radius: 0.75rem; + --background: #f7f8fb; + --foreground: #0f172a; + --card: #ffffff; + --card-foreground: #0f172a; + --popover: #ffffff; + --popover-foreground: #0f172a; + --primary: #00e8ff; + --primary-foreground: #020617; + --secondary: #0f172a; + --secondary-foreground: #f8fafc; + --muted: #e2e8f0; + --muted-foreground: #475569; + --accent: #dff7fb; + --accent-foreground: #0f172a; + --destructive: #ef4444; + --border: #d6d8de; + --input: #e4e7ec; + --ring: #00d6eb; + --chart-1: #00d6eb; + --chart-2: #0891b2; + --chart-3: #0e7490; + --chart-4: #155e75; + --chart-5: #0f4c5c; + --sidebar: #f2f5f7; + --sidebar-foreground: #0f172a; + --sidebar-primary: #00e8ff; + --sidebar-primary-foreground: #020617; + --sidebar-accent: #c4eef6; + --sidebar-accent-foreground: #0f172a; + --sidebar-border: #cbd5e1; + --sidebar-ring: #00d6eb; + --destructive-foreground: oklch(1 0 0); + --font-geist-sans: "InterVariable", "Inter", sans-serif; + --font-geist-mono: "JetBrains Mono", "Fira Code", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.dark { + --background: #020617; + --foreground: #f8fafc; + --card: #0b1120; + --card-foreground: #f8fafc; + --popover: #0b1120; + --popover-foreground: #f8fafc; + --primary: #00d6eb; + --primary-foreground: #041019; + --secondary: #1f2937; + --secondary-foreground: #f9fafb; + --muted: #1e293b; + --muted-foreground: #cbd5f5; + --accent: #083344; + --accent-foreground: #f1f5f9; + --destructive: #f87171; + --border: #1f2933; + --input: #1e2933; + --ring: #00e6ff; + --chart-1: #00e6ff; + --chart-2: #0891b2; + --chart-3: #0e7490; + --chart-4: #155e75; + --chart-5: #0f4c5c; + --sidebar: #050c16; + --sidebar-foreground: #f8fafc; + --sidebar-primary: #00d6eb; + --sidebar-primary-foreground: #041019; + --sidebar-accent: #083344; + --sidebar-accent-foreground: #f8fafc; + --sidebar-border: #0f1b2a; + --sidebar-ring: #00e6ff; + --destructive-foreground: oklch(1 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground font-sans antialiased; + } +} + +@layer components { + /* Tippy.js (menções do editor) */ + .tippy-box { + @apply rounded-lg border border-slate-200 bg-white shadow-lg; + } + .tippy-content { @apply p-0; } + /* Tipografia básica para conteúdos rich text (Tiptap) */ + .rich-text { + @apply text-foreground; + } + .rich-text p { @apply my-2; } + .rich-text a { @apply text-neutral-900 underline; } + .rich-text [data-ticket-mention="true"], + .rich-text .ProseMirror [data-ticket-mention="true"], + .rich-text .ticket-mention, + .rich-text .ProseMirror .ticket-mention { + @apply inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200; + } + /* Fallback for legacy editing markup before the custom node view hydrates. */ + .rich-text .ProseMirror [data-type="ticketMention"]:not([data-ticket-mention="true"]) { + @apply inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 align-middle; + position: relative; + } + .rich-text .ProseMirror [data-type="ticketMention"]:not([data-ticket-mention="true"])::before { + content: ""; + @apply inline-block size-2 rounded-full bg-slate-400; + margin-right: 0.375rem; /* ~gap-1.5 */ + } + .rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="PENDING"]:not([data-ticket-mention="true"])::before { @apply bg-amber-400; } + .rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="AWAITING_ATTENDANCE"]:not([data-ticket-mention="true"])::before { @apply bg-sky-500; } + .rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="PAUSED"]:not([data-ticket-mention="true"])::before { @apply bg-violet-500; } + .rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="RESOLVED"]:not([data-ticket-mention="true"])::before { @apply bg-emerald-500; } + .rich-text [data-ticket-mention="true"] .ticket-mention-dot, + .rich-text .ticket-mention .ticket-mention-dot { + @apply inline-flex size-2 rounded-full bg-slate-400; + } + .rich-text [data-ticket-mention="true"] .ticket-mention-ref, + .rich-text .ticket-mention .ticket-mention-ref { + @apply text-neutral-900; + } + .rich-text [data-ticket-mention="true"] .ticket-mention-sep, + .rich-text .ticket-mention .ticket-mention-sep { + @apply text-neutral-400; + } + .rich-text [data-ticket-mention="true"] .ticket-mention-subject, + .rich-text .ticket-mention .ticket-mention-subject { + @apply max-w-[220px] truncate text-neutral-700; + } + .rich-text [data-ticket-mention="true"][data-ticket-status="PENDING"] .ticket-mention-dot, + .rich-text .ticket-mention[data-ticket-status="PENDING"] .ticket-mention-dot { + @apply bg-amber-400; + } + .rich-text [data-ticket-mention="true"][data-ticket-status="AWAITING_ATTENDANCE"] .ticket-mention-dot, + .rich-text .ticket-mention[data-ticket-status="AWAITING_ATTENDANCE"] .ticket-mention-dot { + @apply bg-sky-500; + } + .rich-text [data-ticket-mention="true"][data-ticket-status="PAUSED"] .ticket-mention-dot, + .rich-text .ticket-mention[data-ticket-status="PAUSED"] .ticket-mention-dot { + @apply bg-violet-500; + } + .rich-text [data-ticket-mention="true"][data-ticket-status="RESOLVED"] .ticket-mention-dot, + .rich-text .ticket-mention[data-ticket-status="RESOLVED"] .ticket-mention-dot { + @apply bg-emerald-500; + } + .rich-text ul { @apply my-2 list-disc ps-5; } + .rich-text ol { @apply my-2 list-decimal ps-5; } + .rich-text li { @apply my-1; } + .rich-text blockquote { @apply my-3 border-l-2 border-muted-foreground/30 ps-3 text-muted-foreground; } + .rich-text h1 { @apply text-xl font-semibold my-3; } + .rich-text h2 { @apply text-lg font-semibold my-3; } + .rich-text h3 { @apply text-base font-semibold my-2; } + .rich-text code { @apply rounded bg-muted px-1 py-0.5 text-xs; } + .rich-text pre { @apply my-3 overflow-x-auto rounded bg-muted p-3 text-xs; } + + .rich-text .ProseMirror.is-editor-empty::before, + .rich-text .ProseMirror p.is-editor-empty:first-child::before { + color: #94a3b8; + content: attr(data-placeholder); + pointer-events: none; + height: 0; + float: left; + font-weight: 400; + } + + /* Calendar dropdowns */ + .rdp-caption_dropdowns select { + @apply h-8 rounded-lg border border-slate-300 bg-white px-2 text-sm font-medium text-neutral-800 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00e8ff]/20; + } + + .rdp-caption_dropdowns select:disabled { + @apply opacity-60; + } + + @keyframes recent-ticket-enter { + 0% { + opacity: 0; + transform: translateY(-12px); + } + 100% { + opacity: 1; + transform: translateY(0); + } + } + + .recent-ticket-enter { + animation: recent-ticket-enter 0.45s ease-out; + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/referência/sistema-de-chamados-main/src/app/icon.png b/referência/sistema-de-chamados-main/src/app/icon.png new file mode 100644 index 0000000..ab005ba Binary files /dev/null and b/referência/sistema-de-chamados-main/src/app/icon.png differ diff --git a/referência/sistema-de-chamados-main/src/app/invite/[token]/page.tsx b/referência/sistema-de-chamados-main/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..6f5f263 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/invite/[token]/page.tsx @@ -0,0 +1,41 @@ +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: Promise<{ token: string }> }) { + const { token } = await params + const invite = await prisma.authInvite.findUnique({ + where: { 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 ( +
+ + + Aceitar convite + + Conclua seu cadastro para acessar a plataforma Raven. + + + + + + +
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/layout.tsx b/referência/sistema-de-chamados-main/src/app/layout.tsx new file mode 100644 index 0000000..336b058 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/layout.tsx @@ -0,0 +1,42 @@ +import "@/lib/performance-measure-polyfill" +import type { Metadata } from "next" +import "./globals.css" +import { ConvexClientProvider } from "./ConvexClientProvider" +import { AuthProvider } from "@/lib/auth-client" +import { Toaster } from "@/components/ui/sonner" + +export const metadata: Metadata = { + title: "Raven - Sistema de chamados", + description: "Plataforma Raven da Rever", + icons: { + icon: [ + { url: "/favicon.ico", rel: "icon" }, + { url: "/icon.png", rel: "icon", type: "image/png" }, + ], + shortcut: "/favicon.ico", + apple: "/icon.png", + }, +} + +// Força renderização dinâmica em todo o app para evitar páginas estáticas +// cacheadas sem passar pelo middleware de autenticação. +export const dynamic = "force-dynamic" + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + + {children} + + + + + + ) +} diff --git a/referência/sistema-de-chamados-main/src/app/login/login-page-client.tsx b/referência/sistema-de-chamados-main/src/app/login/login-page-client.tsx new file mode 100644 index 0000000..f932c65 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/login/login-page-client.tsx @@ -0,0 +1,89 @@ +"use client" + +import { useEffect, useState } from "react" +import Image from "next/image" +import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" +import dynamic from "next/dynamic" + +import { LoginForm } from "@/components/login-form" +import { useSession } from "@/lib/auth-client" + +const ShaderBackground = dynamic( + () => import("@/components/background-paper-shaders-wrapper"), + { ssr: false } +) + +export function LoginPageClient() { + const router = useRouter() + const searchParams = useSearchParams() + const { data: session, isPending } = useSession() + const callbackUrl = searchParams?.get("callbackUrl") ?? undefined + const [isHydrated, setIsHydrated] = useState(false) + const sessionUser = session?.user + const userId = sessionUser?.id ?? null + const normalizedRole = sessionUser?.role ? sessionUser.role.toLowerCase() : null + const persona = typeof sessionUser?.machinePersona === "string" ? sessionUser.machinePersona.toLowerCase() : null + + useEffect(() => { + if (isPending) return + if (!userId) return + const defaultDest = + normalizedRole === "machine" + ? persona === "manager" + ? "/dashboard" + : "/portal/tickets" + : "/dashboard" + const destination = callbackUrl ?? defaultDest + router.replace(destination) + }, [ + callbackUrl, + isPending, + normalizedRole, + persona, + router, + userId, + ]) + + useEffect(() => { + setIsHydrated(true) + }, []) + + const shouldDisable = !isHydrated || isPending + + return ( +
+
+
+ +
+ Sistema de chamados + Por Rever Tecnologia +
+ +
+
+
+ +
+
+
+ Logotipo Raven +
+
+ Desenvolvido por Esdras Renan +
+
+
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/login/page.tsx b/referência/sistema-de-chamados-main/src/app/login/page.tsx new file mode 100644 index 0000000..17c43d7 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/login/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react" + +import { LoginPageClient } from "./login-page-client" + +export default function LoginPage() { + return ( + Carregando…}> + + + ) +} diff --git a/referência/sistema-de-chamados-main/src/app/machines/handshake/route.ts b/referência/sistema-de-chamados-main/src/app/machines/handshake/route.ts new file mode 100644 index 0000000..0ea4258 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/machines/handshake/route.ts @@ -0,0 +1,173 @@ +import { NextRequest, NextResponse } from "next/server" + +import { createMachineSession, MachineInactiveError } from "@/server/machines-session" +import { env } from "@/lib/env" + +const ERROR_TEMPLATE = ` + + + + + + Falha na autenticação da dispositivo + + + +
+

Não foi possível autenticar esta dispositivo

+

O token informado é inválido, expirou ou não está mais associado a uma dispositivo ativa.

+

Volte ao agente desktop, gere um novo token ou realize o provisionamento novamente.

+
Voltar para o Raven +
+ + +` + +const INACTIVE_TEMPLATE = ` + + + + + + Dispositivo desativada + + + +
+
Acesso bloqueado
+

Esta dispositivo está desativada

+

O acesso ao portal foi suspenso pelos administradores da Rever. Enquanto isso, você não poderá abrir chamados ou enviar atualizações.

+

Entre em contato com a equipe da Rever para solicitar a reativação desta dispositivo.

+ Falar com o suporte +
+ + +` + +export async function GET(request: NextRequest) { + const token = request.nextUrl.searchParams.get("token") + if (!token) { + return NextResponse.redirect(new URL("/", request.nextUrl.origin)) + } + + const redirectParam = request.nextUrl.searchParams.get("redirect") ?? "/" + const forwardedProto = request.headers.get("x-forwarded-proto") + const hostHeader = request.headers.get("x-forwarded-host") ?? request.headers.get("host") + const schemeFromUrl = request.nextUrl.protocol.replace(/:$/, "") || undefined + const derivedOrigin = hostHeader + ? `${forwardedProto ?? schemeFromUrl ?? "https"}://${hostHeader}` + : null + const baseOrigin = env.NEXT_PUBLIC_APP_URL ?? derivedOrigin ?? request.nextUrl.origin + const redirectUrl = new URL(redirectParam, baseOrigin) + + try { + const session = await createMachineSession(token, true) + const response = NextResponse.redirect(redirectUrl) + + // Propaga os cookies de sessão do Better Auth (podem vir múltiplos) + const headersAny = session.headers as unknown as { getSetCookie?: () => string[] } + let setCookies: string[] = [] + try { + if (typeof headersAny?.getSetCookie === "function") { + setCookies = headersAny.getSetCookie() ?? [] + } else { + const single = session.headers.get("set-cookie") + if (single) setCookies = [single] + } + } catch { + const single = session.headers.get("set-cookie") + if (single) setCookies = [single] + } + + // Converte os Set-Cookie recebidos em cookies do Next (maior compatibilidade) + const toPairs = (raw: string) => { + const [nameValue, ...attrs] = raw.split(/;\s*/) + const [name, ...v] = nameValue.split("=") + const value = v.join("=") + const record: Record = { name, value } + for (const attr of attrs) { + const [k, ...vv] = attr.split("=") + const key = k.toLowerCase() + const val = vv.join("=") + if (!val && (key === "httponly" || key === "secure")) { + record[key] = true + } else if (val) { + record[key] = val + } + } + return record + } + for (const raw of setCookies) { + const rec = toPairs(raw) + const name = String(rec.name) + const value = String(rec.value) + const options: Parameters[2] = { + httpOnly: Boolean(rec["httponly"]) || /httponly/i.test(raw), + secure: /;\s*secure/i.test(raw), + path: typeof rec["path"] === "string" ? (rec["path"] as string) : "/", + } + if (typeof rec["samesite"] === "string") { + const s = String(rec["samesite"]).toLowerCase() as "lax" | "strict" | "none" + options.sameSite = s + } + if (typeof rec["domain"] === "string") options.domain = rec["domain"] as string + if (typeof rec["expires"] === "string") options.expires = new Date(rec["expires"] as string) + if (typeof rec["max-age"] === "string") options.maxAge = Number(rec["max-age"]) + response.cookies.set(name, value, options) + } + + const machineCookiePayload = { + machineId: session.machine.id, + persona: session.machine.persona, + assignedUserId: session.machine.assignedUserId, + assignedUserEmail: session.machine.assignedUserEmail, + assignedUserName: session.machine.assignedUserName, + assignedUserRole: session.machine.assignedUserRole, + } + const encodedContext = Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url") + const isSecure = redirectUrl.protocol === "https:" + response.cookies.set({ + name: "machine_ctx", + value: encodedContext, + httpOnly: true, + sameSite: "lax", + secure: isSecure, + path: "/", + maxAge: 60 * 60 * 24 * 30, + }) + + return response + } catch (error) { + if (error instanceof MachineInactiveError) { + return new NextResponse(INACTIVE_TEMPLATE, { + status: 423, + headers: { + "Content-Type": "text/html; charset=utf-8", + }, + }) + } + console.error("[machines.handshake] Falha ao autenticar dispositivo", error) + return new NextResponse(ERROR_TEMPLATE, { + status: 500, + headers: { + "Content-Type": "text/html; charset=utf-8", + }, + }) + } +} diff --git a/referência/sistema-de-chamados-main/src/app/page.tsx b/referência/sistema-de-chamados-main/src/app/page.tsx new file mode 100644 index 0000000..442982d --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/page.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation" +import { getServerSession } from "@/lib/auth-server" +import { isStaff } from "@/lib/authz" + +export default async function Home() { + const session = await getServerSession() + if (!session?.user) { + redirect("/login") + } + if (isStaff(session.user.role)) { + redirect("/dashboard") + } + redirect("/portal") +} diff --git a/referência/sistema-de-chamados-main/src/app/play/page.tsx b/referência/sistema-de-chamados-main/src/app/play/page.tsx new file mode 100644 index 0000000..ffcd538 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/play/page.tsx @@ -0,0 +1,25 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader, SiteHeaderPrimaryButton } from "@/components/site-header" +import { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card" +import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export default async function PlayPage() { + await requireAuthenticatedSession() + return ( + Iniciar sessão} + /> + } + > +
+ + +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/portal/layout.tsx b/referência/sistema-de-chamados-main/src/app/portal/layout.tsx new file mode 100644 index 0000000..3461d1c --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/portal/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react" + +import { PortalShell } from "@/components/portal/portal-shell" + +export default function PortalLayout({ children }: { children: ReactNode }) { + return {children} +} diff --git a/referência/sistema-de-chamados-main/src/app/portal/page.tsx b/referência/sistema-de-chamados-main/src/app/portal/page.tsx new file mode 100644 index 0000000..3153317 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/portal/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next" +import { redirect } from "next/navigation" + +export const metadata: Metadata = { + title: "Portal do cliente", + description: "Acompanhe seus chamados e atualizações como cliente.", +} + +export default function PortalPage() { + redirect("/portal/tickets") +} diff --git a/referência/sistema-de-chamados-main/src/app/portal/profile/page.tsx b/referência/sistema-de-chamados-main/src/app/portal/profile/page.tsx new file mode 100644 index 0000000..e6a00a9 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/portal/profile/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next" +import { redirect } from "next/navigation" + +import { requireAuthenticatedSession } from "@/lib/auth-server" +import { PortalProfileSettings } from "@/components/portal/portal-profile-settings" + +export const metadata: Metadata = { + title: "Meu perfil", + description: "Atualize seu e-mail e defina uma senha de acesso ao portal.", +} + +export default async function PortalProfilePage() { + const session = await requireAuthenticatedSession() + const role = (session.user.role ?? "").toLowerCase() + const persona = (session.user.machinePersona ?? "").toLowerCase() + const allowedRoles = new Set(["collaborator", "manager", "admin", "agent"]) + const isMachinePersonaAllowed = role === "machine" && (persona === "collaborator" || persona === "manager") + const allowed = allowedRoles.has(role) || isMachinePersonaAllowed + if (!allowed) { + redirect("/portal") + } + + return ( +
+ +
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/portal/tickets/[id]/page.tsx b/referência/sistema-de-chamados-main/src/app/portal/tickets/[id]/page.tsx new file mode 100644 index 0000000..e54fba8 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/portal/tickets/[id]/page.tsx @@ -0,0 +1,6 @@ +import { PortalTicketDetail } from "@/components/portal/portal-ticket-detail" + +export default async function PortalTicketDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + return +} diff --git a/referência/sistema-de-chamados-main/src/app/portal/tickets/new/page.tsx b/referência/sistema-de-chamados-main/src/app/portal/tickets/new/page.tsx new file mode 100644 index 0000000..c7c8eba --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/portal/tickets/new/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next" + +import { PortalTicketForm } from "@/components/portal/portal-ticket-form" + +export const metadata: Metadata = { + title: "Abrir chamado", + description: "Registre um novo chamado para a equipe de suporte.", +} + +export default function PortalNewTicketPage() { + return +} diff --git a/referência/sistema-de-chamados-main/src/app/portal/tickets/page.tsx b/referência/sistema-de-chamados-main/src/app/portal/tickets/page.tsx new file mode 100644 index 0000000..9973092 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/portal/tickets/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next" + +import { PortalTicketList } from "@/components/portal/portal-ticket-list" + +export const metadata: Metadata = { + title: "Meus chamados", + description: "Acompanhe os chamados abertos com a equipe de suporte.", +} + +export default function PortalTicketsPage() { + return +} diff --git a/referência/sistema-de-chamados-main/src/app/reports/backlog/page.tsx b/referência/sistema-de-chamados-main/src/app/reports/backlog/page.tsx new file mode 100644 index 0000000..a0387a3 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/reports/backlog/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell" +import { BacklogReport } from "@/components/reports/backlog-report" +import { SiteHeader } from "@/components/site-header" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function ReportsBacklogPage() { + await requireAuthenticatedSession() + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/reports/categories/page.tsx b/referência/sistema-de-chamados-main/src/app/reports/categories/page.tsx new file mode 100644 index 0000000..6cea5be --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/reports/categories/page.tsx @@ -0,0 +1,25 @@ +import { AppShell } from "@/components/app-shell" +import { CategoryReport } from "@/components/reports/category-report" +import { SiteHeader } from "@/components/site-header" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function ReportsCategoriesPage() { + await requireAuthenticatedSession() + return ( + + } + > +
+ +
+
+ ) +} + diff --git a/referência/sistema-de-chamados-main/src/app/reports/company/page.tsx b/referência/sistema-de-chamados-main/src/app/reports/company/page.tsx new file mode 100644 index 0000000..b8baccf --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/reports/company/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell" +import { CompanyReport } from "@/components/reports/company-report" +import { SiteHeader } from "@/components/site-header" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function ReportsCompanyPage() { + await requireAuthenticatedSession() + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/reports/csat/page.tsx b/referência/sistema-de-chamados-main/src/app/reports/csat/page.tsx new file mode 100644 index 0000000..bc92568 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/reports/csat/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell" +import { CsatReport } from "@/components/reports/csat-report" +import { SiteHeader } from "@/components/site-header" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function ReportsCsatPage() { + await requireAuthenticatedSession() + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/reports/hours/page.tsx b/referência/sistema-de-chamados-main/src/app/reports/hours/page.tsx new file mode 100644 index 0000000..6038913 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/reports/hours/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { HoursReport } from "@/components/reports/hours-report" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function ReportsHoursPage() { + await requireAuthenticatedSession() + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/reports/sla/page.tsx b/referência/sistema-de-chamados-main/src/app/reports/sla/page.tsx new file mode 100644 index 0000000..670831d --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/reports/sla/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell" +import { SlaReport } from "@/components/reports/sla-report" +import { SiteHeader } from "@/components/site-header" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function ReportsSlaPage() { + await requireAuthenticatedSession() + return ( + + } + > +
+ +
+
+ ) +} diff --git a/referência/sistema-de-chamados-main/src/app/settings/page.tsx b/referência/sistema-de-chamados-main/src/app/settings/page.tsx new file mode 100644 index 0000000..e9d619f --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/settings/page.tsx @@ -0,0 +1,15 @@ +import { AppShell } from "@/components/app-shell" +import { SettingsContent } from "@/components/settings/settings-content" +import { SiteHeader } from "@/components/site-header" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export default async function SettingsPage() { + await requireAuthenticatedSession() + return ( + } + > + + + ) +} diff --git a/referência/sistema-de-chamados-main/src/app/settings/templates/page.tsx b/referência/sistema-de-chamados-main/src/app/settings/templates/page.tsx new file mode 100644 index 0000000..5d76883 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/settings/templates/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell" +import { CommentTemplatesManager } from "@/components/settings/comment-templates-manager" +import { SiteHeader } from "@/components/site-header" +import { requireStaffSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" +export const runtime = "nodejs" + +export default async function CommentTemplatesPage() { + await requireStaffSession() + + return ( + + } + > + + + ) +} diff --git a/referência/sistema-de-chamados-main/src/app/tickets/[id]/page.tsx b/referência/sistema-de-chamados-main/src/app/tickets/[id]/page.tsx new file mode 100644 index 0000000..b0b7b4f --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/tickets/[id]/page.tsx @@ -0,0 +1,28 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { TicketDetailView } from "@/components/tickets/ticket-detail-view" +import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +type TicketDetailPageProps = { + params: Promise<{ id: string }> +} + +export default async function TicketDetailPage({ params }: TicketDetailPageProps) { + await requireAuthenticatedSession() + const { id } = await params + + return ( + } + /> + } + > + + + ) +} diff --git a/referência/sistema-de-chamados-main/src/app/tickets/new/page.tsx b/referência/sistema-de-chamados-main/src/app/tickets/new/page.tsx new file mode 100644 index 0000000..3f4cd62 --- /dev/null +++ b/referência/sistema-de-chamados-main/src/app/tickets/new/page.tsx @@ -0,0 +1,559 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation, useQuery } from "convex/react" +import { toast } from "sonner" +import type { Doc, Id } from "@/convex/_generated/dataModel" +import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { useAuth } from "@/lib/auth-client" +import { api } from "@/convex/_generated/api" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-editor" +import { Spinner } from "@/components/ui/spinner" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { PriorityIcon, priorityBadgeClass, priorityItemClass, priorityTriggerClass } from "@/components/tickets/priority-select" +import { CategorySelectFields } from "@/components/tickets/category-select" +import { priorityStyles } from "@/lib/ticket-priority-style" + +type CustomerOption = { + id: string + name: string + email: string + role: string + companyId: string | null + companyName: string | null + companyIsAvulso: boolean + avatarUrl: string | null +} + +const ALL_COMPANIES_VALUE = "__all__" +const NO_COMPANY_VALUE = "__no_company__" +const NO_REQUESTER_VALUE = "__no_requester__" + + +export default function NewTicketPage() { + const router = useRouter() + const { convexUserId, isStaff, role, session, machineContext } = useAuth() + const sessionUser = session?.user ?? null + const queuesEnabled = Boolean(isStaff && convexUserId) + const queuesRemote = useQuery( + api.queues.summary, + queuesEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" + ) + const queues = useMemo( + () => (Array.isArray(queuesRemote) ? (queuesRemote as TicketQueueSummary[]) : []), + [queuesRemote] + ) + const create = useMutation(api.tickets.create) + const addComment = useMutation(api.tickets.addComment) + const staffRaw = useQuery(api.users.listAgents, { tenantId: DEFAULT_TENANT_ID }) as Doc<"users">[] | undefined + const staff = useMemo( + () => (staffRaw ?? []).sort((a, b) => a.name.localeCompare(b.name, "pt-BR")), + [staffRaw] + ) + + const directoryQueryEnabled = queuesEnabled && Boolean(convexUserId) + const companiesRemote = useQuery( + api.companies.list, + directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" + ) + const companies = useMemo( + () => + (Array.isArray(companiesRemote) ? companiesRemote : []).map((company) => ({ + id: String(company.id), + name: company.name, + slug: company.slug ?? null, + })), + [companiesRemote] + ) + + const customersRemote = useQuery( + api.users.listCustomers, + directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" + ) + const rawCustomers = useMemo( + () => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []), + [customersRemote] + ) + const viewerCustomer = useMemo(() => { + if (!convexUserId || !sessionUser) return null + return { + id: convexUserId, + name: sessionUser.name ?? sessionUser.email, + email: sessionUser.email, + role: sessionUser.role ?? "customer", + companyId: machineContext?.companyId ?? null, + companyName: null, + companyIsAvulso: false, + avatarUrl: sessionUser.avatarUrl, + } + }, [convexUserId, sessionUser, machineContext?.companyId]) + const customers = useMemo(() => { + if (!viewerCustomer) return rawCustomers + const exists = rawCustomers.some((customer) => customer.id === viewerCustomer.id) + return exists ? rawCustomers : [...rawCustomers, viewerCustomer] + }, [rawCustomers, viewerCustomer]) + + const [subject, setSubject] = useState("") + const [summary, setSummary] = useState("") + const [priority, setPriority] = useState("MEDIUM") + const [channel, setChannel] = useState("MANUAL") + const [queueName, setQueueName] = useState(null) + const [assigneeId, setAssigneeId] = useState(null) + const [description, setDescription] = useState("") + const [loading, setLoading] = useState(false) + const [subjectError, setSubjectError] = useState(null) + const [categoryId, setCategoryId] = useState(null) + const [subcategoryId, setSubcategoryId] = useState(null) + const [categoryError, setCategoryError] = useState(null) + const [subcategoryError, setSubcategoryError] = useState(null) + const [descriptionError, setDescriptionError] = useState(null) + const [assigneeInitialized, setAssigneeInitialized] = useState(false) + + const [companyFilter, setCompanyFilter] = useState(ALL_COMPANIES_VALUE) + const [requesterId, setRequesterId] = useState(convexUserId) + const [requesterError, setRequesterError] = useState(null) + const [customersInitialized, setCustomersInitialized] = useState(false) + + const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]) + const assigneeSelectValue = assigneeId ?? "NONE" + + const companyOptions = useMemo(() => { + const map = new Map() + companies.forEach((company) => { + map.set(company.id, { id: company.id, name: company.name, isAvulso: false }) + }) + customers.forEach((customer) => { + if (customer.companyId && !map.has(customer.companyId)) { + map.set(customer.companyId, { + id: customer.companyId, + name: customer.companyName ?? "Empresa sem nome", + isAvulso: customer.companyIsAvulso, + }) + } + }) + const includeNoCompany = customers.some((customer) => !customer.companyId) + const result: Array<{ id: string; name: string; isAvulso?: boolean }> = [ + { id: ALL_COMPANIES_VALUE, name: "Todas as empresas" }, + ] + if (includeNoCompany) { + result.push({ id: NO_COMPANY_VALUE, name: "Sem empresa" }) + } + const sorted = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) + return [...result, ...sorted] + }, [companies, customers]) + + const filteredCustomers = useMemo(() => { + if (companyFilter === ALL_COMPANIES_VALUE) return customers + if (companyFilter === NO_COMPANY_VALUE) { + return customers.filter((customer) => !customer.companyId) + } + return customers.filter((customer) => customer.companyId === companyFilter) + }, [companyFilter, customers]) + + useEffect(() => { + if (customersInitialized) return + if (!customers.length) return + let initialRequester = requesterId + if (!initialRequester || !customers.some((customer) => customer.id === initialRequester)) { + if (convexUserId && customers.some((customer) => customer.id === convexUserId)) { + initialRequester = convexUserId + } else { + initialRequester = customers[0]?.id ?? null + } + } + if (initialRequester) { + setRequesterId(initialRequester) + const selected = customers.find((customer) => customer.id === initialRequester) + if (selected?.companyId) { + setCompanyFilter(selected.companyId) + } else if (selected) { + setCompanyFilter(NO_COMPANY_VALUE) + } + } + setCustomersInitialized(true) + }, [customersInitialized, customers, requesterId, convexUserId]) + + useEffect(() => { + if (!customersInitialized) return + const available = filteredCustomers + if (available.length === 0) { + if (requesterId !== null) { + setRequesterId(null) + } + return + } + if (!requesterId || !available.some((customer) => customer.id === requesterId)) { + setRequesterId(available[0].id) + } + }, [filteredCustomers, customersInitialized, requesterId]) + + useEffect(() => { + if (requesterId && requesterError) { + setRequesterError(null) + } + }, [requesterId, requesterError]) + + + useEffect(() => { + if (assigneeInitialized) return + if (!convexUserId) return + setAssigneeId(convexUserId) + setAssigneeInitialized(true) + }, [assigneeInitialized, convexUserId]) + + // Default queue to "Chamados" if available + useEffect(() => { + if (queueName) return + const hasChamados = queueOptions.includes("Chamados") + if (hasChamados) setQueueName("Chamados") + }, [queueOptions, queueName]) + + const allowTicketMentions = useMemo(() => { + const normalized = (role ?? "").toLowerCase() + return normalized === "admin" || normalized === "agent" || normalized === "collaborator" + }, [role]) + + async function submit(event: React.FormEvent) { + event.preventDefault() + if (!convexUserId || loading) return + + const trimmedSubject = subject.trim() + if (trimmedSubject.length < 3) { + setSubjectError("Informe um assunto com pelo menos 3 caracteres.") + return + } + setSubjectError(null) + + if (!categoryId) { + setCategoryError("Selecione uma categoria.") + return + } + if (!subcategoryId) { + setSubcategoryError("Selecione uma categoria secundária.") + return + } + + const sanitizedDescription = sanitizeEditorHtml(description) + const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim() + if (plainDescription.length === 0) { + setDescriptionError("Descreva o contexto do chamado.") + return + } + setDescriptionError(null) + + if (!requesterId) { + setRequesterError("Selecione um solicitante.") + toast.error("Selecione um solicitante para o chamado.") + return + } + + setLoading(true) + toast.loading("Criando ticket...", { id: "create-ticket" }) + try { + const selQueue = queues.find((q) => q.name === queueName) + const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined + const assigneeToSend = assigneeId ? (assigneeId as Id<"users">) : undefined + const requesterToSend = requesterId as Id<"users"> + const id = await create({ + actorId: convexUserId as Id<"users">, + tenantId: DEFAULT_TENANT_ID, + subject: trimmedSubject, + summary: summary.trim() || undefined, + priority, + channel, + queueId, + requesterId: requesterToSend, + assigneeId: assigneeToSend, + categoryId: categoryId as Id<"ticketCategories">, + subcategoryId: subcategoryId as Id<"ticketSubcategories">, + }) + if (plainDescription.length > 0) { + await addComment({ + ticketId: id as Id<"tickets">, + authorId: convexUserId as Id<"users">, + visibility: "INTERNAL", + body: sanitizedDescription, + attachments: [], + }) + } + toast.success("Ticket criado!", { id: "create-ticket" }) + router.replace(`/tickets/${id}`) + } catch (error) { + console.error(error) + toast.error("Não foi possível criar o ticket.", { id: "create-ticket" }) + } finally { + setLoading(false) + } + } + + const selectTriggerClass = "flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" + const selectItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10" + + return ( +
+ + + Novo ticket + Preencha as informações básicas para abrir um chamado. + + +
+
+ + { + setSubject(event.target.value) + if (subjectError) setSubjectError(null) + }} + placeholder="Ex.: Erro 500 no portal" + aria-invalid={subjectError ? "true" : undefined} + /> + {subjectError ?

{subjectError}

: null} +
+
+ +