Skip to content

Commit 7b5fb1e

Browse files
committed
Github webhook implementation
1 parent 4743bfb commit 7b5fb1e

File tree

9 files changed

+488
-0
lines changed

9 files changed

+488
-0
lines changed

.env.sample

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
POSTGRES_SCHEMA="public"
22
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=${POSTGRES_SCHEMA}"
33

4+
# GitHub Webhook Configuration
5+
GITHUB_WEBHOOK_SECRET="your_webhook_secret_here"
6+
47
# API configs
58
BUS_API_URL="https://api.topcoder-dev.com/v5/bus/events"
69
CHALLENGE_API_URL="https://api.topcoder-dev.com/v5/challenges/"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
-- DropForeignKey
2+
ALTER TABLE "reviewApplication" DROP CONSTRAINT "reviewApplication_opportunityId_fkey";
3+
4+
-- AlterTable
5+
ALTER TABLE "reviewApplication" ALTER COLUMN "opportunityId" SET DATA TYPE TEXT,
6+
ALTER COLUMN "updatedAt" DROP DEFAULT;
7+
8+
-- AlterTable
9+
ALTER TABLE "reviewOpportunity" ALTER COLUMN "updatedAt" DROP DEFAULT;
10+
11+
-- CreateTable
12+
CREATE TABLE "gitWebhookLog" (
13+
"id" VARCHAR(14) NOT NULL DEFAULT nanoid(),
14+
"eventId" TEXT NOT NULL,
15+
"event" TEXT NOT NULL,
16+
"eventPayload" JSONB NOT NULL,
17+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18+
19+
CONSTRAINT "gitWebhookLog_pkey" PRIMARY KEY ("id")
20+
);
21+
22+
-- CreateIndex
23+
CREATE INDEX "gitWebhookLog_eventId_idx" ON "gitWebhookLog"("eventId");
24+
25+
-- CreateIndex
26+
CREATE INDEX "gitWebhookLog_event_idx" ON "gitWebhookLog"("event");
27+
28+
-- CreateIndex
29+
CREATE INDEX "gitWebhookLog_createdAt_idx" ON "gitWebhookLog"("createdAt");
30+
31+
-- AddForeignKey
32+
ALTER TABLE "reviewApplication" ADD CONSTRAINT "reviewApplication_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "reviewOpportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,15 @@ model reviewApplication {
381381
@@index([userId])
382382
@@index([opportunityId])
383383
}
384+
385+
model gitWebhookLog {
386+
id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14)
387+
eventId String // X-GitHub-Delivery header
388+
event String // X-GitHub-Event header
389+
eventPayload Json // Complete webhook payload
390+
createdAt DateTime @default(now())
391+
392+
@@index([eventId])
393+
@@index([event])
394+
@@index([createdAt])
395+
}

src/api/api.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { ReviewOpportunityService } from './review-opportunity/reviewOpportunity
1313
import { ReviewApplicationService } from './review-application/reviewApplication.service';
1414
import { ReviewHistoryController } from './review-history/reviewHistory.controller';
1515
import { ChallengeApiService } from 'src/shared/modules/global/challenge.service';
16+
import { WebhookController } from './webhook/webhook.controller';
17+
import { WebhookService } from './webhook/webhook.service';
18+
import { GitHubSignatureGuard } from '../shared/guards/github-signature.guard';
1619

1720
@Module({
1821
imports: [HttpModule, GlobalProvidersModule],
@@ -26,11 +29,14 @@ import { ChallengeApiService } from 'src/shared/modules/global/challenge.service
2629
ReviewOpportunityController,
2730
ReviewApplicationController,
2831
ReviewHistoryController,
32+
WebhookController,
2933
],
3034
providers: [
3135
ReviewOpportunityService,
3236
ReviewApplicationService,
3337
ChallengeApiService,
38+
WebhookService,
39+
GitHubSignatureGuard,
3440
],
3541
})
3642
export class ApiModule {}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export interface GitHubWebhookHeaders {
2+
'x-github-delivery': string;
3+
'x-github-event': string;
4+
'x-hub-signature-256': string;
5+
'content-type': string;
6+
}
7+
8+
export interface WebhookRequest {
9+
headers: GitHubWebhookHeaders;
10+
body: any; // GitHub webhook payload (varies by event type)
11+
}
12+
13+
export interface WebhookResponse {
14+
success: boolean;
15+
message?: string;
16+
}
17+
18+
export interface ErrorResponse {
19+
statusCode: number;
20+
message: string;
21+
error: string;
22+
timestamp: string;
23+
path: string;
24+
}

