Commit 0b0931ce authored by Administrator's avatar Administrator

Update 18 files via Son of Anton

parent f3429de4
...@@ -58,6 +58,11 @@ import { ReportsModule } from './modules/reports/reports.module'; ...@@ -58,6 +58,11 @@ import { ReportsModule } from './modules/reports/reports.module';
// ─── Phase 3A: Admin & Intelligence ───────────────────────── // ─── Phase 3A: Admin & Intelligence ─────────────────────────
import { AnalyticsModule } from './modules/analytics/analytics.module'; import { AnalyticsModule } from './modules/analytics/analytics.module';
// ─── Phase 3B: API & Integration ────────────────────────────
import { ApiKeysModule } from './modules/api-keys/api-keys.module';
import { WebhooksModule } from './modules/webhooks/webhooks.module';
import { SearchModule } from './modules/search/search.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard'; import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor';
...@@ -113,6 +118,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware'; ...@@ -113,6 +118,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
ReportsModule, ReportsModule,
// Phase 3A // Phase 3A
AnalyticsModule, AnalyticsModule,
// Phase 3B
ApiKeysModule,
WebhooksModule,
SearchModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
......
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiKeysService } from './api-keys.service';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('api-keys')
@Roles('SUPER_ADMIN')
export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {}
@Post()
async create(@Body() dto: CreateApiKeyDto, @CurrentUser() user: RequestUser) {
return this.apiKeysService.create(dto, user);
}
@Get()
async findAll(@CurrentUser() user: RequestUser) {
return this.apiKeysService.findAll(user);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.apiKeysService.findById(id, user);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateApiKeyDto,
@CurrentUser() user: RequestUser,
) {
return this.apiKeysService.update(id, dto, user);
}
@Post(':id/revoke')
@HttpCode(HttpStatus.OK)
async revoke(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.apiKeysService.revoke(id, user);
}
@Post(':id/reactivate')
@HttpCode(HttpStatus.OK)
async reactivate(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.apiKeysService.reactivate(id, user);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.apiKeysService.delete(id, user);
return { message: 'API key permanently deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
@Module({
controllers: [ApiKeysController],
providers: [ApiKeysService],
exports: [ApiKeysService],
})
export class ApiKeysModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import * as crypto from 'crypto';
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateApiKeyDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage API keys');
}
const validScopes = ['READ_ONLY', 'READ_WRITE', 'ADMIN'];
if (!validScopes.includes(dto.scope)) {
throw new BadRequestException(`Scope must be one of: ${validScopes.join(', ')}`);
}
// Generate a cryptographically secure API key
const rawKey = `grind_${dto.scope.toLowerCase()}_${crypto.randomBytes(32).toString('hex')}`;
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
// Store only the first 8 chars as prefix for identification
const keyPrefix = rawKey.substring(0, 16);
const expiresAt = dto.expiresInDays
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
: null;
const apiKey = await this.prisma.apiKey.create({
data: {
name: dto.name,
keyHash,
keyPrefix,
scope: dto.scope,
description: dto.description || null,
expiresAt,
isActive: true,
createdById: currentUser.id,
rateLimit: dto.rateLimit || 1000, // requests per hour
},
});
this.logger.log(
`API key "${dto.name}" created by ${currentUser.email} — scope: ${dto.scope}`,
);
// Return the raw key ONCE — it can never be retrieved again
return {
id: apiKey.id,
name: apiKey.name,
key: rawKey, // ONLY time the full key is shown
keyPrefix: apiKey.keyPrefix,
scope: apiKey.scope,
description: apiKey.description,
expiresAt: apiKey.expiresAt,
rateLimit: apiKey.rateLimit,
isActive: apiKey.isActive,
createdAt: apiKey.createdAt,
warning: 'Store this key securely. It will NOT be shown again.',
};
}
async findAll(currentUser: RequestUser): Promise<any[]> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage API keys');
}
const keys = await this.prisma.apiKey.findMany({
orderBy: { createdAt: 'desc' },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
return keys.map((k) => ({
id: k.id,
name: k.name,
keyPrefix: k.keyPrefix,
scope: k.scope,
description: k.description,
isActive: k.isActive,
expiresAt: k.expiresAt,
isExpired: k.expiresAt ? new Date() > k.expiresAt : false,
rateLimit: k.rateLimit,
lastUsedAt: k.lastUsedAt,
createdBy: k.createdBy,
createdAt: k.createdAt,
}));
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage API keys');
}
const key = await this.prisma.apiKey.findUnique({
where: { id },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!key) throw new NotFoundException('API key not found');
return {
id: key.id,
name: key.name,
keyPrefix: key.keyPrefix,
scope: key.scope,
description: key.description,
isActive: key.isActive,
expiresAt: key.expiresAt,
isExpired: key.expiresAt ? new Date() > key.expiresAt : false,
rateLimit: key.rateLimit,
lastUsedAt: key.lastUsedAt,
createdBy: key.createdBy,
createdAt: key.createdAt,
updatedAt: key.updatedAt,
};
}
async update(id: string, dto: UpdateApiKeyDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage API keys');
}
const key = await this.prisma.apiKey.findUnique({ where: { id } });
if (!key) throw new NotFoundException('API key not found');
const updateData: any = {};
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.description !== undefined) updateData.description = dto.description;
if (dto.rateLimit !== undefined) updateData.rateLimit = dto.rateLimit;
if (dto.scope !== undefined) {
const validScopes = ['READ_ONLY', 'READ_WRITE', 'ADMIN'];
if (!validScopes.includes(dto.scope)) {
throw new BadRequestException(`Scope must be one of: ${validScopes.join(', ')}`);
}
updateData.scope = dto.scope;
}
if (dto.expiresInDays !== undefined) {
updateData.expiresAt = dto.expiresInDays
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
: null;
}
const updated = await this.prisma.apiKey.update({
where: { id },
data: updateData,
});
this.logger.log(`API key "${updated.name}" updated by ${currentUser.email}`);
return this.findById(id, currentUser);
}
async revoke(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage API keys');
}
const key = await this.prisma.apiKey.findUnique({ where: { id } });
if (!key) throw new NotFoundException('API key not found');
if (!key.isActive) {
throw new BadRequestException('API key is already revoked');
}
const updated = await this.prisma.apiKey.update({
where: { id },
data: { isActive: false },
});
this.logger.log(`API key "${updated.name}" revoked by ${currentUser.email}`);
return this.findById(id, currentUser);
}
async reactivate(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage API keys');
}
const key = await this.prisma.apiKey.findUnique({ where: { id } });
if (!key) throw new NotFoundException('API key not found');
if (key.isActive) {
throw new BadRequestException('API key is already active');
}
const updated = await this.prisma.apiKey.update({
where: { id },
data: { isActive: true },
});
this.logger.log(`API key "${updated.name}" reactivated by ${currentUser.email}`);
return this.findById(id, currentUser);
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage API keys');
}
const key = await this.prisma.apiKey.findUnique({ where: { id } });
if (!key) throw new NotFoundException('API key not found');
await this.prisma.apiKey.delete({ where: { id } });
this.logger.log(`API key "${key.name}" permanently deleted by ${currentUser.email}`);
}
async validateKey(rawKey: string): Promise<{ valid: boolean; key?: any }> {
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
const key = await this.prisma.apiKey.findFirst({
where: {
keyHash,
isActive: true,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
});
if (!key) return { valid: false };
// Update last used timestamp
await this.prisma.apiKey.update({
where: { id: key.id },
data: { lastUsedAt: new Date() },
});
return { valid: true, key };
}
}
\ No newline at end of file
import { IsString, IsOptional, IsInt, Min, Max, MinLength, MaxLength } from 'class-validator';
export class CreateApiKeyDto {
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsString()
scope: string; // READ_ONLY, READ_WRITE, ADMIN
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(365)
expiresInDays?: number;
@IsOptional()
@IsInt()
@Min(10)
@Max(100000)
rateLimit?: number; // requests per hour
}
\ No newline at end of file
import { IsString, IsOptional, IsInt, Min, Max, MaxLength } from 'class-validator';
export class UpdateApiKeyDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
scope?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(365)
expiresInDays?: number;
@IsOptional()
@IsInt()
@Min(10)
@Max(100000)
rateLimit?: number;
}
\ No newline at end of file
import { IsString, IsOptional, IsInt, Min, Max, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
export class SearchQueryDto {
@IsString()
@MinLength(2, { message: 'Search query must be at least 2 characters' })
q: string;
@IsOptional()
@IsString()
entityTypes?: string;
// Comma-separated: CARDS,USERS,BOARDS,DEDUCTIONS,LABELS,MESSAGES,NOTIFICATIONS
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
\ No newline at end of file
export class SearchResultDto {
type: string;
id: string;
title: string;
subtitle: string | null;
context: string | null;
url: string;
score: number;
}
export class SearchResponseDto {
query: string;
totalResults: number;
results: SearchResultDto[];
groupedResults: Record<string, SearchResultDto[]>;
}
\ No newline at end of file
import { Controller, Get, Query } from '@nestjs/common';
import { SearchService } from './search.service';
import { SearchQueryDto } from './dto/search-query.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Get()
async search(@Query() query: SearchQueryDto, @CurrentUser() user: RequestUser) {
return this.searchService.search(query, user);
}
@Get('quick')
async quickSearch(@Query('q') q: string, @CurrentUser() user: RequestUser) {
if (!q || q.trim().length < 2) {
return { results: [] };
}
return this.searchService.quickSearch(q.trim(), user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
@Module({
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
})
export class SearchModule {}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { SearchQueryDto } from './dto/search-query.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
interface SearchResult {
type: string;
id: string;
title: string;
subtitle: string | null;
context: string | null;
url: string;
score: number;
}
interface SearchResponse {
query: string;
totalResults: number;
results: SearchResult[];
groupedResults: Record<string, SearchResult[]>;
}
@Injectable()
export class SearchService {
private readonly logger = new Logger(SearchService.name);
constructor(private readonly prisma: PrismaService) {}
async search(query: SearchQueryDto, currentUser: RequestUser): Promise<SearchResponse> {
const q = query.q?.trim();
if (!q || q.length < 2) {
return { query: q || '', totalResults: 0, results: [], groupedResults: {} };
}
const limit = query.limit || 50;
const entityTypes = query.entityTypes
? query.entityTypes.split(',').map((t) => t.trim().toUpperCase())
: null; // null means search all
const allResults: SearchResult[] = [];
// ─── CARDS ──────────────────────────────────────────────
if (!entityTypes || entityTypes.includes('CARDS')) {
const cards = await this.searchCards(q, currentUser, 20);
allResults.push(...cards);
}
// ─── USERS / CONTRACTORS ────────────────────────────────
if (!entityTypes || entityTypes.includes('USERS') || entityTypes.includes('CONTRACTORS')) {
const users = await this.searchUsers(q, currentUser, 10);
allResults.push(...users);
}
// ─── BOARDS ─────────────────────────────────────────────
if (!entityTypes || entityTypes.includes('BOARDS')) {
const boards = await this.searchBoards(q, currentUser, 10);
allResults.push(...boards);
}
// ─── DEDUCTIONS ─────────────────────────────────────────
if (!entityTypes || entityTypes.includes('DEDUCTIONS')) {
const deductions = await this.searchDeductions(q, currentUser, 10);
allResults.push(...deductions);
}
// ─── LABELS ─────────────────────────────────────────────
if (!entityTypes || entityTypes.includes('LABELS')) {
const labels = await this.searchLabels(q, currentUser, 10);
allResults.push(...labels);
}
// ─── MESSAGES ───────────────────────────────────────────
if (!entityTypes || entityTypes.includes('MESSAGES')) {
const messages = await this.searchMessages(q, currentUser, 10);
allResults.push(...messages);
}
// ─── NOTIFICATIONS ──────────────────────────────────────
if (!entityTypes || entityTypes.includes('NOTIFICATIONS')) {
const notifications = await this.searchNotifications(q, currentUser, 10);
allResults.push(...notifications);
}
// Sort by score descending, then slice to limit
allResults.sort((a, b) => b.score - a.score);
const limitedResults = allResults.slice(0, limit);
// Group by type
const groupedResults: Record<string, SearchResult[]> = {};
for (const result of limitedResults) {
if (!groupedResults[result.type]) {
groupedResults[result.type] = [];
}
groupedResults[result.type].push(result);
}
return {
query: q,
totalResults: limitedResults.length,
results: limitedResults,
groupedResults,
};
}
async quickSearch(q: string, currentUser: RequestUser): Promise<{ results: SearchResult[] }> {
const allResults: SearchResult[] = [];
// Quick search only hits the most common entities with lower limits
const [cards, users, boards] = await Promise.all([
this.searchCards(q, currentUser, 5),
this.searchUsers(q, currentUser, 3),
this.searchBoards(q, currentUser, 3),
]);
allResults.push(...cards, ...users, ...boards);
allResults.sort((a, b) => b.score - a.score);
return { results: allResults.slice(0, 10) };
}
// ─── ENTITY-SPECIFIC SEARCH METHODS ────────────────────────
private async searchCards(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
const where: any = {
deletedAt: null,
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ cardNumber: { contains: q, mode: 'insensitive' } },
],
};
// Permission: contractors only see cards on their boards
if (user.role === 'CONTRACTOR') {
where.column = {
board: { members: { some: { userId: user.id } } },
};
} else if (user.role === 'TEAM_LEAD') {
where.column = {
board: { members: { some: { userId: user.id } } },
};
}
const cards = await this.prisma.card.findMany({
where,
take: limit,
orderBy: { updatedAt: 'desc' },
select: {
id: true,
cardNumber: true,
title: true,
description: true,
isArchived: true,
column: {
select: {
name: true,
board: { select: { id: true, name: true, key: true } },
},
},
},
});
return cards.map((c) => {
const isExactCardNumber = c.cardNumber.toLowerCase() === q.toLowerCase();
const isTitleMatch = c.title.toLowerCase().includes(q.toLowerCase());
return {
type: 'CARD',
id: c.id,
title: `${c.cardNumber}: ${c.title}`,
subtitle: `${c.column.board.name}${c.column.name}${c.isArchived ? ' (Archived)' : ''}`,
context: c.description
? this.extractContext(c.description, q, 100)
: null,
url: `/boards/${c.column.board.id}?card=${c.id}`,
score: isExactCardNumber ? 100 : isTitleMatch ? 80 : 50,
};
});
}
private async searchUsers(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
const where: any = {
deletedAt: null,
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ username: { contains: q, mode: 'insensitive' } },
{ displayName: { contains: q, mode: 'insensitive' } },
],
};
// Contractors can see the directory but with limited data
const users = await this.prisma.user.findMany({
where,
take: limit,
orderBy: { firstName: 'asc' },
select: {
id: true,
firstName: true,
lastName: true,
username: true,
role: true,
status: true,
contractorType: true,
},
});
return users.map((u) => {
const isExactUsername = u.username.toLowerCase() === q.toLowerCase();
return {
type: 'USER',
id: u.id,
title: `${u.firstName} ${u.lastName}`,
subtitle: `@${u.username} · ${u.role} · ${u.status}`,
context: u.contractorType || null,
url: user.role === 'CONTRACTOR' ? `/directory` : `/admin/contractors/${u.id}`,
score: isExactUsername ? 90 : 60,
};
});
}
private async searchBoards(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
const where: any = {
deletedAt: null,
isArchived: false,
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ key: { contains: q, mode: 'insensitive' } },
],
};
// Non-admins only see their boards
if (user.role !== 'SUPER_ADMIN' && user.role !== 'ADMIN') {
where.members = { some: { userId: user.id } };
}
const boards = await this.prisma.board.findMany({
where,
take: limit,
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
key: true,
description: true,
_count: { select: { members: true } },
},
});
return boards.map((b: any) => ({
type: 'BOARD',
id: b.id,
title: `${b.name} (${b.key})`,
subtitle: `${b._count.members} members`,
context: b.description
? this.extractContext(b.description, q, 80)
: null,
url: `/boards/${b.id}`,
score: b.key.toLowerCase() === q.toLowerCase() ? 95 : 70,
}));
}
private async searchDeductions(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
// Contractors only see their own deductions
const where: any = {
OR: [
{ description: { contains: q, mode: 'insensitive' } },
{ category: { contains: q, mode: 'insensitive' } },
{ subCategory: { contains: q, mode: 'insensitive' } },
],
};
if (user.role === 'CONTRACTOR') {
where.userId = user.id;
} else if (user.role === 'TEAM_LEAD') {
// PLs see deductions for their team (counts only, but search should return)
where.OR2 = [
{ initiatedById: user.id },
{ user: { assignedProjectLeaderId: user.id } },
];
// Merge conditions
where.AND = [
{ OR: where.OR },
{ OR: [{ initiatedById: user.id }, { user: { assignedProjectLeaderId: user.id } }] },
];
delete where.OR;
delete where.OR2;
}
const deductions = await this.prisma.deduction.findMany({
where,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
category: true,
subCategory: true,
description: true,
status: true,
amountPiasters: true,
violationDate: true,
user: { select: { firstName: true, lastName: true } },
},
});
return deductions.map((d) => ({
type: 'DEDUCTION',
id: d.id,
title: `Deduction ${d.subCategory}: ${d.user.firstName} ${d.user.lastName}`,
subtitle: `${d.status} · ${d.violationDate?.toISOString().split('T')[0] || 'N/A'}`,
context: d.description
? this.extractContext(d.description, q, 100)
: null,
url: `/salary`,
score: 40,
}));
}
private async searchLabels(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
const labels = await this.prisma.label.findMany({
where: {
name: { contains: q, mode: 'insensitive' },
},
take: limit,
select: {
id: true,
name: true,
color: true,
scope: true,
boardId: true,
board: { select: { name: true } },
},
});
return labels.map((l: any) => ({
type: 'LABEL',
id: l.id,
title: l.name,
subtitle: l.scope === 'ORGANIZATION' ? 'Organization label' : `Board: ${l.board?.name || 'Unknown'}`,
context: null,
url: l.boardId ? `/boards/${l.boardId}` : '/admin/templates',
score: 30,
}));
}
private async searchMessages(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
const where: any = {
content: { contains: q, mode: 'insensitive' },
deletedAt: null,
};
// Only search own conversations (SA can search all)
if (user.role !== 'SUPER_ADMIN') {
where.conversation = {
participants: { some: { userId: user.id } },
};
}
const messages = await this.prisma.message.findMany({
where,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
content: true,
conversationId: true,
sender: { select: { firstName: true, lastName: true } },
createdAt: true,
},
});
return messages.map((m) => ({
type: 'MESSAGE',
id: m.id,
title: `Message from ${m.sender.firstName} ${m.sender.lastName}`,
subtitle: m.createdAt.toISOString().split('T')[0],
context: this.extractContext(m.content, q, 100),
url: `/messages/${m.conversationId}`,
score: 35,
}));
}
private async searchNotifications(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
// Everyone only searches their own notifications
const notifications = await this.prisma.notification.findMany({
where: {
userId: user.id,
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ message: { contains: q, mode: 'insensitive' } },
],
},
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
message: true,
actionUrl: true,
isRead: true,
createdAt: true,
},
});
return notifications.map((n) => ({
type: 'NOTIFICATION',
id: n.id,
title: n.title,
subtitle: `${n.isRead ? 'Read' : 'Unread'} · ${n.createdAt.toISOString().split('T')[0]}`,
context: n.message
? this.extractContext(n.message, q, 80)
: null,
url: n.actionUrl || '/notifications',
score: 20,
}));
}
// ─── HELPERS ────────────────────────────────────────────────
/**
* Extract a snippet of text around the search query for context display.
*/
private extractContext(text: string, query: string, maxLength: number): string | null {
if (!text) return null;
// Strip HTML tags if any
const plainText = text.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
const lowerText = plainText.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) {
// Query not found in plain text — return start of text
return plainText.length > maxLength
? plainText.substring(0, maxLength) + '…'
: plainText;
}
// Center the context around the match
const contextStart = Math.max(0, index - Math.floor(maxLength / 3));
const contextEnd = Math.min(plainText.length, contextStart + maxLength);
let context = plainText.substring(contextStart, contextEnd);
if (contextStart > 0) context = '…' + context;
if (contextEnd < plainText.length) context = context + '…';
return context;
}
}
\ No newline at end of file
import { IsString, IsOptional, IsArray, MinLength, MaxLength, IsUrl } from 'class-validator';
export class CreateWebhookDto {
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsString()
url: string;
@IsOptional()
@IsString()
secret?: string;
@IsArray()
@IsString({ each: true })
events: string[];
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
}
\ No newline at end of file
import { IsString, IsOptional, IsArray, MaxLength } from 'class-validator';
export class UpdateWebhookDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
url?: string;
@IsOptional()
@IsString()
secret?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
events?: string[];
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
}
\ No newline at end of file
import { IsOptional, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class WebhookFilterDto extends PaginationDto {
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isActive?: boolean;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { WebhooksService } from './webhooks.service';
import { CreateWebhookDto } from './dto/create-webhook.dto';
import { UpdateWebhookDto } from './dto/update-webhook.dto';
import { WebhookFilterDto } from './dto/webhook-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('webhooks')
@Roles('SUPER_ADMIN')
export class WebhooksController {
constructor(private readonly webhooksService: WebhooksService) {}
@Post()
async create(@Body() dto: CreateWebhookDto, @CurrentUser() user: RequestUser) {
return this.webhooksService.create(dto, user);
}
@Get()
async findAll(@Query() filter: WebhookFilterDto, @CurrentUser() user: RequestUser) {
return this.webhooksService.findAll(filter, user);
}
@Get('events')
async getAvailableEvents() {
return this.webhooksService.getAvailableEvents();
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.webhooksService.findById(id, user);
}
@Get(':id/deliveries')
async getDeliveries(
@Param('id') id: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@CurrentUser() user?: RequestUser,
) {
return this.webhooksService.getDeliveries(
id,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50,
);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateWebhookDto,
@CurrentUser() user: RequestUser,
) {
return this.webhooksService.update(id, dto, user);
}
@Post(':id/test')
@HttpCode(HttpStatus.OK)
async testWebhook(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.webhooksService.sendTestEvent(id, user);
}
@Post(':id/activate')
@HttpCode(HttpStatus.OK)
async activate(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.webhooksService.setActive(id, true, user);
}
@Post(':id/deactivate')
@HttpCode(HttpStatus.OK)
async deactivate(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.webhooksService.setActive(id, false, user);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.webhooksService.delete(id, user);
return { message: 'Webhook deleted' };
}
@Post('deliveries/:deliveryId/retry')
@HttpCode(HttpStatus.OK)
async retryDelivery(
@Param('deliveryId') deliveryId: string,
@CurrentUser() user: RequestUser,
) {
return this.webhooksService.retryDelivery(deliveryId, user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';
@Module({
controllers: [WebhooksController],
providers: [WebhooksService],
exports: [WebhooksService],
})
export class WebhooksModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateWebhookDto } from './dto/create-webhook.dto';
import { UpdateWebhookDto } from './dto/update-webhook.dto';
import { WebhookFilterDto } from './dto/webhook-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
import * as crypto from 'crypto';
const AVAILABLE_EVENTS = [
'card.created',
'card.moved',
'card.assigned',
'card.done',
'card.overdue',
'card.updated',
'card.deleted',
'report.submitted',
'report.missed',
'deduction.created',
'deduction.applied',
'deduction.dismissed',
'bounty.paid',
'contractor.activated',
'contractor.terminated',
'payroll.approved',
'payroll.paid',
'evaluation.compiled',
'pip.created',
'pip.completed',
'meeting.created',
'meeting.cancelled',
'adjustment.approved',
'notice.created',
];
@Injectable()
export class WebhooksService {
private readonly logger = new Logger(WebhooksService.name);
constructor(private readonly prisma: PrismaService) {}
getAvailableEvents(): { events: string[]; descriptions: Record<string, string> } {
const descriptions: Record<string, string> = {
'card.created': 'A new card was created on any board',
'card.moved': 'A card was moved between columns',
'card.assigned': 'A card was assigned or unassigned',
'card.done': 'A card was moved to Done',
'card.overdue': 'A card passed its deadline without completion',
'card.updated': 'A card\'s fields were updated',
'card.deleted': 'A card was archived or deleted',
'report.submitted': 'A daily report was submitted',
'report.missed': 'A contractor missed their report deadline',
'deduction.created': 'A new deduction was initiated',
'deduction.applied': 'A deduction was applied to a contractor\'s salary',
'deduction.dismissed': 'A deduction was dismissed',
'bounty.paid': 'A bounty was paid out to contractor(s)',
'contractor.activated': 'A new contractor was activated',
'contractor.terminated': 'A contractor was terminated',
'payroll.approved': 'Monthly payroll was approved',
'payroll.paid': 'Monthly payroll was marked as paid',
'evaluation.compiled': 'A monthly evaluation was compiled',
'pip.created': 'A Performance Improvement Plan was created',
'pip.completed': 'A PIP was completed (passed or failed)',
'meeting.created': 'A new meeting was scheduled',
'meeting.cancelled': 'A meeting was cancelled',
'adjustment.approved': 'A salary adjustment was approved',
'notice.created': 'A notice/announcement was created',
};
return { events: AVAILABLE_EVENTS, descriptions };
}
async create(dto: CreateWebhookDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage webhooks');
}
// Validate URL format
try {
new URL(dto.url);
} catch {
throw new BadRequestException('Invalid webhook URL');
}
// Validate events
const invalidEvents = dto.events.filter((e) => !AVAILABLE_EVENTS.includes(e));
if (invalidEvents.length > 0) {
throw new BadRequestException(`Invalid event(s): ${invalidEvents.join(', ')}`);
}
if (dto.events.length === 0) {
throw new BadRequestException('At least one event must be subscribed');
}
// Generate a secret for signature verification
const secret = dto.secret || crypto.randomBytes(32).toString('hex');
const webhook = await this.prisma.webhook.create({
data: {
name: dto.name,
url: dto.url,
secret,
events: dto.events,
description: dto.description || null,
isActive: true,
createdById: currentUser.id,
},
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
this.logger.log(
`Webhook "${dto.name}" created by ${currentUser.email}${dto.url} (${dto.events.length} events)`,
);
return {
...webhook,
secret, // Show secret only on creation
secretWarning: 'Store this secret securely. It will be used to verify webhook signatures.',
};
}
async findAll(filter: WebhookFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage webhooks');
}
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
if (filter.isActive !== undefined) {
where.isActive = filter.isActive;
}
if (filter.search) {
where.OR = [
{ name: { contains: filter.search, mode: 'insensitive' } },
{ url: { contains: filter.search, mode: 'insensitive' } },
];
}
const [data, total] = await Promise.all([
this.prisma.webhook.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
_count: { select: { deliveries: true } },
},
}),
this.prisma.webhook.count({ where }),
]);
// Get last delivery stats for each webhook
const enriched = await Promise.all(
data.map(async (w: any) => {
const lastDelivery = await this.prisma.webhookDelivery.findFirst({
where: { webhookId: w.id },
orderBy: { createdAt: 'desc' },
select: { status: true, createdAt: true, responseCode: true },
});
const failedCount = await this.prisma.webhookDelivery.count({
where: {
webhookId: w.id,
status: { in: ['FAILED', 'PERMANENTLY_FAILED'] },
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
},
});
return {
id: w.id,
name: w.name,
url: w.url,
events: w.events,
description: w.description,
isActive: w.isActive,
totalDeliveries: w._count.deliveries,
failedLast24h: failedCount,
lastDelivery: lastDelivery
? {
status: lastDelivery.status,
responseCode: lastDelivery.responseCode,
at: lastDelivery.createdAt,
}
: null,
createdBy: w.createdBy,
createdAt: w.createdAt,
};
}),
);
return buildPaginatedResponse(enriched, total, { page, limit, sortOrder: 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage webhooks');
}
const webhook = await this.prisma.webhook.findUnique({
where: { id },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
_count: { select: { deliveries: true } },
},
});
if (!webhook) throw new NotFoundException('Webhook not found');
// Recent delivery stats
const deliveryStats = await this.prisma.webhookDelivery.groupBy({
by: ['status'],
where: { webhookId: id },
_count: true,
});
return {
id: webhook.id,
name: webhook.name,
url: webhook.url,
events: webhook.events,
description: webhook.description,
isActive: webhook.isActive,
// Secret is NOT returned after creation — security
hasSecret: !!webhook.secret,
totalDeliveries: webhook._count.deliveries,
deliveryStats: deliveryStats.map((s) => ({
status: s.status,
count: s._count,
})),
createdBy: webhook.createdBy,
createdAt: webhook.createdAt,
updatedAt: webhook.updatedAt,
};
}
async getDeliveries(
webhookId: string,
page: number = 1,
limit: number = 50,
): Promise<PaginatedResult<any>> {
const webhook = await this.prisma.webhook.findUnique({ where: { id: webhookId } });
if (!webhook) throw new NotFoundException('Webhook not found');
const where = { webhookId };
const [data, total] = await Promise.all([
this.prisma.webhookDelivery.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.webhookDelivery.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
async update(id: string, dto: UpdateWebhookDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage webhooks');
}
const webhook = await this.prisma.webhook.findUnique({ where: { id } });
if (!webhook) throw new NotFoundException('Webhook not found');
const updateData: any = {};
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.description !== undefined) updateData.description = dto.description;
if (dto.url !== undefined) {
try {
new URL(dto.url);
} catch {
throw new BadRequestException('Invalid webhook URL');
}
updateData.url = dto.url;
}
if (dto.events !== undefined) {
const invalidEvents = dto.events.filter((e) => !AVAILABLE_EVENTS.includes(e));
if (invalidEvents.length > 0) {
throw new BadRequestException(`Invalid event(s): ${invalidEvents.join(', ')}`);
}
if (dto.events.length === 0) {
throw new BadRequestException('At least one event must be subscribed');
}
updateData.events = dto.events;
}
if (dto.secret !== undefined) {
updateData.secret = dto.secret || crypto.randomBytes(32).toString('hex');
}
await this.prisma.webhook.update({ where: { id }, data: updateData });
this.logger.log(`Webhook "${webhook.name}" updated by ${currentUser.email}`);
return this.findById(id, currentUser);
}
async setActive(id: string, isActive: boolean, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage webhooks');
}
const webhook = await this.prisma.webhook.findUnique({ where: { id } });
if (!webhook) throw new NotFoundException('Webhook not found');
await this.prisma.webhook.update({ where: { id }, data: { isActive } });
this.logger.log(
`Webhook "${webhook.name}" ${isActive ? 'activated' : 'deactivated'} by ${currentUser.email}`,
);
return this.findById(id, currentUser);
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage webhooks');
}
const webhook = await this.prisma.webhook.findUnique({ where: { id } });
if (!webhook) throw new NotFoundException('Webhook not found');
// Delete deliveries first (cascade should handle it but let's be explicit)
await this.prisma.webhookDelivery.deleteMany({ where: { webhookId: id } });
await this.prisma.webhook.delete({ where: { id } });
this.logger.log(`Webhook "${webhook.name}" deleted by ${currentUser.email}`);
}
async sendTestEvent(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can test webhooks');
}
const webhook = await this.prisma.webhook.findUnique({ where: { id } });
if (!webhook) throw new NotFoundException('Webhook not found');
if (!webhook.isActive) {
throw new BadRequestException('Cannot test an inactive webhook. Activate it first.');
}
const testPayload = {
event: 'webhook.test',
timestamp: new Date().toISOString(),
data: {
message: 'This is a test webhook delivery from The Grind.',
webhookId: webhook.id,
webhookName: webhook.name,
triggeredBy: currentUser.id,
},
};
// Create delivery record
const delivery = await this.prisma.webhookDelivery.create({
data: {
webhookId: webhook.id,
event: 'webhook.test',
payload: testPayload,
status: 'PENDING',
attempts: 0,
},
});
// Attempt delivery
try {
const signature = this.computeSignature(testPayload, webhook.secret || '');
const response = await fetch(webhook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Event': 'webhook.test',
'X-Webhook-Delivery': delivery.id,
'X-Webhook-Attempt': '1',
},
body: JSON.stringify(testPayload),
signal: AbortSignal.timeout(10000),
});
const updated = await this.prisma.webhookDelivery.update({
where: { id: delivery.id },
data: {
status: response.ok ? 'DELIVERED' : 'FAILED',
responseCode: response.status,
deliveredAt: response.ok ? new Date() : null,
lastError: response.ok ? null : `HTTP ${response.status}: ${response.statusText}`,
attempts: 1,
},
});
return {
success: response.ok,
delivery: updated,
responseCode: response.status,
message: response.ok
? 'Test webhook delivered successfully!'
: `Test delivery failed with HTTP ${response.status}`,
};
} catch (err) {
const updated = await this.prisma.webhookDelivery.update({
where: { id: delivery.id },
data: {
status: 'FAILED',
lastError: err.message,
attempts: 1,
},
});
return {
success: false,
delivery: updated,
message: `Test delivery failed: ${err.message}`,
};
}
}
async retryDelivery(deliveryId: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can retry webhook deliveries');
}
const delivery = await this.prisma.webhookDelivery.findUnique({
where: { id: deliveryId },
include: { webhook: true },
});
if (!delivery) throw new NotFoundException('Delivery not found');
if (delivery.status === 'DELIVERED') {
throw new BadRequestException('This delivery was already successful');
}
if (!delivery.webhook.isActive) {
throw new BadRequestException('Cannot retry — webhook is inactive');
}
// Reset for retry
await this.prisma.webhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'PENDING',
attempts: 0,
nextRetryAt: new Date(),
lastError: null,
},
});
return { message: 'Delivery queued for retry. It will be processed within 15 minutes.' };
}
/**
* Dispatch a webhook event. Called by other services when events occur.
*/
async dispatch(event: string, payload: any): Promise<void> {
try {
const webhooks = await this.prisma.webhook.findMany({
where: {
isActive: true,
events: { has: event },
},
});
for (const webhook of webhooks) {
const fullPayload = {
event,
timestamp: new Date().toISOString(),
data: payload,
};
const delivery = await this.prisma.webhookDelivery.create({
data: {
webhookId: webhook.id,
event,
payload: fullPayload,
status: 'PENDING',
attempts: 0,
},
});
// Attempt immediate delivery
try {
const signature = this.computeSignature(fullPayload, webhook.secret || '');
const response = await fetch(webhook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Event': event,
'X-Webhook-Delivery': delivery.id,
'X-Webhook-Attempt': '1',
},
body: JSON.stringify(fullPayload),
signal: AbortSignal.timeout(10000),
});
if (response.ok) {
await this.prisma.webhookDelivery.update({
where: { id: delivery.id },
data: {
status: 'DELIVERED',
responseCode: response.status,
deliveredAt: new Date(),
attempts: 1,
},
});
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
await this.prisma.webhookDelivery.update({
where: { id: delivery.id },
data: {
status: 'FAILED',
lastError: err.message,
attempts: 1,
nextRetryAt: new Date(Date.now() + 60 * 1000), // retry in 1 min
},
});
}
}
} catch (err) {
this.logger.error(`Webhook dispatch error for event "${event}": ${err.message}`);
}
}
private computeSignature(payload: any, secret: string): string {
if (!secret) return '';
return crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
}
}
\ No newline at end of file
// ─── API INTEGRATION MODELS ─────────────────────────────────
// Phase 3B: API Keys, Webhooks, Search
model Webhook {
id String @id @default(uuid())
name String
url String
secret String?
events String[] // Array of event names subscribed to
isActive Boolean @default(true)
description String?
createdById String
createdBy User @relation("WebhookCreator", fields: [createdById], references: [id], onDelete: RESTRICT)
deliveries WebhookDelivery[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([createdById])
}
model WebhookDelivery {
id String @id @default(uuid())
webhookId String
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: CASCADE)
event String
payload Json
status String @default("PENDING") // PENDING, DELIVERED, FAILED, PERMANENTLY_FAILED, CANCELLED
responseCode Int?
lastError String?
attempts Int @default(0)
nextRetryAt DateTime?
deliveredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([webhookId])
@@index([status])
@@index([nextRetryAt])
@@index([createdAt])
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment