Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,55 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the architecture deep-dive

## Quick Start

### 🚀 One-Command Local Development (In-Memory MongoDB)

Run the local dev stack with zero setup required (starts an in-memory MongoDB, seeds 165 FAQs + 5 Community Posts, launches backend on port 6767, and frontend on port 5173):

```bash
node start-local.mjs
```

Or from the `crowd-source-faq` directory in PowerShell:
```powershell
cd crowd-source-faq
node start-local.mjs
```

---

### 💻 Terminal Access & CLI Commands

You can interact with and query the live portal directly through your terminal:

#### 🤖 Ask the AI Chatbot:
```powershell
Invoke-RestMethod -Uri 'http://localhost:5173/csfaq/api/ask-ai' -Method Post -Body '{"question":"What are Spurti points?"}' -ContentType 'application/json' | Select-Object -ExpandProperty answer
```

#### 📚 Browse FAQs:
```powershell
(Invoke-RestMethod -Uri 'http://localhost:5173/csfaq/api/faq?limit=5').faqs | Select-Object question, category
```

#### 💬 Browse Community Threads:
```powershell
(Invoke-RestMethod -Uri 'http://localhost:5173/csfaq/api/community/').posts | Select-Object title, status
```

#### 🗂️ Check Active Programs:
```powershell
(Invoke-RestMethod -Uri 'http://localhost:5173/csfaq/api/batches').batches | Select-Object name, isActive, isDefault, faqCount
```

#### 🏥 System Health Audit:
```powershell
Invoke-RestMethod -Uri 'http://localhost:5173/csfaq/api/health'
```

---

### 🛠️ Production / Full-Stack Runner (`run.sh`)

```bash
./run.sh # Full-stack runner: env setup, ngrok, backend + frontend
```
Expand All @@ -55,6 +104,24 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the architecture deep-dive

---

## Push to GitHub

To push this project to your own GitHub repository, follow these steps:

1. Create a new empty repository on GitHub.
2. Open your terminal in the project root (`crowd-source-faq`).
3. Run the following commands:
```bash
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin <your-github-repo-url>
git push -u origin main
```

---

## Key Features

Eight flagship capabilities define this platform:
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@
"@types/node": "^25.9.1",
"@types/range-parser": "^1.2.7",
"@types/uuid": "^10.0.0",
"canvas": "^3.2.3",
"nodemon": "^3.0.2",
"playwright": "^1.61.1",
"tsx": "^4.22.3",
"typescript": "^6.0.3",
"vitest": "^1.6.0"
Expand Down
245 changes: 245 additions & 0 deletions apps/backend/src/__tests__/discordIngestion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleMessageReaction } from '../integrations/discord/discordIngestionService.js';
import { Types } from 'mongoose';

// Hoisted mocks for dependencies
const mockFaqCreate = vi.fn();
const mockChatWithConfig = vi.fn();
const mockGetPipelineProviderConfig = vi.fn();

vi.mock('../modules/faq/faq.model.js', () => ({
default: {
create: (data: any) => mockFaqCreate(data),
},
}));

vi.mock('../utils/ai/aiProvider.js', () => ({
chatWithConfig: (cfg: any, msgs: any[]) => mockChatWithConfig(cfg, msgs),
getPipelineProviderConfig: (name: string) => mockGetPipelineProviderConfig(name),
}));

// Mock logger to avoid spamming test output
vi.mock('../utils/http/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));

describe('discordIngestionService: handleMessageReaction', () => {
const dummyConfig = {
batchId: new Types.ObjectId().toString(),
guildId: '123456789',
botToken: 'mock-token',
applicationId: 'mock-app-id',
clientId: 'mock-client-id',
webhookUrl: null,
notificationChannelId: null,
adminUserIds: [],
publicChannelId: null,
publicUrl: 'http://localhost:6767',
internalApiKey: null,
};

beforeEach(() => {
vi.clearAllMocks();
});

it('ignores bots reacting', async () => {
const mockReaction = {
emoji: { name: '✅' },
partial: false,
message: {
author: { bot: false },
content: 'answer content',
react: vi.fn(),
},
};
const mockUser = { bot: true, tag: 'bot#1234' };

await handleMessageReaction(mockReaction as any, mockUser as any, dummyConfig);

expect(mockFaqCreate).not.toHaveBeenCalled();
});

it('ignores non-trigger emojis', async () => {
const mockReaction = {
emoji: { name: '👎' },
partial: false,
message: {
author: { bot: false },
content: 'answer content',
react: vi.fn(),
},
};
const mockUser = { bot: false, tag: 'user#1234' };

await handleMessageReaction(mockReaction as any, mockUser as any, dummyConfig);

expect(mockFaqCreate).not.toHaveBeenCalled();
});

it('ignores bot-authored messages', async () => {
const mockReaction = {
emoji: { name: '✅' },
partial: false,
message: {
author: { bot: true },
content: 'bot answer content',
react: vi.fn(),
},
};
const mockUser = { bot: false, tag: 'user#1234' };

await handleMessageReaction(mockReaction as any, mockUser as any, dummyConfig);

expect(mockFaqCreate).not.toHaveBeenCalled();
});

it('extracts Q&A and creates a pending FAQ from thread context', async () => {
const mockReact = vi.fn();
const fetchStarterMessageMock = vi.fn().mockResolvedValue({ content: 'What is the program deadline?' });
const mockReaction = {
emoji: { name: '✅' },
partial: false,
message: {
id: 'msg_98765',
author: { bot: false },
content: 'The deadline is next Monday.',
channel: {
isThread: () => true,
fetchStarterMessage: fetchStarterMessageMock,
},
react: mockReact,
},
};
const mockUser = { bot: false, tag: 'reviewer#5678' };

mockGetPipelineProviderConfig.mockResolvedValue({ provider: 'openai', model: 'gpt-4' });
mockChatWithConfig.mockResolvedValue('{"question": "What is the program deadline?", "answer": "The deadline is next Monday."}');
mockFaqCreate.mockResolvedValue({ _id: 'faq_123' });

await handleMessageReaction(mockReaction as any, mockUser as any, dummyConfig);

expect(fetchStarterMessageMock).toHaveBeenCalled();
expect(mockGetPipelineProviderConfig).toHaveBeenCalledWith('auto_answer');
expect(mockChatWithConfig).toHaveBeenCalled();
expect(mockFaqCreate).toHaveBeenCalledWith(
expect.objectContaining({
question: 'What is the program deadline?',
answer: 'The deadline is next Monday.',
status: 'pending',
reviewStatus: 'pending_review',
batchId: expect.any(Types.ObjectId),
})
);
expect(mockReact).toHaveBeenCalledWith('💾');
});

it('extracts Q&A and creates pending FAQ when not a thread', async () => {
const mockReact = vi.fn();
const mockReaction = {
emoji: { name: '💡' },
partial: false,
message: {
id: 'msg_98766',
author: { bot: false },
content: 'How do I contact support? Email support@example.com',
channel: {
isThread: () => false,
},
react: mockReact,
},
};
const mockUser = { bot: false, tag: 'reviewer#5678' };

mockGetPipelineProviderConfig.mockResolvedValue({ provider: 'openai', model: 'gpt-4' });
mockChatWithConfig.mockResolvedValue('{"question": "How do I contact support?", "answer": "Email support@example.com"}');
mockFaqCreate.mockResolvedValue({ _id: 'faq_124' });

await handleMessageReaction(mockReaction as any, mockUser as any, dummyConfig);

expect(mockGetPipelineProviderConfig).toHaveBeenCalledWith('auto_answer');
expect(mockChatWithConfig).toHaveBeenCalled();
expect(mockFaqCreate).toHaveBeenCalledWith(
expect.objectContaining({
question: 'How do I contact support?',
answer: 'Email support@example.com',
status: 'pending',
reviewStatus: 'pending_review',
})
);
expect(mockReact).toHaveBeenCalledWith('💾');
});

it('extracts Q&A and fetches referenced message content when reacting to a reply', async () => {
const mockReact = vi.fn();
const fetchMessageMock = vi.fn().mockResolvedValue({ content: 'How do I generate API keys?' });
const mockReaction = {
emoji: { name: '✅' },
partial: false,
message: {
id: 'msg_98768',
author: { bot: false },
content: 'Go to settings -> API and click generate.',
reference: {
messageId: 'msg_referenced_123',
},
channel: {
isThread: () => false,
messages: {
fetch: fetchMessageMock,
},
},
react: mockReact,
},
};
const mockUser = { bot: false, tag: 'reviewer#5678' };

mockGetPipelineProviderConfig.mockResolvedValue({ provider: 'openai', model: 'gpt-4' });
mockChatWithConfig.mockResolvedValue('{"question": "How do I generate API keys?", "answer": "Go to settings -> API and click generate."}');
mockFaqCreate.mockResolvedValue({ _id: 'faq_125' });

await handleMessageReaction(mockReaction as any, mockUser as any, dummyConfig);

expect(fetchMessageMock).toHaveBeenCalledWith('msg_referenced_123');
expect(mockGetPipelineProviderConfig).toHaveBeenCalledWith('auto_answer');
expect(mockChatWithConfig).toHaveBeenCalled();
expect(mockFaqCreate).toHaveBeenCalledWith(
expect.objectContaining({
question: 'How do I generate API keys?',
answer: 'Go to settings -> API and click generate.',
status: 'pending',
reviewStatus: 'pending_review',
})
);
expect(mockReact).toHaveBeenCalledWith('💾');
});

it('gracefully handles invalid JSON from AI', async () => {
const mockReact = vi.fn();
const mockReaction = {
emoji: { name: '✅' },
partial: false,
message: {
id: 'msg_98767',
author: { bot: false },
content: 'Just check the website.',
channel: {
isThread: () => false,
},
react: mockReact,
},
};
const mockUser = { bot: false, tag: 'reviewer#5678' };

mockGetPipelineProviderConfig.mockResolvedValue({ provider: 'openai', model: 'gpt-4' });
mockChatWithConfig.mockResolvedValue('This is not JSON text.');

await handleMessageReaction(mockReaction as any, mockUser as any, dummyConfig);

expect(mockFaqCreate).not.toHaveBeenCalled();
expect(mockReact).not.toHaveBeenCalled();
});
});
36 changes: 36 additions & 0 deletions apps/backend/src/bootstrap/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,42 @@ export async function startup(config: any): Promise<void> {
startupLog.error('startup DB connect / migrate failed', { error: (e as Error).message });
}

// ── Auto-seed a default program on first boot ──────────────────────────────
// On a fresh in-memory or empty MongoDB, there are no programs so the
// portal shows "No programs yet." This seeds one so the app is immediately
// usable. It is idempotent — runs only if no programs exist at all.
try {
const { default: Batch } = await import('../modules/program/batch.model.js');
const { bootstrapProgram } = await import('../modules/program/provisioning.service.js');
const existingCount = await Batch.countDocuments();
if (existingCount === 0) {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1); // First of this month
const end = new Date(now.getFullYear(), now.getMonth() + 6, 0); // 6 months later
const seeded = await Batch.create({
name: 'Default Program',
description: 'Auto-created default program. Rename or replace it from Admin → Programs.',
startDate: start,
endDate: end,
isActive: true,
isDefault: true,
status: 'active',
enrollmentMode: 'open',
maxEnrollment: null,
createdBy: null,
ownerUserId: null,
});
try {
await bootstrapProgram(seeded._id);
} catch (_bootstrapErr) {
// Non-fatal — categories/settings can be set up manually
}
startupLog.info(`[seed] Created default program "${seeded.name}" (${seeded._id})`);
}
} catch (e) {
startupLog.warn(`[seed] Default program seed failed (non-fatal): ${(e as Error).message}`);
}

// Lazy-init the RegistrationConfig singleton
try {
const { ensureRegistrationConfig } = await import('../modules/program/registration-config.model.js');
Expand Down
Loading