src/api/webhook/webhook.controller.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
Controller,
3+
Post,
4+
Body,
5+
Headers,
6+
HttpCode,
7+
HttpStatus,
8+
UseGuards,
9+
} from '@nestjs/common';
10+
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger';
11+
import { WebhookService } from './webhook.service';
12+
import {
13+
WebhookEventDto,
14+
WebhookResponseDto,
15+
} from '../../dto/webhook-event.dto';
16+
import { GitHubSignatureGuard } from '../../shared/guards/github-signature.guard';
17+
import { LoggerService } from '../../shared/modules/global/logger.service';
18+
19+
@ApiTags('Webhooks')
20+
@Controller('webhooks')
21+
export class WebhookController {
22+
private readonly logger = LoggerService.forRoot('WebhookController');
23+
24+
constructor(private readonly webhookService: WebhookService) {}
25+
26+
@Post('git')
27+
@HttpCode(HttpStatus.OK)
28+
@UseGuards(GitHubSignatureGuard)
29+
@ApiOperation({
30+
summary: 'GitHub Webhook Endpoint',
31+
description:
32+
'Receives and processes GitHub webhook events with signature verification',
33+
})
34+
@ApiHeader({
35+
name: 'X-GitHub-Delivery',
36+
description: 'GitHub delivery UUID',
37+
required: true,
38+
})
39+
@ApiHeader({
40+
name: 'X-GitHub-Event',
41+
description: 'GitHub event type',
42+
required: true,
43+
})
44+
@ApiHeader({
45+
name: 'X-Hub-Signature-256',
46+
description: 'HMAC-SHA256 signature for request verification',
47+
required: true,
48+
})
49+
@ApiResponse({
50+
status: 200,
51+
description: 'Webhook processed successfully',
52+
type: WebhookResponseDto,
53+
})
54+
@ApiResponse({
55+
status: 400,
56+
description: 'Bad Request - Missing required headers or invalid payload',
57+
})
58+
@ApiResponse({
59+
status: 403,
60+
description: 'Forbidden - Invalid signature',
61+
})
62+
@ApiResponse({
63+
status: 500,
64+
description: 'Internal Server Error - Processing failed',
65+
})
66+
async handleGitHubWebhook(
67+
@Body() payload: any,
68+
@Headers('x-github-delivery') delivery: string,
69+
@Headers('x-github-event') event: string,
70+
): Promise<WebhookResponseDto> {
71+
try {
72+
this.logger.log({
73+
message: 'Received GitHub webhook',
74+
delivery,
75+
event,
76+
timestamp: new Date().toISOString(),
77+
});
78+
79+
// Create webhook event DTO
80+
const webhookEvent: WebhookEventDto = {
81+
eventId: delivery,
82+
event: event,
83+
eventPayload: payload,
84+
};
85+
86+
// Process the webhook
87+
const result = await this.webhookService.processWebhook(webhookEvent);
88+
89+
this.logger.log({
90+
message: 'Successfully processed GitHub webhook',
91+
delivery,
92+
event,
93+
success: result.success,
94+
});
95+
96+
return result;
97+
} catch (error) {
98+
this.logger.error({
99+
message: 'Failed to process GitHub webhook',
100+
delivery,
101+
event,
102+
error: error.message,
103+
stack: error.stack,
104+
});
105+
106+
throw error;
107+
}
108+
}
109+
}

