FEAT: Adicionado configuraçãoes de pausa e presença
All checks were successful
Deploy Dev / deploy (push) Successful in 3s
All checks were successful
Deploy Dev / deploy (push) Successful in 3s
This commit is contained in:
parent
e1a31f3f07
commit
1e28ecc349
@ -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 {}
|
||||
|
||||
32
src/modules/admin/agent-presence.controller.ts
Normal file
32
src/modules/admin/agent-presence.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
260
src/modules/admin/agent-presence.service.ts
Normal file
260
src/modules/admin/agent-presence.service.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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`,
|
||||
|
||||
@ -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],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user