diff --git a/src/__tests__/donation.service.test.ts b/src/__tests__/donation.service.test.ts new file mode 100644 index 0000000..04dfe13 --- /dev/null +++ b/src/__tests__/donation.service.test.ts @@ -0,0 +1,488 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DonationService } from '../components/v1/Donation/donation.service'; +import type { DonationEntity } from '../components/v1/Donation/donation.entity'; +import { DonationStatus } from '../types/enums'; + +const extractColName = (clause: string): string | null => { + const match = clause.match(/\.(\w+)\s*=/); + return match ? match[1] : null; +}; + +type MockWhereEntry = { + col: string | null; + paramKey: string; + value: any; +}; + +type MockQueryBuilder = { + _wheres: MockWhereEntry[]; + _orderBy: [string, 'ASC' | 'DESC'] | null; + _skip: number | null; + _take: number | null; + where: (_clause: string, _params: Record) => MockQueryBuilder; + andWhere: ( + _clause: string, + _params: Record + ) => MockQueryBuilder; + orderBy: (_col: string, _dir: 'ASC' | 'DESC') => MockQueryBuilder; + skip: (_n: number) => MockQueryBuilder; + take: (_n: number) => MockQueryBuilder; + getCount: () => Promise; + getMany: () => Promise; + select: (_expr: string, _alias: string) => MockQueryBuilder; + addSelect: (_expr: string, _alias: string) => MockQueryBuilder; + getRawOne: () => Promise; +}; + +const makeMockQB = (data: () => any[]): MockQueryBuilder => { + const mock: MockQueryBuilder = { + _wheres: [], + _orderBy: null, + _skip: null, + _take: null, + + where(clause: string, params: Record) { + mock._wheres = Object.entries(params).map(([key, value]) => ({ + col: extractColName(clause), + paramKey: key, + value, + })); + return mock; + }, + andWhere(clause: string, params: Record) { + for (const [key, value] of Object.entries(params)) { + mock._wheres.push({ + col: extractColName(clause), + paramKey: key, + value, + }); + } + return mock; + }, + orderBy(col: string, dir: 'ASC' | 'DESC') { + mock._orderBy = [col, dir]; + return mock; + }, + skip(n: number) { + mock._skip = n; + return mock; + }, + take(n: number) { + mock._take = n; + return mock; + }, + async getCount() { + const items = data(); + return items.filter((item) => { + for (const entry of mock._wheres) { + const col = entry.col ?? entry.paramKey; + if ((item as any)[col] !== entry.value) return false; + } + return true; + }).length; + }, + async getMany() { + let items = data().filter((item) => { + for (const entry of mock._wheres) { + const col = entry.col ?? entry.paramKey; + if ((item as any)[col] !== entry.value) return false; + } + return true; + }); + + if (mock._orderBy) { + const [col, dir] = mock._orderBy; + items = [...items].sort((a: any, b: any) => { + const colName = col.split('.').pop() ?? col; + const aVal = a[colName] ?? ''; + const bVal = b[colName] ?? ''; + const cmp = + typeof aVal === 'string' + ? aVal.localeCompare(bVal) + : Number(aVal) - Number(bVal); + return dir === 'DESC' ? -cmp : cmp; + }); + } + + const skip = mock._skip ?? 0; + const take = mock._take ?? items.length; + return items.slice(skip, skip + take); + }, + + select(_expr: string, _alias: string) { + return mock; + }, + addSelect(_expr: string, _alias: string) { + return mock; + }, + async getRawOne(): Promise { + const items = data(); + const campEntry = mock._wheres.find( + (e) => e.paramKey === 'campaignId' + ); + const filtered = campEntry + ? items.filter((d) => (d as any).campaignId === campEntry.value) + : items; + + const totalDonations = filtered.length; + const totalAmount = filtered.reduce( + (s, d) => s + Number((d as any).amount), + 0 + ); + const totalUsdAmount = filtered.reduce( + (s, d) => s + Number((d as any).usdAmount ?? 0), + 0 + ); + const uniqueDonors = new Set( + filtered.map((d) => (d as any).donorAddress) + ).size; + + return { + totalDonations: String(totalDonations), + totalAmount: String(totalAmount), + totalUsdAmount: String(totalUsdAmount), + uniqueDonors: String(uniqueDonors), + } as T; + }, + }; + + return mock; +}; + +type Repo = { + data: T[]; + findOne: (_arg: any) => Promise; + find: (_arg?: any) => Promise; + create: (_partial: Partial) => T; + save: (_entity: any) => Promise; + createQueryBuilder: (_alias: string) => MockQueryBuilder; +}; + +const makeRepo = >(): Repo => { + const repo: Repo = { + data: [], + async findOne(arg: any) { + const where = arg?.where ?? {}; + return ( + repo.data.find((d) => + Object.keys(where).every((k) => (d as any)[k] === where[k]) + ) ?? null + ); + }, + async find() { + return repo.data; + }, + create(partial: Partial) { + return { ...partial } as T; + }, + async save(entity: any) { + const e = { id: `don_${repo.data.length + 1}`, ...entity } as T; + const idx = repo.data.findIndex( + (d) => (d as any).id === (e as any).id + ); + if (idx >= 0) repo.data[idx] = e; + else repo.data.push(e); + return e; + }, + createQueryBuilder() { + return makeMockQB(() => repo.data); + }, + }; + + return repo; +}; + +test('DonationService.createDonation persists and returns formatted response', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + const result = await service.createDonation({ + campaignId: 'camp_1', + donorAddress: '0x1234567890abcdef1234567890abcdef12345678', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '100.50', + usdAmount: '100.50', + }); + + assert.equal(result.campaignId, 'camp_1'); + assert.equal( + result.donorAddress, + '0x1234567890abcdef1234567890abcdef12345678' + ); + assert.equal(result.tokenSymbol, 'USDC'); + assert.equal(result.amount, '100.50'); + assert.equal(result.status, DonationStatus.PENDING); + assert.equal(result.campaignTitle, null); + assert.equal(repo.data.length, 1); +}); + +test('DonationService.createDonation stores campaignTitle', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + const result = await service.createDonation({ + campaignId: 'camp_1', + campaignTitle: 'Help the kids', + donorAddress: '0x1234567890abcdef1234567890abcdef12345678', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '50', + }); + + assert.equal(result.campaignTitle, 'Help the kids'); + assert.equal(repo.data[0].campaignTitle, 'Help the kids'); +}); + +test('DonationService.createDonation normalizes addresses and symbols', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + const result = await service.createDonation({ + campaignId: 'camp_1', + donorAddress: '0xABCdef1234567890ABCdef1234567890ABCdef12', + tokenAddress: '0xDEFabc1234567890DEFabc1234567890DEFabc12', + tokenSymbol: 'usdc', + tokenDecimals: 6, + amount: '50', + }); + + assert.equal( + result.donorAddress, + '0xabcdef1234567890abcdef1234567890abcdef12' + ); + assert.equal( + result.tokenAddress, + '0xdefabc1234567890defabc1234567890defabc12' + ); + assert.equal(result.tokenSymbol, 'USDC'); +}); + +test('DonationService.getDonationById returns null for missing id', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + const result = await service.getDonationById('nonexistent'); + assert.equal(result, null); +}); + +test('DonationService.getDonationById returns entity when found', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + await service.createDonation({ + campaignId: 'camp_1', + donorAddress: '0x1234567890abcdef1234567890abcdef12345678', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'DAI', + tokenDecimals: 18, + amount: '200', + }); + + const result = await service.getDonationById(repo.data[0].id); + assert.notEqual(result, null); + assert.equal(result!.amount, '200'); +}); + +test('DonationService.listDonations returns paginated results', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + for (let i = 0; i < 5; i++) { + await service.createDonation({ + campaignId: `camp_${i}`, + donorAddress: '0x1234567890abcdef1234567890abcdef12345678', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: String((i + 1) * 10), + }); + } + + const result = await service.listDonations({ + page: 1, + limit: 3, + sort_by: 'created_at', + sort_order: 'desc', + }); + + assert.equal(result.data.length, 3); + assert.equal(result.meta.page, 1); + assert.equal(result.meta.limit, 3); + assert.equal(result.meta.totalRows, 5); + assert.equal(result.meta.totalPages, 2); +}); + +test('DonationService.listDonations sorts by campaign_ref and donor_address', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + await service.createDonation({ + campaignId: 'camp_b', + campaignRef: 'B', + campaignTitle: 'Zoo fundraiser', + donorAddress: '0x2222222222222222222222222222222222222222', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '100', + }); + await service.createDonation({ + campaignId: 'camp_a', + campaignRef: 'A', + campaignTitle: 'Animal shelter', + donorAddress: '0x1111111111111111111111111111111111111111', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '200', + }); + + const byCampaignAsc = await service.listDonations({ + page: 1, + limit: 20, + sort_by: 'campaign_ref', + sort_order: 'asc', + }); + assert.equal(byCampaignAsc.data[0].campaignId, 'camp_a'); + assert.equal(byCampaignAsc.data[1].campaignId, 'camp_b'); + + const byTitleAsc = await service.listDonations({ + page: 1, + limit: 20, + sort_by: 'campaign_title', + sort_order: 'asc', + }); + assert.equal(byTitleAsc.data[0].campaignTitle, 'Animal shelter'); + assert.equal(byTitleAsc.data[1].campaignTitle, 'Zoo fundraiser'); + + const byDonorAsc = await service.listDonations({ + page: 1, + limit: 20, + sort_by: 'donor_address', + sort_order: 'asc', + }); + assert.ok( + byDonorAsc.data[0].donorAddress < byDonorAsc.data[1].donorAddress + ); +}); + +test('DonationService.listDonations filters by confirmed', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + await service.createDonation({ + campaignId: 'camp_1', + donorAddress: '0x1111111111111111111111111111111111111111', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '100', + }); + + repo.data[0].status = DonationStatus.CONFIRMED; + + const confirmedResult = await service.listDonations({ + page: 1, + limit: 20, + sort_by: 'created_at', + sort_order: 'asc', + confirmed: true, + }); + assert.equal(confirmedResult.data.length, 1); + + const unconfirmedResult = await service.listDonations({ + page: 1, + limit: 20, + sort_by: 'created_at', + sort_order: 'asc', + confirmed: false, + }); + assert.equal(unconfirmedResult.data.length, 0); +}); + +test('DonationService.listDonations filters by campaign_id', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + for (let i = 0; i < 3; i++) { + await service.createDonation({ + campaignId: 'camp_a', + donorAddress: '0x1234567890abcdef1234567890abcdef12345678', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: String((i + 1) * 10), + }); + } + await service.createDonation({ + campaignId: 'camp_b', + donorAddress: '0x1234567890abcdef1234567890abcdef12345678', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '100', + }); + + const result = await service.listDonations({ + page: 1, + limit: 20, + sort_by: 'created_at', + sort_order: 'asc', + campaign_id: 'camp_a', + }); + + assert.equal(result.data.length, 3); + assert.equal(result.meta.totalRows, 3); +}); + +test('DonationService.getDonationStats returns correct aggregation', async () => { + const repo = makeRepo(); + const service = new DonationService(repo as any); + + await service.createDonation({ + campaignId: 'camp_1', + donorAddress: '0x1111111111111111111111111111111111111111', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '100', + usdAmount: '100', + }); + + await service.createDonation({ + campaignId: 'camp_1', + donorAddress: '0x2222222222222222222222222222222222222222', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '200', + usdAmount: '200', + }); + + await service.createDonation({ + campaignId: 'camp_2', + donorAddress: '0x1111111111111111111111111111111111111111', + tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '300', + usdAmount: '300', + }); + + const allStats = await service.getDonationStats(); + assert.equal(allStats.totalDonations, 3); + assert.equal(allStats.totalAmount, '600'); + assert.equal(allStats.uniqueDonors, 2); + assert.equal(allStats.averageAmount, '200'); + + const campStats = await service.getDonationStats('camp_1'); + assert.equal(campStats.totalDonations, 2); + assert.equal(campStats.totalAmount, '300'); + assert.equal(campStats.uniqueDonors, 2); + assert.equal(campStats.averageAmount, '150'); +}); diff --git a/src/__tests__/donation.validation.test.ts b/src/__tests__/donation.validation.test.ts new file mode 100644 index 0000000..37ce86e --- /dev/null +++ b/src/__tests__/donation.validation.test.ts @@ -0,0 +1,221 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createDonationSchema, + listDonationsQuerySchema, + donationParamsSchema, +} from '../components/v1/Donation/donation.validation'; + +const validAddress = '0x1234567890abcdef1234567890abcdef12345678'; +const validTokenAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + +test('createDonationSchema validates required fields', () => { + assert.throws(() => createDonationSchema.parse({}), /Required/); +}); + +test('createDonationSchema rejects invalid Ethereum addresses', () => { + assert.throws( + () => + createDonationSchema.parse({ + campaignId: 'camp_1', + donorAddress: 'invalid', + tokenAddress: validTokenAddress, + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '100', + }), + /donorAddress/ + ); + + assert.throws( + () => + createDonationSchema.parse({ + campaignId: 'camp_1', + donorAddress: validAddress, + tokenAddress: 'invalid', + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '100', + }), + /tokenAddress/ + ); +}); + +test('createDonationSchema rejects invalid decimal strings', () => { + assert.throws( + () => + createDonationSchema.parse({ + campaignId: 'camp_1', + donorAddress: validAddress, + tokenAddress: validTokenAddress, + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: 'abc', + }), + /amount/ + ); +}); + +test('createDonationSchema rejects invalid token decimals', () => { + assert.throws( + () => + createDonationSchema.parse({ + campaignId: 'camp_1', + donorAddress: validAddress, + tokenAddress: validTokenAddress, + tokenSymbol: 'USDC', + tokenDecimals: -1, + amount: '100', + }), + /tokenDecimals/ + ); + + assert.throws( + () => + createDonationSchema.parse({ + campaignId: 'camp_1', + donorAddress: validAddress, + tokenAddress: validTokenAddress, + tokenSymbol: 'USDC', + tokenDecimals: 31, + amount: '100', + }), + /tokenDecimals/ + ); +}); + +test('createDonationSchema accepts valid input with optional fields', () => { + const result = createDonationSchema.parse({ + campaignId: 'camp_1', + campaignRef: 'REF123', + donorId: 'user_1', + donorAddress: validAddress, + donorName: 'John Doe', + tokenAddress: validTokenAddress, + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '100.50', + usdAmount: '100.50', + gasFee: '0.001', + transactionHash: + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + network: 'mainnet', + isAnonymous: false, + message: 'Great campaign!', + campaignTitle: 'Help the kids', + }); + + assert.equal(result.campaignId, 'camp_1'); + assert.equal(result.donorAddress, validAddress); + assert.equal(result.amount, '100.50'); + assert.equal(result.isAnonymous, false); + assert.equal(result.message, 'Great campaign!'); + assert.equal(result.campaignTitle, 'Help the kids'); +}); + +test('createDonationSchema accepts minimal valid input', () => { + const result = createDonationSchema.parse({ + campaignId: 'camp_1', + donorAddress: validAddress, + tokenAddress: validTokenAddress, + tokenSymbol: 'USDC', + tokenDecimals: 6, + amount: '50', + }); + + assert.equal(result.campaignId, 'camp_1'); + assert.equal(result.amount, '50'); +}); + +test('listDonationsQuerySchema provides defaults for page and limit', () => { + const result = listDonationsQuerySchema.parse({}); + + assert.equal(result.page, 1); + assert.equal(result.limit, 20); + assert.equal(result.sort_by, 'created_at'); + assert.equal(result.sort_order, 'desc'); +}); + +test('listDonationsQuerySchema clamps limit to max 100', () => { + const result = listDonationsQuerySchema.parse({ + limit: '999', + }); + + assert.equal(result.limit, 100); +}); + +test('listDonationsQuerySchema converts string page/limit to numbers', () => { + const result = listDonationsQuerySchema.parse({ + page: '3', + limit: '10', + }); + + assert.equal(result.page, 3); + assert.equal(result.limit, 10); +}); + +test('listDonationsQuerySchema accepts all filter fields', () => { + const result = listDonationsQuerySchema.parse({ + page: '1', + limit: '20', + sort_by: 'amount', + sort_order: 'asc', + from_date: '2024-01-01T00:00:00.000Z', + to_date: '2024-12-31T23:59:59.000Z', + min_amount: '10', + max_amount: '1000', + campaign_id: 'camp_1', + donor_id: 'user_1', + donation_token: validTokenAddress, + status: 'confirmed', + confirmed: 'true', + search: 'john', + }); + + assert.equal(result.sort_by, 'amount'); + assert.equal(result.sort_order, 'asc'); + assert.equal(result.status, 'confirmed'); + assert.equal(result.confirmed, true); + assert.equal(result.search, 'john'); +}); + +test('listDonationsQuerySchema parses sort_by campaign_ref, campaign_title and donor_address', () => { + const byCampaign = listDonationsQuerySchema.parse({ + sort_by: 'campaign_ref', + }); + assert.equal(byCampaign.sort_by, 'campaign_ref'); + + const byTitle = listDonationsQuerySchema.parse({ + sort_by: 'campaign_title', + }); + assert.equal(byTitle.sort_by, 'campaign_title'); + + const byDonor = listDonationsQuerySchema.parse({ + sort_by: 'donor_address', + }); + assert.equal(byDonor.sort_by, 'donor_address'); +}); + +test('listDonationsQuerySchema parses confirmed param', () => { + const confirmed = listDonationsQuerySchema.parse({ confirmed: 'true' }); + assert.equal(confirmed.confirmed, true); + + const notConfirmed = listDonationsQuerySchema.parse({ confirmed: 'false' }); + assert.equal(notConfirmed.confirmed, false); + + const unset = listDonationsQuerySchema.parse({}); + assert.equal(unset.confirmed, undefined); +}); + +test('donationParamsSchema validates UUID format', () => { + assert.throws( + () => donationParamsSchema.parse({ id: 'not-a-uuid' }), + /uuid/ + ); + assert.doesNotThrow(() => + donationParamsSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + }) + ); +}); diff --git a/src/appMiddlewares/jwtAuth.api.ts b/src/appMiddlewares/jwtAuth.api.ts index 12a2434..e0bd1f4 100644 --- a/src/appMiddlewares/jwtAuth.api.ts +++ b/src/appMiddlewares/jwtAuth.api.ts @@ -1,55 +1,89 @@ -import type { NextFunction, Response } from "express" -import jwt from "jsonwebtoken" +import type { NextFunction, Response } from 'express'; +import jwt from 'jsonwebtoken'; -import type { IRequest } from "../types/global" -import appConfigs from "../config" -import { sendError } from "../utils/apiResponse" +import type { IRequest } from '../types/global'; +import appConfigs from '../config'; +import { sendError } from '../utils/apiResponse'; type JwtClaims = jwt.JwtPayload & { - sub?: string - userId?: string - id?: string - walletAddress?: string - address?: string - email?: string -} - -export const requireJwtAuthApi = (req: IRequest, res: Response, next: NextFunction) => { - const header = req.headers.authorization - const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : "" - - if (!token) { - return sendError(res, 401, { - code: "AUTH_MISSING_TOKEN", - message: "Missing authentication token", - }) - } - - try { - const decoded = jwt.verify(token, appConfigs.authConfig.jwtSecret) as JwtClaims - - const userId = decoded.sub ?? decoded.userId ?? decoded.id - if (!userId) { - return sendError(res, 401, { - code: "AUTH_INVALID_TOKEN", - message: "Invalid authentication token", - }) + sub?: string; + userId?: string; + id?: string; + walletAddress?: string; + address?: string; + email?: string; + role?: string; + userType?: string; +}; + +export const requireJwtAuthApi = ( + req: IRequest, + res: Response, + next: NextFunction +) => { + const header = req.headers.authorization; + const token = header?.startsWith('Bearer ') + ? header.slice('Bearer '.length).trim() + : ''; + + if (!token) { + return sendError(res, 401, { + code: 'AUTH_MISSING_TOKEN', + message: 'Missing authentication token', + }); + } + + try { + const decoded = jwt.verify( + token, + appConfigs.authConfig.jwtSecret + ) as JwtClaims; + + const userId = decoded.sub ?? decoded.userId ?? decoded.id; + if (!userId) { + return sendError(res, 401, { + code: 'AUTH_INVALID_TOKEN', + message: 'Invalid authentication token', + }); + } + + req.auth = { + userId: String(userId), + walletAddress: decoded.walletAddress ?? decoded.address, + email: decoded.email, + claims: decoded, + }; + + return next(); + } catch (error) { + return sendError(res, 401, { + code: 'AUTH_INVALID_TOKEN', + message: 'Invalid authentication token', + details: error instanceof Error ? { name: error.name } : {}, + }); } +}; + +const ADMIN_ROLES = ['super-admin', 'admin']; - req.auth = { - userId: String(userId), - walletAddress: decoded.walletAddress ?? decoded.address, - email: decoded.email, - claims: decoded, +export const requireAdminApi = ( + req: IRequest, + res: Response, + next: NextFunction +) => { + const role = req.auth?.claims?.role as string | undefined; + const userType = req.auth?.claims?.userType as string | undefined; + + if (role && ADMIN_ROLES.includes(role)) { + return next(); } - return next() - } catch (error) { - return sendError(res, 401, { - code: "AUTH_INVALID_TOKEN", - message: "Invalid authentication token", - details: error instanceof Error ? { name: error.name } : {}, - }) - } -} + if (userType && ADMIN_ROLES.includes(userType)) { + return next(); + } + return sendError(res, 403, { + code: 'FORBIDDEN', + message: 'Admin access required', + }); +}; diff --git a/src/components/v1/Donation/donation.controller.ts b/src/components/v1/Donation/donation.controller.ts index e69de29..4a8c5f6 100644 --- a/src/components/v1/Donation/donation.controller.ts +++ b/src/components/v1/Donation/donation.controller.ts @@ -0,0 +1,146 @@ +import type { Request, Response } from 'express'; +import type { IRequest } from '../../../types/global'; +import { sendSuccess, sendError } from '../../../utils/apiResponse'; +import AppDataSource from '../../../config/persistence/data-source'; +import DonationEntity from './donation.entity'; +import { DonationService } from './donation.service'; +import type { + CreateDonationInput, + ListDonationsQuery, +} from './donation.validation'; + +const getService = () => { + if (!AppDataSource.isInitialized) { + throw new Error('Database not initialized'); + } + return new DonationService(AppDataSource.getRepository(DonationEntity)); +}; + +const handleError = ( + res: Response, + error: unknown, + defaultMessage = 'Internal server error' +) => { + const message = error instanceof Error ? error.message : defaultMessage; + return sendError(res, 500, { code: 'INTERNAL_ERROR', message }); +}; + +export const createDonation = async ( + req: Request, + res: Response +): Promise => { + try { + const service = getService(); + const data = req.body as CreateDonationInput; + const donation = await service.createDonation(data); + sendSuccess(res, donation, 201); + } catch (error) { + handleError(res, error, 'Failed to create donation'); + } +}; + +export const listDonations = async ( + req: Request, + res: Response +): Promise => { + try { + const service = getService(); + const query = req.query as unknown as ListDonationsQuery; + const result = await service.listDonations(query); + sendSuccess(res, result); + } catch (error) { + handleError(res, error, 'Failed to list donations'); + } +}; + +export const getDonationById = async ( + req: Request, + res: Response +): Promise => { + try { + const service = getService(); + const { id } = req.params; + const donation = await service.getDonationById(id); + + if (!donation) { + sendError(res, 404, { + code: 'NOT_FOUND', + message: 'Donation not found', + }); + return; + } + + sendSuccess(res, donation); + } catch (error) { + handleError(res, error, 'Failed to get donation'); + } +}; + +export const getCampaignDonations = async ( + req: IRequest, + res: Response +): Promise => { + try { + const service = getService(); + const { campaignId } = req.params; + const query = req.query as unknown as ListDonationsQuery; + const result = await service.getDonationsByCampaign(campaignId, query); + sendSuccess(res, result); + } catch (error) { + handleError(res, error, 'Failed to get campaign donations'); + } +}; + +export const getUserDonations = async ( + req: IRequest, + res: Response +): Promise => { + try { + const service = getService(); + const { userId } = req.params; + const query = req.query as unknown as ListDonationsQuery; + const result = await service.getDonationsByDonor(userId, query); + sendSuccess(res, result); + } catch (error) { + handleError(res, error, 'Failed to get user donations'); + } +}; + +export const getMyDonations = async ( + req: IRequest, + res: Response +): Promise => { + try { + if (!req.auth?.userId) { + sendError(res, 401, { + code: 'AUTH_REQUIRED', + message: 'Authentication required', + }); + return; + } + + const service = getService(); + const query = req.query as unknown as ListDonationsQuery; + const result = await service.getDonationsByDonor( + req.auth.userId, + query + ); + sendSuccess(res, result); + } catch (error) { + handleError(res, error, 'Failed to get your donations'); + } +}; + +export const getDonationStats = async ( + req: Request, + res: Response +): Promise => { + try { + const service = getService(); + const campaignId = req.query.campaign_id as string | undefined; + const stats = await service.getDonationStats(campaignId); + sendSuccess(res, stats); + } catch (error) { + handleError(res, error, 'Failed to get donation stats'); + } +}; diff --git a/src/components/v1/Donation/donation.dto.ts b/src/components/v1/Donation/donation.dto.ts index d62a712..9fd0d5c 100644 --- a/src/components/v1/Donation/donation.dto.ts +++ b/src/components/v1/Donation/donation.dto.ts @@ -1,14 +1,52 @@ -export interface CampaignResponseDto { - campaignId: string - campaignRef: string - targetAmount: string - donationToken: string - transactionHash: string - createdAt: Date +import type { DonationStatus, Network } from '../../../types/enums'; + +export interface DonationResponseDto { + id: string; + campaignId: string; + campaignRef: string | null; + campaignTitle: string | null; + donorId: string | null; + donorAddress: string; + donorName: string | null; + transactionHash: string | null; + blockNumber: number | null; + blockTimestamp: Date | null; + gasFee: string; + amount: string; + usdAmount: string; + tokenAddress: string; + tokenSymbol: string; + tokenDecimals: number; + status: DonationStatus; + confirmedAt: Date | null; + isAnonymous: boolean; + message: string | null; + network: Network; + createdAt: Date; + updatedAt: Date; +} + +export interface PaginatedResponse { + data: T[]; + meta: { + page: number; + limit: number; + totalRows: number; + totalPages: number; + }; +} + +export interface DonationStatsDto { + totalAmount: string; + totalUsdAmount: string; + totalDonations: number; + uniqueDonors: number; + averageAmount: string; + averageUsdAmount: string; } export interface ApiResponse { - data: T | null - success: boolean - message?: string -} \ No newline at end of file + data: T | null; + success: boolean; + message?: string; +} diff --git a/src/components/v1/Donation/donation.entity.ts b/src/components/v1/Donation/donation.entity.ts index 2e078d8..ee591ea 100644 --- a/src/components/v1/Donation/donation.entity.ts +++ b/src/components/v1/Donation/donation.entity.ts @@ -1,42 +1,134 @@ import { - Entity, - Column, - PrimaryColumn, - CreateDateColumn, - BeforeInsert, -} from "typeorm" -import { uuid } from "../../../utils" - -@Entity("Campaign") -export class CampaignEntity { - @PrimaryColumn("text", { name: "campaign_id" }) - campaignId: string - - @Column("text", { name: "campaign_ref", nullable: false }) - campaignRef: string - - @Column("text", { name: "target_amount", nullable: false }) - targetAmount: string - - @Column("text", { name: "donation_token", nullable: false }) - donationToken: string - - @Column("text", { name: "transaction_hash", nullable: false }) - transactionHash: string - - @CreateDateColumn({ - name: "created_at", - type: "timestamp", - default: () => "CURRENT_TIMESTAMP", - }) - createdAt: Date - - @BeforeInsert() - generateId() { - if (!this.campaignId) { - this.campaignId = uuid() + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + BeforeInsert, +} from 'typeorm'; +import { uuid } from '../../../utils'; +import { DonationStatus, Network } from '../../../types/enums'; + +@Entity('donations') +@Index('donations_campaign_id_idx', ['campaignId']) +@Index('donations_donor_address_idx', ['donorAddress']) +@Index('donations_status_idx', ['status']) +@Index('donations_created_at_idx', ['createdAt']) +@Index('donations_transaction_hash_idx', ['transactionHash']) +export class DonationEntity { + @PrimaryColumn('text') + id: string; + + @Column('text', { name: 'campaign_id', nullable: false }) + campaignId: string; + + @Column('text', { name: 'campaign_ref', nullable: true }) + campaignRef: string | null; + + @Column('text', { name: 'campaign_title', nullable: true }) + campaignTitle: string | null; + + @Column('text', { name: 'donor_id', nullable: true }) + donorId: string | null; + + @Column('text', { name: 'donor_address', nullable: false }) + donorAddress: string; + + @Column('text', { name: 'donor_name', nullable: true }) + donorName: string | null; + + @Column('text', { name: 'transaction_hash', nullable: true }) + transactionHash: string | null; + + @Column('bigint', { name: 'block_number', nullable: true }) + blockNumber: number | null; + + @Column('timestamp', { + name: 'block_timestamp', + precision: 3, + nullable: true, + }) + blockTimestamp: Date | null; + + @Column('decimal', { + name: 'gas_fee', + precision: 65, + scale: 30, + default: '0', + }) + gasFee: string; + + @Column('decimal', { + name: 'amount', + precision: 65, + scale: 30, + nullable: false, + }) + amount: string; + + @Column('decimal', { + name: 'usd_amount', + precision: 65, + scale: 30, + default: '0', + }) + usdAmount: string; + + @Column('text', { name: 'token_address', nullable: false }) + tokenAddress: string; + + @Column('text', { name: 'token_symbol', nullable: false }) + tokenSymbol: string; + + @Column('integer', { name: 'token_decimals', nullable: false }) + tokenDecimals: number; + + @Column({ + type: 'enum', + enum: DonationStatus, + default: DonationStatus.PENDING, + nullable: false, + }) + status: DonationStatus; + + @Column('timestamp', { name: 'confirmed_at', precision: 3, nullable: true }) + confirmedAt: Date | null; + + @Column('boolean', { name: 'is_anonymous', default: false }) + isAnonymous: boolean; + + @Column('text', { nullable: true }) + message: string | null; + + @Column({ + type: 'enum', + enum: Network, + default: Network.MAINNET, + nullable: false, + }) + network: Network; + + @CreateDateColumn({ + name: 'created_at', + type: 'timestamp', + precision: 3, + }) + createdAt: Date; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + precision: 3, + }) + updatedAt: Date; + + @BeforeInsert() + generateId() { + if (!this.id) { + this.id = uuid(); + } } - } } -export default CampaignEntity +export default DonationEntity; diff --git a/src/components/v1/Donation/donation.routes.ts b/src/components/v1/Donation/donation.routes.ts new file mode 100644 index 0000000..249f831 --- /dev/null +++ b/src/components/v1/Donation/donation.routes.ts @@ -0,0 +1,65 @@ +import EnhancedRouter from '../../../utils/enhancedRouter'; +import policyMiddleware from '../../../appMiddlewares/policy.middleware'; +import { + requireJwtAuthApi, + requireAdminApi, +} from '../../../appMiddlewares/jwtAuth.api'; +import { + createDonationSchema, + listDonationsQuerySchema, + listCampaignDonationsQuerySchema, + listUserDonationsQuerySchema, + donationParamsSchema, +} from './donation.validation'; +import { + createDonation, + listDonations, + getDonationById, + getCampaignDonations, + getUserDonations, + getMyDonations, + getDonationStats, +} from './donation.controller'; + +const router = new EnhancedRouter(); + +router.post( + '/', + requireJwtAuthApi, + policyMiddleware(createDonationSchema), + createDonation +); + +router.get('/stats', getDonationStats); +router.get( + '/campaigns/:campaignId', + policyMiddleware(listCampaignDonationsQuerySchema, 'query'), + getCampaignDonations +); +router.get( + '/users/me', + requireJwtAuthApi, + policyMiddleware(listUserDonationsQuerySchema, 'query'), + getMyDonations +); +router.get( + '/users/:userId', + requireJwtAuthApi, + policyMiddleware(listUserDonationsQuerySchema, 'query'), + getUserDonations +); +router.get( + '/', + requireJwtAuthApi, + requireAdminApi, + policyMiddleware(listDonationsQuerySchema, 'query'), + listDonations +); +router.get( + '/:id', + requireJwtAuthApi, + policyMiddleware(donationParamsSchema, 'params'), + getDonationById +); + +export default router.getRouter(); diff --git a/src/components/v1/Donation/donation.service.ts b/src/components/v1/Donation/donation.service.ts index e69de29..1567b73 100644 --- a/src/components/v1/Donation/donation.service.ts +++ b/src/components/v1/Donation/donation.service.ts @@ -0,0 +1,296 @@ +import { Decimal } from 'decimal.js'; +import type { Repository, SelectQueryBuilder } from 'typeorm'; +import type { DonationEntity } from './donation.entity'; +import type { + CreateDonationInput, + ListDonationsQuery, +} from './donation.validation'; +import type { + DonationResponseDto, + PaginatedResponse, + DonationStatsDto, +} from './donation.dto'; +import { DonationStatus } from '../../../types/enums'; + +const SORT_FIELD_MAP: Record = { + created_at: 'donation.createdAt', + amount: 'donation.amount', + status: 'donation.status', + confirmed_at: 'donation.confirmedAt', + campaign_ref: 'donation.campaignRef', + campaign_title: 'donation.campaignTitle', + donor_address: 'donation.donorAddress', +}; + +export class DonationService { + constructor( + private readonly donationRepository: Repository + ) {} + + async createDonation( + dto: CreateDonationInput + ): Promise { + const data: Partial = { + campaignId: dto.campaignId, + campaignRef: dto.campaignRef ?? null, + campaignTitle: dto.campaignTitle ?? null, + donorId: dto.donorId ?? null, + donorAddress: dto.donorAddress.toLowerCase(), + donorName: dto.donorName ?? null, + tokenAddress: dto.tokenAddress.toLowerCase(), + tokenSymbol: dto.tokenSymbol.toUpperCase(), + tokenDecimals: dto.tokenDecimals, + amount: dto.amount, + usdAmount: dto.usdAmount ?? '0', + gasFee: dto.gasFee ?? '0', + transactionHash: dto.transactionHash ?? null, + blockNumber: dto.blockNumber ?? null, + blockTimestamp: dto.blockTimestamp + ? new Date(dto.blockTimestamp) + : null, + network: dto.network ?? ('mainnet' as any), + isAnonymous: dto.isAnonymous ?? false, + message: dto.message ?? null, + status: DonationStatus.PENDING, + }; + + const entity = this.donationRepository.create(data); + const saved = await this.donationRepository.save(entity); + return this.formatResponse(saved); + } + + async listDonations( + query: ListDonationsQuery + ): Promise> { + const qb = this.buildBaseQuery(query); + + const totalRows = await qb.getCount(); + const totalPages = Math.ceil(totalRows / query.limit); + + const sortColumn = + SORT_FIELD_MAP[query.sort_by] ?? 'donation.createdAt'; + qb.orderBy(sortColumn, query.sort_order === 'asc' ? 'ASC' : 'DESC'); + qb.skip((query.page - 1) * query.limit).take(query.limit); + + const entities = await qb.getMany(); + const data = entities.map((e) => this.formatResponse(e)); + + return { + data, + meta: { + page: query.page, + limit: query.limit, + totalRows, + totalPages, + }, + }; + } + + async getDonationById(id: string): Promise { + const entity = await this.donationRepository.findOne({ where: { id } }); + return entity ? this.formatResponse(entity) : null; + } + + async getDonationsByCampaign( + campaignId: string, + query: Omit + ): Promise> { + const qb = this.buildBaseQuery({ ...query, campaign_id: campaignId }); + + const totalRows = await qb.getCount(); + const totalPages = Math.ceil(totalRows / query.limit); + + const sortColumn = + SORT_FIELD_MAP[query.sort_by] ?? 'donation.createdAt'; + qb.orderBy(sortColumn, query.sort_order === 'asc' ? 'ASC' : 'DESC'); + qb.skip((query.page - 1) * query.limit).take(query.limit); + + const entities = await qb.getMany(); + const data = entities.map((e) => this.formatResponse(e)); + + return { + data, + meta: { + page: query.page, + limit: query.limit, + totalRows, + totalPages, + }, + }; + } + + async getDonationsByDonor( + donorId: string, + query: Omit + ): Promise> { + const qb = this.buildBaseQuery({ ...query, donor_id: donorId }); + + const totalRows = await qb.getCount(); + const totalPages = Math.ceil(totalRows / query.limit); + + const sortColumn = + SORT_FIELD_MAP[query.sort_by] ?? 'donation.createdAt'; + qb.orderBy(sortColumn, query.sort_order === 'asc' ? 'ASC' : 'DESC'); + qb.skip((query.page - 1) * query.limit).take(query.limit); + + const entities = await qb.getMany(); + const data = entities.map((e) => this.formatResponse(e)); + + return { + data, + meta: { + page: query.page, + limit: query.limit, + totalRows, + totalPages, + }, + }; + } + + async getDonationStats(campaignId?: string): Promise { + const qb = this.donationRepository + .createQueryBuilder('donation') + .select('COUNT(donation.id)', 'totalDonations') + .addSelect( + 'COALESCE(SUM(CAST(donation.amount AS numeric)), 0)', + 'totalAmount' + ) + .addSelect( + 'COALESCE(SUM(CAST(donation.usdAmount AS numeric)), 0)', + 'totalUsdAmount' + ) + .addSelect('COUNT(DISTINCT donation.donorAddress)', 'uniqueDonors'); + + if (campaignId) { + qb.where('donation.campaignId = :campaignId', { campaignId }); + } + + const result = await qb.getRawOne<{ + totalDonations: string; + totalAmount: string; + totalUsdAmount: string; + uniqueDonors: string; + }>(); + + const totalDonations = Number(result?.totalDonations ?? 0); + const totalAmount = result?.totalAmount ?? '0'; + const totalUsdAmount = result?.totalUsdAmount ?? '0'; + + const avgAmount = + totalDonations > 0 + ? new Decimal(totalAmount).div(totalDonations).toString() + : '0'; + const avgUsdAmount = + totalDonations > 0 + ? new Decimal(totalUsdAmount).div(totalDonations).toString() + : '0'; + + return { + totalAmount, + totalUsdAmount, + totalDonations, + uniqueDonors: Number(result?.uniqueDonors ?? 0), + averageAmount: avgAmount, + averageUsdAmount: avgUsdAmount, + }; + } + + private buildBaseQuery( + query: Partial + ): SelectQueryBuilder { + const qb = this.donationRepository.createQueryBuilder('donation'); + + if (query.campaign_id) { + qb.andWhere('donation.campaignId = :campaignId', { + campaignId: query.campaign_id, + }); + } + + if (query.donor_id) { + qb.andWhere('donation.donorId = :donorId', { + donorId: query.donor_id, + }); + } + + if (query.donation_token) { + qb.andWhere('donation.tokenAddress = :tokenAddress', { + tokenAddress: query.donation_token.toLowerCase(), + }); + } + + if (query.status) { + qb.andWhere('donation.status = :status', { status: query.status }); + } + + if (query.confirmed === true) { + qb.andWhere('donation.status = :confirmedStatus', { + confirmedStatus: DonationStatus.CONFIRMED, + }); + } else if (query.confirmed === false) { + qb.andWhere('donation.status != :confirmedStatus', { + confirmedStatus: DonationStatus.CONFIRMED, + }); + } + + if (query.from_date) { + qb.andWhere('donation.createdAt >= :fromDate', { + fromDate: new Date(query.from_date), + }); + } + + if (query.to_date) { + qb.andWhere('donation.createdAt <= :toDate', { + toDate: new Date(query.to_date), + }); + } + + if (query.min_amount) { + qb.andWhere('CAST(donation.amount AS numeric) >= :minAmount', { + minAmount: query.min_amount, + }); + } + + if (query.max_amount) { + qb.andWhere('CAST(donation.amount AS numeric) <= :maxAmount', { + maxAmount: query.max_amount, + }); + } + + if (query.search) { + qb.andWhere( + '(donation.donorAddress ILIKE :search OR donation.donorName ILIKE :search OR donation.campaignRef ILIKE :search OR donation.campaignTitle ILIKE :search)', + { search: `%${query.search}%` } + ); + } + + return qb; + } + + private formatResponse(entity: DonationEntity): DonationResponseDto { + return { + id: entity.id, + campaignId: entity.campaignId, + campaignRef: entity.campaignRef, + campaignTitle: entity.campaignTitle, + donorId: entity.donorId, + donorAddress: entity.donorAddress, + donorName: entity.donorName, + transactionHash: entity.transactionHash, + blockNumber: entity.blockNumber, + blockTimestamp: entity.blockTimestamp, + gasFee: entity.gasFee, + amount: entity.amount, + usdAmount: entity.usdAmount, + tokenAddress: entity.tokenAddress, + tokenSymbol: entity.tokenSymbol, + tokenDecimals: entity.tokenDecimals, + status: entity.status, + confirmedAt: entity.confirmedAt, + isAnonymous: entity.isAnonymous, + message: entity.message, + network: entity.network, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + } +} diff --git a/src/components/v1/Donation/donation.validation.ts b/src/components/v1/Donation/donation.validation.ts index 87845e5..7b3ecbf 100644 --- a/src/components/v1/Donation/donation.validation.ts +++ b/src/components/v1/Donation/donation.validation.ts @@ -1,23 +1,171 @@ -import { z } from "zod" -import { Network } from "../../../types/enums" +import { z } from 'zod'; +import { DonationStatus, Network } from '../../../types/enums'; -const ethereumAddressRegex = /^0x[a-fA-F0-9]{40}$/ -const decimalStringRegex = /^\d+(\.\d+)?$/ +const ethereumAddressRegex = /^0x[a-fA-F0-9]{40}$/; +const decimalStringRegex = /^\d+(\.\d+)?$/; +const starknetTransactionHashRegex = /^0x([A-Fa-f0-9]{1,64})$/; export const createDonationSchema = z.object({ - campaignId: z.string().min(1, "campaignId is required"), + campaignId: z.string().min(1, 'campaignId is required'), - donorAddress: z.string().regex(ethereumAddressRegex, "donorAddress must be a valid Ethereum address"), + campaignRef: z.string().nullable().optional(), + campaignTitle: z + .string() + .max(255, 'campaignTitle must be at most 255 characters') + .nullable() + .optional(), - donationToken: z.string().regex(ethereumAddressRegex, "donationToken must be a valid Ethereum address"), + donorId: z.string().nullable().optional(), - donationAmount: z.string().regex(decimalStringRegex, "donationAmount must be a valid decimal string"), + donorAddress: z + .string() + .regex( + ethereumAddressRegex, + 'donorAddress must be a valid Ethereum address' + ), - transactionHash: z.string().regex(/^0x([A-Fa-f0-9]{64})$/, "transactionHash must be a valid transaction hash"), + donorName: z + .string() + .max(255, 'donorName must be at most 255 characters') + .nullable() + .optional(), - network: z.nativeEnum(Network, { - errorMap: () => ({ message: "network must be a valid Network" }), - }), -}) + tokenAddress: z + .string() + .regex( + ethereumAddressRegex, + 'tokenAddress must be a valid Ethereum address' + ), -export type CreateDonationInput = z.infer + tokenSymbol: z + .string() + .min(1, 'tokenSymbol is required') + .max(20, 'tokenSymbol must be at most 20 characters'), + + tokenDecimals: z + .number() + .int('tokenDecimals must be an integer') + .min(0, 'tokenDecimals must be at least 0') + .max(30, 'tokenDecimals must be at most 30'), + + amount: z + .string() + .regex(decimalStringRegex, 'amount must be a valid decimal string'), + + usdAmount: z + .string() + .regex(decimalStringRegex, 'usdAmount must be a valid decimal string') + .optional(), + + gasFee: z + .string() + .regex(decimalStringRegex, 'gasFee must be a valid decimal string') + .optional(), + + transactionHash: z + .string() + .regex( + starknetTransactionHashRegex, + 'transactionHash must be a valid transaction hash' + ) + .nullable() + .optional(), + + blockNumber: z + .number() + .int('blockNumber must be an integer') + .nullable() + .optional(), + + blockTimestamp: z.string().datetime().nullable().optional(), + + network: z + .nativeEnum(Network, { + errorMap: () => ({ message: 'network must be a valid Network' }), + }) + .optional(), + + isAnonymous: z.boolean().optional(), + + message: z + .string() + .max(1000, 'message must be at most 1000 characters') + .nullable() + .optional(), +}); + +export const listDonationsQuerySchema = z.object({ + page: z + .string() + .optional() + .transform((val) => { + const n = val ? Number(val) : 1; + return Number.isNaN(n) || n < 1 ? 1 : Math.floor(n); + }), + limit: z + .string() + .optional() + .transform((val) => { + const n = val ? Number(val) : 20; + if (Number.isNaN(n) || n < 1) return 20; + return Math.min(Math.floor(n), 100); + }), + confirmed: z + .enum(['true', 'false']) + .optional() + .transform((val) => { + if (val === 'true') return true; + if (val === 'false') return false; + return undefined; + }), + sort_by: z + .enum([ + 'created_at', + 'amount', + 'status', + 'confirmed_at', + 'campaign_ref', + 'campaign_title', + 'donor_address', + ]) + .optional() + .default('created_at'), + sort_order: z.enum(['asc', 'desc']).optional().default('desc'), + from_date: z.string().datetime().optional(), + to_date: z.string().datetime().optional(), + min_amount: z + .string() + .regex(decimalStringRegex, 'min_amount must be a valid decimal string') + .optional(), + max_amount: z + .string() + .regex(decimalStringRegex, 'max_amount must be a valid decimal string') + .optional(), + campaign_id: z.string().optional(), + donor_id: z.string().optional(), + donation_token: z.string().optional(), + status: z + .nativeEnum(DonationStatus, { + errorMap: () => ({ + message: 'status must be a valid DonationStatus', + }), + }) + .optional(), + search: z + .string() + .max(255, 'search must be at most 255 characters') + .optional(), +}); + +export const listCampaignDonationsQuerySchema = listDonationsQuerySchema.omit({ + campaign_id: true, +}); +export const listUserDonationsQuerySchema = listDonationsQuerySchema.omit({ + donor_id: true, +}); +export const donationParamsSchema = z.object({ + id: z.string().uuid('id must be a valid UUID'), +}); + +export type CreateDonationInput = z.infer; +export type ListDonationsQuery = z.infer; diff --git a/src/components/v1/campaign/campaign.controller.ts b/src/components/v1/campaign/campaign.controller.ts index c3fa89b..87fed67 100644 --- a/src/components/v1/campaign/campaign.controller.ts +++ b/src/components/v1/campaign/campaign.controller.ts @@ -1,97 +1,128 @@ -import type { Response } from "express" -import { ZodError } from "zod" +import type { Response } from 'express'; +import { ZodError } from 'zod'; -import type { IRequest } from "../../../types/global" -import { sendError, sendSuccess } from "../../../utils/apiResponse" -import { createCampaignSchema } from "./campaign.validation" -import AppDataSource from "../../../config/persistence/data-source" -import CampaignEntity from "./campaign.entity" -import AuditLogEntity from "../audit/auditLog.entity" -import { CampaignService } from "./campaign.service" -import { createCairoCampaignClient } from "../../../services/cairo/campaignFactory.client" -import logger from "../../../utils/logger" -import WalletEntity from "../wallet/wallet.entity" -import UserEntity from "../user/user.entity" +import type { IRequest } from '../../../types/global'; +import { sendError, sendSuccess } from '../../../utils/apiResponse'; +import { createCampaignSchema } from './campaign.validation'; +import AppDataSource from '../../../config/persistence/data-source'; +import CampaignEntity from './campaign.entity'; +import AuditLogEntity from '../audit/auditLog.entity'; +import { CampaignService } from './campaign.service'; +import { createCairoCampaignClient } from '../../../services/cairo/campaignFactory.client'; +import logger from '../../../utils/logger'; +import WalletEntity from '../wallet/wallet.entity'; +import UserEntity from '../user/user.entity'; const mapCreateCampaignError = (error: unknown) => { - const code = typeof (error as any)?.code === "string" ? (error as any).code : "INTERNAL_ERROR" + const code = + typeof (error as any)?.code === 'string' + ? (error as any).code + : 'INTERNAL_ERROR'; - switch (code) { - case "DUPLICATE_CAMPAIGN_REF": - return { status: 409, code, message: "campaign_ref already exists" } - case "INSUFFICIENT_BALANCE": - return { status: 400, code, message: "Insufficient wallet balance for transaction fees" } - case "WALLET_NOT_FOUND": - return { status: 400, code, message: "Wallet not found" } - case "MISSING_WALLET_ADDRESS": - return { status: 400, code, message: "Token does not include a wallet address claim" } - default: - return { - status: 500, - code, - message: error instanceof Error ? error.message : "Internal server error", - } - } -} + switch (code) { + case 'DUPLICATE_CAMPAIGN_REF': + return { + status: 409, + code, + message: 'campaign_ref already exists', + }; + case 'INSUFFICIENT_BALANCE': + return { + status: 400, + code, + message: 'Insufficient wallet balance for transaction fees', + }; + case 'WALLET_NOT_FOUND': + return { status: 400, code, message: 'Wallet not found' }; + case 'MISSING_WALLET_ADDRESS': + return { + status: 400, + code, + message: 'Token does not include a wallet address claim', + }; + default: + return { + status: 500, + code, + message: + error instanceof Error + ? error.message + : 'Internal server error', + }; + } +}; export const createCampaign = async (req: IRequest, res: Response) => { - if (!req.auth?.userId) { - return sendError(res, 401, { code: "AUTH_REQUIRED", message: "Authentication required" }) - } + if (!req.auth?.userId) { + return sendError(res, 401, { + code: 'AUTH_REQUIRED', + message: 'Authentication required', + }); + } - try { - const parsed = createCampaignSchema.parse(req.body) + try { + const parsed = createCampaignSchema.parse(req.body); - if (!AppDataSource.isInitialized) { - return sendError(res, 500, { code: "DB_NOT_READY", message: "Database not initialized" }) - } + if (!AppDataSource.isInitialized) { + return sendError(res, 500, { + code: 'DB_NOT_READY', + message: 'Database not initialized', + }); + } - const service = new CampaignService( - AppDataSource.getRepository(CampaignEntity), - AppDataSource.getRepository(AuditLogEntity), - AppDataSource.getRepository(WalletEntity), - AppDataSource.getRepository(UserEntity), - createCairoCampaignClient() - ) + const service = new CampaignService( + AppDataSource.getRepository(CampaignEntity), + AppDataSource.getRepository(AuditLogEntity), + AppDataSource.getRepository(WalletEntity), + AppDataSource.getRepository(UserEntity), + createCairoCampaignClient() + ); - const saved = await service.createCampaign({ - userId: req.auth.userId, - walletAddress: req.auth.walletAddress, - campaignRef: parsed.campaign_ref, - targetAmount: parsed.target_amount, - donationToken: parsed.donation_token, - }) + const saved = await service.createCampaign({ + userId: req.auth.userId, + walletAddress: req.auth.walletAddress, + campaignRef: parsed.campaign_ref, + targetAmount: parsed.target_amount, + donationToken: parsed.donation_token, + title: parsed.title, + }); - return sendSuccess( - res, - { - campaign_id: saved.campaignId, - campaign_ref: saved.campaignRef, - target_amount: saved.targetAmount, - donation_token: saved.donationToken, - transaction_hash: saved.transactionHash, - created_at: saved.createdAt.toISOString(), - }, - 201 - ) - } catch (error) { - if (error instanceof ZodError) { - const issue = error.issues?.[0] - return sendError(res, 400, { - code: "VALIDATION_ERROR", - message: issue ? `${issue.path.join(".")} ${issue.message}` : "Invalid request", - details: { issues: error.issues }, - }) - } + return sendSuccess( + res, + { + campaign_id: saved.campaignId, + campaign_ref: saved.campaignRef, + title: saved.title, + target_amount: saved.targetAmount, + donation_token: saved.donationToken, + transaction_hash: saved.transactionHash, + created_at: saved.createdAt.toISOString(), + }, + 201 + ); + } catch (error) { + if (error instanceof ZodError) { + const issue = error.issues?.[0]; + return sendError(res, 400, { + code: 'VALIDATION_ERROR', + message: issue + ? `${issue.path.join('.')} ${issue.message}` + : 'Invalid request', + details: { issues: error.issues }, + }); + } - const mapped = mapCreateCampaignError(error) - logger.error( - JSON.stringify({ - name: (error as any)?.name, - code: mapped.code, - message: mapped.message, - }) - ) - return sendError(res, mapped.status, { code: mapped.code, message: mapped.message }) - } -} + const mapped = mapCreateCampaignError(error); + logger.error( + JSON.stringify({ + name: (error as any)?.name, + code: mapped.code, + message: mapped.message, + }) + ); + return sendError(res, mapped.status, { + code: mapped.code, + message: mapped.message, + }); + } +}; diff --git a/src/components/v1/campaign/campaign.entity.ts b/src/components/v1/campaign/campaign.entity.ts index 2f3dfbf..6a62be9 100644 --- a/src/components/v1/campaign/campaign.entity.ts +++ b/src/components/v1/campaign/campaign.entity.ts @@ -1,41 +1,55 @@ -import { Entity, Column, PrimaryColumn, CreateDateColumn, Index, BeforeInsert } from "typeorm" -import { uuid } from "../../../utils" - -@Entity("campaigns") -@Index("campaigns_campaign_ref_key", ["campaignRef"], { unique: true }) -@Index("campaigns_user_id_idx", ["userId"]) +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + Index, + BeforeInsert, +} from 'typeorm'; +import { uuid } from '../../../utils'; + +@Entity('campaigns') +@Index('campaigns_campaign_ref_key', ['campaignRef'], { unique: true }) +@Index('campaigns_user_id_idx', ['userId']) export class CampaignEntity { - @PrimaryColumn("text", { name: "campaign_id" }) - campaignId: string - - @Column("text", { name: "user_id", nullable: false }) - userId: string - - @Column("text", { name: "campaign_ref", nullable: false }) - campaignRef: string - - @Column("numeric", { name: "target_amount", precision: 78, scale: 0, nullable: false }) - targetAmount: string - - @Column("text", { name: "donation_token", nullable: false }) - donationToken: string - - @Column("text", { name: "transaction_hash", nullable: false }) - transactionHash: string - - @CreateDateColumn({ - name: "created_at", - type: "timestamp", - precision: 3, - default: () => "CURRENT_TIMESTAMP", - }) - createdAt: Date - - @BeforeInsert() - ensureId() { - if (!this.campaignId) this.campaignId = uuid() - } + @PrimaryColumn('text', { name: 'campaign_id' }) + campaignId: string; + + @Column('text', { name: 'user_id', nullable: false }) + userId: string; + + @Column('text', { name: 'campaign_ref', nullable: false }) + campaignRef: string; + + @Column('numeric', { + name: 'target_amount', + precision: 78, + scale: 0, + nullable: false, + }) + targetAmount: string; + + @Column('text', { name: 'donation_token', nullable: false }) + donationToken: string; + + @Column('text', { nullable: true }) + title: string | null; + + @Column('text', { name: 'transaction_hash', nullable: false }) + transactionHash: string; + + @CreateDateColumn({ + name: 'created_at', + type: 'timestamp', + precision: 3, + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date; + + @BeforeInsert() + ensureId() { + if (!this.campaignId) this.campaignId = uuid(); + } } -export default CampaignEntity - +export default CampaignEntity; diff --git a/src/components/v1/campaign/campaign.routes.ts b/src/components/v1/campaign/campaign.routes.ts index 1f95419..8025c80 100644 --- a/src/components/v1/campaign/campaign.routes.ts +++ b/src/components/v1/campaign/campaign.routes.ts @@ -1,31 +1,31 @@ -import { Router } from "express" -import rateLimit from "express-rate-limit" +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; -import { requireJwtAuthApi } from "../../../appMiddlewares/jwtAuth.api" -import type { IRequest } from "../../../types/global" -import { sendError } from "../../../utils/apiResponse" -import { createCampaign } from "./campaign.controller" +import { requireJwtAuthApi } from '../../../appMiddlewares/jwtAuth.api'; +import type { IRequest } from '../../../types/global'; +import { sendError } from '../../../utils/apiResponse'; +import { createCampaign } from './campaign.controller'; const campaignsRateLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, - limit: 5, - standardHeaders: "draft-6", - legacyHeaders: false, - keyGenerator: (req) => { - const r = req as IRequest - return r.auth?.userId ?? req.ip ?? "unknown" - }, - handler: (req, res) => { - return sendError(res, 429, { - code: "RATE_LIMITED", - message: "Too many campaigns created. Try again later.", - details: { limit: 5, windowSeconds: 3600 }, - }) - }, -}) + windowMs: 60 * 60 * 1000, + limit: 5, + standardHeaders: 'draft-6', + legacyHeaders: false, + keyGenerator: (req) => { + const r = req as IRequest; + return r.auth?.userId ?? req.ip ?? 'unknown'; + }, + handler: (req, res) => { + return sendError(res, 429, { + code: 'RATE_LIMITED', + message: 'Too many campaigns created. Try again later.', + details: { limit: 5, windowSeconds: 3600 }, + }); + }, +}); -const router = Router() +const router = Router(); -router.post("/", requireJwtAuthApi, campaignsRateLimiter, createCampaign) +router.post('/', requireJwtAuthApi, campaignsRateLimiter, createCampaign); -export default router +export default router; diff --git a/src/components/v1/campaign/campaign.service.ts b/src/components/v1/campaign/campaign.service.ts index 9bde28e..ee07f11 100644 --- a/src/components/v1/campaign/campaign.service.ts +++ b/src/components/v1/campaign/campaign.service.ts @@ -1,98 +1,119 @@ -import type { Repository } from "typeorm" +import type { Repository } from 'typeorm'; -import CampaignEntity from "./campaign.entity" -import type AuditLogEntity from "../audit/auditLog.entity" +import CampaignEntity from './campaign.entity'; +import type AuditLogEntity from '../audit/auditLog.entity'; -import WalletEntity from "../wallet/wallet.entity" -import UserEntity from "../user/user.entity" -import logger from "../../../utils/logger" -import type { CairoCampaignClient } from "../../../services/cairo/campaignFactory.client" +import WalletEntity from '../wallet/wallet.entity'; +import UserEntity from '../user/user.entity'; +import logger from '../../../utils/logger'; +import type { CairoCampaignClient } from '../../../services/cairo/campaignFactory.client'; export class CampaignService { - constructor( - private readonly campaignRepository: Repository, - private readonly auditRepository: Repository, - private readonly walletRepository: Repository, - private readonly userRepository: Repository, - private readonly cairoClient: CairoCampaignClient - ) {} + constructor( + private readonly campaignRepository: Repository, + private readonly auditRepository: Repository, + private readonly walletRepository: Repository, + private readonly userRepository: Repository, + private readonly cairoClient: CairoCampaignClient + ) {} - async createCampaign(args: { - userId: string - walletAddress?: string - campaignRef: string - targetAmount: string - donationToken: string - }) { - const { userId, walletAddress, campaignRef, targetAmount, donationToken } = args + async createCampaign(args: { + userId: string; + walletAddress?: string; + campaignRef: string; + targetAmount: string; + donationToken: string; + title?: string | null; + }) { + const { + userId, + walletAddress, + campaignRef, + targetAmount, + donationToken, + title, + } = args; - const existing = await this.campaignRepository.findOne({ where: { campaignRef } }) - if (existing) { - const error = new Error("Duplicate campaign_ref") - ;(error as any).code = "DUPLICATE_CAMPAIGN_REF" - throw error - } + const existing = await this.campaignRepository.findOne({ + where: { campaignRef }, + }); + if (existing) { + const error = new Error('Duplicate campaign_ref'); + (error as any).code = 'DUPLICATE_CAMPAIGN_REF'; + throw error; + } - if (!walletAddress) { - const error = new Error("Missing wallet address in token claims") - ;(error as any).code = "MISSING_WALLET_ADDRESS" - throw error - } + if (!walletAddress) { + const error = new Error('Missing wallet address in token claims'); + (error as any).code = 'MISSING_WALLET_ADDRESS'; + throw error; + } - const wallet = await this.walletRepository.findOne({ where: { address: walletAddress } }) + const wallet = await this.walletRepository.findOne({ + where: { address: walletAddress }, + }); - if (!wallet) { - const error = new Error("Wallet not found") - ;(error as any).code = "WALLET_NOT_FOUND" - throw error - } + if (!wallet) { + const error = new Error('Wallet not found'); + (error as any).code = 'WALLET_NOT_FOUND'; + throw error; + } - const walletBalance = Number.parseFloat(wallet.balance ?? "0") - if (!Number.isFinite(walletBalance) || walletBalance <= 0) { - const error = new Error("Insufficient wallet balance for fees") - ;(error as any).code = "INSUFFICIENT_BALANCE" - throw error - } + const walletBalance = Number.parseFloat(wallet.balance ?? '0'); + if (!Number.isFinite(walletBalance) || walletBalance <= 0) { + const error = new Error('Insufficient wallet balance for fees'); + (error as any).code = 'INSUFFICIENT_BALANCE'; + throw error; + } - const { transactionHash, campaignId } = await this.cairoClient.createCampaign({ - campaignRef, - targetAmount, - donationToken, - }) + const { transactionHash, campaignId } = + await this.cairoClient.createCampaign({ + campaignRef, + targetAmount, + donationToken, + }); - const saved = await this.campaignRepository.save( - this.campaignRepository.create({ - campaignId, - userId, - campaignRef, - targetAmount, - donationToken, - transactionHash, - }) - ) + const saved = await this.campaignRepository.save( + this.campaignRepository.create({ + campaignId, + userId, + campaignRef, + targetAmount, + donationToken, + transactionHash, + title: title ?? null, + }) + ); - await this.auditRepository.save( - this.auditRepository.create({ - userId, - action: "campaign.create", - entity: "campaign", - entityId: saved.campaignId, - details: { - campaignRef, - donationToken, - transactionHash, - }, - } as any) - ) + await this.auditRepository.save( + this.auditRepository.create({ + userId, + action: 'campaign.create', + entity: 'campaign', + entityId: saved.campaignId, + details: { + campaignRef, + donationToken, + transactionHash, + }, + } as any) + ); - const user = await this.userRepository.findOne({ where: { id: userId } }) - if (user) { - const nextCount = (user.campaignCount ?? 0) + 1 - await this.userRepository.update({ id: userId }, { campaignCount: nextCount }) - } else { - logger.info(`User ${userId} not found; skipped campaignCount update`) - } + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + if (user) { + const nextCount = (user.campaignCount ?? 0) + 1; + await this.userRepository.update( + { id: userId }, + { campaignCount: nextCount } + ); + } else { + logger.info( + `User ${userId} not found; skipped campaignCount update` + ); + } - return saved - } + return saved; + } } diff --git a/src/components/v1/campaign/campaign.validation.ts b/src/components/v1/campaign/campaign.validation.ts index 1844d60..c8a576f 100644 --- a/src/components/v1/campaign/campaign.validation.ts +++ b/src/components/v1/campaign/campaign.validation.ts @@ -1,46 +1,57 @@ -import { z } from "zod" +import { z } from 'zod'; -const MAX_U256 = (1n << 256n) - 1n +const MAX_U256 = (1n << 256n) - 1n; export const isPositiveU256String = (value: string) => { - if (!/^\d+$/.test(value)) return false - const asBigInt = BigInt(value) - return asBigInt > 0n && asBigInt <= MAX_U256 -} + if (!/^\d+$/.test(value)) return false; + const asBigInt = BigInt(value); + return asBigInt > 0n && asBigInt <= MAX_U256; +}; export const isValidStarknetAddress = (value: string) => { - if (!/^0x[0-9a-fA-F]{1,64}$/.test(value)) return false - try { - const n = BigInt(value) - return n >= 0n && n < (1n << 251n) - } catch { - return false - } -} + if (!/^0x[0-9a-fA-F]{1,64}$/.test(value)) return false; + try { + const n = BigInt(value); + return n >= 0n && n < 1n << 251n; + } catch { + return false; + } +}; export const createCampaignSchema = z.object({ - campaign_ref: z - .string() - .trim() - .length(5, "campaign_ref must be exactly 5 characters long") - .refine((s) => s.trim().length === 5, "campaign_ref cannot be empty"), - target_amount: z - .string() - .trim() - .refine((s) => isPositiveU256String(s), "target_amount must be a positive u256 integer string"), - donation_token: z - .string() - .trim() - .refine((s) => isValidStarknetAddress(s), "donation_token must be a valid contract address"), -}) + campaign_ref: z + .string() + .trim() + .length(5, 'campaign_ref must be exactly 5 characters long') + .refine((s) => s.trim().length === 5, 'campaign_ref cannot be empty'), + target_amount: z + .string() + .trim() + .refine( + (s) => isPositiveU256String(s), + 'target_amount must be a positive u256 integer string' + ), + title: z + .string() + .trim() + .max(255, 'title must be at most 255 characters') + .optional(), -export type CreateCampaignInput = z.infer + donation_token: z + .string() + .trim() + .refine( + (s) => isValidStarknetAddress(s), + 'donation_token must be a valid contract address' + ), +}); -export const toU256Parts = (value: string) => { - const n = BigInt(value) - const lowMask = (1n << 128n) - 1n - const low = n & lowMask - const high = n >> 128n - return { low: `0x${low.toString(16)}`, high: `0x${high.toString(16)}` } -} +export type CreateCampaignInput = z.infer; +export const toU256Parts = (value: string) => { + const n = BigInt(value); + const lowMask = (1n << 128n) - 1n; + const low = n & lowMask; + const high = n >> 128n; + return { low: `0x${low.toString(16)}`, high: `0x${high.toString(16)}` }; +}; diff --git a/src/components/v1/routes.api.v1.ts b/src/components/v1/routes.api.v1.ts index ee09f65..91a9deb 100644 --- a/src/components/v1/routes.api.v1.ts +++ b/src/components/v1/routes.api.v1.ts @@ -1,10 +1,40 @@ -import EnhancedRouter from "../../utils/enhancedRouter" +import EnhancedRouter from '../../utils/enhancedRouter'; +import policyMiddleware from '../../appMiddlewares/policy.middleware'; +import { requireJwtAuthApi } from '../../appMiddlewares/jwtAuth.api'; -import campaignRoutes from "./campaign/campaign.routes" +import campaignRoutes from './campaign/campaign.routes'; +import donationRoutes from './Donation/donation.routes'; +import { + listCampaignDonationsQuerySchema, + listUserDonationsQuerySchema, +} from './Donation/donation.validation'; +import { + getCampaignDonations, + getUserDonations, + getMyDonations, +} from './Donation/donation.controller'; -const router = new EnhancedRouter() +const router = new EnhancedRouter(); -router.use("/campaigns", campaignRoutes) +router.use('/campaigns', campaignRoutes); +router.use('/donations', donationRoutes); -export default router.getRouter() +router.get( + '/campaigns/:campaignId/donations', + policyMiddleware(listCampaignDonationsQuerySchema, 'query'), + getCampaignDonations +); +router.get( + '/users/me/donations', + requireJwtAuthApi, + policyMiddleware(listUserDonationsQuerySchema, 'query'), + getMyDonations +); +router.get( + '/users/:userId/donations', + requireJwtAuthApi, + policyMiddleware(listUserDonationsQuerySchema, 'query'), + getUserDonations +); +export default router.getRouter(); diff --git a/src/config/persistence/data-source.ts b/src/config/persistence/data-source.ts index 160c940..a938386 100644 --- a/src/config/persistence/data-source.ts +++ b/src/config/persistence/data-source.ts @@ -15,6 +15,7 @@ import FeeConfigEntity from '../../components/v1/feeConfig/feeConfig.entity'; import UserEntity from '../../components/v1/user/user.entity'; import CampaignEntity from '../../components/v1/campaign/campaign.entity'; import AuditLogEntity from '../../components/v1/audit/auditLog.entity'; +import DonationEntity from '../../components/v1/Donation/donation.entity'; const { dbConfigs } = appConfigs; @@ -56,6 +57,7 @@ const AppDataSource = new DataSource({ UserEntity, CampaignEntity, AuditLogEntity, + DonationEntity, ], migrations: ['src/migrations/*.js'], ...(appConfigs.isProd || appConfigs.isStaging diff --git a/src/migrations/AddCampaignTitleColumns1760000000003.js b/src/migrations/AddCampaignTitleColumns1760000000003.js new file mode 100644 index 0000000..bf127e3 --- /dev/null +++ b/src/migrations/AddCampaignTitleColumns1760000000003.js @@ -0,0 +1,22 @@ +const { MigrationInterface } = require("typeorm") + +module.exports = class AddCampaignTitleColumns1760000000003 { + name = "AddCampaignTitleColumns1760000000003" + + async up(queryRunner) { + await queryRunner.query(` + ALTER TABLE "campaigns" + ADD COLUMN IF NOT EXISTS "title" text + `) + + await queryRunner.query(` + ALTER TABLE "donations" + ADD COLUMN IF NOT EXISTS "campaign_title" text + `) + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "donations" DROP COLUMN IF EXISTS "campaign_title"`) + await queryRunner.query(`ALTER TABLE "campaigns" DROP COLUMN IF EXISTS "title"`) + } +} diff --git a/src/migrations/CreateDonationsTable1760000000002.js b/src/migrations/CreateDonationsTable1760000000002.js new file mode 100644 index 0000000..e72b7f8 --- /dev/null +++ b/src/migrations/CreateDonationsTable1760000000002.js @@ -0,0 +1,55 @@ +const { MigrationInterface } = require("typeorm") + +module.exports = class CreateDonationsTable1760000000002 { + name = "CreateDonationsTable1760000000002" + + async up(queryRunner) { + await queryRunner.query(` + CREATE TYPE "donation_status" AS ENUM('pending', 'confirmed', 'failed', 'refunded') + `) + + await queryRunner.query(` + CREATE TABLE "donations" ( + "id" text NOT NULL, + "campaign_id" text NOT NULL, + "campaign_ref" text, + "donor_id" text, + "donor_address" text NOT NULL, + "donor_name" text, + "transaction_hash" text, + "block_number" bigint, + "block_timestamp" TIMESTAMP(3), + "gas_fee" decimal(65,30) DEFAULT '0', + "amount" decimal(65,30) NOT NULL, + "usd_amount" decimal(65,30) DEFAULT '0', + "token_address" text NOT NULL, + "token_symbol" text NOT NULL, + "token_decimals" integer NOT NULL, + "status" "donation_status" NOT NULL DEFAULT 'pending', + "confirmed_at" TIMESTAMP(3), + "is_anonymous" boolean NOT NULL DEFAULT false, + "message" text, + "network" "network" NOT NULL DEFAULT 'mainnet', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_donations_id" PRIMARY KEY ("id") + ) + `) + + await queryRunner.query(`CREATE INDEX "donations_campaign_id_idx" ON "donations" ("campaign_id")`) + await queryRunner.query(`CREATE INDEX "donations_donor_address_idx" ON "donations" ("donor_address")`) + await queryRunner.query(`CREATE INDEX "donations_status_idx" ON "donations" ("status")`) + await queryRunner.query(`CREATE INDEX "donations_created_at_idx" ON "donations" ("created_at")`) + await queryRunner.query(`CREATE INDEX "donations_transaction_hash_idx" ON "donations" ("transaction_hash")`) + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX IF EXISTS "donations_transaction_hash_idx"`) + await queryRunner.query(`DROP INDEX IF EXISTS "donations_created_at_idx"`) + await queryRunner.query(`DROP INDEX IF EXISTS "donations_status_idx"`) + await queryRunner.query(`DROP INDEX IF EXISTS "donations_donor_address_idx"`) + await queryRunner.query(`DROP INDEX IF EXISTS "donations_campaign_id_idx"`) + await queryRunner.query(`DROP TABLE IF EXISTS "donations"`) + await queryRunner.query(`DROP TYPE IF EXISTS "donation_status"`) + } +} diff --git a/src/types/enums.ts b/src/types/enums.ts index f3fb7cc..cd419c1 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -13,6 +13,13 @@ export enum DistributionStatus { CANCELLED = "cancelled", } +export enum DonationStatus { + PENDING = "pending", + CONFIRMED = "confirmed", + FAILED = "failed", + REFUNDED = "refunded", +} + export enum Network { MAINNET = "mainnet", TESTNET = "testnet",