diff --git a/platforms/eCurrency-api/package.json b/platforms/eCurrency-api/package.json index 7a34f384e..f943c4c2f 100644 --- a/platforms/eCurrency-api/package.json +++ b/platforms/eCurrency-api/package.json @@ -17,6 +17,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2", + "graphql-request": "^6.1.0", "jsonwebtoken": "^9.0.2", "pg": "^8.11.3", "reflect-metadata": "^0.2.1", diff --git a/platforms/eCurrency-api/src/controllers/WebhookController.ts b/platforms/eCurrency-api/src/controllers/WebhookController.ts index 5b80e900e..bc0c90061 100644 --- a/platforms/eCurrency-api/src/controllers/WebhookController.ts +++ b/platforms/eCurrency-api/src/controllers/WebhookController.ts @@ -1,18 +1,23 @@ import { Request, Response } from "express"; import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; +import { MessageService } from "../services/MessageService"; import { adapter } from "../web3adapter/watchers/subscriber"; import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; +import { Message } from "../database/entities/Message"; import axios from "axios"; export class WebhookController { userService: UserService; groupService: GroupService; + messageService: MessageService; adapter: typeof adapter; constructor() { this.userService = new UserService(); this.groupService = new GroupService(); + this.messageService = new MessageService(); this.adapter = adapter; } @@ -179,6 +184,86 @@ export class WebhookController { finalLocalId = group.id; } } + } else if (mapping.tableName === "messages") { + console.log("Processing message with data:", local.data); + + // Extract sender and group from the message data + let sender: User | null = null; + let group: Group | null = null; + + if (local.data.sender && typeof local.data.sender === "string") { + const senderId = local.data.sender.split("(")[1].split(")")[0]; + sender = await this.userService.getUserById(senderId); + } + + if (local.data.group && typeof local.data.group === "string") { + const groupId = local.data.group.split("(")[1].split(")")[0]; + group = await this.groupService.getGroupById(groupId); + } + + // Check if this is a system message (no sender required) + const isSystemMessage = local.data.isSystemMessage === true || + (local.data.text && typeof local.data.text === 'string' && local.data.text.startsWith('$$system-message$$')); + + if (!group) { + console.error("Group not found for message"); + return res.status(500).send(); + } + + // For system messages, sender can be null + if (!isSystemMessage && !sender) { + console.error("Sender not found for non-system message"); + return res.status(500).send(); + } + + if (localId) { + console.log("Updating existing message with localId:", localId); + const message = await this.messageService.getMessageById(localId); + if (!message) { + console.error("Message not found for localId:", localId); + return res.status(500).send(); + } + + // For system messages, ensure the prefix is preserved + if (isSystemMessage && !(local.data.text as string).startsWith('$$system-message$$')) { + message.text = `$$system-message$$ ${local.data.text as string}`; + } else { + message.text = local.data.text as string; + } + message.sender = sender || undefined; + message.group = group; + message.isSystemMessage = isSystemMessage as boolean; + + this.adapter.addToLockedIds(localId); + await this.messageService.messageRepository.save(message); + console.log("Updated message:", message.id); + finalLocalId = message.id; + } else { + console.log("Creating new message"); + let message: Message; + + if (isSystemMessage) { + message = await this.messageService.createSystemMessageWithoutPrefix({ + text: local.data.text as string, + groupId: group.id, + }); + } else { + message = await this.messageService.createMessage({ + text: local.data.text as string, + senderId: sender!.id, // We know sender exists for non-system messages + groupId: group.id, + }); + } + + console.log("Created message with ID:", message.id); + this.adapter.addToLockedIds(message.id); + await this.adapter.mappingDb.storeMapping({ + localId: message.id, + globalId: req.body.id, + }); + console.log("Stored mapping for message:", message.id, "->", req.body.id); + finalLocalId = message.id; + } } res.status(200).send(); diff --git a/platforms/eCurrency-api/src/database/data-source.ts b/platforms/eCurrency-api/src/database/data-source.ts index 19a609b3e..49c695d4d 100644 --- a/platforms/eCurrency-api/src/database/data-source.ts +++ b/platforms/eCurrency-api/src/database/data-source.ts @@ -6,6 +6,8 @@ import { User } from "./entities/User"; import { Group } from "./entities/Group"; import { Currency } from "./entities/Currency"; import { Ledger } from "./entities/Ledger"; +import { Message } from "./entities/Message"; +import { UserEVaultMapping } from "./entities/UserEVaultMapping"; import { PostgresSubscriber } from "../web3adapter/watchers/subscriber"; // Use absolute path for better CLI compatibility @@ -16,7 +18,7 @@ export const dataSourceOptions: DataSourceOptions = { type: "postgres", url: process.env.ECURRENCY_DATABASE_URL, synchronize: false, // Auto-sync in development - entities: [User, Group, Currency, Ledger], + entities: [User, Group, Currency, Ledger, Message, UserEVaultMapping], migrations: [path.join(__dirname, "migrations", "*.ts")], logging: process.env.NODE_ENV === "development", subscribers: [PostgresSubscriber], diff --git a/platforms/eCurrency-api/src/database/entities/Group.ts b/platforms/eCurrency-api/src/database/entities/Group.ts index 98eaaa054..e5ddf6571 100644 --- a/platforms/eCurrency-api/src/database/entities/Group.ts +++ b/platforms/eCurrency-api/src/database/entities/Group.ts @@ -5,9 +5,11 @@ import { PrimaryGeneratedColumn, Column, ManyToMany, + OneToMany, JoinTable, } from "typeorm"; import { User } from "./User"; +import { Message } from "./Message"; @Entity() export class Group { @@ -68,6 +70,9 @@ export class Group { @Column({ type: "json", nullable: true }) originalMatchParticipants!: string[]; // Store user IDs from the original match + @OneToMany(() => Message, (message) => message.group) + messages!: Message[]; + @CreateDateColumn() createdAt!: Date; diff --git a/platforms/eCurrency-api/src/database/entities/Message.ts b/platforms/eCurrency-api/src/database/entities/Message.ts new file mode 100644 index 000000000..18793ae44 --- /dev/null +++ b/platforms/eCurrency-api/src/database/entities/Message.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, +} from "typeorm"; +import { User } from "./User"; +import { Group } from "./Group"; + +@Entity("messages") +export class Message { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @ManyToOne(() => User, { nullable: true }) + sender?: User; // Nullable for system messages + + @Column("text") + text!: string; + + @ManyToOne(() => Group, (group) => group.messages) + group!: Group; + + @Column({ default: false }) + isSystemMessage!: boolean; // Flag to identify system messages + + @Column("uuid", { nullable: true }) + voteId?: string; // ID of the vote/poll this system message relates to + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @Column({ default: false }) + isArchived!: boolean; +} + diff --git a/platforms/eCurrency-api/src/database/entities/UserEVaultMapping.ts b/platforms/eCurrency-api/src/database/entities/UserEVaultMapping.ts new file mode 100644 index 000000000..4b1ccf983 --- /dev/null +++ b/platforms/eCurrency-api/src/database/entities/UserEVaultMapping.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("user_evault_mappings") +export class UserEVaultMapping { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + localUserId!: string; + + @Column() + evaultW3id!: string; + + @Column() + evaultUri!: string; + + @Column({ nullable: true }) + userProfileId!: string; // ID of the UserProfile object in the eVault + + @Column({ type: "jsonb", nullable: true }) + userProfileData!: any; // Store the UserProfile data + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/eCurrency-api/src/database/migrations/1765208128946-migration.ts b/platforms/eCurrency-api/src/database/migrations/1765208128946-migration.ts new file mode 100644 index 000000000..e4b9a58a0 --- /dev/null +++ b/platforms/eCurrency-api/src/database/migrations/1765208128946-migration.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1765208128946 implements MigrationInterface { + name = 'Migration1765208128946' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" text NOT NULL, "isSystemMessage" boolean NOT NULL DEFAULT false, "voteId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isArchived" boolean NOT NULL DEFAULT false, "senderId" uuid, "groupId" uuid, CONSTRAINT "PK_18325f38ae6de43878487eff986" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "user_evault_mappings" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "localUserId" character varying NOT NULL, "evaultW3id" character varying NOT NULL, "evaultUri" character varying NOT NULL, "userProfileId" character varying, "userProfileData" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_744ddb4ddca6af2de54773e9213" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "messages" ADD CONSTRAINT "FK_2db9cf2b3ca111742793f6c37ce" FOREIGN KEY ("senderId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "messages" ADD CONSTRAINT "FK_438f09ab5b4bbcd27683eac2a5e" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_438f09ab5b4bbcd27683eac2a5e"`); + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_2db9cf2b3ca111742793f6c37ce"`); + await queryRunner.query(`DROP TABLE "user_evault_mappings"`); + await queryRunner.query(`DROP TABLE "messages"`); + } + +} diff --git a/platforms/eCurrency-api/src/index.ts b/platforms/eCurrency-api/src/index.ts index 5d049caba..6250285d7 100644 --- a/platforms/eCurrency-api/src/index.ts +++ b/platforms/eCurrency-api/src/index.ts @@ -13,6 +13,7 @@ import { CurrencyController } from "./controllers/CurrencyController"; import { LedgerController } from "./controllers/LedgerController"; import { authMiddleware, authGuard } from "./middleware/auth"; import { adapter } from "./web3adapter/watchers/subscriber"; +import { PlatformEVaultService } from "./services/PlatformEVaultService"; config({ path: path.resolve(__dirname, "../../../.env") }); @@ -24,6 +25,23 @@ AppDataSource.initialize() .then(async () => { console.log("Database connection established"); console.log("Web3 adapter initialized"); + + // Initialize platform eVault for eCurrency + try { + const platformService = PlatformEVaultService.getInstance(); + const exists = await platformService.checkPlatformEVaultExists(); + + if (!exists) { + console.log("🔧 Creating platform eVault for eCurrency..."); + const result = await platformService.createPlatformEVault(); + console.log(`✅ Platform eVault created successfully: ${result.w3id}`); + } else { + console.log("✅ Platform eVault already exists for eCurrency"); + } + } catch (error) { + console.error("❌ Failed to initialize platform eVault:", error); + // Don't exit the process, just log the error + } }) .catch((error: unknown) => { console.error("Error during initialization:", error); diff --git a/platforms/eCurrency-api/src/services/GroupService.ts b/platforms/eCurrency-api/src/services/GroupService.ts index 663cb16db..e78807c30 100644 --- a/platforms/eCurrency-api/src/services/GroupService.ts +++ b/platforms/eCurrency-api/src/services/GroupService.ts @@ -12,6 +12,73 @@ export class GroupService { this.userRepository = AppDataSource.getRepository(User); } + // Group CRUD Operations + async findGroupByMembers(memberIds: string[]): Promise { + if (memberIds.length === 0) { + return null; + } + + // Use a more efficient query to find groups with exactly these members + const sortedMemberIds = memberIds.sort(); + + // For 2-member groups (DMs), use a more efficient query + if (sortedMemberIds.length === 2) { + // Use a subquery to find groups where both members are present + const groups = await this.groupRepository + .createQueryBuilder("group") + .leftJoinAndSelect("group.members", "members") + .where("group.isPrivate = :isPrivate", { isPrivate: true }) + .andWhere((qb) => { + const subQuery = qb.subQuery() + .select("gm.group_id") + .from("group_members", "gm") + .where("gm.user_id IN (:...memberIds)", { + memberIds: sortedMemberIds + }) + .groupBy("gm.group_id") + .having("COUNT(DISTINCT gm.user_id) = :memberCount", { memberCount: 2 }) + .getQuery(); + return "group.id IN " + subQuery; + }) + .getMany(); + + // Filter groups that have exactly the same 2 members + for (const group of groups) { + if (group.members && group.members.length === 2) { + const groupMemberIds = group.members.map((m: User) => m.id).sort(); + + if (groupMemberIds.length === sortedMemberIds.length && + groupMemberIds.every((id: string, index: number) => id === sortedMemberIds[index])) { + return group; + } + } + } + } + + // Fallback to general search for other group sizes + const groups = await this.groupRepository + .createQueryBuilder("group") + .leftJoinAndSelect("group.members", "members") + .where("group.isPrivate = :isPrivate", { isPrivate: true }) + .getMany(); + + // Filter groups that have exactly the same members (order doesn't matter) + for (const group of groups) { + if (!group.members || group.members.length !== sortedMemberIds.length) { + continue; + } + + const groupMemberIds = group.members.map((m: User) => m.id).sort(); + + if (groupMemberIds.length === sortedMemberIds.length && + groupMemberIds.every((id: string, index: number) => id === sortedMemberIds[index])) { + return group; + } + } + + return null; + } + async getGroupById(id: string): Promise { return await this.groupRepository.findOne({ where: { id }, @@ -24,28 +91,52 @@ export class GroupService { description: string, owner: string, adminIds: string[] = [], - participantIds: string[] = [], - charter?: string + memberIds: string[] = [], + charter?: string, + isPrivate: boolean = false, + visibility: "public" | "private" | "restricted" = "public", + avatarUrl?: string, + bannerUrl?: string, + originalMatchParticipants?: string[], ): Promise { + // For eCurrency Chat groups, check if a DM already exists between these users + // This prevents duplicate chat creation in race conditions + if (isPrivate && (name.startsWith("eCurrency Chat") || name.includes("eCurrency Chat")) && memberIds.length === 2) { + const existingDM = await this.findGroupByMembers(memberIds); + if (existingDM) { + console.log(`⚠️ DM already exists between users ${memberIds.join(", ")}, returning existing DM: ${existingDM.id}`); + return existingDM; + } + } + + const members = await this.userRepository.findBy({ + id: In(memberIds), + }); + if (members.length !== memberIds.length) { + throw new Error("One or more members not found"); + } + + const admins = await this.userRepository.findBy({ + id: In(adminIds), + }); + if (admins.length !== adminIds.length) { + throw new Error("One or more admins not found"); + } + const group = this.groupRepository.create({ name, description, owner, charter, + members, + admins, + participants: members, // Also set participants for compatibility + isPrivate, + visibility, + avatarUrl, + bannerUrl, + originalMatchParticipants: originalMatchParticipants || [], }); - - // Add admins - if (adminIds.length > 0) { - const admins = await this.userRepository.findBy({ id: In(adminIds) }); - group.admins = admins; - } - - // Add participants - if (participantIds.length > 0) { - const participants = await this.userRepository.findBy({ id: In(participantIds) }); - group.participants = participants; - } - return await this.groupRepository.save(group); } diff --git a/platforms/eCurrency-api/src/services/LedgerService.ts b/platforms/eCurrency-api/src/services/LedgerService.ts index 6e6315dd8..ca16b4fb3 100644 --- a/platforms/eCurrency-api/src/services/LedgerService.ts +++ b/platforms/eCurrency-api/src/services/LedgerService.ts @@ -4,6 +4,7 @@ import { Ledger, AccountType, LedgerType } from "../database/entities/Ledger"; import { Currency } from "../database/entities/Currency"; import { User } from "../database/entities/User"; import { Group } from "../database/entities/Group"; +import { TransactionNotificationService } from "./TransactionNotificationService"; export class LedgerService { ledgerRepository: Repository; @@ -127,6 +128,24 @@ export class LedgerService { toAccountType // receiver type ); + // Send transaction notifications for all transfer types + // (USER-to-USER, USER-to-GROUP, GROUP-to-USER, GROUP-to-GROUP) + try { + const notificationService = new TransactionNotificationService(); + await notificationService.sendTransactionNotifications( + amount, + currency, + fromAccountId, + fromAccountType, + toAccountId, + toAccountType, + description + ); + } catch (error) { + // Don't fail the transfer if notification fails + console.error("Error sending transaction notifications:", error); + } + return { debit, credit }; } diff --git a/platforms/eCurrency-api/src/services/MessageService.ts b/platforms/eCurrency-api/src/services/MessageService.ts new file mode 100644 index 000000000..cc9a8301c --- /dev/null +++ b/platforms/eCurrency-api/src/services/MessageService.ts @@ -0,0 +1,135 @@ +import { AppDataSource } from "../database/data-source"; +import { Message } from "../database/entities/Message"; +import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; + +export class MessageService { + public messageRepository = AppDataSource.getRepository(Message); + private userRepository = AppDataSource.getRepository(User); + private groupRepository = AppDataSource.getRepository(Group); + + async createMessage(messageData: { + text: string; + senderId: string; + groupId: string; + }): Promise { + const sender = await this.userRepository.findOne({ where: { id: messageData.senderId } }); + const group = await this.groupRepository.findOne({ where: { id: messageData.groupId } }); + + if (!sender || !group) { + throw new Error("Sender or group not found"); + } + + const message = this.messageRepository.create({ + text: messageData.text, + sender, + group, + isSystemMessage: false, + }); + + return await this.messageRepository.save(message); + } + + async createSystemMessage(messageData: { + text: string; + groupId: string; + }): Promise { + const group = await this.groupRepository.findOne({ where: { id: messageData.groupId } }); + + if (!group) { + throw new Error("Group not found"); + } + + // Add the system message prefix for web3-adapter compatibility + const prefixedText = `$$system-message$$ ${messageData.text}`; + + const message = this.messageRepository.create({ + text: prefixedText, + sender: undefined, // Use undefined instead of null for optional field + group, + isSystemMessage: true, + }); + + return await this.messageRepository.save(message); + } + + async createSystemMessageWithoutPrefix(messageData: { + text: string; + groupId: string; + }): Promise { + const group = await this.groupRepository.findOne({ where: { id: messageData.groupId } }); + + if (!group) { + throw new Error("Group not found"); + } + + const message = this.messageRepository.create({ + text: messageData.text, + sender: undefined, // Use undefined instead of null for optional field + group, + isSystemMessage: true, + }); + + return await this.messageRepository.save(message); + } + + async getMessageById(id: string): Promise { + return await this.messageRepository.findOne({ + where: { id }, + relations: ['sender', 'group'] + }); + } + + async getGroupMessages(groupId: string): Promise { + return await this.messageRepository.find({ + where: { group: { id: groupId } }, + relations: ['sender', 'group'], + order: { createdAt: 'ASC' } + }); + } + + async updateMessage(id: string, messageData: Partial): Promise { + // Get the current message, merge the data, and save it to trigger ORM events + const currentMessage = await this.getMessageById(id); + if (!currentMessage) { + throw new Error("Message not found"); + } + + // Merge the new data with the existing message + Object.assign(currentMessage, messageData); + + // Save the merged message to trigger ORM subscribers + const updatedMessage = await this.messageRepository.save(currentMessage); + return updatedMessage; + } + + async deleteMessage(id: string): Promise { + const result = await this.messageRepository.delete(id); + return result.affected ? result.affected > 0 : false; + } + + async getUserMessages(userId: string): Promise { + const messages = await this.messageRepository.find({ + where: { sender: { id: userId } }, + relations: ['sender', 'group'], + order: { createdAt: 'DESC' } + }); + + return messages; + } + + async archiveMessage(id: string): Promise { + // Get the current message, set archived flag, and save it to trigger ORM events + const currentMessage = await this.getMessageById(id); + if (!currentMessage) { + throw new Error("Message not found"); + } + + currentMessage.isArchived = true; + + // Save the updated message to trigger ORM subscribers + const archivedMessage = await this.messageRepository.save(currentMessage); + return archivedMessage; + } +} + diff --git a/platforms/eCurrency-api/src/services/PlatformEVaultService.ts b/platforms/eCurrency-api/src/services/PlatformEVaultService.ts new file mode 100644 index 000000000..522addcfe --- /dev/null +++ b/platforms/eCurrency-api/src/services/PlatformEVaultService.ts @@ -0,0 +1,321 @@ +import axios from "axios"; +import { GraphQLClient } from "graphql-request"; +import { v4 as uuidv4 } from "uuid"; +import { UserEVaultMapping } from "../database/entities/UserEVaultMapping"; +import { AppDataSource } from "../database/data-source"; + +const STORE_META_ENVELOPE = ` + mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { + storeMetaEnvelope(input: $input) { + metaEnvelope { + id + ontology + parsed + } + } + } +`; + +interface MetaEnvelopeResponse { + storeMetaEnvelope: { + metaEnvelope: { + id: string; + ontology: string; + parsed: any; + }; + }; +} + +interface PlatformProfile { + platformName: string; + displayName: string; + description: string; + version: string; + ename: string; + isActive: boolean; + createdAt: string; + updatedAt: string; + isArchived: boolean; +} + +export class PlatformEVaultService { + private static instance: PlatformEVaultService; + private client: GraphQLClient | null = null; + private endpoint: string | null = null; + private w3id: string | null = null; + + private constructor() {} + + public static getInstance(): PlatformEVaultService { + if (!PlatformEVaultService.instance) { + PlatformEVaultService.instance = new PlatformEVaultService(); + } + return PlatformEVaultService.instance; + } + + /** + * Check if eCurrency platform eVault already exists + */ + async checkPlatformEVaultExists(): Promise { + const mappingRepository = + AppDataSource.getRepository(UserEVaultMapping); + const existingMapping = await mappingRepository.findOne({ + where: { localUserId: "ecurrency-platform" }, + }); + return !!existingMapping; + } + + /** + * Create eVault for eCurrency platform (one-time setup) + */ + async createPlatformEVault(): Promise<{ + w3id: string; + uri: string; + userProfileId: string; + }> { + console.log("Creating platform eVault for eCurrency..."); + + // Check if platform eVault already exists + const exists = await this.checkPlatformEVaultExists(); + if (exists) { + throw new Error("Platform eVault already exists for eCurrency"); + } + + try { + // Step 1: Get entropy from registry + const registryUrl = + process.env.PUBLIC_REGISTRY_URL || "http://localhost:3000"; + const { + data: { token: registryEntropy }, + } = await axios.get(new URL("/entropy", registryUrl).toString()); + + // Step 2: Provision eVault + const provisionerUrl = + process.env.PUBLIC_PROVISIONER_URL || "http://localhost:3001"; + const verificationId = + process.env.DEMO_VERIFICATION_CODE || + "d66b7138-538a-465f-a6ce-f6985854c3f4"; + + const { data } = await axios.post( + new URL("/provision", provisionerUrl).toString(), + { + registryEntropy, + namespace: uuidv4(), + verificationId, + publicKey: "0x00000000000000000000000000000000000000", + }, + ); + + if (!data || data.success !== true) { + throw new Error("Failed to provision platform eVault"); + } + + const { w3id, uri } = data; + + // Step 3: Create PlatformProfile in eVault + const userProfileId = await this.createPlatformProfileInEVault( + w3id, + uri, + ); + + // Step 4: Store mapping in database + const mappingRepository = + AppDataSource.getRepository(UserEVaultMapping); + const mapping = new UserEVaultMapping(); + mapping.localUserId = "ecurrency-platform"; + mapping.evaultW3id = w3id; + mapping.evaultUri = uri; + mapping.userProfileId = userProfileId; + mapping.userProfileData = { + platformName: "ecurrency", + displayName: "eCurrency Platform", + description: + "eCurrency - Digital currency and ledger management platform", + version: "1.0.0", + }; + + await mappingRepository.save(mapping); + + console.log("Platform eVault created successfully:", { + w3id, + uri, + userProfileId, + }); + + return { w3id, uri, userProfileId }; + } catch (error) { + console.error("Failed to create platform eVault:", error); + throw error; + } + } + + /** + * Resolve eVault endpoint from registry + */ + private async resolveEndpoint(w3id: string): Promise { + try { + const registryUrl = + process.env.PUBLIC_REGISTRY_URL || "http://localhost:3000"; + const response = await axios.get( + new URL(`resolve?w3id=${w3id}`, registryUrl).toString(), + ); + return new URL("/graphql", response.data.uri).toString(); + } catch (error) { + console.error("Error resolving eVault endpoint:", error); + throw new Error("Failed to resolve eVault endpoint"); + } + } + + /** + * Ensure we have a valid GraphQL client + */ + private async ensureClient(w3id: string): Promise { + // Recreate client if w3id changed or client/endpoint is missing + if (!this.endpoint || !this.client || this.w3id !== w3id) { + this.endpoint = await this.resolveEndpoint(w3id); + this.client = new GraphQLClient(this.endpoint, { + headers: { + "X-ENAME": w3id, + }, + }); + this.w3id = w3id; + } + return this.client; + } + + /** + * Create PlatformProfile in eVault with retry mechanism + */ + private async createPlatformProfileInEVault( + w3id: string, + uri: string, + maxRetries = 20, + ): Promise { + console.log("Creating PlatformProfile in eVault..."); + + const now = new Date().toISOString(); + const platformProfile: PlatformProfile = { + platformName: "ecurrency", + displayName: "eCurrency Platform", + description: + "eCurrency - Digital currency and ledger management platform", + version: "1.0.0", + ename: w3id, + isActive: true, + createdAt: now, + updatedAt: now, + isArchived: false, + }; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const client = await this.ensureClient(w3id); + + console.log( + `Attempting to create PlatformProfile in eVault (attempt ${attempt}/${maxRetries})`, + ); + + const response = await client.request( + STORE_META_ENVELOPE, + { + input: { + ontology: "550e8400-e29b-41d4-a716-446655440000", // UserProfile ontology + payload: platformProfile, + acl: ["*"], + }, + }, + ); + + const userProfileId = + response.storeMetaEnvelope.metaEnvelope.id; + console.log( + "PlatformProfile created successfully in eVault:", + userProfileId, + ); + return userProfileId; + } catch (error) { + console.error( + `Failed to create PlatformProfile in eVault (attempt ${attempt}/${maxRetries}):`, + error, + ); + + if (attempt === maxRetries) { + console.error( + "Max retries reached, giving up on PlatformProfile creation", + ); + throw error; + } + + // Wait before retrying (exponential backoff) + const delay = Math.min(1000 * 2 ** (attempt - 1), 20000); + console.log(`Waiting ${delay}ms before retry...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new Error("Failed to create PlatformProfile after all retries"); + } + + /** + * Get platform eVault mapping + */ + async getPlatformEVaultMapping(): Promise { + const mappingRepository = + AppDataSource.getRepository(UserEVaultMapping); + return await mappingRepository.findOne({ + where: { localUserId: "ecurrency-platform" }, + }); + } + + /** + * Get platform eName (W3ID) + */ + async getPlatformEName(): Promise { + const mapping = await this.getPlatformEVaultMapping(); + return mapping?.evaultW3id || null; + } + + /** + * Get platform eVault URI + */ + async getPlatformEVaultUri(): Promise { + const mapping = await this.getPlatformEVaultMapping(); + return mapping?.evaultUri || null; + } + + /** + * Update platform profile in eVault + */ + async updatePlatformProfile( + updates: Partial, + ): Promise { + const mapping = await this.getPlatformEVaultMapping(); + if (!mapping) { + throw new Error("Platform eVault mapping not found"); + } + + const client = await this.ensureClient(mapping.evaultW3id); + + // Get current profile data + const currentData = mapping.userProfileData as PlatformProfile; + const updatedData = { + ...currentData, + ...updates, + updatedAt: new Date().toISOString(), + }; + + // Update in eVault + await client.request(STORE_META_ENVELOPE, { + input: { + ontology: "550e8400-e29b-41d4-a716-446655440000", + payload: updatedData, + acl: ["*"], + }, + }); + + // Update local mapping + mapping.userProfileData = updatedData; + await AppDataSource.getRepository(UserEVaultMapping).save(mapping); + } +} + diff --git a/platforms/eCurrency-api/src/services/TransactionNotificationService.ts b/platforms/eCurrency-api/src/services/TransactionNotificationService.ts new file mode 100644 index 000000000..39f3a7d4c --- /dev/null +++ b/platforms/eCurrency-api/src/services/TransactionNotificationService.ts @@ -0,0 +1,363 @@ +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; +import { Message } from "../database/entities/Message"; +import { Currency } from "../database/entities/Currency"; +import { AccountType } from "../database/entities/Ledger"; +import { UserService } from "./UserService"; +import { GroupService } from "./GroupService"; +import { MessageService } from "./MessageService"; + +export class TransactionNotificationService { + private userService: UserService; + private groupService: GroupService; + private messageService: MessageService; + private ecurrencyUser: User | null = null; + + constructor() { + this.userService = new UserService(); + this.groupService = new GroupService(); + this.messageService = new MessageService(); + } + + /** + * Find the eCurrency platform user by searching for "eCurrency Platform" in their name + */ + public async findECurrencyUser(): Promise { + if (this.ecurrencyUser) { + return this.ecurrencyUser; + } + + try { + // Search for users with "eCurrency Platform" in their name + const users = await this.userService.searchUsers("eCurrency Platform"); + this.ecurrencyUser = users.find(user => + user.name?.includes("eCurrency Platform") + ) || null; + + if (!this.ecurrencyUser) { + console.error("❌ eCurrency platform user not found in database"); + } else { + console.log(`✅ Found eCurrency platform user: ${this.ecurrencyUser.id}`); + } + + return this.ecurrencyUser; + } catch (error) { + console.error("Error finding eCurrency user:", error); + return null; + } + } + + /** + * Find or create a mutual chat between eCurrency user and another user + * Returns both the chat and whether it was just created + */ + async findOrCreateMutualChat(targetUserId: string): Promise<{ chat: Group | null; wasCreated: boolean }> { + console.log(`🔍 Looking for mutual chat between eCurrency and user: ${targetUserId}`); + + const ecurrencyUser = await this.findECurrencyUser(); + if (!ecurrencyUser) { + console.error("❌ Cannot create mutual chat: eCurrency user not found"); + return { chat: null, wasCreated: false }; + } + + console.log(`👤 eCurrency user found: ${ecurrencyUser.id} (${ecurrencyUser.name || ecurrencyUser.ename})`); + + try { + // Check if a mutual chat already exists between these two users + console.log(`🔍 Checking for existing mutual chat between eCurrency (${ecurrencyUser.id}) and user (${targetUserId})`); + + const existingChat = await this.groupService.findGroupByMembers([ + ecurrencyUser.id, + targetUserId + ]); + + if (existingChat) { + console.log(`✅ Found existing mutual chat: ${existingChat.id}`); + console.log(`📋 Chat details: Name="${existingChat.name}", Private=${existingChat.isPrivate}, Members=${existingChat.members?.length || 0}`); + return { chat: existingChat, wasCreated: false }; + } + + console.log(`🆕 No existing mutual chat found, creating new one...`); + + // Create a new mutual chat + const chatName = `eCurrency Chat with ${targetUserId}`; + const chatDescription = `DM ID: ${targetUserId}::${ecurrencyUser.id}`; + + console.log(`🔧 Creating mutual chat with:`); + console.log(` - Name: ${chatName}`); + console.log(` - Description: ${chatDescription}`); + console.log(` - Owner: ${ecurrencyUser.id}`); + console.log(` - Members: [${ecurrencyUser.id}, ${targetUserId}]`); + console.log(` - Private: true`); + + const mutualChat = await this.groupService.createGroup( + chatName, + chatDescription, + ecurrencyUser.id, // eCurrency is the owner + [ecurrencyUser.id], // eCurrency is admin + [ecurrencyUser.id, targetUserId], // Both users are participants + undefined, // No charter + true, // isPrivate + "private", // visibility + undefined, // avatarUrl + undefined, // bannerUrl + [] // originalMatchParticipants + ); + + // Double-check: if createGroup returned an existing chat (due to race condition), verify it's the right one + if (mutualChat.id) { + const verifyChat = await this.groupService.findGroupByMembers([ + ecurrencyUser.id, + targetUserId + ]); + + if (verifyChat && verifyChat.id !== mutualChat.id) { + console.log(`⚠️ Race condition detected: found different chat ${verifyChat.id}, using it instead`); + return { chat: verifyChat, wasCreated: false }; + } + } + + console.log(`✅ Created new mutual chat: ${mutualChat.id}`); + console.log(`📋 New chat details: Name="${mutualChat.name}", Private=${mutualChat.isPrivate}, Members=${mutualChat.members?.length || 0}`); + return { chat: mutualChat, wasCreated: true }; + } catch (error) { + console.error("❌ Error creating mutual chat:", error); + return { chat: null, wasCreated: false }; + } + } + + /** + * Send transaction notification to a specific user + */ + private async sendNotificationToUser( + userId: string, + transactionDetails: { + amount: number; + currency: Currency; + description?: string; + senderId: string; + senderAccountType: AccountType; + receiverId: string; + receiverAccountType: AccountType; + senderName?: string; + receiverName?: string; + timestamp: Date; + isSender: boolean; + accountId?: string; // The account this notification is about (for group admins) + accountType?: AccountType; // The account type this notification is about + } + ): Promise { + try { + const ecurrencyUser = await this.findECurrencyUser(); + if (!ecurrencyUser) { + console.error("❌ Cannot send notification: eCurrency user not found"); + return; + } + + // Find or create mutual chat + const chatResult = await this.findOrCreateMutualChat(userId); + if (!chatResult.chat) { + console.error(`❌ Cannot send notification: failed to create chat for user ${userId}`); + return; + } + + const mutualChat = chatResult.chat; + const wasCreated = chatResult.wasCreated; + + // If chat was just created, wait 15 seconds before sending message + if (wasCreated) { + console.log(`⏳ Chat was just created, waiting 15 seconds before sending message...`); + await new Promise(resolve => setTimeout(resolve, 15000)); + console.log(`✅ 15-second delay completed for transaction message`); + } + + // Generate the transaction message + const messageContent = this.generateTransactionMessage({ + ...transactionDetails, + accountId: transactionDetails.accountId, + accountType: transactionDetails.accountType, + }); + + console.log(`💾 Creating transaction notification message...`); + const messageRepository = AppDataSource.getRepository(Message); + const message = messageRepository.create({ + text: messageContent, + sender: ecurrencyUser, + group: mutualChat, + isSystemMessage: true, + }); + + console.log(`💾 Saving message to database...`); + const savedMessage = await messageRepository.save(message); + console.log(`✅ Message saved with ID: ${savedMessage.id}`); + console.log(`✅ Transaction notification sent to user ${userId} in chat ${mutualChat.id}`); + } catch (error) { + console.error(`❌ Error sending transaction notification to user ${userId}:`, error); + console.error(`❌ Error details:`, (error as Error).message); + console.error(`❌ Error stack:`, (error as Error).stack); + } + } + + /** + * Generate transaction message content + */ + private generateTransactionMessage(details: { + amount: number; + currency: Currency; + description?: string; + senderId: string; + senderAccountType: AccountType; + receiverId: string; + receiverAccountType: AccountType; + senderName?: string; + receiverName?: string; + timestamp: Date; + isSender: boolean; + accountId?: string; // The account this notification is about + accountType?: AccountType; // The account type this notification is about + }): string { + const { amount, currency, senderName, receiverName, timestamp, isSender, accountType, senderAccountType, receiverAccountType } = details; + + const formattedAmount = amount.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + const formattedTime = timestamp.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + const emoji = isSender ? '💸' : '💰'; + const action = isSender ? 'Sent' : 'Received'; + + // Determine account type text + let accountText = 'personal account'; + if (accountType === AccountType.GROUP) { + const accountName = isSender ? senderName : receiverName; + accountText = `group account of ${accountName || 'Group'}`; + } + + // Determine sender/recipient info + const otherPartyName = isSender + ? (receiverAccountType === AccountType.GROUP ? `${receiverName} (Group)` : receiverName || 'User') + : (senderAccountType === AccountType.GROUP ? `${senderName} (Group)` : senderName || 'User'); + + const otherPartyLabel = isSender ? 'recipient' : 'sender'; + + return `$$system-message$$ + +${emoji} ${currency.name} ${action} + +Transaction for your ${accountText} has been processed. + +${isSender ? 'sent amount' : 'received amount'}: ${formattedAmount} +currency: ${currency.name} (${currency.ename}) +time: ${formattedTime} +${otherPartyLabel}: ${otherPartyName}`; + } + + /** + * Get users to notify for an account (user or group admins) + */ + private async getUsersToNotify(accountId: string, accountType: AccountType): Promise { + if (accountType === AccountType.USER) { + const user = await this.userService.getUserById(accountId); + return user ? [user] : []; + } else { + // For groups, get all admins + const group = await this.groupService.getGroupById(accountId); + if (!group || !group.admins || group.admins.length === 0) { + console.warn(`⚠️ Group ${accountId} has no admins, cannot send notification`); + return []; + } + return group.admins; + } + } + + /** + * Get account display name (user name or group name) + */ + private async getAccountDisplayName(accountId: string, accountType: AccountType): Promise { + if (accountType === AccountType.USER) { + const user = await this.userService.getUserById(accountId); + return user ? (user.name || user.ename) : 'User'; + } else { + const group = await this.groupService.getGroupById(accountId); + return group ? (group.name || 'Group') : 'Group'; + } + } + + /** + * Send transaction notifications to both sender and receiver + * Handles USER-to-USER, USER-to-GROUP, GROUP-to-USER, and GROUP-to-GROUP transfers + */ + async sendTransactionNotifications( + amount: number, + currency: Currency, + senderId: string, + senderAccountType: AccountType, + receiverId: string, + receiverAccountType: AccountType, + description?: string + ): Promise { + try { + // Get users to notify for sender and receiver + const senderUsers = await this.getUsersToNotify(senderId, senderAccountType); + const receiverUsers = await this.getUsersToNotify(receiverId, receiverAccountType); + + if (senderUsers.length === 0 && receiverUsers.length === 0) { + console.error("❌ Cannot send notifications: no users found for sender or receiver"); + return; + } + + // Get display names + const senderName = await this.getAccountDisplayName(senderId, senderAccountType); + const receiverName = await this.getAccountDisplayName(receiverId, receiverAccountType); + + const transactionDetails = { + amount, + currency, + description, + senderId, + senderAccountType, + receiverId, + receiverAccountType, + senderName, + receiverName, + timestamp: new Date(), + }; + + // Send notification to all sender users (user or group admins) + for (const senderUser of senderUsers) { + console.log(`📤 Sending transaction notification to sender: ${senderUser.id} (${senderAccountType}:${senderId})`); + await this.sendNotificationToUser(senderUser.id, { + ...transactionDetails, + isSender: true, + accountId: senderId, // The account this notification is about + accountType: senderAccountType, + }); + } + + // Send notification to all receiver users (user or group admins) + for (const receiverUser of receiverUsers) { + console.log(`📥 Sending transaction notification to receiver: ${receiverUser.id} (${receiverAccountType}:${receiverId})`); + await this.sendNotificationToUser(receiverUser.id, { + ...transactionDetails, + isSender: false, + accountId: receiverId, // The account this notification is about + accountType: receiverAccountType, + }); + } + + console.log(`✅ Transaction notifications sent successfully`); + } catch (error) { + console.error("❌ Error sending transaction notifications:", error); + } + } +} + diff --git a/platforms/eCurrency-api/src/web3adapter/mappings/message.mapping.json b/platforms/eCurrency-api/src/web3adapter/mappings/message.mapping.json new file mode 100644 index 000000000..ac51a0215 --- /dev/null +++ b/platforms/eCurrency-api/src/web3adapter/mappings/message.mapping.json @@ -0,0 +1,16 @@ +{ + "tableName": "messages", + "schemaId": "550e8400-e29b-41d4-a716-446655440004", + "ownerEnamePath": "groups(group.ename)||users(group.members[].ename)", + "ownedJunctionTables": [], + "localToUniversalMap": { + "text": "content", + "sender": "users(sender.id),senderId", + "group": "groups(group.id),chatId", + "isSystemMessage": "isSystemMessage", + "createdAt": "createdAt", + "updatedAt": "updatedAt", + "isArchived": "isArchived" + } +} + diff --git a/platforms/eCurrency-api/src/web3adapter/watchers/subscriber.ts b/platforms/eCurrency-api/src/web3adapter/watchers/subscriber.ts index 0f45909e2..d378fdc3c 100644 --- a/platforms/eCurrency-api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/eCurrency-api/src/web3adapter/watchers/subscriber.ts @@ -57,6 +57,45 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } + /** + * Special enrichment method for Message entities to ensure group and admin data is loaded + */ + private async enrichMessageEntity(messageEntity: any): Promise { + try { + const enrichedMessage = { ...messageEntity }; + + // If the message has a group, load the full group with admins and members + if (enrichedMessage.group && enrichedMessage.group.id) { + const groupRepository = AppDataSource.getRepository("Group"); + const fullGroup = await groupRepository.findOne({ + where: { id: enrichedMessage.group.id }, + relations: ["admins", "members", "participants"] + }); + + if (fullGroup) { + enrichedMessage.group = fullGroup; + } + } + + // If the message has a sender, ensure it's loaded + if (enrichedMessage.sender && enrichedMessage.sender.id) { + const userRepository = AppDataSource.getRepository("User"); + const fullSender = await userRepository.findOne({ + where: { id: enrichedMessage.sender.id } + }); + + if (fullSender) { + enrichedMessage.sender = fullSender; + } + } + + return enrichedMessage; + } catch (error) { + console.error("Error enriching Message entity:", error); + return messageEntity; + } + } + /** * Called after entity insertion. */ @@ -70,6 +109,12 @@ export class PostgresSubscriber implements EntitySubscriberInterface { )) as ObjectLiteral; } + // Special handling for Message entities to ensure complete data + if (event.metadata.tableName === "messages" && entity) { + entity = await this.enrichMessageEntity(entity); + } + + this.handleChange( // @ts-ignore entity ?? event.entityId, @@ -333,6 +378,8 @@ export class PostgresSubscriber implements EntitySubscriberInterface { return ["followers", "following"]; case "Group": return ["participants", "admins", "members"]; + case "Message": + return ["sender", "group"]; case "Currency": return ["group", "creator"]; case "Ledger": diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a9ef1a33..f468e42c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1410,6 +1410,9 @@ importers: express: specifier: ^4.18.2 version: 4.21.2 + graphql-request: + specifier: ^6.1.0 + version: 6.1.0(encoding@0.1.13)(graphql@16.12.0) jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -4921,15 +4924,9 @@ packages: '@milkdown/plugin-upload@7.17.3': resolution: {integrity: sha512-NrnrSPYqA3pxbuUT6wGJthtOyRunQFCWT+VEAPUM99qJPp/4f4+WTDDslrZfAzUps2OykPIbCXYPu+hLN2c6mQ==} - '@milkdown/preset-commonmark@7.17.2': - resolution: {integrity: sha512-0CE6wfmuvJRN+Y+vszHRwDmkEGcxugiCa2VZ5ZFJtk0eakiwVxQx04KVnbaAQJQ44NIZLyrBD7oL9gTG2HxN8g==} - '@milkdown/preset-commonmark@7.17.3': resolution: {integrity: sha512-f875wPTwg5kxKYuDWs0S9AFVcaY0PjQ1YpO0NsAdVgDANwurUpqUY9/KoRKJGAFDRiE59Yq5YD9Lx5vnQ07OuQ==} - '@milkdown/preset-gfm@7.17.2': - resolution: {integrity: sha512-g0p9EFeV//2fAYpdXVEZH+KOhs9T89rOmMBXRucJSB2RS6czrnIzuUCJoKNoQ/WjFRjEh6BOYFI/ZcbeJQ5TEA==} - '@milkdown/preset-gfm@7.17.3': resolution: {integrity: sha512-IY+Hyhe6cCUF2Fi+KNfzbFdTaepKCkYNbbv65Dze7Dkb0VgoPfyiRdzznKiX7luXJnZv8Y+QHr+8KXirxzKVfg==} @@ -4954,9 +4951,6 @@ packages: '@milkdown/transformer@7.17.3': resolution: {integrity: sha512-XIeMV/X6R9YhaYSTytZGOwVMsVCXBgXVaP9OpABMceXR9hYfbrZZlurXirTMJYayYZ3IVZZJHQ56/wQ4URD2pg==} - '@milkdown/utils@7.17.2': - resolution: {integrity: sha512-DmPYFbcFKN8VnahtBQ96DH2ohGX46D5D2UB4PYeNHuZj6mO1rC1AeZP+e7pLFvCq4cV6BiKmIxKy1+WHej9MKA==} - '@milkdown/utils@7.17.3': resolution: {integrity: sha512-8JWqfhdupzoZMv7btqYAAXRyqhaOGffh2zpSoD+t9enL0MFvIHsenWS8W8h7XRTL6E6+N6kFZ1qyBHMg6E5Idg==} @@ -17402,20 +17396,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@milkdown/preset-commonmark@7.17.2': - dependencies: - '@milkdown/core': 7.17.2 - '@milkdown/ctx': 7.17.2 - '@milkdown/exception': 7.17.2 - '@milkdown/prose': 7.17.2 - '@milkdown/transformer': 7.17.2 - '@milkdown/utils': 7.17.2 - remark-inline-links: 7.0.0 - unist-util-visit: 5.0.0 - unist-util-visit-parents: 6.0.2 - transitivePeerDependencies: - - supports-color - '@milkdown/preset-commonmark@7.17.3': dependencies: '@milkdown/core': 7.17.3 @@ -17430,20 +17410,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@milkdown/preset-gfm@7.17.2': - dependencies: - '@milkdown/core': 7.17.2 - '@milkdown/ctx': 7.17.2 - '@milkdown/exception': 7.17.2 - '@milkdown/preset-commonmark': 7.17.2 - '@milkdown/prose': 7.17.2 - '@milkdown/transformer': 7.17.2 - '@milkdown/utils': 7.17.2 - prosemirror-safari-ime-span: 1.0.2 - remark-gfm: 4.0.1 - transitivePeerDependencies: - - supports-color - '@milkdown/preset-gfm@7.17.3': dependencies: '@milkdown/core': 7.17.3 @@ -17535,17 +17501,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@milkdown/utils@7.17.2': - dependencies: - '@milkdown/core': 7.17.2 - '@milkdown/ctx': 7.17.2 - '@milkdown/exception': 7.17.2 - '@milkdown/prose': 7.17.2 - '@milkdown/transformer': 7.17.2 - nanoid: 5.1.6 - transitivePeerDependencies: - - supports-color - '@milkdown/utils@7.17.3': dependencies: '@milkdown/core': 7.17.3