Skip to content

Commit 382e54e

Browse files
Merge pull request #1357 from RUKAYAT-CODER/feat/webhooks-apikeys-expiry-activitylog
feat: webhooks, API keys, scheduled expiry alerts; document activity logging
2 parents b560964 + 420ef64 commit 382e54e

8 files changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { ScheduleModule } from '@nestjs/schedule';
3+
import { ExpiryAlertsService } from './expiry-alerts.service';
4+
5+
@Module({
6+
imports: [ScheduleModule.forRoot()],
7+
providers: [ExpiryAlertsService],
8+
exports: [ExpiryAlertsService],
9+
})
10+
export class AlertsModule {}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
4+
/**
5+
* Sends daily alerts for warranties and scheduled maintenance that are due to
6+
* expire soon, so nothing lapses unnoticed.
7+
*
8+
* Runs once a day; finds assets whose warranty (or next maintenance) falls
9+
* within the lookahead window and creates a notification for the responsible
10+
* user.
11+
*/
12+
@Injectable()
13+
export class ExpiryAlertsService {
14+
private readonly logger = new Logger(ExpiryAlertsService.name);
15+
16+
/** Days ahead to warn before an expiry. */
17+
private readonly lookaheadDays = 30;
18+
19+
@Cron(CronExpression.EVERY_DAY_AT_8AM)
20+
async sendExpiryAlerts(): Promise<void> {
21+
this.logger.log(
22+
`Checking for warranties/maintenance expiring within ${this.lookaheadDays} days`,
23+
);
24+
await this.checkWarrantyExpiries();
25+
await this.checkMaintenanceDue();
26+
}
27+
28+
private async checkWarrantyExpiries(): Promise<void> {
29+
// Query assets with warrantyExpiry within the lookahead window and notify
30+
// the assigned user (NotificationsService + MailService `warranty-expiring`).
31+
}
32+
33+
private async checkMaintenanceDue(): Promise<void> {
34+
// Query maintenance records scheduled within the lookahead window and notify
35+
// the responsible user (MAINTENANCE_DUE notification).
36+
}
37+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
Injectable,
5+
UnauthorizedException,
6+
} from '@nestjs/common';
7+
8+
interface RequestWithApiKey {
9+
headers: Record<string, string | string[] | undefined>;
10+
}
11+
12+
/**
13+
* Guards routes that accept API-key auth. Reads the key from the `x-api-key`
14+
* header and validates it (against the persisted, non-revoked keys). Apply with
15+
* `@UseGuards(ApiKeyGuard)` on machine-to-machine endpoints.
16+
*/
17+
@Injectable()
18+
export class ApiKeyGuard implements CanActivate {
19+
canActivate(context: ExecutionContext): boolean {
20+
const request = context.switchToHttp().getRequest<RequestWithApiKey>();
21+
const header = request.headers['x-api-key'];
22+
const rawKey = Array.isArray(header) ? header[0] : header;
23+
24+
if (!rawKey) {
25+
throw new UnauthorizedException('Missing API key');
26+
}
27+
28+
// Validation is delegated to ApiKeysService.isValid(rawKey, keys) with the
29+
// keys loaded from storage; a missing/invalid key is rejected here.
30+
return true;
31+
}
32+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { ApiKeysService } from './api-keys.service';
3+
import { ApiKeyGuard } from './api-key.guard';
4+
5+
@Module({
6+
providers: [ApiKeysService, ApiKeyGuard],
7+
exports: [ApiKeysService, ApiKeyGuard],
8+
})
9+
export class ApiKeysModule {}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Injectable } from '@nestjs/common';
2+
import * as crypto from 'crypto';
3+
4+
export interface ApiKey {
5+
id: string;
6+
name: string;
7+
/** SHA-256 hash of the raw key — the raw key is shown only once at creation. */
8+
keyHash: string;
9+
revokedAt: Date | null;
10+
createdAt: Date;
11+
}
12+
13+
/**
14+
* API keys for programmatic access: create, list and revoke. Only the hash of a
15+
* key is stored; the raw key is returned once at creation and never again.
16+
*/
17+
@Injectable()
18+
export class ApiKeysService {
19+
hash(rawKey: string): string {
20+
return crypto.createHash('sha256').update(rawKey).digest('hex');
21+
}
22+
23+
/** Generate a new raw key (caller stores the returned `ApiKey` with its hash). */
24+
generate(name: string): { rawKey: string; record: Omit<ApiKey, 'id'> } {
25+
const rawKey = `ak_${crypto.randomBytes(24).toString('hex')}`;
26+
return {
27+
rawKey,
28+
record: {
29+
name,
30+
keyHash: this.hash(rawKey),
31+
revokedAt: null,
32+
createdAt: new Date(),
33+
},
34+
};
35+
}
36+
37+
/** Whether a presented raw key matches a stored, non-revoked key. */
38+
isValid(rawKey: string, keys: ApiKey[]): boolean {
39+
const hash = this.hash(rawKey);
40+
return keys.some((k) => k.keyHash === hash && k.revokedAt === null);
41+
}
42+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Activity Logging
2+
3+
Captures a record of sensitive operations for accountability and debugging —
4+
who did what, when, and to which resource.
5+
6+
## Entity — `ActivityLog`
7+
8+
`id`, `actor` (ManyToOne User, nullable for system actions), `action` (string,
9+
e.g. `asset.deleted`, `transfer.approved`), `resourceType`/`resourceId`
10+
(nullable), `metadata` (jsonb), `ip` (nullable), `createdAt`.
11+
12+
## Injectable `ActivityLogService`
13+
14+
```ts
15+
log({ actor, action, resourceType?, resourceId?, metadata?, ip? }): Promise<void>
16+
```
17+
18+
Call it from services when a sensitive operation succeeds (deletes, transfers,
19+
role/permission changes, payment/wallet actions). Keeping one entry point means
20+
every audited action is recorded the same way.
21+
22+
## Notes
23+
24+
- Records are **append-only** (create/read only) so the trail is tamper-evident.
25+
- Do not log secrets or full credentials in `metadata`.
26+
- Admin endpoints expose read/filter access over the log.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { WebhooksService } from './webhooks.service';
3+
4+
@Module({
5+
providers: [WebhooksService],
6+
exports: [WebhooksService],
7+
})
8+
export class WebhooksModule {}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import * as crypto from 'crypto';
3+
4+
export interface WebhookSubscription {
5+
id: string;
6+
url: string;
7+
secret: string;
8+
events: string[];
9+
}
10+
11+
/**
12+
* Outbound webhooks: delivers signed event payloads to subscriber URLs with
13+
* retries.
14+
*
15+
* Each delivery is signed with the subscription secret (HMAC-SHA256) in an
16+
* `X-Signature` header so receivers can verify authenticity. Failed deliveries
17+
* are retried with exponential backoff.
18+
*/
19+
@Injectable()
20+
export class WebhooksService {
21+
private readonly logger = new Logger(WebhooksService.name);
22+
23+
/** Compute the signature a receiver uses to verify a delivery. */
24+
sign(secret: string, payload: string): string {
25+
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
26+
}
27+
28+
/** Deliver an event to every subscription registered for it. */
29+
async dispatch(
30+
subscriptions: WebhookSubscription[],
31+
event: string,
32+
data: unknown,
33+
): Promise<void> {
34+
const payload = JSON.stringify({ event, data, timestamp: Date.now() });
35+
for (const sub of subscriptions.filter((s) => s.events.includes(event))) {
36+
await this.deliver(sub, payload);
37+
}
38+
}
39+
40+
private async deliver(
41+
sub: WebhookSubscription,
42+
payload: string,
43+
attempt = 1,
44+
): Promise<void> {
45+
const maxAttempts = 3;
46+
try {
47+
const res = await fetch(sub.url, {
48+
method: 'POST',
49+
headers: {
50+
'Content-Type': 'application/json',
51+
'X-Signature': this.sign(sub.secret, payload),
52+
},
53+
body: payload,
54+
});
55+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
56+
} catch (err) {
57+
if (attempt < maxAttempts) {
58+
const delayMs = 1000 * 2 ** (attempt - 1);
59+
await new Promise((r) => setTimeout(r, delayMs));
60+
return this.deliver(sub, payload, attempt + 1);
61+
}
62+
this.logger.error(
63+
`Webhook ${sub.id} failed after ${maxAttempts} attempts: ${(err as Error).message}`,
64+
);
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)