Skip to content
Open
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
31 changes: 24 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ Today this runs entirely on trust — no contracts, no guarantees, frequent frau
│ Next.js App │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Public Pages│ │ API Routes │ │ Server Services │ │
│ │ /circles │ │ /api/circles│ │ circle.service │ │
│ │ /dashboard │ │ /api/auth │ │ payout.service │ │
│ │ /auth/login │ │ /api/cron │ │ scheduler │ │
│ │ /circles │ │/api/v1/circle│ │ circle.service │ │
│ │ /dashboard │ │/api/auth │ │ payout.service │ │
│ │ /auth/login │ │/api/v1/cron │ │ scheduler │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │ │
Expand Down Expand Up @@ -73,11 +73,13 @@ src/
│ ├── dashboard/ # User's circles
│ ├── auth/login/ # Phone OTP login
│ └── api/
│ ├── circles/ # Circle CRUD + join
│ ├── auth/ # OTP + NextAuth
│ └── cron/cycle/ # Payout scheduler
│ ├── auth/ # NextAuth route handlers (exempt from v1 prefix)
│ └── v1/ # Versioned API routes (/api/v1/)
│ ├── circles/ # Circle CRUD + join + waitlist
│ ├── auth/ # OTP v1 routes
│ └── cron/cycle/ # Payout scheduler
├── server/
│ ├── services/ # circle, payout, scheduler
│ ├── services/ # circle, payout, scheduler, waitlist
│ ├── middleware/ # Auth, rate limiting
│ └── config/
├── components/
Expand All @@ -95,6 +97,21 @@ scripts/

---

## API Versioning

All API endpoints are strictly versioned under the `/api/v1/` path to ensure backward compatibility and smooth future integrations.

### Standard Redirection & Deprecation
- **Dynamic Redirects**: Any unversioned legacy request directed at `/api/*` is dynamically intercepted by the Next.js middleware and redirected to `/api/v1/*` automatically.
- **GET requests**: Responds with `301 Moved Permanently`.
- **Non-GET requests** (POST, PUT, DELETE, PATCH): Responds with `308 Permanent Redirect` to safely preserve the request body and HTTP method.
- **Deprecation Headers**: Legacy redirected responses automatically append:
- `X-API-Deprecated: true`
- `X-API-Deprecation-Info: This endpoint is deprecated. Use /api/v1/{endpoint} instead.`
- **Exceptions**: Authentication endpoints (`/api/auth/*` for NextAuth) are explicitly excluded from redirection to ensure login flows remain uninterrupted.

---

## Smart Contract

