Skip to content

Commit 0c1a804

Browse files
authored
Merge pull request #77 from Trustless-Work/feature/implement-bk-investor
feat: investor implementation
2 parents 647d66c + b19fa45 commit 0c1a804

File tree

32 files changed

+594
-796
lines changed

32 files changed

+594
-796
lines changed
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# Trustless Work API Configuration
2-
NEXT_PUBLIC_API_KEY=""
2+
NEXT_PUBLIC_API_KEY=
33

44
# Server-side only (for contract deployment)
5-
SOURCE_SECRET=""
5+
SOURCE_SECRET=
66

77
# Core API URL (NestJS backend)
8-
NEXT_PUBLIC_CORE_API_URL="http://localhost:4000"
8+
NEXT_PUBLIC_CORE_API_URL=http://localhost:4000
9+
10+
# Core API Authentication
11+
NEXT_PUBLIC_BACKOFFICE_API_KEY=
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createHttpClient } from "@tokenization/shared/lib/httpClient";
2+
3+
export const httpClient = createHttpClient({
4+
baseURL: process.env.NEXT_PUBLIC_CORE_API_URL ?? "http://localhost:4000",
5+
apiKey: process.env.NEXT_PUBLIC_BACKOFFICE_API_KEY ?? "",
6+
});

apps/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@stellar/stellar-sdk": "^14.6.1",
3333
"class-transformer": "^0.5.1",
3434
"class-validator": "^0.15.1",
35+
"dotenv": "^16.6.1",
3536
"prisma": "^6.19.2",
3637
"reflect-metadata": "^0.2.2",
3738
"rxjs": "^7.8.1"

apps/core/src/investments/investments.controller.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,28 @@ import { CreateInvestmentDto } from './dto/create-investment.dto';
44

