Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d7b6d7c
docs(notifications): architecture for event-driven notification platf…
Villarley Jul 26, 2026
a82292d
feat(types): add notification platform domain event, preference, rout…
Villarley Jul 26, 2026
5f0a5c1
feat(db): notification platform migration - outbox, dedup, preference…
Villarley Jul 27, 2026
57c899b
feat(notifications): outbox dispatcher core - ordering, retry/backoff…
Villarley Jul 27, 2026
23ec0ab
feat(notifications): DST-correct quiet hours, digest windowing, and p…
Villarley Jul 27, 2026
b5bbaa2
fix(notifications): correct quiet-hours days convention doc mismatch …
Villarley Jul 27, 2026
0897a00
feat(notifications): channel adapters and versioned i18n template eng…
Villarley Jul 27, 2026
2a15e78
feat(notifications): resumable realtime transport - cursor catch-up, …
Villarley Jul 27, 2026
01a0371
feat(notifications): Postgres-backed outbox/dedup/preferences/recipie…
Villarley Jul 27, 2026
223630c
feat(notifications): wire production module providers; close audit/no…
Villarley Jul 27, 2026
ebae6f8
feat(notifications): inbox filters/search/bulk-actions and preference…
Villarley Jul 27, 2026
a518773
feat(notifications): tracing hooks, per-recipient rate limiting, sign…
Villarley Jul 27, 2026
20bc2dd
feat(web): notification inbox, live-source abstraction, and preferenc…
Villarley Jul 27, 2026
c727233
fix(web): protect /notificaciones route in auth middleware (#37)
Villarley Jul 28, 2026
dded397
docs(notifications): document architecture, notification catalog, and…
Villarley Jul 28, 2026
9046864
chore(notifications): remove unused imports flagged by lint (#37)
Villarley Jul 28, 2026
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
10 changes: 8 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@stellar/stellar-sdk": "^15.1.0",
Expand All @@ -41,12 +42,14 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"express": "^5.1.0",
"luxon": "^3.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ws": "^8.21.0",
"zod": "^4.4.3"
"zod": "^4.4.3",
"sanitize-html": "^2.13.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
Expand All @@ -56,6 +59,7 @@
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/luxon": "^3.4.2",
"@types/multer": "^2.1.0",
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
Expand All @@ -65,6 +69,7 @@
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"fast-check": "^3.23.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
Expand All @@ -75,7 +80,8 @@
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
"typescript-eslint": "^8.20.0",
"@types/sanitize-html": "^2.13.0"
},
"jest": {
"moduleFileExtensions": [
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { SupabaseModule } from './common/supabase/supabase.module';
Expand Down Expand Up @@ -29,6 +30,7 @@ function parsePositiveInt(value: string | undefined, fallback: number): number {
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand Down
21 changes: 21 additions & 0 deletions apps/api/src/bonds/bonds.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ export class BondsService {
}
}

private async tseUserIds(): Promise<string[]> {
const { data } = await this.supabase.admin
.from('profiles')
.select('id')
.eq('role', 'tse');
return (data ?? []).map((p: any) => p.id);
}

/** Cuenta (profile emisor) del partido, dueña inicial de los bonos a su nombre. */
private async partyOwner(partyId: string) {
const [{ data: owner }, { data: party }] = await Promise.all([
Expand Down Expand Up @@ -349,6 +357,19 @@ export class BondsService {
.select()
.single();
if (error) throw new BadRequestException(error.message);

await this.audit.emit({
type: AuditEventType.BOND_REQUEST_CREATED,
actorId,
payload: { requestId: data.id, partyId, faceValue: input.faceValue },
});
for (const tseId of await this.tseUserIds()) {
await this.notifications.emit(tseId, NotificationType.BOND_REQUEST_RECEIVED, {
requestId: data.id,
partyId,
faceValue: input.faceValue,
});
}
return data;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { findContract } from '@velar/types';
import { AuthController } from '../../auth/auth.controller';
import { BondsController } from '../../bonds/bonds.controller';
import { NotificationsController } from '../../notifications/notifications.controller';
import { PreferencesController } from '../../notifications/preferences.controller';
import { ReportsController } from '../../reports/reports.controller';
import { TransfersController } from '../../transfers/transfers.controller';
import { UsersController } from '../../users/users.controller';

const CONTROLLERS = [AuthController, BondsController, TransfersController, ReportsController, NotificationsController, UsersController];
const CONTROLLERS = [AuthController, BondsController, TransfersController, ReportsController, NotificationsController, PreferencesController, UsersController];
const METHODS: Record<number, string> = {
[RequestMethod.GET]: 'GET',
[RequestMethod.POST]: 'POST',
Expand Down
84 changes: 84 additions & 0 deletions apps/api/src/notifications/channels/email-digest.channel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { RenderedNotification } from '@velar/types';
import { HmacPayloadSigner } from '../security/hmac-payload-signer';
import {
EmailChannel,
type EmailProvider,
} from './email-digest.channel';

function sample(): RenderedNotification {
return {
notificationId: 'n-1',
recipientId: 'user-1',
category: 'transfer',
severity: 'info',
subject: 'Nueva oferta recibida',
body: '<p>Recibiste una oferta.</p>',
channel: 'email',
idempotencyKey: 'dedup:user-1:email',
};
}

describe('EmailChannel', () => {
it('success path sends to provider and returns ok', async () => {
const calls: Array<{
recipientId: string;
subject: string;
html: string;
}> = [];
const provider: EmailProvider = {
async send(recipientId, subject, html) {
calls.push({ recipientId, subject, html });
},
};
const channel = new EmailChannel(provider);
const n = sample();

const result = await channel.send(n);

expect(result).toEqual({ ok: true, retryable: false });
expect(calls).toEqual([
{
recipientId: 'user-1',
subject: 'Nueva oferta recibida',
html: '<p>Recibiste una oferta.</p>',
},
]);
expect(channel.kind).toBe('email');
});

it('failure path returns retryable error', async () => {
const provider: EmailProvider = {
async send() {
throw new Error('smtp unavailable');
},
};
const channel = new EmailChannel(provider);

const result = await channel.send(sample());

expect(result).toEqual({
ok: false,
retryable: true,
error: 'smtp unavailable',
});
});

it('passes a non-empty HMAC signature through to the provider', async () => {
let receivedSignature: string | undefined;
const provider: EmailProvider = {
async send(_recipientId, _subject, _html, signature) {
receivedSignature = signature;
},
};
const channel = new EmailChannel(
provider,
new HmacPayloadSigner('test-secret'),
);

await channel.send(sample());

expect(receivedSignature).toBeDefined();
expect(receivedSignature!.length).toBeGreaterThan(0);
expect(receivedSignature).toMatch(/^[a-f0-9]{64}$/);
});
});
76 changes: 76 additions & 0 deletions apps/api/src/notifications/channels/email-digest.channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { RenderedNotification } from '@velar/types';
import type {
ChannelSendResult,
NotificationChannel,
} from '../domain/channel.interface';
import type { PayloadSigner } from '../domain/signer.interface';
import { NoopPayloadSigner } from '../security/noop-payload-signer';

export interface EmailProvider {
send(
recipientId: string,
subject: string,
html: string,
signature?: string,
): Promise<void>;
}

/**
* Safe default when no external email provider is configured.
* A real provider (SendGrid/SES/etc.) plugs in here later by implementing
* EmailProvider and resolving recipientId → email address (out of scope).
*/
export class NoopEmailProvider implements EmailProvider {
constructor(
private readonly log: (msg: string) => void = (m) => console.debug(m),
) {}

async send(
recipientId: string,
subject: string,
html: string,
signature?: string,
): Promise<void> {
this.log(
`[noop-email] to=${recipientId} subject=${subject} htmlLength=${html.length}` +
(signature ? ` signature=${signature.slice(0, 8)}…` : ''),
);
}
}

export class EmailChannel implements NotificationChannel {
readonly kind = 'email' as const;
private readonly signer: PayloadSigner;

constructor(
private readonly provider: EmailProvider,
signer?: PayloadSigner,
) {
this.signer = signer ?? new NoopPayloadSigner();
}

async send(notification: RenderedNotification): Promise<ChannelSendResult> {
try {
const signature = this.signer.sign(
JSON.stringify({
subject: notification.subject,
body: notification.body,
recipientId: notification.recipientId,
}),
);
await this.provider.send(
notification.recipientId,
notification.subject,
notification.body,
signature,
);
return { ok: true, retryable: false };
} catch (err) {
return {
ok: false,
retryable: true,
error: err instanceof Error ? err.message : String(err),
};
}
}
}
50 changes: 50 additions & 0 deletions apps/api/src/notifications/channels/in-app.channel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { RenderedNotification } from '@velar/types';
import {
InAppChannel,
InMemoryInAppWriter,
} from './in-app.channel';

function sample(): RenderedNotification {
return {
notificationId: 'n-1',
recipientId: 'user-1',
category: 'bond',
severity: 'info',
subject: 'Bono emitido',
body: '<p>Se emitió el bono.</p>',
channel: 'in_app',
idempotencyKey: 'dedup:user-1:in_app',
};
}

describe('InAppChannel', () => {
it('success path inserts and returns ok', async () => {
const writer = new InMemoryInAppWriter();
const channel = new InAppChannel(writer);
const n = sample();

const result = await channel.send(n);

expect(result).toEqual({ ok: true, retryable: false });
expect(writer.inserted).toEqual([n]);
expect(channel.kind).toBe('in_app');
});

it('failure path returns retryable error', async () => {
const writer: InMemoryInAppWriter = {
inserted: [],
async insert() {
throw new Error('db down');
},
};
const channel = new InAppChannel(writer);

const result = await channel.send(sample());

expect(result).toEqual({
ok: false,
retryable: true,
error: 'db down',
});
});
});
52 changes: 52 additions & 0 deletions apps/api/src/notifications/channels/in-app.channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { RenderedNotification } from '@velar/types';
import type {
ChannelSendResult,
NotificationChannel,
} from '../domain/channel.interface';

export interface InAppWriter {
insert(rendered: RenderedNotification): Promise<void>;
}

export class InMemoryInAppWriter implements InAppWriter {
readonly inserted: RenderedNotification[] = [];

async insert(rendered: RenderedNotification): Promise<void> {
this.inserted.push(rendered);
}
}

/**
* Adapts NotificationsService.insertRendered to InAppWriter.insert
* (naming mismatch — keeps the service method name explicit).
*/
export class NotificationsServiceInAppWriter implements InAppWriter {
constructor(
private readonly service: {
insertRendered(rendered: RenderedNotification): Promise<void>;
},
) {}

insert(rendered: RenderedNotification): Promise<void> {
return this.service.insertRendered(rendered);
}
}

export class InAppChannel implements NotificationChannel {
readonly kind = 'in_app' as const;

constructor(private readonly writer: InAppWriter) {}

async send(notification: RenderedNotification): Promise<ChannelSendResult> {
try {
await this.writer.insert(notification);
return { ok: true, retryable: false };
} catch (err) {
return {
ok: false,
retryable: true,
error: err instanceof Error ? err.message : String(err),
};
}
}
}
Loading
Loading