The Ajo contract (`contracts/ajo/`) handles the full circle lifecycle:
Expand Down
4 changes: 2 additions & 2 deletions instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ export async function register() {
await import("./sentry.server.config");

if (process.env.NODE_ENV === "production") {
const { default: runner } = await import("node-pg-migrate");
const { runner } = await import("node-pg-migrate");
const path = await import("path");
await runner({
databaseUrl: process.env.DATABASE_URL!,
dir: path.join(process.cwd(), "migrations"),
direction: "up",
migrationsTable: "pgmigrations",
log: (msg) => console.log("[migrate]", msg),
log: (msg: any) => console.log("[migrate]", msg),
});
}

Expand Down
5 changes: 4 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ const createJestConfig = nextJest({ dir: "./" });
const config = {
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
testEnvironment: "jest-environment-jsdom",
moduleNameMapper: { "^@/(.*)$": "<rootDir>/src/$1" },
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"^jose$": require.resolve("jose"),
},
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/.next/", "<rootDir>/e2e/"],
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
Expand Down
48 changes: 48 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,51 @@ import { TextEncoder, TextDecoder } from "util";

global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder as any;

const timers = require("timers");
global.setImmediate = global.setImmediate || timers.setImmediate;
global.clearImmediate = global.clearImmediate || timers.clearImmediate;

// Polyfill fetch globals for JSDOM environment using next's compiled primitives
const primitives = require("next/dist/compiled/@edge-runtime/primitives");
global.Request = primitives.Request;
global.Response = primitives.Response;
global.Headers = primitives.Headers;
global.fetch = primitives.fetch;

// Global mocks for Redis/ioredis to prevent tests from initiating real connections
jest.mock("ioredis", () => {
return jest.fn().mockImplementation(() => {
return {
on: jest.fn(),
info: jest.fn().mockResolvedValue("redis_version:7.0.0"),
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue("OK"),
del: jest.fn().mockResolvedValue(1),
quit: jest.fn().mockResolvedValue("OK"),
disconnect: jest.fn().mockResolvedValue("OK"),
zRemRangeByScore: jest.fn().mockResolvedValue(0),
zCard: jest.fn().mockResolvedValue(0),
zRange: jest.fn().mockResolvedValue([]),
zAdd: jest.fn().mockResolvedValue(1),
pExpire: jest.fn().mockResolvedValue(true),
};
});
});

// Global mocks for BullMQ to prevent queue background workers from spinning up in tests
jest.mock("bullmq", () => {
return {
Queue: jest.fn().mockImplementation(() => ({
add: jest.fn().mockResolvedValue({ id: "job-id" }),
on: jest.fn(),
})),
Worker: jest.fn().mockImplementation(() => ({
on: jest.fn(),
})),
QueueScheduler: jest.fn().mockImplementation(() => ({
on: jest.fn(),
})),
Job: jest.fn(),
};
});
38 changes: 12 additions & 26 deletions migrations/1745592000000_add-payout-randomization.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,19 @@
import { Kysely } from "kysely";
import { MigrationBuilder } from "node-pg-migrate";

export async function up(db: Kysely<any>): Promise<void> {
export async function up(pgm: MigrationBuilder): Promise<void> {
// Add payout_method and randomization_seed columns to circles table
await db.schema
.alterTable("circles")
.addColumn("payout_method", "varchar(20)", (col) =>
col.notNull().defaultTo("fixed")
)
.addColumn("randomization_seed", "varchar(255)")
.execute();
pgm.addColumn("circles", {
payout_method: { type: "varchar(20)", notNull: true, default: "fixed" },
randomization_seed: { type: "varchar(255)" },
});

// Add updated_at column to members table
await db.schema
.alterTable("members")
.addColumn("updated_at", "timestamp", (col) =>
col.notNull().defaultTo(db.fn("now"))
)
.execute();
pgm.addColumn("members", {
updated_at: { type: "timestamp", notNull: true, default: pgm.func("NOW()") },
});
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable("circles")
.dropColumn("payout_method")
.dropColumn("randomization_seed")
.execute();

await db.schema
.alterTable("members")
.dropColumn("updated_at")
.execute();
export async function down(pgm: MigrationBuilder): Promise<void> {
pgm.dropColumn("circles", ["payout_method", "randomization_seed"]);
pgm.dropColumn("members", ["updated_at"]);
}
8 changes: 4 additions & 4 deletions migrations/1745600000000_add-private-circles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ export async function up(pgm: MigrationBuilder): Promise<void> {
notNull: true,
default: "pending",
check: "status IN ('pending', 'active', 'rejected', 'defaulted', 'completed')",
});
} as any);

// Make position nullable for pending members
pgm.alterColumn("members", "position", {
type: "integer",
notNull: false,
check: "position > 0",
});
} as any);

// Add reviewed_at column to track when creator approved/rejected
pgm.addColumn("members", {
Expand All @@ -51,15 +51,15 @@ export async function down(pgm: MigrationBuilder): Promise<void> {
type: "integer",
notNull: true,
check: "position > 0",
});
} as any);

// Revert members status check
pgm.alterColumn("members", "status", {
type: "varchar(20)",
notNull: true,
default: "pending",
check: "status IN ('pending', 'active', 'defaulted', 'completed')",
});
} as any);

// Drop circle_type index
pgm.dropIndex("circles", "circle_type", {
Expand Down
4 changes: 2 additions & 2 deletions migrations/1745900000000_members-db-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function up(pgm: MigrationBuilder): Promise<void> {
notNull: true,
default: "pending",
check: "status IN ('pending','confirmed','missed','refund_pending')",
});
} as any);

// Add authorization_url column if not already present (used by Paystack flow)
pgm.addColumn("contributions", {
Expand Down Expand Up @@ -43,5 +43,5 @@ export async function down(pgm: MigrationBuilder): Promise<void> {
notNull: true,
default: "pending",
check: "status IN ('pending','confirmed','missed')",
});
} as any);
}
4 changes: 2 additions & 2 deletions migrations/1746000000000_add-refunded-contribution-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function up(pgm: MigrationBuilder): Promise<void> {
notNull: true,
default: "pending",
check: "status IN ('pending','confirmed','missed','refund_pending','refunded')",
});
} as any);

// Add updated_at to contributions if not already present
pgm.addColumn("contributions", {
Expand All @@ -35,5 +35,5 @@ export async function down(pgm: MigrationBuilder): Promise<void> {
notNull: true,
default: "pending",
check: "status IN ('pending','confirmed','missed','refund_pending')",
});
} as any);
}
2 changes: 1 addition & 1 deletion migrations/1746800000000_add-audit-logs-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,6 @@ export async function up(pgm: MigrationBuilder): Promise<void> {

export async function down(pgm: MigrationBuilder): Promise<void> {
pgm.dropTrigger("audit_logs", "audit_logs_immutable");
pgm.dropFunction("raise_immutable_error");
pgm.dropFunction("raise_immutable_error", []);
pgm.dropTable("audit_logs");
}
Loading
Loading