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';
// ─── Phase 3A: Admin & Intelligence ─────────────────────────
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 { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
......@@ -113,6 +118,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
ReportsModule,
// Phase 3A
AnalyticsModule,
// Phase 3B
ApiKeysModule,
WebhooksModule,
SearchModule,
],
providers: [
{ 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
This diff is collapsed.
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
This diff is collapsed.
// ─── 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