FEAT: Adicionado configuraçãoes de pausa e presença
All checks were successful
Deploy Dev / deploy (push) Successful in 3s

This commit is contained in:
Rafael Alves Lopes 2026-05-25 14:32:20 -03:00
parent e1a31f3f07
commit 1e28ecc349
5 changed files with 336 additions and 5 deletions

View File

@ -3,11 +3,14 @@ import { AdminAccessController } from './admin-access.controller';
import { AdminAccessService } from './admin-access.service';
import { AgentNotesController } from './agent-notes.controller';
import { AgentNotesService } from './agent-notes.service';
import { AgentPresenceController } from './agent-presence.controller';
import { AgentPresenceService } from './agent-presence.service';
import { CustomerContactsController } from './customer-contacts.controller';
import { CustomerContactsService } from './customer-contacts.service';
@Module({
controllers: [AdminAccessController, AgentNotesController, CustomerContactsController],
providers: [AdminAccessService, AgentNotesService, CustomerContactsService],
controllers: [AdminAccessController, AgentNotesController, AgentPresenceController, CustomerContactsController],
providers: [AdminAccessService, AgentNotesService, AgentPresenceService, CustomerContactsService],
exports: [AgentPresenceService],
})
export class AdminModule {}

View File

@ -0,0 +1,32 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { AgentPresenceService } from './agent-presence.service';
@Controller('agent/presence')
export class AgentPresenceController {
constructor(private readonly agentPresenceService: AgentPresenceService) {}
@Get()
listPresence() {
return this.agentPresenceService.listPresence();
}
@Get('me')
getPresence(@Query('userId') userId: string) {
return this.agentPresenceService.getPresence(Number(userId));
}
@Post('pause')
pause(@Body() body: { userId: number }) {
return this.agentPresenceService.pause(Number(body.userId));
}
@Post('resume')
resume(@Body() body: { userId: number }) {
return this.agentPresenceService.resume(Number(body.userId));
}
@Post('offline')
offline(@Body() body: { userId: number }) {
return this.agentPresenceService.offline(Number(body.userId));
}
}

View File

