This document summarizes the implementation of 4 major features for the Vaultix platform.
What Already Exists:
- ✅ Notification entity with all required fields
- ✅ NotificationPreference entity
- ✅ NotificationService with
handleEscrowEvent()method - ✅ EmailSender with Nodemailer integration
- ✅ Cron job for processing pending notifications every 30 seconds
- ✅ Controller endpoints: GET /notifications, POST /notifications/mark-as-read, GET /notifications/unread-count
- ✅ PreferenceService for managing notification preferences
What Needs to be Added:
- Wire notifications to escrow lifecycle events in
escrow.service.ts - Add PARTY_INVITED event type to enum
- Ensure email addresses are passed in notification payloads
Implementation Steps:
// In escrow.service.ts, add NotificationService to constructor:
constructor(
// ... existing dependencies
private readonly notificationService: NotificationService,
) {}
// Then call notificationService.handleEscrowEvent() on each state change:
// Example in fund() method:
await this.notificationService.handleEscrowEvent(
creatorId,
NotificationEventType.ESCROW_FUNDED,
{
escrowId: escrow.id,
escrowTitle: escrow.title,
amount: escrow.amount,
asset: escrow.assetCode,
actionUrl: `${FRONTEND_URL}/escrow/${escrow.id}`,
}
);Files to Modify:
apps/backend/src/modules/escrow/services/escrow.service.ts- Add notification callsapps/backend/src/notifications/enums/notification-event.enum.ts- Add PARTY_INVITEDapps/backend/src/notifications/notifications.service.ts- Already complete
Required Dependencies:
cd apps/backend
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
npm install --save-dev @types/socket.ioImplementation Files to Create:
apps/backend/src/gateways/escrow.gateway.ts
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { UseGuards } from '@nestjs/common';
import { WsAuthGuard } from './ws-auth.guard';
@WebSocketGateway({
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:3001',
credentials: true,
},
})
@UseGuards(WsAuthGuard)
export class EscrowGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private userSocketMap: Map<string, string[]> = new Map(); // userId -> socketIds[]
private socketUserMap: Map<string, string> = new Map(); // socketId -> userId
handleConnection(client: Socket) {
const userId = client.data.user?.id; // From JWT guard
if (!userId) {
client.disconnect();
return;
}
this.socketUserMap.set(client.id, userId);
const userSockets = this.userSocketMap.get(userId) || [];
userSockets.push(client.id);
this.userSocketMap.set(userId, userSockets);
}
handleDisconnect(client: Socket) {
const userId = this.socketUserMap.get(client.id);
if (userId) {
const userSockets = this.userSocketMap.get(userId) || [];
const updatedSockets = userSockets.filter(id => id !== client.id);
if (updatedSockets.length === 0) {
this.userSocketMap.delete(userId);
} else {
this.userSocketMap.set(userId, updatedSockets);
}
this.socketUserMap.delete(client.id);
}
}
@SubscribeMessage('joinEscrow')
handleJoinEscrow(client: Socket, escrowId: string) {
client.join(`escrow:${escrowId}`);
}
@SubscribeMessage('leaveEscrow')
handleLeaveEscrow(client: Socket, escrowId: string) {
client.leave(`escrow:${escrowId}`);
}
// Broadcast methods to be called from EscrowService
broadcastEscrowStatusChanged(escrowId: string, data: any) {
this.server.to(`escrow:${escrowId}`).emit('escrow:status_changed', data);
}
broadcastMilestoneReleased(escrowId: string, data: any) {
this.server.to(`escrow:${escrowId}`).emit('escrow:milestone_released', data);
}
broadcastDisputeFiled(escrowId: string, data: any) {
this.server.to(`escrow:${escrowId}`).emit('escrow:dispute_filed', data);
}
broadcastDisputeResolved(escrowId: string, data: any) {
this.server.to(`escrow:${escrowId}`).emit('escrow:dispute_resolved', data);
}
broadcastPartyJoined(escrowId: string, data: any) {
this.server.to(`escrow:${escrowId}`).emit('escrow:party_joined', data);
}
broadcastNotification(userId: string, data: any) {
const socketIds = this.userSocketMap.get(userId) || [];
socketIds.forEach(socketId => {
this.server.to(socketId).emit('notification:new', data);
});
}
}apps/backend/src/gateways/ws-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class WsAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const client: Socket = context.switchToWs().getClient();
const token = client.handshake.auth.token ||
client.handshake.headers.authorization?.split(' ')[1];
if (!token) {
throw new WsException('Unauthorized');
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
client.data.user = decoded;
return true;
} catch {
throw new WsException('Unauthorized');
}
}
}- Register in
app.module.ts:
import { EscrowGateway } from './gateways/escrow.gateway';
@Module({
providers: [EscrowGateway, /* ... */],
})Integration with EscrowService:
// Inject EscrowGateway
constructor(
private readonly escrowGateway: EscrowGateway,
) {}
// Call on state changes:
await this.escrowGateway.broadcastEscrowStatusChanged(escrow.id, {
escrowId: escrow.id,
newStatus: escrow.status,
timestamp: new Date().toISOString(),
});What Already Exists:
- ✅
lib/escrow-api.tswith basic API client - ✅
app/contexts/WalletContext.tsxwith real wallet connection - ✅ Some API functions already implemented (acceptPartyInvitation, fulfillCondition, etc.)
What Needs to be Done:
- Create Unified API Client with Auth:
Create apps/frontend/lib/api-client.ts:
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
let authToken: string | null = null;
export const setAuthToken = (token: string | null) => {
authToken = token;
if (token) {
localStorage.setItem('vaultix_token', token);
} else {
localStorage.removeItem('vaultix_token');
}
};
export const initAuthToken = () => {
authToken = localStorage.getItem('vaultix_token');
return authToken;
};
export const apiClient = {
async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...(options.headers as Record<string, string> || {}),
};
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
if (response.status === 204) return undefined as T;
return response.json();
},
get: <T>(path: string) => apiClient.request<T>(path),
post: <T>(path: string, body?: any) => apiClient.request<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
}),
patch: <T>(path: string, body?: any) => apiClient.request<T>(path, {
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(path: string) => apiClient.request<T>(path, { method: 'DELETE' }),
};- Implement Auth Flow:
Create apps/frontend/lib/auth.ts:
import { apiClient, setAuthToken } from './api-client';
import * as StellarSdk from 'stellar-sdk';
export interface AuthChallenge {
challenge: string;
expiresAt: string;
}
export const initiateAuth = async (publicKey: string): Promise<AuthChallenge> => {
return apiClient.post<AuthChallenge>('/auth/challenge', { publicKey });
};
export const completeAuth = async (
publicKey: string,
signature: string,
): Promise<{ accessToken: string; refreshToken: string }> => {
const result = await apiClient.post<{ accessToken: string; refreshToken: string }>(
'/auth/verify',
{ publicKey, signature }
);
setAuthToken(result.accessToken);
return result;
};
export const signChallenge = async (
challenge: string,
signTransaction: (xdr: string) => Promise<string>,
): Promise<string> => {
// Create a transaction from the challenge and sign it
const transaction = new StellarSdk.Transaction(
challenge,
StellarSdk.Networks.TESTNET
);
return await signTransaction(transaction.toEnvelope().toXDR('base64'));
};
export const walletAuthFlow = async (
publicKey: string,
signTransaction: (xdr: string) => Promise<string>,
): Promise<void> => {
const challenge = await initiateAuth(publicKey);
const signature = await signChallenge(challenge.challenge, signTransaction);
await completeAuth(publicKey, signature);
};- Replace Mock EscrowService:
Replace apps/frontend/services/escrow.ts with:
import { apiClient } from '@/lib/api-client';
import { IEscrow, IEscrowResponse, IEscrowFilters, IEscrowEvent, IEscrowEventResponse } from '@/types/escrow';
export class EscrowService {
static async getEscrows(filters: IEscrowFilters = {}): Promise<IEscrowResponse> {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.search) params.set('search', filters.search);
if (filters.page) params.set('page', String(filters.page));
if (filters.limit) params.set('limit', String(filters.limit));
return apiClient.get<IEscrowResponse>(`/escrows?${params.toString()}`);
}
static async getEscrowById(id: string): Promise<IEscrow> {
return apiClient.get<IEscrow>(`/escrows/${id}`);
}
static async createEscrow(data: any): Promise<IEscrow> {
return apiClient.post<IEscrow>('/escrows', data);
}
static async fundEscrow(id: string, data: any): Promise<IEscrow> {
return apiClient.post<IEscrow>(`/escrows/${id}/fund`, data);
}
static async releaseFunds(id: string): Promise<IEscrow> {
return apiClient.post<IEscrow>(`/escrows/${id}/release`);
}
static async cancelEscrow(id: string, reason?: string): Promise<IEscrow> {
return apiClient.post<IEscrow>(`/escrows/${id}/cancel`, { reason });
}
static async fileDispute(id: string, data: any): Promise<IEscrow> {
return apiClient.post<IEscrow>(`/escrows/${id}/dispute`, data);
}
static async fulfillCondition(escrowId: string, conditionId: string, data: any): Promise<IEscrow> {
return apiClient.post<IEscrow>(
`/escrows/${escrowId}/conditions/${conditionId}/fulfill`,
data
);
}
static async confirmCondition(escrowId: string, conditionId: string): Promise<IEscrow> {
return apiClient.post<IEscrow>(
`/escrows/${escrowId}/conditions/${conditionId}/confirm`
);
}
static async getEvents(escrowId: string, filters: any = {}): Promise<IEscrowEventResponse> {
const params = new URLSearchParams();
if (filters.page) params.set('page', String(filters.page));
if (filters.limit) params.set('limit', String(filters.limit));
return apiClient.get<IEscrowEventResponse>(
`/escrows/${escrowId}/events?${params.toString()}`
);
}
}- Update React Query Hooks:
Update apps/frontend/hooks/useEscrows.ts:
import { useInfiniteQuery } from '@tanstack/react-query';
import { IEscrowResponse } from '@/types/escrow';
import { EscrowService } from '@/services/escrow';
export const useEscrows = (params: any = {}) => {
return useInfiniteQuery<IEscrowResponse>({
queryKey: ['escrows', params],
queryFn: async ({ pageParam = 1 }) => {
return EscrowService.getEscrows({
...params,
page: pageParam as number,
limit: 10,
});
},
getNextPageParam: (lastPage, pages) => {
return lastPage.hasNextPage ? pages.length + 1 : undefined;
},
initialPageParam: 1,
enabled: params.enabled !== false,
retry: 3,
staleTime: 30000, // 30 seconds
});
};Strategy:
- Move all files from
component/tocomponents/ - Update all import paths
- Delete old
component/directory - Verify build passes
Commands to Execute:
cd apps/frontend
# Create missing directories
mkdir -p components/dashboard components/escrow/create components/layout components/wallet components/homepage
# Move all files
cp -r component/dashboard/* components/dashboard/
cp -r component/escrow/* components/escrow/
cp -r component/homepage/* components/homepage/
cp -r component/layout/* components/layout/
cp -r component/wallet/* components/wallet/
cp -r component/ui/* components/ui/
cp component/Providers.tsx components/
# Update all imports (using sed or manual replacement)
# Replace @/component/ with @/components/
# Delete old directory
rm -rf componentImport Path Updates:
Search and replace across all .tsx and .ts files:
@/component/→@/components/
- Implement WebSocket gateway - Create the files listed above
- Wire notifications - Add notification calls to escrow.service.ts
- Replace mock API - Update services/escrow.ts with real API calls
- Consolidate components - Run the commands above
- Test everything - Run builds and verify all features work
- ✅
notifications/- Already complete - 🆕
gateways/escrow.gateway.ts- Needs creation - 🆕
gateways/ws-auth.guard.ts- Needs creation - 🔄
modules/escrow/services/escrow.service.ts- Needs notification integration - 🔄
app.module.ts- Needs gateway registration
- 🆕
lib/api-client.ts- Needs creation - 🆕
lib/auth.ts- Needs creation - 🔄
services/escrow.ts- Replace mock with real API - 🔄
hooks/*.ts- Update to use real API - 🔄 All component imports - Update paths
Estimated Time to Complete: 4-6 hours for full implementation Complexity: Medium-High Priority: All 4 features are foundational and should be implemented together