diff --git a/apps/api/package.json b/apps/api/package.json index 42c672c..6c7b65d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { - "build": "prisma generate && nest build && cp generated/prisma/package.json dist/generated/prisma/package.json", + "build": "prisma generate && nest build && node -e \"const fs=require('fs'); const source=['generated/prisma/package.json','node_modules/.prisma/client/package.json','node_modules/@prisma/client/package.json'].find((file)=>fs.existsSync(file)); if(source){ fs.mkdirSync('dist/generated/prisma',{recursive:true}); fs.copyFileSync(source,'dist/generated/prisma/package.json'); }\"", "prisma:generate": "prisma generate", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", diff --git a/apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql b/apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql new file mode 100644 index 0000000..27c6789 --- /dev/null +++ b/apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "read" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Notification_merchantId_createdAt_idx" ON "Notification"("merchantId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260601115707/migration.sql b/apps/api/prisma/migrations/20260601115707/migration.sql new file mode 100644 index 0000000..edcfb1a --- /dev/null +++ b/apps/api/prisma/migrations/20260601115707/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "Payment" DROP CONSTRAINT "Payment_quoteId_fkey"; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "Quote"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 68d71cf..d5d39bb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -46,6 +46,7 @@ model Merchant { teamMembers TeamMember[] webhookEvents WebhookEvent[] apiKeys ApiKey[] + notifications Notification[] settlementKey MerchantSettlementKey? } @@ -355,3 +356,17 @@ enum WebhookStatus { FAILED EXHAUSTED } + +model Notification { + id String @id @default(cuid()) + merchantId String + type String + title String + body String + read Boolean @default(false) + metadata Json? + createdAt DateTime @default(now()) + merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) + + @@index([merchantId, createdAt]) +} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index b218300..f5459ce 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -205,6 +205,10 @@ export class AuthService { }, }); + void this.notifications + .notifyApiKeyCreated(merchantId, apiKey.id, apiKey.name) + .catch(() => undefined); + return { apiKey: plainTextKey, id: apiKey.id, diff --git a/apps/api/src/modules/events/events/events.service.ts b/apps/api/src/modules/events/events/events.service.ts index bb92424..0f5970d 100644 --- a/apps/api/src/modules/events/events/events.service.ts +++ b/apps/api/src/modules/events/events/events.service.ts @@ -9,6 +9,15 @@ interface WebhookEventData { createdAt: Date | string; } +interface NotificationCreatedPayload { + id: string; + type: string; + title: string; + body: string; + metadata?: unknown; + createdAt: Date | string; +} + /** * Events Service * Handles real-time event emission to connected WebSocket clients @@ -242,4 +251,33 @@ export class EventsService { ); } } + + emitNotificationCreated( + merchantId: string, + notification: NotificationCreatedPayload, + ): void { + try { + const normalizedPayload = { + id: notification.id, + type: notification.type, + title: notification.title, + body: notification.body, + metadata: notification.metadata, + createdAt: new Date(notification.createdAt).toISOString(), + }; + + this.gateway.server + .to(`merchant:${merchantId}`) + .emit('notification.created', normalizedPayload); + + this.gateway.server.to(`merchant:${merchantId}`).emit('message', { + event: 'notification.created', + data: normalizedPayload, + }); + } catch (error) { + this.logger.error( + `Failed to emit notification.created: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } } diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts index 01b29e9..cd75bc2 100644 --- a/apps/api/src/modules/invoices/invoices.service.ts +++ b/apps/api/src/modules/invoices/invoices.service.ts @@ -510,6 +510,10 @@ export class InvoicesService { }); if (newStatus === InvoiceStatus.PAID) { + void this.notifications + .notifyInvoicePaid(merchantId, id, existing.invoiceNumber) + .catch(() => undefined); + await this.webhooks.dispatch(merchantId, 'invoice.paid', { invoiceId: id, customerEmail: existing.customerEmail, diff --git a/apps/api/src/modules/merchant/merchant.module.ts b/apps/api/src/modules/merchant/merchant.module.ts index af33273..b1d285d 100644 --- a/apps/api/src/modules/merchant/merchant.module.ts +++ b/apps/api/src/modules/merchant/merchant.module.ts @@ -3,11 +3,12 @@ import { AuthModule } from '../auth/auth.module'; import { PrismaModule } from '../prisma/prisma.module'; import { MerchantController } from './merchant.controller'; import { MerchantService } from './merchant.service'; +import { NotificationsModule } from '../notifications/notifications.module'; import { MerchantSettlementService } from './merchant-settlement.service'; import { RolesGuard } from './guards/roles.guard'; @Module({ - imports: [AuthModule, PrismaModule], + imports: [AuthModule, PrismaModule, NotificationsModule], controllers: [MerchantController], providers: [MerchantService, MerchantSettlementService, RolesGuard], // Export MerchantSettlementService so AuthService can call .provision() diff --git a/apps/api/src/modules/merchant/merchant.service.ts b/apps/api/src/modules/merchant/merchant.service.ts index 26f5725..7205c6f 100644 --- a/apps/api/src/modules/merchant/merchant.service.ts +++ b/apps/api/src/modules/merchant/merchant.service.ts @@ -10,11 +10,15 @@ import { SettlementDto } from './dto/settlement.dto'; import { BrandingDto } from './dto/branding.dto'; import { InviteMemberDto } from './dto/invite-member.dto'; import { KybSubmissionDto } from './dto/kyb-submission.dto'; +import { NotificationsService } from '../notifications/notifications.service'; import { detectAddressChain, type Chain } from '@useroutr/types'; @Injectable() export class MerchantService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + ) {} // ── Profile ────────────────────────────────────────────────── @@ -138,7 +142,9 @@ export class MerchantService { }, }); - // TODO: send invite email via NotificationsModule once implemented + void this.notifications + .notifyTeamMemberJoined(merchantId, member.email, member.role) + .catch(() => undefined); return member; } diff --git a/apps/api/src/modules/notifications/notifications.controller.ts b/apps/api/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..37d69d6 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { CurrentMerchant } from '../merchant/decorators/current-merchant.decorator'; +import { NotificationsService } from './notifications.service'; + +@Controller('v1/notifications') +@UseGuards(JwtAuthGuard) +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + async listNotifications( + @CurrentMerchant('id') merchantId: string, + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + ) { + return this.notificationsService.listNotifications(merchantId, { + limit: limit ? Number(limit) : undefined, + cursor, + }); + } + + @Patch(':id/read') + @HttpCode(HttpStatus.OK) + async markAsRead( + @CurrentMerchant('id') merchantId: string, + @Param('id') notificationId: string, + ) { + return this.notificationsService.markAsRead(merchantId, notificationId); + } + + @Post('mark-all-read') + @HttpCode(HttpStatus.OK) + async markAllAsRead(@CurrentMerchant('id') merchantId: string) { + return this.notificationsService.markAllAsRead(merchantId); + } +} diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index eda5d50..d31052f 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -1,16 +1,22 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; +import { PrismaModule } from '../prisma/prisma.module'; +import { EventsModule } from '../events/events.module'; +import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; import { NotificationsProcessor } from './notifications.processor'; @Module({ imports: [ ConfigModule, + PrismaModule, + EventsModule, BullModule.registerQueue({ name: 'notifications', }), ], + controllers: [NotificationsController], providers: [NotificationsService, NotificationsProcessor], exports: [NotificationsService], }) diff --git a/apps/api/src/modules/notifications/notifications.service.ts b/apps/api/src/modules/notifications/notifications.service.ts index ceebe19..7284815 100644 --- a/apps/api/src/modules/notifications/notifications.service.ts +++ b/apps/api/src/modules/notifications/notifications.service.ts @@ -1,10 +1,49 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectQueue } from '@nestjs/bullmq'; import { ConfigService } from '@nestjs/config'; +import { Notification, Prisma } from '@prisma/client'; import { Queue } from 'bullmq'; -import { EmailJobData, Invoice, Payment, Payout } from './types'; +import { EventsService } from '../events/events/events.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + EmailJobData, + Invoice as EmailInvoice, + Payment as EmailPayment, + Payout as EmailPayout, +} from './types'; import * as templates from './templates'; +interface NotificationListOptions { + limit?: number; + cursor?: string; +} + +interface CreateNotificationInput { + merchantId: string; + type: string; + title: string; + body: string; + metadata?: Prisma.InputJsonValue | null; +} + +function formatCurrency(amount: number | string, currency = 'USD'): string { + const numericAmount = typeof amount === 'number' ? amount : Number(amount); + + if (!Number.isFinite(numericAmount)) { + return String(amount); + } + + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(numericAmount); + } catch { + return `${numericAmount.toFixed(2)} ${currency}`; + } +} + @Injectable() export class NotificationsService { private readonly appUrl: string; @@ -13,6 +52,8 @@ export class NotificationsService { @InjectQueue('notifications') private readonly notificationsQueue: Queue, private readonly configService: ConfigService, + private readonly prisma: PrismaService, + private readonly eventsService: EventsService, ) { this.appUrl = this.configService.get( 'FRONTEND_URL', @@ -34,6 +75,249 @@ export class NotificationsService { }); } + async createNotification( + input: CreateNotificationInput, + ): Promise { + const notification = await this.prisma.notification.create({ + data: { + merchantId: input.merchantId, + type: input.type, + title: input.title, + body: input.body, + ...(input.metadata !== undefined + ? { metadata: input.metadata ?? Prisma.JsonNull } + : {}), + }, + }); + + this.eventsService.emitNotificationCreated(input.merchantId, { + id: notification.id, + type: notification.type, + title: notification.title, + body: notification.body, + metadata: notification.metadata ?? undefined, + createdAt: notification.createdAt, + }); + + return notification; + } + + async listNotifications( + merchantId: string, + options: NotificationListOptions = {}, + ) { + const take = Math.min(Math.max(options.limit ?? 50, 1), 50); + + let createdAtCursor: Date | undefined; + if (options.cursor) { + const cursorItem = await this.prisma.notification.findFirst({ + where: { + id: options.cursor, + merchantId, + }, + select: { createdAt: true }, + }); + + createdAtCursor = cursorItem?.createdAt; + } + + const where: Prisma.NotificationWhereInput = { + merchantId, + ...(createdAtCursor ? { createdAt: { lt: createdAtCursor } } : {}), + }; + + const [items, unreadCount, total] = await Promise.all([ + this.prisma.notification.findMany({ + where, + take, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }), + this.prisma.notification.count({ + where: { + merchantId, + read: false, + }, + }), + this.prisma.notification.count({ where: { merchantId } }), + ]); + + return { + data: items, + meta: { + total, + limit: take, + unreadCount, + nextCursor: items.length === take ? items[items.length - 1]?.id : null, + }, + }; + } + + async markAsRead(merchantId: string, notificationId: string) { + const existing = await this.prisma.notification.findFirst({ + where: { id: notificationId, merchantId }, + }); + + if (!existing) { + throw new NotFoundException('Notification not found'); + } + + if (existing.read) { + return existing; + } + + return this.prisma.notification.update({ + where: { id: notificationId }, + data: { read: true }, + }); + } + + async markAllAsRead(merchantId: string) { + const result = await this.prisma.notification.updateMany({ + where: { + merchantId, + read: false, + }, + data: { read: true }, + }); + + return { + updatedCount: result.count, + }; + } + + async notifyPaymentReceived( + merchantId: string, + paymentId: string, + amount: number | string, + currency = 'USD', + customerEmail?: string, + ) { + return this.createNotification({ + merchantId, + type: 'payment.received', + title: 'Payment received', + body: `${formatCurrency(amount, currency)}${customerEmail ? ` from ${customerEmail}` : ''}`, + metadata: { + paymentId, + amount: String(amount), + currency, + ...(customerEmail ? { customerEmail } : {}), + }, + }); + } + + async notifyPayoutCompleted( + merchantId: string, + payoutId: string, + recipientName: string, + ) { + return this.createNotification({ + merchantId, + type: 'payout.completed', + title: 'Payout completed', + body: `Payout to ${recipientName} completed`, + metadata: { + payoutId, + recipientName, + }, + }); + } + + async notifyPayoutFailed( + merchantId: string, + payoutId: string, + recipientName: string, + failureReason?: string, + ) { + return this.createNotification({ + merchantId, + type: 'payout.failed', + title: 'Payout failed', + body: failureReason + ? `Payout to ${recipientName} failed: ${failureReason}` + : `Payout to ${recipientName} failed`, + metadata: { + payoutId, + recipientName, + ...(failureReason ? { failureReason } : {}), + }, + }); + } + + async notifyInvoicePaid( + merchantId: string, + invoiceId: string, + invoiceNumber?: string | null, + ) { + return this.createNotification({ + merchantId, + type: 'invoice.paid', + title: 'Invoice paid', + body: `Invoice ${invoiceNumber ? `#${invoiceNumber}` : ''} marked as paid`.trim(), + metadata: { + invoiceId, + ...(invoiceNumber ? { invoiceNumber } : {}), + }, + }); + } + + async notifyRefundInitiated(merchantId: string, paymentId: string) { + return this.createNotification({ + merchantId, + type: 'refund.initiated', + title: 'Refund initiated', + body: 'A refund has been started for one of your payments.', + metadata: { paymentId }, + }); + } + + async notifyTeamMemberJoined( + merchantId: string, + email: string, + role: string, + ) { + return this.createNotification({ + merchantId, + type: 'team.member_joined', + title: 'Team member joined', + body: `${email} was added to your team as ${role.toLowerCase()}.`, + metadata: { email, role }, + }); + } + + async notifyApiKeyCreated( + merchantId: string, + keyId: string, + keyName: string, + ) { + return this.createNotification({ + merchantId, + type: 'api_key.created', + title: 'API key created', + body: `API key '${keyName}' was created successfully.`, + metadata: { apiKeyId: keyId, keyName }, + }); + } + + async notifyWebhookFailed( + merchantId: string, + webhookUrl: string, + eventType?: string, + eventId?: string, + ) { + return this.createNotification({ + merchantId, + type: 'webhook.failed', + title: 'Webhook failed', + body: `Webhook delivery to ${webhookUrl} failed`, + metadata: { + webhookUrl, + ...(eventType ? { eventType } : {}), + ...(eventId ? { eventId } : {}), + }, + }); + } + // Auth emails async sendVerificationEmail(email: string, token: string): Promise { await this.dispatch({ @@ -75,7 +359,7 @@ export class NotificationsService { // Payments async sendPaymentReceipt( customerEmail: string, - payment: Payment, + payment: EmailPayment, ): Promise { await this.dispatch({ to: customerEmail, @@ -86,7 +370,7 @@ export class NotificationsService { async sendPaymentNotification( merchantEmail: string, - payment: Payment, + payment: EmailPayment, ): Promise { await this.dispatch({ to: merchantEmail, @@ -98,7 +382,7 @@ export class NotificationsService { // Invoices async sendInvoice( customerEmail: string, - invoice: Invoice, + invoice: EmailInvoice, pdfBuffer: Buffer, ): Promise { await this.dispatch({ @@ -116,7 +400,7 @@ export class NotificationsService { async sendInvoiceReminder( customerEmail: string, - invoice: Invoice, + invoice: EmailInvoice, ): Promise { const now = new Date(); const diff = Math.ceil( @@ -139,7 +423,7 @@ export class NotificationsService { // Payouts async sendPayoutConfirmation( merchantEmail: string, - payout: Payout, + payout: EmailPayout, ): Promise { await this.dispatch({ to: merchantEmail, diff --git a/apps/api/src/modules/payments/payments.module.ts b/apps/api/src/modules/payments/payments.module.ts index cca9527..96d9e3d 100644 --- a/apps/api/src/modules/payments/payments.module.ts +++ b/apps/api/src/modules/payments/payments.module.ts @@ -13,6 +13,7 @@ import { QuotesModule } from '../quotes/quotes.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; import { StripeWebhooksController } from '../webhooks/webhooks.controller'; import { AuthModule } from '../auth/auth.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { LinksModule } from '../links/links.module'; import { CctpModule } from '../cctp/cctp.module'; @@ -36,6 +37,7 @@ import { CctpModule } from '../cctp/cctp.module'; // (not CctpModule) so the processor can inject PaymentsService without // a module-level circular dependency. BullModule.registerQueue({ name: CCTP_OBSERVE_QUEUE }), + NotificationsModule, ], providers: [PaymentsService, CctpProcessor], controllers: [ diff --git a/apps/api/src/modules/payments/payments.service.ts b/apps/api/src/modules/payments/payments.service.ts index 94376b6..de36842 100644 --- a/apps/api/src/modules/payments/payments.service.ts +++ b/apps/api/src/modules/payments/payments.service.ts @@ -40,6 +40,7 @@ import { ethers } from 'ethers'; import { CreatePaymentDto } from './dto/create-payment.dto'; import { PaymentFiltersDto } from './dto/payment-filters.dto'; import { PaymentResponseDto } from './dto/payment-response.dto'; +import { NotificationsService } from '../notifications/notifications.service'; import * as crypto from 'crypto'; interface CheckoutLineItem { @@ -151,6 +152,7 @@ export class PaymentsService implements OnModuleInit { private readonly cctpService: CctpService, @InjectQueue(CCTP_OBSERVE_QUEUE) private readonly cctpQueue: Queue, private readonly configService: ConfigService, + private readonly notificationsService: NotificationsService, ) { const secretKey = this.configService.get('STRIPE_SECRET_KEY'); this.stripe = secretKey ? new Stripe(secretKey) : null; @@ -208,6 +210,37 @@ export class PaymentsService implements OnModuleInit { updatedPayment.id, ); + const metadata = + updatedPayment.metadata && typeof updatedPayment.metadata === 'object' + ? (updatedPayment.metadata as Record) + : {}; + const customerEmail = + typeof metadata.customerEmail === 'string' + ? metadata.customerEmail + : typeof metadata.email === 'string' + ? metadata.email + : undefined; + + if (status === PaymentStatus.COMPLETED) { + const receivedAmount = updatedPayment.destAmount?.toString() ?? '0'; + + void this.notificationsService + .notifyPaymentReceived( + updatedPayment.merchantId, + updatedPayment.id, + receivedAmount, + updatedPayment.destAsset || 'USD', + customerEmail, + ) + .catch(() => undefined); + } + + if (status === PaymentStatus.REFUNDING) { + void this.notificationsService + .notifyRefundInitiated(updatedPayment.merchantId, updatedPayment.id) + .catch(() => undefined); + } + return updatedPayment; } @@ -391,9 +424,13 @@ export class PaymentsService implements OnModuleInit { const payment = await this.prisma.payment.create({ data: { merchantId: internal.merchant.id, + quoteId: null, status: PaymentStatus.PENDING, // Source fields stay null until the customer picks a method. // Quote stays null too — created when the method is chosen. + sourceChain: null, + sourceAsset: null, + sourceAmount: null, destChain: internal.merchant.settlementChain, destAsset: internal.merchant.settlementAsset, destAmount, diff --git a/apps/api/src/modules/payouts/payouts.module.ts b/apps/api/src/modules/payouts/payouts.module.ts index 581fcc7..2e36b3c 100644 --- a/apps/api/src/modules/payouts/payouts.module.ts +++ b/apps/api/src/modules/payouts/payouts.module.ts @@ -5,9 +5,11 @@ import { PrismaModule } from '../prisma/prisma.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; import { StellarModule } from '../stellar/stellar.module'; import { AuthModule } from '../auth/auth.module'; +import { EventsModule } from '../events/events.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule], + imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule, EventsModule, NotificationsModule], providers: [PayoutsService], controllers: [PayoutsController], exports: [PayoutsService], diff --git a/apps/api/src/modules/payouts/payouts.service.ts b/apps/api/src/modules/payouts/payouts.service.ts index 618ed05..bf013b8 100644 --- a/apps/api/src/modules/payouts/payouts.service.ts +++ b/apps/api/src/modules/payouts/payouts.service.ts @@ -9,6 +9,8 @@ import { DestType, Payout, PayoutStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { StellarService } from '../stellar/stellar.service'; +import { EventsService } from '../events/events/events.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { CreatePayoutDto, BulkPayoutDto } from './dto/create-payout.dto'; import { PayoutFiltersDto } from './dto/payout-filters.dto'; import { randomUUID } from 'crypto'; @@ -42,6 +44,8 @@ export class PayoutsService { private readonly prisma: PrismaService, private readonly webhooks: WebhooksService, private readonly stellar: StellarService, + private readonly eventsService: EventsService, + private readonly notifications: NotificationsService, ) {} // ── Create single payout ────────────────────────────────────────────────── @@ -254,6 +258,25 @@ export class PayoutsService { where: { id: payout.id }, data: { status: PayoutStatus.FAILED, failureReason }, }); + this.eventsService.emitPayoutStatus( + payout.merchantId, + payout.id, + PayoutStatus.FAILED, + { + amount: failed.amount.toString(), + currency: failed.currency, + failureReason, + updatedAt: new Date(), + }, + ); + void this.notifications + .notifyPayoutFailed( + payout.merchantId, + payout.id, + payout.recipientName, + failureReason, + ) + .catch(() => undefined); this.webhooks .dispatch(payout.merchantId, 'payout.failed', { ...this.webhookPayload(failed), @@ -307,6 +330,25 @@ export class PayoutsService { }, }); + this.eventsService.emitPayoutStatus( + payout.merchantId, + payout.id, + PayoutStatus.COMPLETED, + { + amount: completed.amount.toString(), + currency: completed.currency, + stellarTxHash: txHash, + updatedAt: new Date(), + }, + ); + void this.notifications + .notifyPayoutCompleted( + payout.merchantId, + payout.id, + payout.recipientName, + ) + .catch(() => undefined); + this.webhooks .dispatch(payout.merchantId, 'payout.completed', { ...this.webhookPayload(completed), diff --git a/apps/api/src/modules/webhooks/webhooks.module.ts b/apps/api/src/modules/webhooks/webhooks.module.ts index a1e10ab..5a1532f 100644 --- a/apps/api/src/modules/webhooks/webhooks.module.ts +++ b/apps/api/src/modules/webhooks/webhooks.module.ts @@ -4,11 +4,13 @@ import { WebhooksService } from './webhooks.service'; import { WebhooksProcessor } from './webhooks.processor'; import { WebhooksController } from './webhooks.controller'; import { PrismaModule } from '../prisma/prisma.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { WEBHOOK_QUEUE_NAME } from './webhooks.constants'; @Module({ imports: [ PrismaModule, + NotificationsModule, BullModule.registerQueue({ name: WEBHOOK_QUEUE_NAME }), ], providers: [WebhooksService, WebhooksProcessor], diff --git a/apps/api/src/modules/webhooks/webhooks.processor.ts b/apps/api/src/modules/webhooks/webhooks.processor.ts index 0e574dc..be124be 100644 --- a/apps/api/src/modules/webhooks/webhooks.processor.ts +++ b/apps/api/src/modules/webhooks/webhooks.processor.ts @@ -3,6 +3,7 @@ import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq'; import { Job, Queue } from 'bullmq'; import axios, { AxiosError } from 'axios'; import { PrismaService } from '../prisma/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { WebhookStatus } from '@prisma/client'; import { WEBHOOK_QUEUE_NAME, @@ -22,6 +23,7 @@ export class WebhooksProcessor extends WorkerHost { constructor( @InjectQueue(WEBHOOK_QUEUE_NAME) private readonly webhookQueue: Queue, private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, ) { super(); } @@ -117,6 +119,12 @@ export class WebhooksProcessor extends WorkerHost { }, }); + if (attempt === 1) { + void this.notifications + .notifyWebhookFailed(merchantId, webhookUrl, eventType, eventId) + .catch(() => undefined); + } + this.logger.log( `Webhook ${eventId} will retry at ${nextRetryAt.toISOString()} (delay: ${delayMs}ms)`, ); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 7561134..7ab7cf6 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "module": "CommonJS", - "moduleResolution": "node", "esModuleInterop": true, "isolatedModules": true, "declaration": true, @@ -12,7 +11,6 @@ "target": "ES2020", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true, diff --git a/apps/dashboard/src/app/(dashboard)/layout.tsx b/apps/dashboard/src/app/(dashboard)/layout.tsx index 6e42f32..2c5c12b 100644 --- a/apps/dashboard/src/app/(dashboard)/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/layout.tsx @@ -4,6 +4,7 @@ import { AppSidebar } from "@/components/app-sidebar"; import { SiteHeader } from "@/components/site-header"; import { SidebarInset, SidebarProvider, SidebarRail } from "@useroutr/ui"; import { useAuth } from "@/providers/AuthProvider"; +import { useRefundEvents } from "@/hooks/useRefundEvents"; export default function DashboardLayout({ children, @@ -12,6 +13,9 @@ export default function DashboardLayout({ }) { const { merchant, isLoading } = useAuth(); + // Subscribe to real-time refund events across all dashboard pages + useRefundEvents(); + if (isLoading) { return (
diff --git a/apps/dashboard/src/app/(dashboard)/links/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/links/[id]/page.tsx new file mode 100644 index 0000000..74a7cf8 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/links/[id]/page.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { use } from "react"; +import { notFound, useRouter } from "next/navigation"; +import { + usePaymentLink, + usePaymentLinkStats, + useDeactivatePaymentLink, +} from "@/hooks/usePaymentLinks"; +import { useToast } from "@useroutr/ui"; +import { Button, Skeleton } from "@useroutr/ui"; +import { PageHeader } from "@/components/brand/PageHeader"; +import { LinkStatusBadge } from "@/components/links/LinkStatusBadge"; +import { CopyButton } from "@/components/links/CopyButton"; +import { formatCurrency } from "@/lib/utils"; +import { ArrowLeft, QrCode, Trash } from "@phosphor-icons/react"; +import { QRCodeModal } from "@/components/links/QRCodeModal"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@useroutr/ui"; + +function DetailRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +export default function LinkDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const router = useRouter(); + const { toast } = useToast(); + const [isQROpen, setIsQROpen] = useState(false); + const [isDeactivateOpen, setIsDeactivateOpen] = useState(false); + + const { data: link, isLoading, isError } = usePaymentLink(id); + const { data: stats } = usePaymentLinkStats(id); + const deactivateMutation = useDeactivatePaymentLink(); + + if (isLoading) { + return ( +
+
+ +
+ + +
+
+
+
+ {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+ ); + } + + if (isError || !link) { + notFound(); + } + + const canDeactivate = link.status === "active"; + + const confirmDeactivate = () => { + deactivateMutation.mutate(id, { + onSuccess: () => { + toast("Link deactivated successfully", "success"); + setIsDeactivateOpen(false); + router.push("/links"); + }, + onError: (err) => { + toast(`Failed to deactivate: ${err.message}`, "error"); + }, + }); + }; + + const expiryLabel = link.expiresAt + ? new Date(link.expiresAt).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }) + : "Never"; + + const createdLabel = new Date(link.createdAt).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + + return ( +
+ + {link.description ? ( + link.description + ) : ( + + {link.id} + + )} + + } + actions={ +
+ + + + {canDeactivate && ( + + )} +
+ } + /> + +
+ {/* Main details card */} +
+
+

+ Link details +

+ +
+ +
+ + {link.id} + + + {link.amount + ? formatCurrency(link.amount, link.currency) + : "Open amount"} + + {link.currency} + + {link.type === "single-use" ? "Single-use" : "Multi-use"} + + + + + {link.description && ( + {link.description} + )} + {expiryLabel} + {createdLabel} +
+ + {/* URL row */} +
+

+ Payment URL +

+
+ + {link.url} + + +
+
+
+ + {/* Stats sidebar */} +
+
+

+ Usage stats +

+
+
+
+ Total uses +
+
+ {link.usageCount} +
+
+ {stats && ( + <> +
+
+ Total collected +
+
+ {formatCurrency(stats.totalAmount, link.currency)} +
+
+ {stats.lastPaymentAt && ( +
+
+ Last payment +
+
+ {new Date(stats.lastPaymentAt).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + year: "numeric", + } + )} +
+
+ )} + + )} +
+
+
+
+ + {/* QR Code modal */} + + + {/* Deactivate confirmation */} + + + + Deactivate Link + + Are you sure? No more payments will be accepted through this link. + + +
+

+ {link.description ?? link.id} +

+

{link.url}

+
+ + + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/links/page.tsx b/apps/dashboard/src/app/(dashboard)/links/page.tsx index ee86758..1b77a98 100644 --- a/apps/dashboard/src/app/(dashboard)/links/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/links/page.tsx @@ -1,10 +1,9 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Button, Input, - Select, EmptyState, Skeleton, Dialog, @@ -13,6 +12,7 @@ import { DialogTitle, DialogDescription, DialogFooter, + Pagination, } from "@useroutr/ui"; import { Plus, MagnifyingGlass, Link as LinkIcon } from "@phosphor-icons/react"; import { useToast } from "@useroutr/ui"; @@ -23,13 +23,15 @@ import { CreateLinkModal } from "@/components/links/CreateLinkModal"; import { LinkCreatedModal } from "@/components/links/LinkCreatedModal"; import { QRCodeModal } from "@/components/links/QRCodeModal"; import { - usePaymentLinks, - useCreatePaymentLink, - useDeactivatePaymentLink, -} from "@/hooks/usePaymentLinks"; + useLinks, + useCreateLink, + useDeactivateLink, +} from "@/hooks/useLinks"; import { useDashboardSocket } from "@/hooks/useDashboardSocket"; import type { PaymentLink, CreatePaymentLinkInput } from "@useroutr/types"; +type StatusFilter = "all" | "active" | "expired" | "deactivated"; + // Simple debounce hook function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); @@ -72,7 +74,9 @@ function LinkCardSkeleton() { export default function PaymentLinksPage() { const { toast } = useToast(); const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(12); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [createdLink, setCreatedLink] = useState(null); const [isQRModalOpen, setIsQRModalOpen] = useState(false); @@ -82,37 +86,42 @@ export default function PaymentLinksPage() { // Debounce search input (300ms) const debouncedSearch = useDebounce(search, 300); - const { data, isLoading, refetch } = usePaymentLinks({ + const { data, isLoading, refetch } = useLinks({ + page, + limit, status: statusFilter !== "all" ? statusFilter : undefined, - search: debouncedSearch || undefined, }); - const createMutation = useCreatePaymentLink(); - const deactivateMutation = useDeactivatePaymentLink(); + const createMutation = useCreateLink(); + const deactivateMutation = useDeactivateLink(); // WebSocket for real-time payment notifications const { subscribe } = useDashboardSocket(); useEffect(() => { - // Subscribe to payment link payment events - const unsubscribe = subscribe("payment-link.payment", (...args: unknown[]) => { - const payload = args[0] as { linkId: string; amount: number }; - toast(`Payment received: $${payload.amount}`, "success"); + // Refresh the link list when a real-time link payment event arrives. + const unsubscribe = subscribe("payment-link.payment", () => { refetch(); }); return () => unsubscribe(); - }, [subscribe, toast, refetch]); + }, [subscribe, refetch]); const handleCreate = (data: CreatePaymentLinkInput) => { createMutation.mutate(data, { - onSuccess: (newLink) => { + onSuccess: async (newLink) => { setCreatedLink(newLink); setIsCreateModalOpen(false); - toast("Payment link created successfully!", "success"); + + try { + await navigator.clipboard.writeText(newLink.url); + toast(`Payment link created and copied: ${newLink.url}`, "success"); + } catch { + toast(`Payment link created: ${newLink.url}`, "success"); + } }, onError: (error) => { - toast(`Failed to create link: ${error.message}`, "error"); + toast(error.message, "error"); }, }); }; @@ -130,7 +139,7 @@ export default function PaymentLinksPage() { setLinkToDeactivate(null); }, onError: (error) => { - toast(`Failed to deactivate: ${error.message}`, "error"); + toast(error.message, "error"); }, }); }; @@ -140,7 +149,18 @@ export default function PaymentLinksPage() { setIsQRModalOpen(true); }; - const links = data?.data ?? []; + const allLinks = data?.data ?? []; + const links = useMemo(() => { + const query = debouncedSearch.trim().toLowerCase(); + if (!query) return allLinks; + + return allLinks.filter((link) => + (link.description ?? "").toLowerCase().includes(query), + ); + }, [allLinks, debouncedSearch]); + + const totalLinks = data?.meta.total ?? 0; + const totalPages = data?.meta.totalPages ?? 0; const hasLinks = links.length > 0; // Determine if filters are active @@ -150,6 +170,13 @@ export default function PaymentLinksPage() { const showNoResults = hasActiveFilters && !hasLinks; const showNoLinks = !hasActiveFilters && !hasLinks; + const statusChips: Array<{ value: StatusFilter; label: string }> = [ + { value: "all", label: "All" }, + { value: "active", label: "Active" }, + { value: "expired", label: "Expired" }, + { value: "deactivated", label: "Deactivated" }, + ]; + return (
{/* Filters */} -
+
-