feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Headers,
|
||||
UseGuards,
|
||||
Request,
|
||||
Logger,
|
||||
Patch,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { FeishuService } from './feishu.service';
|
||||
import { FeishuAssessmentService } from './services/feishu-assessment.service';
|
||||
import { CreateFeishuBotDto } from './dto/create-bot.dto';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@Controller('feishu')
|
||||
export class FeishuController {
|
||||
private readonly logger = new Logger(FeishuController.name);
|
||||
|
||||
constructor(
|
||||
private readonly feishuService: FeishuService,
|
||||
private readonly feishuAssessmentService: FeishuAssessmentService,
|
||||
) {}
|
||||
|
||||
// ─── Bot Management Endpoints (JWT-protected) ─────────────────────────────
|
||||
|
||||
/** GET /feishu/bots - List user's bots, masking sensitive fields */
|
||||
@Get('bots')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async listBots(@Request() req) {
|
||||
const bots = await this.feishuService.getUserBots(
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
return bots.map((bot) => ({
|
||||
id: bot.id,
|
||||
appId: bot.appId,
|
||||
botName: bot.botName,
|
||||
enabled: bot.enabled,
|
||||
isDefault: bot.isDefault,
|
||||
webhookUrl: `/api/feishu/webhook/${bot.appId}`,
|
||||
createdAt: bot.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/** POST /feishu/bots - Create or update a bot */
|
||||
@Post('bots')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async createBot(@Request() req, @Body() dto: CreateFeishuBotDto) {
|
||||
const bot = await this.feishuService.createBot(
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
dto,
|
||||
);
|
||||
return {
|
||||
id: bot.id,
|
||||
appId: bot.appId,
|
||||
botName: bot.botName,
|
||||
enabled: bot.enabled,
|
||||
webhookUrl: `/api/feishu/webhook/${bot.appId}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** PATCH /feishu/bots/:id/toggle - Enable or disable a bot */
|
||||
@Patch('bots/:id/toggle')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async toggleBot(
|
||||
@Request() req,
|
||||
@Param('id') botId: string,
|
||||
@Body() body: { enabled: boolean },
|
||||
) {
|
||||
const bot = await this.feishuService.setBotEnabled(botId, body.enabled);
|
||||
return { id: bot.id, enabled: bot.enabled };
|
||||
}
|
||||
|
||||
/** DELETE /feishu/bots/:id - Delete a bot */
|
||||
@Delete('bots/:id')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async deleteBot(@Request() req, @Param('id') botId: string) {
|
||||
await this.feishuService.deleteBot(req.user.id, botId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── WebSocket Management Endpoints ────────────────────────────────────
|
||||
|
||||
/** POST /feishu/bots/:id/ws/connect - Start WebSocket connection */
|
||||
@Post('bots/:id/ws/connect')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async connectWs(@Request() req, @Param('id') botId: string) {
|
||||
const bot = await this.feishuService.getBotById(botId);
|
||||
if (!bot || bot.userId !== req.user.id) {
|
||||
return { success: false, error: 'Bot not found' };
|
||||
}
|
||||
try {
|
||||
await this.feishuService.startWsConnection(botId);
|
||||
return { success: true, botId, status: 'connecting' };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
botId,
|
||||
error: error?.message || 'Failed to connect',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /feishu/bots/:id/ws/disconnect - Stop WebSocket connection */
|
||||
@Post('bots/:id/ws/disconnect')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async disconnectWs(@Request() req, @Param('id') botId: string) {
|
||||
const bot = await this.feishuService.getBotById(botId);
|
||||
if (!bot || bot.userId !== req.user.id) {
|
||||
return { success: false, error: 'Bot not found' };
|
||||
}
|
||||
try {
|
||||
await this.feishuService.stopWsConnection(botId);
|
||||
return { success: true, botId, status: 'disconnected' };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
botId,
|
||||
error: error?.message || 'Failed to disconnect',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /feishu/bots/:id/ws/status - Get connection status */
|
||||
@Get('bots/:id/ws/status')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async getWsStatus(@Request() req, @Param('id') botId: string) {
|
||||
const bot = await this.feishuService.getBotById(botId);
|
||||
if (!bot || bot.userId !== req.user.id) {
|
||||
return { success: false, error: 'Bot not found' };
|
||||
}
|
||||
const status = await this.feishuService.getWsStatus(botId);
|
||||
if (!status) {
|
||||
return { botId, state: 'disconnected' };
|
||||
}
|
||||
return {
|
||||
botId: status.botId,
|
||||
state: status.state,
|
||||
connectedAt: status.connectedAt?.toISOString(),
|
||||
lastHeartbeat: status.lastHeartbeat?.toISOString(),
|
||||
error: status.error,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /feishu/ws/status - Get all active WS connection statuses */
|
||||
@Get('ws/status')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
async getAllWsStatus() {
|
||||
const statuses = await this.feishuService.getAllWsStatuses();
|
||||
return {
|
||||
connections: statuses.map((s) => ({
|
||||
botId: s.botId,
|
||||
state: s.state,
|
||||
connectedAt: s.connectedAt?.toISOString(),
|
||||
lastHeartbeat: s.lastHeartbeat?.toISOString(),
|
||||
error: s.error,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Feishu Webhook Endpoint (Public) ────────────────────────────────────
|
||||
@Get('webhook/:appId')
|
||||
@Post('webhook/:appId')
|
||||
@Public()
|
||||
async handleWebhook(
|
||||
@Param('appId') appId: string,
|
||||
@Body() body: any,
|
||||
@Headers() headers: any,
|
||||
@Request() req: any,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const logEntry = `\n[${new Date().toISOString()}] ${req.method} /api/feishu/webhook/${appId}\nHeaders: ${JSON.stringify(headers)}\nBody: ${JSON.stringify(body)}\n`;
|
||||
fs.appendFileSync('feishu_webhook.log', logEntry);
|
||||
|
||||
this.logger.log(
|
||||
`Incoming Feishu webhook [${req.method}] for appId: ${appId}`,
|
||||
);
|
||||
|
||||
// GET request for simple connection test
|
||||
if (req.method === 'GET') {
|
||||
return res.status(200).json({
|
||||
status: 'ok',
|
||||
message: 'AuraK Feishu Webhook is active.',
|
||||
appId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: URL verification handshake
|
||||
const challenge = body?.challenge || body?.event?.challenge;
|
||||
if (body?.type === 'url_verification' || challenge) {
|
||||
this.logger.log(
|
||||
`URL verification active for appId: ${appId}, challenge: ${challenge}`,
|
||||
);
|
||||
return res.status(200).json({ challenge });
|
||||
}
|
||||
|
||||
// Step 2: Return 200 immediately for all other events
|
||||
res.status(200).json({ success: true });
|
||||
|
||||
// Step 3: Process the event asynchronously
|
||||
if (body?.type === 'event_callback' || body?.header?.event_type) {
|
||||
setImmediate(() =>
|
||||
this._processEvent(appId, body).catch((e) =>
|
||||
this.logger.error('Failed to process Feishu event async', e),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private Event Processor ─────────────────────────────────────────────
|
||||
|
||||
private async _processEvent(appId: string, body: any): Promise<void> {
|
||||
const { type, event, header } = body;
|
||||
|
||||
if (type !== 'event_callback') return;
|
||||
|
||||
const eventType = header?.event_type || body.event_type;
|
||||
const eventId = header?.event_id;
|
||||
this.logger.log(
|
||||
`Processing Feishu event [${eventId}]: ${eventType} for appId: ${appId}`,
|
||||
);
|
||||
|
||||
const bot = await this.feishuService.getBotByAppId(appId);
|
||||
if (!bot || !bot.enabled) {
|
||||
this.logger.warn(`Bot not found or disabled for appId: ${appId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (eventType) {
|
||||
case 'im.message.receive_v1':
|
||||
case 'im.message.p2p_msg_received':
|
||||
case 'im.message.group_at_msg_received':
|
||||
await this._handleMessage(bot, event);
|
||||
break;
|
||||
default:
|
||||
this.logger.log(`Unhandled event type: ${eventType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse incoming IM message and route to chatService via FeishuService.
|
||||
* Implements Chunk 5 integration.
|
||||
*/
|
||||
private async _handleMessage(bot: any, event: any): Promise<void> {
|
||||
const message = event?.message;
|
||||
if (!message) return;
|
||||
|
||||
const messageId = message.message_id;
|
||||
const openId = event?.sender?.sender_id?.open_id;
|
||||
|
||||
if (!openId) {
|
||||
this.logger.warn('No sender open_id found in Feishu event');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse text content
|
||||
let userText = '';
|
||||
try {
|
||||
const content = JSON.parse(message.content || '{}');
|
||||
userText = content.text || '';
|
||||
} catch {
|
||||
this.logger.warn('Failed to parse Feishu message content');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userText.trim()) return;
|
||||
|
||||
try {
|
||||
// Centralized routing via FeishuService
|
||||
await this.feishuService.handleIncomingMessage(
|
||||
bot,
|
||||
openId,
|
||||
messageId,
|
||||
userText,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Message handling failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user