@ -0,0 +1,260 @@
import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common';
import { DatabaseService } from '../../infra/database/database.service';
type AgentPresenceStatus = 'available' | 'paused' | 'offline';
@Injectable()
export class AgentPresenceService implements OnModuleInit {
constructor(private readonly database: DatabaseService) {}
async onModuleInit() {
await this.ensureSchema();
}
async ensureSchema() {
await this.database.query(`
CREATE TABLE IF NOT EXISTS agent_presence (
user_id INTEGER PRIMARY KEY REFERENCES usuarios(id) ON DELETE CASCADE,
status VARCHAR(40) NOT NULL DEFAULT 'offline',
paused_at TIMESTAMP WITH TIME ZONE,
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`);
await this.database.query(`
ALTER TABLE whatsapp_chat_atribuicoes
ADD COLUMN IF NOT EXISTS reserved_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS pause_released_at TIMESTAMP WITH TIME ZONE;
`);
}
async listPresence() {
const result = await this.database.query(`
SELECT
u.id AS user_id,
u.nome,
u.email,
COALESCE(ap.status, 'offline') AS status,
ap.paused_at,
ap.last_seen_at,
ap.updated_at,
CASE
WHEN ap.status = 'paused' AND ap.paused_at IS NOT NULL
THEN FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - ap.paused_at)))::INTEGER
ELSE 0
END AS paused_seconds,
COUNT(DISTINCT assigned.chat_id)::INTEGER AS assigned_count,
COUNT(DISTINCT reserved.chat_id)::INTEGER AS reserved_count
FROM usuarios u
LEFT JOIN agent_presence ap ON ap.user_id = u.id
LEFT JOIN whatsapp_chat_atribuicoes assigned
ON assigned.user_id = u.id
AND assigned.status = 'assigned'
LEFT JOIN whatsapp_chat_atribuicoes reserved
ON reserved.reserved_user_id = u.id
AND reserved.status = 'queued'
AND reserved.user_id IS NULL
WHERE u.ativo = TRUE
GROUP BY u.id, u.nome, u.email, ap.status, ap.paused_at, ap.last_seen_at, ap.updated_at
ORDER BY u.nome ASC
`);
return result.rows;
}
async getPresence(userId: number) {
this.assertUserId(userId);
await this.touchPresence(userId, 'available', false);
const result = await this.database.query(
`
SELECT
user_id,
status,
paused_at,
last_seen_at,
updated_at,
CASE
WHEN status = 'paused' AND paused_at IS NOT NULL
THEN FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - paused_at)))::INTEGER
ELSE 0
END AS paused_seconds
FROM agent_presence
WHERE user_id = $1
`,
[userId],
);
return result.rows[0];
}
async pause(userId: number) {
this.assertUserId(userId);
return this.database.transaction(async (client) => {
const presence = await client.query(
`
INSERT INTO agent_presence (user_id, status, paused_at, last_seen_at, updated_at)
VALUES ($1, 'paused', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (user_id) DO UPDATE SET
status = 'paused',
paused_at = COALESCE(agent_presence.paused_at, CURRENT_TIMESTAMP),
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`,
[userId],
);
const released = await client.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
reserved_user_id = $1,
reserved_at = CURRENT_TIMESTAMP,
pause_released_at = CURRENT_TIMESTAMP,
user_id = NULL,
status = 'queued',
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $1
AND status = 'assigned'
RETURNING *
`,
[userId],
);
return {
presence: presence.rows[0],
releasedCount: released.rowCount,
releasedChats: released.rows,
};
});
}
async resume(userId: number) {
this.assertUserId(userId);
return this.database.transaction(async (client) => {
const presence = await client.query(
`
INSERT INTO agent_presence (user_id, status, paused_at, last_seen_at, updated_at)
VALUES ($1, 'available', NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (user_id) DO UPDATE SET
status = 'available',
paused_at = NULL,
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`,
[userId],
);
const restored = await client.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
user_id = $1,
status = 'assigned',
assigned_at = CURRENT_TIMESTAMP,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE reserved_user_id = $1
AND status = 'queued'
AND user_id IS NULL
RETURNING *
`,
[userId],
);
return {
presence: presence.rows[0],
restoredCount: restored.rowCount,
restoredChats: restored.rows,
};
});
}
async offline(userId: number) {
this.assertUserId(userId);
return this.database.transaction(async (client) => {
const presence = await client.query(
`
INSERT INTO agent_presence (user_id, status, paused_at, last_seen_at, updated_at)
VALUES ($1, 'offline', NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (user_id) DO UPDATE SET
status = 'offline',
paused_at = NULL,
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`,
[userId],
);
const released = await client.query(
`
UPDATE whatsapp_chat_atribuicoes
SET
user_id = NULL,
status = 'queued',
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE (user_id = $1 OR reserved_user_id = $1)
AND status IN ('assigned', 'queued')
RETURNING *
`,
[userId],
);
return {
presence: presence.rows[0],
releasedCount: released.rowCount,
releasedChats: released.rows,
};
});
}
async isAvailable(userId: number) {
this.assertUserId(userId);
const result = await this.database.query<{ status: AgentPresenceStatus }>(
`
SELECT COALESCE(ap.status, 'offline') AS status
FROM usuarios u
LEFT JOIN agent_presence ap ON ap.user_id = u.id
WHERE u.id = $1
AND u.ativo = TRUE
LIMIT 1
`,
[userId],
);
return result.rows[0]?.status === 'available';
}
private async touchPresence(userId: number, fallbackStatus: AgentPresenceStatus, updateStatus = true) {
await this.database.query(
`
INSERT INTO agent_presence (user_id, status, last_seen_at, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (user_id) DO UPDATE SET
status = CASE WHEN $3 THEN EXCLUDED.status ELSE agent_presence.status END,
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
`,
[userId, fallbackStatus, updateStatus],
);
}
private assertUserId(userId: number) {
if (!Number.isFinite(userId) || userId <= 0) {
throw new BadRequestException('Usuario invalido para controle de presenca.');
}
}
}

View File

