import { v } from 'convex/values'; import { api, internal } from './_generated/api'; import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { internalMutation, mutation, query } from './_generated/server'; import { getCurrentUserFunction } from './auth'; // ========== HELPERS ========== async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { const user = await getCurrentUserFunction(ctx); if (!user) throw new Error('Unauthorized'); return user; } // ========== QUERIES ========== export const list = query({ args: {}, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), status: v.union( v.literal('em_rascunho'), v.literal('aguardando_aceite'), v.literal('em_analise'), v.literal('precisa_ajustes'), v.literal('cancelado'), v.literal('concluido') ), // acaoId removed from return criadoPor: v.id('usuarios'), criadoEm: v.number(), atualizadoEm: v.number() }) ), handler: async (ctx) => { return await ctx.db.query('pedidos').collect(); } }); export const get = query({ args: { id: v.id('pedidos') }, returns: v.union( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), status: v.union( v.literal('em_rascunho'), v.literal('aguardando_aceite'), v.literal('em_analise'), v.literal('precisa_ajustes'), v.literal('cancelado'), v.literal('concluido') ), acaoId: v.optional(v.id('acoes')), criadoPor: v.id('usuarios'), criadoEm: v.number(), atualizadoEm: v.number() }), v.null() ), handler: async (ctx, args) => { return await ctx.db.get(args.id); } }); export const getItems = query({ args: { pedidoId: v.id('pedidos') }, returns: v.array( v.object({ _id: v.id('objetoItems'), _creationTime: v.number(), pedidoId: v.id('pedidos'), objetoId: v.id('objetos'), acaoId: v.optional(v.id('acoes')), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), valorEstimado: v.string(), valorReal: v.optional(v.string()), quantidade: v.number(), adicionadoPor: v.id('funcionarios'), adicionadoPorNome: v.string(), criadoEm: v.number() }) ), handler: async (ctx, args) => { const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .collect(); // Get employee names const itemsWithNames = await Promise.all( items.map(async (item) => { const funcionario = await ctx.db.get(item.adicionadoPor); return { ...item, adicionadoPorNome: funcionario?.nome || 'Desconhecido' }; }) ); return itemsWithNames; } }); export const getHistory = query({ args: { pedidoId: v.id('pedidos') }, handler: async (ctx, args) => { const history = await ctx.db .query('historicoPedidos') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .order('desc') .collect(); // Get user names const historyWithNames = await Promise.all( history.map(async (entry) => { const usuario = await ctx.db.get(entry.usuarioId); return { _id: entry._id, _creationTime: entry._creationTime, pedidoId: entry.pedidoId, usuarioId: entry.usuarioId, usuarioNome: usuario?.nome || 'Desconhecido', acao: entry.acao, detalhes: entry.detalhes, data: entry.data }; }) ); return historyWithNames; } }); export const checkExisting = query({ args: { acaoId: v.optional(v.id('acoes')), // Used to filter items numeroSei: v.optional(v.string()), objetoIds: v.optional(v.array(v.id('objetos'))) }, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), status: v.union( v.literal('em_rascunho'), v.literal('aguardando_aceite'), v.literal('em_analise'), v.literal('precisa_ajustes'), v.literal('cancelado'), v.literal('concluido') ), // acaoId removed criadoPor: v.id('usuarios'), criadoEm: v.number(), atualizadoEm: v.number(), matchingItems: v.optional( v.array( v.object({ objetoId: v.id('objetos'), quantidade: v.number() }) ) ) }) ), handler: async (ctx, args) => { const user = await getCurrentUserFunction(ctx); if (!user) return []; const openStatuses: Array< 'em_rascunho' | 'aguardando_aceite' | 'em_analise' | 'precisa_ajustes' > = ['em_rascunho', 'aguardando_aceite', 'em_analise', 'precisa_ajustes']; // 1) Buscar todos os pedidos "abertos" usando o índice by_status let pedidosAbertos: Doc<'pedidos'>[] = []; for (const status of openStatuses) { const partial = await ctx.db .query('pedidos') .withIndex('by_status', (q) => q.eq('status', status)) .collect(); pedidosAbertos = pedidosAbertos.concat(partial); } // 2) Filtros opcionais: numeroSei pedidosAbertos = pedidosAbertos.filter((p) => { if (args.numeroSei && p.numeroSei !== args.numeroSei) return false; return true; }); // 3) Filtro por acaoId (via items) if (args.acaoId) { // This is expensive, but for now we iterate. Better would be to query items by acaoId first. // Optimization: Query items by acaoId and get unique pedidoIds. const itemsComAcao = await ctx.db .query('objetoItems') .withIndex('by_acaoId', (q) => q.eq('acaoId', args.acaoId)) .collect(); const pedidoIdsComAcao = new Set(itemsComAcao.map((i) => i.pedidoId)); pedidosAbertos = pedidosAbertos.filter((p) => pedidoIdsComAcao.has(p._id)); } // 4) Filtro por objetos (se informado) e coleta de matchingItems const resultados = []; for (const pedido of pedidosAbertos) { let include = true; let matchingItems: { objetoId: Id<'objetos'>; quantidade: number }[] = []; // Se houver filtro de objetos, verificamos se o pedido tem ALGUM dos objetos if (args.objetoIds && args.objetoIds.length > 0) { const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id)) .collect(); const matching = items.filter((i) => args.objetoIds?.includes(i.objetoId)); if (matching.length > 0) { matchingItems = matching.map((i) => ({ objetoId: i.objetoId, quantidade: i.quantidade })); } else { include = false; } } if (include) { resultados.push({ _id: pedido._id, _creationTime: pedido._creationTime, numeroSei: pedido.numeroSei, status: pedido.status, criadoPor: pedido.criadoPor, criadoEm: pedido.criadoEm, atualizadoEm: pedido.atualizadoEm, matchingItems: matchingItems.length > 0 ? matchingItems : undefined }); } } return resultados; } }); // ========== MUTATIONS ========== export const create = mutation({ args: { numeroSei: v.optional(v.string()) // acaoId removed }, returns: v.id('pedidos'), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); // 1. Check Config const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) { throw new Error('Setor de Compras não configurado. Contate o administrador.'); } // 2. Check Existing (Double check) - Removed acaoId check here as it's now per item // 3. Create Order const pedidoId = await ctx.db.insert('pedidos', { numeroSei: args.numeroSei, status: 'em_rascunho', criadoPor: user._id, criadoEm: Date.now(), atualizadoEm: Date.now() }); // 4. Create History await ctx.db.insert('historicoPedidos', { pedidoId, usuarioId: user._id, acao: 'criacao', detalhes: JSON.stringify({ numeroSei: args.numeroSei }), data: Date.now() }); return pedidoId; } }); export const updateSeiNumber = mutation({ args: { pedidoId: v.id('pedidos'), numeroSei: v.string() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido not found'); // Check if SEI number is already taken by another order const existing = await ctx.db .query('pedidos') .filter((q) => q.and(q.eq(q.field('numeroSei'), args.numeroSei), q.neq(q.field('_id'), args.pedidoId)) ) .first(); if (existing) { throw new Error('Este número SEI já está em uso por outro pedido.'); } const oldSei = pedido.numeroSei; await ctx.db.patch(args.pedidoId, { numeroSei: args.numeroSei, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'atualizacao_sei', detalhes: JSON.stringify({ de: oldSei, para: args.numeroSei }), data: Date.now() }); } }); export const addItem = mutation({ args: { pedidoId: v.id('pedidos'), objetoId: v.id('objetos'), acaoId: v.optional(v.id('acoes')), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), valorEstimado: v.string(), quantidade: v.number() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); // Ensure user has a funcionarioId linked if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } // Check if item already exists with same parameters (user, object, action, modalidade) const existingItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.and( q.eq(q.field('objetoId'), args.objetoId), q.eq(q.field('adicionadoPor'), user.funcionarioId), q.eq(q.field('acaoId'), args.acaoId), q.eq(q.field('modalidade'), args.modalidade) ) ) .first(); if (existingItem) { // Increment quantity const novaQuantidade = existingItem.quantidade + args.quantidade; await ctx.db.patch(existingItem._id, { quantidade: novaQuantidade }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'adicao_item_incremento', detalhes: JSON.stringify({ objetoId: args.objetoId, quantidadeAdicionada: args.quantidade, novaQuantidade }), data: Date.now() }); } else { // Insert new item await ctx.db.insert('objetoItems', { pedidoId: args.pedidoId, objetoId: args.objetoId, acaoId: args.acaoId, modalidade: args.modalidade, valorEstimado: args.valorEstimado, quantidade: args.quantidade, adicionadoPor: user.funcionarioId, criadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'adicao_item', detalhes: JSON.stringify({ objetoId: args.objetoId, valor: args.valorEstimado, quantidade: args.quantidade, acaoId: args.acaoId, modalidade: args.modalidade }), data: Date.now() }); } await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() }); } }); export const updateItemQuantity = mutation({ args: { itemId: v.id('objetoItems'), novaQuantidade: v.number() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } const item = await ctx.db.get(args.itemId); if (!item) throw new Error('Item não encontrado.'); const quantidadeAnterior = item.quantidade; // Check permission: only item owner can change quantity const isOwner = item.adicionadoPor === user.funcionarioId; if (!isOwner) { throw new Error('Apenas quem adicionou este item pode alterar a quantidade.'); } // Update quantity await ctx.db.patch(args.itemId, { quantidade: args.novaQuantidade }); await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); // Create history entry await ctx.db.insert('historicoPedidos', { pedidoId: item.pedidoId, usuarioId: user._id, acao: 'alteracao_quantidade', detalhes: JSON.stringify({ objetoId: item.objetoId, quantidadeAnterior, novaQuantidade: args.novaQuantidade }), data: Date.now() }); } }); export const removeItem = mutation({ args: { itemId: v.id('objetoItems') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const item = await ctx.db.get(args.itemId); if (!item) throw new Error('Item not found'); await ctx.db.delete(args.itemId); await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: item.pedidoId, usuarioId: user._id, acao: 'remocao_item', detalhes: JSON.stringify({ objetoId: item.objetoId, valor: item.valorEstimado }), data: Date.now() }); } }); export const updateStatus = mutation({ args: { pedidoId: v.id('pedidos'), novoStatus: v.union( v.literal('em_rascunho'), v.literal('aguardando_aceite'), v.literal('em_analise'), v.literal('precisa_ajustes'), v.literal('cancelado'), v.literal('concluido') ) }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido not found'); const oldStatus = pedido.status; await ctx.db.patch(args.pedidoId, { status: args.novoStatus, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: args.novoStatus }), data: Date.now() }); // Trigger Notifications await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus: args.novoStatus, actorId: user._id }); } }); // ========== INTERNAL (NOTIFICATIONS) ========== export const notifyStatusChange = internalMutation({ args: { pedidoId: v.id('pedidos'), oldStatus: v.string(), newStatus: v.string(), actorId: v.id('usuarios') }, returns: v.null(), handler: async (ctx, args) => { const pedido = await ctx.db.get(args.pedidoId); if (!pedido) return; const actor = await ctx.db.get(args.actorId); const actorName = actor ? actor.nome : 'Alguém'; const recipients = new Set(); // Set of User IDs // 1. If status is "aguardando_aceite", notify Purchasing Sector if (args.newStatus === 'aguardando_aceite') { const config = await ctx.db.query('config').first(); if (config && config.comprasSetorId) { // Find all employees in this sector const funcionarioSetores = await ctx.db .query('funcionarioSetores') .withIndex('by_setorId', (q) => q.eq('setorId', config.comprasSetorId!)) .collect(); const funcionarioIds = funcionarioSetores.map((fs) => fs.funcionarioId); // Find users linked to these employees for (const fId of funcionarioIds) { const user = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', fId)) .first(); if (user) recipients.add(user._id); } } } // 2. Notify "Involved" users (Creator + Item Adders) // Always notify creator (unless they are the actor) if (pedido.criadoPor !== args.actorId) { recipients.add(pedido.criadoPor); } // Notify item adders const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .collect(); for (const item of items) { const user = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', item.adicionadoPor)) .first(); if (user && user._id !== args.actorId) { recipients.add(user._id); } } // Send Notifications for (const recipientId of recipients) { const recipientIdTyped = recipientId as Id<'usuarios'>; // 1. In-App Notification await ctx.db.insert('notificacoes', { usuarioId: recipientIdTyped, tipo: 'alerta_seguranca', // Using alerta_seguranca as the closest match for system notifications titulo: `Pedido ${pedido.numeroSei || 'sem número SEI'} atualizado`, descricao: `Status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`, lida: false, criadaEm: Date.now(), remetenteId: args.actorId }); // 2. Email Notification (Async) const recipientUser = await ctx.db.get(recipientIdTyped); if (recipientUser && recipientUser.email) { // Using enfileirarEmail directly await ctx.scheduler.runAfter(0, api.email.enfileirarEmail, { destinatario: recipientUser.email, destinatarioId: recipientIdTyped, assunto: `Atualização no Pedido ${pedido.numeroSei || 'sem número SEI'}`, corpo: `O pedido ${pedido.numeroSei || 'sem número SEI'} teve seu status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`, enviadoPor: args.actorId }); } } } });