Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,14 @@ DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres

################################################################################

# Cairo/StarkNet integration
# Set CAIRO_MOCK=true for local development without a chain connection.
CAIRO_MOCK=true

# When CAIRO_MOCK is false/empty, set these:
# CAIRO_RPC_URL=
# CAIRO_ACCOUNT_ADDRESS=
# CAIRO_PRIVATE_KEY=
# CAIRO_FACTORY_CONTRACT_ADDRESS=
# Optional: CAMPAIGN_CREATED_EVENT_KEY=
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
.env
node_modules
node_modules
docs
scripts
coverage
coverage/**
*.log

*.tsbuildinfo
86 changes: 86 additions & 0 deletions CODEBASE_FINDINGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Codebase Findings + Setup Notes (Fundable Backend API)

Date: 2026-03-23

This repo is a TypeScript/Node/Express API backed by PostgreSQL via TypeORM. The main entrypoint is `src/index.ts` and the compiled output is `dist/`.

## Quick Local Setup

1) Install deps:

- `npm ci`

2) Create `.env`:

- `Copy-Item .env.example .env`

Minimal `.env` to boot locally:

```env
NODE_ENV=local_dev
PORT=8002

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
DATABASE_NAME=fundable_db

# Cairo/StarkNet integration (local mock)
CAIRO_MOCK=true
```

3) Ensure DB exists + migrate:

- `npm run ensure-db`
- `npm run typeorm -- migration:run`

4) Build + start:

- `npm run build`
- `node --env-file=.env dist/index.js`

## Startup Notes

- Seeding is disabled (no-op) to avoid missing-module startup blockers: `src/config/persistence/seeder.ts`.
- DB config is validated early and fails fast if required env vars are missing: `src/config/persistence/data-source.ts`.

## Current Routes

- Health: `GET /` → `{ success: false, message: ... }`
- V1 (existing): `/v1/*`
- Distributions: `GET /v1/distributions`, `POST /v1/distributions`
- API V1 (new): `/api/v1/*`
- Campaigns (new): `POST /api/v1/campaigns` (JWT required, 5/hour per user)

## Campaign Creation (Cairo)

Endpoint:

- `POST /api/v1/campaigns`

Body:

```json
{
"campaign_ref": "ABCDE",
"target_amount": "1000",
"donation_token": "0x1"
}
```

Behavior:

- Validates input (5-char ref, positive u256 string, StarkNet address format).
- Enforces uniqueness on `campaign_ref` (DB + unique index).
- Requires JWT with `sub` and a `walletAddress` (or `address`) claim.
- Rate limited: max 5 campaign creates per user per hour.
- Persists to DB (`campaigns` table) and writes an audit entry (`audit_logs`).
- On-chain integration:
- Local dev: `CAIRO_MOCK=true` returns mock tx hash + campaign id.
- Real chain: set `CAIRO_MOCK=false` and provide `CAIRO_RPC_URL`, `CAIRO_ACCOUNT_ADDRESS`, `CAIRO_PRIVATE_KEY`, `CAIRO_FACTORY_CONTRACT_ADDRESS`.

## Tests

- `npm test` runs `node --test` with `c8` coverage checks (scoped to the new feature modules via `package.json`).

44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,50 @@ src/
To run tests, use the following command:

```bash
pnpm test
npm test
```

Tests are located in the `__tests__` folder and use a library like **Jest** for unit and integration tests.
Tests are located in `src/__tests__` and run with Node's test runner (`node --test`) + coverage (`c8`).

---

## API: Create Campaign (Cairo)

**POST** `/api/v1/campaigns` (JWT required)

Request body:

```json
{
"campaign_ref": "ABCDE",
"target_amount": "1000",
"donation_token": "0x1"
}
```

Notes:

- Rate limit: max **5** campaign creations per user per hour.
- Auth: send `Authorization: Bearer <JWT>` and include a `walletAddress` (or `address`) claim in the token.

### Cairo/StarkNet config (non-mock)

Set these env vars to enable real on-chain calls:

- `CAIRO_RPC_URL`
- `CAIRO_ACCOUNT_ADDRESS`
- `CAIRO_PRIVATE_KEY`
- `CAIRO_FACTORY_CONTRACT_ADDRESS`

Optional:

- `CAMPAIGN_CREATED_EVENT_KEY` (event key to extract `campaign_id` from the tx receipt; otherwise falls back to tx hash)

### Mock mode (local)

For local development without a chain connection:

- `CAIRO_MOCK=true`

---

Expand Down
3 changes: 0 additions & 3 deletions combined.log

This file was deleted.

2 changes: 1 addition & 1 deletion dist/appMiddlewares/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const verifyAllowedMethods = (req, res, next) => {
try {
if (req.method === 'OPTIONS') {
res.header('Access-Control-Allow-Methods', 'POST, PUT, PATCH, GET, DELETE');
return res.status(403).json('Invalid header method');
return res.sendStatus(204);
}
else
return next();
Expand Down
44 changes: 44 additions & 0 deletions dist/appMiddlewares/jwtAuth.api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.requireJwtAuthApi = void 0;
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const config_1 = __importDefault(require("../config"));
const apiResponse_1 = require("../utils/apiResponse");
const requireJwtAuthApi = (req, res, next) => {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : "";
if (!token) {
return (0, apiResponse_1.sendError)(res, 401, {
code: "AUTH_MISSING_TOKEN",
message: "Missing authentication token",
});
}
try {
const decoded = jsonwebtoken_1.default.verify(token, config_1.default.authConfig.jwtSecret);
const userId = decoded.sub ?? decoded.userId ?? decoded.id;
if (!userId) {
return (0, apiResponse_1.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 (0, apiResponse_1.sendError)(res, 401, {
code: "AUTH_INVALID_TOKEN",
message: "Invalid authentication token",
details: error instanceof Error ? { name: error.name } : {},
});
}
};
exports.requireJwtAuthApi = requireJwtAuthApi;
1 change: 1 addition & 0 deletions dist/components/v1/Donation/donation.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"use strict";
2 changes: 2 additions & 0 deletions dist/components/v1/Donation/donation.dto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
66 changes: 66 additions & 0 deletions dist/components/v1/Donation/donation.entity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CampaignEntity = void 0;
const typeorm_1 = require("typeorm");
const utils_1 = require("../../../utils");
let CampaignEntity = class CampaignEntity {
campaignId;
campaignRef;
targetAmount;
donationToken;
transactionHash;
createdAt;
generateId() {
if (!this.campaignId) {
this.campaignId = (0, utils_1.uuid)();
}
}
};
exports.CampaignEntity = CampaignEntity;
__decorate([
(0, typeorm_1.PrimaryColumn)("text", { name: "campaign_id" }),
__metadata("design:type", String)
], CampaignEntity.prototype, "campaignId", void 0);
__decorate([
(0, typeorm_1.Column)("text", { name: "campaign_ref", nullable: false }),
__metadata("design:type", String)
], CampaignEntity.prototype, "campaignRef", void 0);
__decorate([
(0, typeorm_1.Column)("text", { name: "target_amount", nullable: false }),
__metadata("design:type", String)
], CampaignEntity.prototype, "targetAmount", void 0);
__decorate([
(0, typeorm_1.Column)("text", { name: "donation_token", nullable: false }),
__metadata("design:type", String)
], CampaignEntity.prototype, "donationToken", void 0);
__decorate([
(0, typeorm_1.Column)("text", { name: "transaction_hash", nullable: false }),
__metadata("design:type", String)
], CampaignEntity.prototype, "transactionHash", void 0);
__decorate([
(0, typeorm_1.CreateDateColumn)({
name: "created_at",
type: "timestamp",
default: () => "CURRENT_TIMESTAMP",
}),
__metadata("design:type", Date)
], CampaignEntity.prototype, "createdAt", void 0);
__decorate([
(0, typeorm_1.BeforeInsert)(),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", void 0)
], CampaignEntity.prototype, "generateId", null);
exports.CampaignEntity = CampaignEntity = __decorate([
(0, typeorm_1.Entity)("Campaign")
], CampaignEntity);
exports.default = CampaignEntity;
1 change: 1 addition & 0 deletions dist/components/v1/Donation/donation.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"use strict";
17 changes: 17 additions & 0 deletions dist/components/v1/Donation/donation.validation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createDonationSchema = void 0;
const zod_1 = require("zod");
const enums_1 = require("../../../types/enums");
const ethereumAddressRegex = /^0x[a-fA-F0-9]{40}$/;
const decimalStringRegex = /^\d+(\.\d+)?$/;
exports.createDonationSchema = zod_1.z.object({
campaignId: zod_1.z.string().min(1, "campaignId is required"),
donorAddress: zod_1.z.string().regex(ethereumAddressRegex, "donorAddress must be a valid Ethereum address"),
donationToken: zod_1.z.string().regex(ethereumAddressRegex, "donationToken must be a valid Ethereum address"),
donationAmount: zod_1.z.string().regex(decimalStringRegex, "donationAmount must be a valid decimal string"),
transactionHash: zod_1.z.string().regex(/^0x([A-Fa-f0-9]{64})$/, "transactionHash must be a valid transaction hash"),
network: zod_1.z.nativeEnum(enums_1.Network, {
errorMap: () => ({ message: "network must be a valid Network" }),
}),
});
73 changes: 73 additions & 0 deletions dist/components/v1/audit/auditLog.entity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AuditLogEntity = void 0;
const typeorm_1 = require("typeorm");
const utils_1 = require("../../../utils");
let AuditLogEntity = class AuditLogEntity {
auditId;
userId;
action;
entity;
entityId;
details;
createdAt;
ensureId() {
if (!this.auditId)
this.auditId = (0, utils_1.uuid)();
}
};
exports.AuditLogEntity = AuditLogEntity;
__decorate([
(0, typeorm_1.PrimaryColumn)("text", { name: "audit_id" }),
__metadata("design:type", String)
], AuditLogEntity.prototype, "auditId", void 0);
__decorate([
(0, typeorm_1.Column)("text", { name: "user_id", nullable: false }),
__metadata("design:type", String)
], AuditLogEntity.prototype, "userId", void 0);
__decorate([
(0, typeorm_1.Column)("text", { nullable: false }),
__metadata("design:type", String)
], AuditLogEntity.prototype, "action", void 0);
__decorate([
(0, typeorm_1.Column)("text", { nullable: false }),
__metadata("design:type", String)
], AuditLogEntity.prototype, "entity", void 0);
__decorate([
(0, typeorm_1.Column)("text", { name: "entity_id", nullable: false }),
__metadata("design:type", String)
], AuditLogEntity.prototype, "entityId", void 0);
__decorate([
(0, typeorm_1.Column)("jsonb", { nullable: true }),
__metadata("design:type", Object)
], AuditLogEntity.prototype, "details", void 0);
__decorate([
(0, typeorm_1.CreateDateColumn)({
name: "created_at",
type: "timestamp",
precision: 3,
default: () => "CURRENT_TIMESTAMP",
}),
__metadata("design:type", Date)
], AuditLogEntity.prototype, "createdAt", void 0);
__decorate([
(0, typeorm_1.BeforeInsert)(),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", void 0)
], AuditLogEntity.prototype, "ensureId", null);
exports.AuditLogEntity = AuditLogEntity = __decorate([
(0, typeorm_1.Entity)("audit_logs"),
(0, typeorm_1.Index)("audit_logs_user_id_idx", ["userId"]),
(0, typeorm_1.Index)("audit_logs_entity_idx", ["entity", "entityId"])
], AuditLogEntity);
exports.default = AuditLogEntity;
Loading