@ -1,5 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DatabaseService } from '../../infra/database/database.service';
import { AgentPresenceService } from '../admin/agent-presence.service';
interface TransferInput {
chatId: string;
@ -52,7 +53,10 @@ const SALES_KEYWORDS = [
export class WhatsappAssignmentService implements OnModuleInit {
private readonly logger = new Logger(WhatsappAssignmentService.name);
constructor(private readonly db: DatabaseService) {}
constructor(
private readonly db: DatabaseService,
private readonly agentPresenceService: AgentPresenceService,
) {}
async onModuleInit() {
await this.ensureSchema();
@ -75,12 +79,16 @@ export class WhatsappAssignmentService implements OnModuleInit {
ADD COLUMN IF NOT EXISTS last_routed_message_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS last_bot_sent_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS awaiting_customer_reply BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS reserved_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS pause_released_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
`);
}
async assignChat(chatId: string, userId: string | number, areaId?: string | number | null) {
this.logger.log(`Atribuindo chat ${chatId} ao usuario ${userId}`);
await this.assertUserCanReceiveAssignment(Number(userId));
const query = `
INSERT INTO whatsapp_chat_atribuicoes (
@ -92,6 +100,9 @@ export class WhatsappAssignmentService implements OnModuleInit {
area_id = COALESCE(EXCLUDED.area_id, whatsapp_chat_atribuicoes.area_id),
status = 'assigned',
awaiting_customer_reply = FALSE,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
assigned_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
@ -111,6 +122,9 @@ export class WhatsappAssignmentService implements OnModuleInit {
user_id = NULL,
area_id = EXCLUDED.area_id,
status = 'queued',
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
conversation_started_at = CASE
WHEN whatsapp_chat_atribuicoes.expires_at <= CURRENT_TIMESTAMP THEN CURRENT_TIMESTAMP
ELSE whatsapp_chat_atribuicoes.conversation_started_at
@ -130,6 +144,10 @@ export class WhatsappAssignmentService implements OnModuleInit {
async transferChat(input: TransferInput) {
const status = input.userId ? 'assigned' : 'queued';
if (input.userId) {
await this.assertUserCanReceiveAssignment(input.userId);
}
const query = `
INSERT INTO whatsapp_chat_atribuicoes (
chat_id, user_id, area_id, status, conversation_started_at, expires_at, transfer_note, assigned_at, updated_at
@ -140,6 +158,9 @@ export class WhatsappAssignmentService implements OnModuleInit {
area_id = EXCLUDED.area_id,
status = EXCLUDED.status,
transfer_note = EXCLUDED.transfer_note,
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
assigned_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
@ -181,7 +202,13 @@ export class WhatsappAssignmentService implements OnModuleInit {
this.logger.log(`Liberando chat ${chatId}`);
const query = `
UPDATE whatsapp_chat_atribuicoes
SET user_id = NULL, status = 'queued', updated_at = CURRENT_TIMESTAMP
SET
user_id = NULL,
status = 'queued',
reserved_user_id = NULL,
reserved_at = NULL,
pause_released_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE chat_id = $1
RETURNING *;
`;
@ -347,6 +374,13 @@ export class WhatsappAssignmentService implements OnModuleInit {
return this.getAreaByName(targetName);
}
private async assertUserCanReceiveAssignment(userId: number) {
const canReceive = await this.agentPresenceService.isAvailable(userId);
if (!canReceive) {
throw new BadRequestException('Usuario indisponivel para receber atendimento.');
}
}
private async getAreaByName(targetName: string) {
const result = await this.db.query<{ id: number; nome: string }>(
`SELECT id, nome FROM areas WHERE nome = $1 LIMIT 1`,

View File

@ -3,8 +3,10 @@ import { WhatsappService } from './whatsapp.service';
import { WhatsappGateway } from './whatsapp.gateway';
import { WhatsappController } from './whatsapp.controller';
import { WhatsappAssignmentService } from './whatsapp-assignment.service';
import { AdminModule } from '../admin/admin.module';
@Module({
imports: [AdminModule],
providers: [WhatsappService, WhatsappGateway, WhatsappAssignmentService],
controllers: [WhatsappController],
exports: [WhatsappService, WhatsappAssignmentService],