55
@Controller('investments')
66
export class InvestmentsController {
7-
constructor(private readonly investmentsService: InvestmentsService) {}
7+
constructor(private readonly investmentsService: InvestmentsService) { }
88

99
@Get()
1010
findAll() {
1111
return this.investmentsService.findAll();
1212
}
1313

14-
@Get(':id')
15-
findOne(@Param('id') id: string) {
16-
return this.investmentsService.findOne(id);
14+
@Get('investor/:address')
15+
findByInvestor(@Param('address') address: string) {
16+
return this.investmentsService.findByInvestor(address);
1717
}
1818

1919
@Get('campaign/:campaignId')
2020
findByCampaign(@Param('campaignId') campaignId: string) {
2121
return this.investmentsService.findByCampaign(campaignId);
2222
}
2323

24+
@Get(':id')
25+
findOne(@Param('id') id: string) {
26+
return this.investmentsService.findOne(id);
27+
}
28+
2429
@Post()
2530
create(@Body() dto: CreateInvestmentDto) {
2631
return this.investmentsService.create(dto);

apps/core/src/investments/investments.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export class InvestmentsService {
2424
return investment;
2525
}
2626

27+
findByInvestor(investorAddress: string) {
28+
return this.prisma.investment.findMany({
29+
where: { investorAddress },
30+
orderBy: { createdAt: 'desc' },
31+
include: { campaign: true },
32+
});
33+
}
34+
2735
findByCampaign(campaignId: string) {
2836
return this.prisma.investment.findMany({
2937
where: { campaignId },

apps/core/src/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dotenv/config';
2+
13
import { NestFactory } from '@nestjs/core';
24
import { ValidationPipe } from '@nestjs/common';
35
import { AppModule } from './app.module';
@@ -16,5 +18,6 @@ async function bootstrap() {
1618

1719
app.useGlobalGuards(new ApiKeyGuard());
1820
await app.listen(process.env.PORT ?? 4000);
21+
console.log(`Core API is running on port ${process.env.PORT ?? 4000}`);
1922
}
2023
bootstrap();

apps/core/src/prisma/prisma.service.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
11
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
22
import { PrismaClient } from '@prisma/client';
33

4+
function buildDatasourceUrl(): string | undefined {
5+
const raw = process.env.DATABASE_URL;
6+
if (!raw) return undefined;
7+
if (raw.includes('pgbouncer=true')) return raw;
8+
const separator = raw.includes('?') ? '&' : '?';
9+
return `${raw}${separator}pgbouncer=true`;
10+
}
11+
412
@Injectable()
513
export class PrismaService
614
extends PrismaClient
715
implements OnModuleInit, OnModuleDestroy
816
{
17+
constructor() {
18+
super({
19+
datasources: { db: { url: buildDatasourceUrl() } },
20+
});
21+
}
22+
923
async onModuleInit() {
1024
await this.$connect();
1125
}

apps/core/src/token-sale/token-sale.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { SetAdminDto } from './dto/set-admin.dto';
77

88
@Controller('token-sale')
99
export class TokenSaleController {
10-
constructor(private readonly tokenSaleService: TokenSaleService) {}
10+
constructor(private readonly tokenSaleService: TokenSaleService) { }
1111

1212
// ── POST endpoints (writes) ──
1313

apps/investor-tokenization/.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ NEXT_PUBLIC_DEFAULT_USDC_ADDRESS=
77
NEXT_PUBLIC_API_KEY=
88

99
# Server-side only (for contract deployment)
10-
SOURCE_SECRET=
10+
SOURCE_SECRET=
11+
12+
# Core API Authentication
13+
NEXT_PUBLIC_INVESTORS_API_KEY=
14+
NEXT_PUBLIC_CORE_API_URL=http://localhost:4000
Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,117 @@
11
"use client";
22

3-
import { useState, useMemo } from "react";
3+
import { useState, useMemo, useCallback } from "react";
44
import { SectionTitle } from "@/components/shared/section-title";
55
import { CampaignToolbar } from "@/features/roi/components/campaign-toolbar";
66
import { CampaignList } from "@/features/roi/components/campaign-list";
7-
import { mockCampaigns } from "@/features/roi/data/mock-campaigns";
8-
import type { CampaignStatus } from "@/features/roi/types/campaign.types";
7+
import type { Campaign, CampaignStatus } from "@/features/roi/types/campaign.types";
8+
import { useUserInvestments } from "@/features/investments/hooks/useUserInvestments.hook";
9+
import type { InvestmentFromApi } from "@/features/investments/services/investment.service";
10+
import { ClaimROIService } from "@/features/claim-roi/services/claim.service";
11+
import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
12+
import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit";
13+
import { SendTransactionService } from "@/lib/sendTransactionService";
14+
import { toastSuccessWithTx } from "@/lib/toastWithTx";
15+
import { toast } from "sonner";
16+
17+
function toCampaign(inv: InvestmentFromApi): Campaign {
18+
return {
19+
id: inv.campaign.id,
20+
title: inv.campaign.name,
21+
description: inv.campaign.description ?? "",
22+
status: inv.campaign.status as CampaignStatus,
23+
loansCompleted: 0,
24+
minInvestCents: Number(inv.usdcAmount) * 100,
25+
currency: "USD",
26+
vaultId: inv.campaign.vaultId ?? null,
27+
};
28+
}
29+
30+
function deduplicateByCampaign(investments: InvestmentFromApi[]): Campaign[] {
31+
const seen = new Set<string>();
32+
const campaigns: Campaign[] = [];
33+
for (const inv of investments) {
34+
if (!seen.has(inv.campaign.id)) {
35+
seen.add(inv.campaign.id);
36+
campaigns.push(toCampaign(inv));
37+
}
38+
}
39+
return campaigns;
40+
}
941

1042
export default function MyInvestmentsPage() {
1143
const [search, setSearch] = useState("");
1244
const [filter, setFilter] = useState<CampaignStatus | "all">("all");
45+
const { data: investments, isLoading } = useUserInvestments();
46+
const { walletAddress } = useWalletContext();
47+
48+
const campaigns = useMemo(
49+
() => deduplicateByCampaign(investments ?? []),
50+
[investments],
51+
);
1352

1453
const filteredCampaigns = useMemo(() => {
15-
return mockCampaigns.filter((c) => {
54+
return campaigns.filter((c) => {
1655
const matchesStatus = filter === "all" || c.status === filter;
1756
const matchesSearch =
1857
search.trim() === "" ||
1958
c.title.toLowerCase().includes(search.toLowerCase()) ||
2059
c.description.toLowerCase().includes(search.toLowerCase());
2160
return matchesStatus && matchesSearch;
2261
});
23-
}, [search, filter]);
62+
}, [campaigns, search, filter]);
63+
64+
const handleClaimRoi = useCallback(
65+
async (campaignId: string) => {
66+
const campaign = campaigns.find((c) => c.id === campaignId);
67+
68+
if (!campaign?.vaultId) {
69+
toast.error("Vault contract not available for this campaign.");
70+
return;
71+
}
72+
73+
if (!walletAddress) {
74+
toast.error("Please connect your wallet to claim ROI.");
75+
return;
76+
}
77+
78+
try {
79+
const svc = new ClaimROIService();
80+
const claimResponse = await svc.claimROI({
81+
vaultContractId: campaign.vaultId,
82+
beneficiaryAddress: walletAddress,
83+
});
84+
85+
if (!claimResponse?.success || !claimResponse?.xdr) {
86+
throw new Error(
87+
claimResponse?.message ?? "Failed to build claim transaction.",
88+
);
89+
}
90+
91+
const signedTxXdr = await signTransaction({
92+
unsignedTransaction: claimResponse.xdr,
93+
address: walletAddress,
94+
});
95+
96+
const sender = new SendTransactionService();
97+
const submitResponse = await sender.sendTransaction({
98+
signedXdr: signedTxXdr,
99+
});
100+
101+
if (submitResponse.status !== "SUCCESS") {
102+
throw new Error(
103+
submitResponse.message ?? "Transaction submission failed.",
104+
);
105+
}
106+
107+
toastSuccessWithTx("ROI claimed successfully!", submitResponse.hash);
108+
} catch (e) {
109+
const msg = e instanceof Error ? e.message : "Unexpected error while claiming ROI.";
110+
toast.error(msg);
111+
}
112+
},
113+
[campaigns, walletAddress],
114+
);
24115

25116
return (
26117
<div className="flex flex-col gap-6">
@@ -32,7 +123,13 @@ export default function MyInvestmentsPage() {
32123
onSearchChange={setSearch}
33124
onFilterChange={setFilter}
34125
/>
35-
<CampaignList campaigns={filteredCampaigns} />
126+
{isLoading ? (
127+
<p className="text-sm text-muted-foreground text-center py-8">
128+
Loading your investments...
129+
</p>
130+
) : (
131+
<CampaignList campaigns={filteredCampaigns} onClaimRoi={handleClaimRoi} />
132+
)}
36133
</div>
37134
);
38135
}

0 commit comments

Comments
 (0)