src/api/webhook/webhook.service.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { PrismaService } from '../../shared/modules/global/prisma.service';
3+
import { LoggerService } from '../../shared/modules/global/logger.service';
4+
import { PrismaErrorService } from '../../shared/modules/global/prisma-error.service';
5+
import {
6+
WebhookEventDto,
7+
WebhookResponseDto,
8+
} from '../../dto/webhook-event.dto';
9+
10+
@Injectable()
11+
export class WebhookService {
12+
private readonly logger = LoggerService.forRoot('WebhookService');
13+
14+
constructor(
15+
private readonly prisma: PrismaService,
16+
private readonly prismaErrorService: PrismaErrorService,
17+
) {}
18+
19+
async processWebhook(
20+
webhookEvent: WebhookEventDto,
21+
): Promise<WebhookResponseDto> {
22+
try {
23+
this.logger.log({
24+
message: 'Processing GitHub webhook event',
25+
eventId: webhookEvent.eventId,
26+
event: webhookEvent.event,
27+
timestamp: new Date().toISOString(),
28+
});
29+
30+
// Store webhook event in database
31+
const storedEvent = await this.prisma.gitWebhookLog.create({
32+
data: {
33+
eventId: webhookEvent.eventId,
34+
event: webhookEvent.event,
35+
eventPayload: webhookEvent.eventPayload,
36+
},
37+
});
38+
39+
this.logger.log({
40+
message: 'Successfully stored webhook event',
41+
eventId: webhookEvent.eventId,
42+
event: webhookEvent.event,
43+
storedId: storedEvent.id,
44+
createdAt: storedEvent.createdAt,
45+
});
46+
47+
// Future extensibility: Add event-specific handlers here
48+
this.handleEventSpecificProcessing(
49+
webhookEvent.event,
50+
webhookEvent.eventPayload,
51+
);
52+
53+
return {
54+
success: true,
55+
message: 'Webhook processed successfully',
56+
};
57+
} catch (error) {
58+
this.logger.error({
59+
message: 'Failed to process webhook event',
60+
eventId: webhookEvent.eventId,
61+
event: webhookEvent.event,
62+
error: error.message,
63+
stack: error.stack,
64+
});
65+
66+
// Handle Prisma errors with the existing error service
67+
if (error.code) {
68+
this.prismaErrorService.handleError(error);
69+
}
70+
71+
throw error;
72+
}
73+
}
74+
75+
/**
76+
* Placeholder for future event-specific processing logic
77+
* This method can be extended to handle different GitHub events differently
78+
*/
79+
private handleEventSpecificProcessing(event: string, payload: any): void {
80+
this.logger.log({
81+
message: 'Event-specific processing placeholder',
82+
event,
83+
payloadSize: JSON.stringify(payload).length,
84+
});
85+
86+
// Future implementation examples:
87+
// switch (event) {
88+
// case 'push':
89+
// await this.handlePushEvent(payload);
90+
// break;
91+
// case 'pull_request':
92+
// await this.handlePullRequestEvent(payload);
93+
// break;
94+
// case 'issues':
95+
// await this.handleIssuesEvent(payload);
96+
// break;
97+
// default:
98+
// this.logger.log(`No specific handler for event type: ${event}`);
99+
// }
100+
}
101+
102+
/**
103+
* Get webhook logs with pagination and filtering
104+
* This method provides basic querying capabilities for webhook events
105+
*/
106+
async getWebhookLogs(options: {
107+
eventId?: string;
108+
event?: string;
109+
limit?: number;
110+
offset?: number;
111+
startDate?: Date;
112+
endDate?: Date;
113+
}) {
114+
try {
115+
const {
116+
eventId,
117+
event,
118+
limit = 50,
119+
offset = 0,
120+
startDate,
121+
endDate,
122+
} = options;
123+
124+
const where: any = {};
125+
126+
if (eventId) {
127+
where.eventId = eventId;
128+
}
129+
130+
if (event) {
131+
where.event = event;
132+
}
133+
134+
if (startDate || endDate) {
135+
where.createdAt = {};
136+
if (startDate) {
137+
where.createdAt.gte = startDate;
138+
}
139+
if (endDate) {
140+
where.createdAt.lte = endDate;
141+
}
142+
}
143+
144+
const [logs, total] = await this.prisma.$transaction([
145+
this.prisma.gitWebhookLog.findMany({
146+
where,
147+
orderBy: { createdAt: 'desc' },
148+
take: limit,
149+
skip: offset,
150+
}),
151+
this.prisma.gitWebhookLog.count({ where }),
152+
]);
153+
154+
return {
155+
logs,
156+
total,
157+
limit,
158+
offset,
159+
};
160+
} catch (error) {
161+
this.logger.error({
162+
message: 'Failed to retrieve webhook logs',
163+
error: error.message,
164+
options,
165+
});
166+
167+
if (error.code) {
168+
this.prismaErrorService.handleError(error);
169+
}
170+
171+
throw error;
172+
}
173+
}
174+
}

0 commit comments

Comments
 (0)