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
1 change: 1 addition & 0 deletions prisma/schema/activity.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum ActivityType {
GOVERNANCE_PROPOSAL_CREATED
TIMELOCK_LOCKUP_PROPOSED
CIRCUIT_BREAKER_THRESHOLD_UPDATED
SUPPLY_FULLY_FUNDED
}

model Activity {
Expand Down
15 changes: 15 additions & 0 deletions prisma/schema/factory.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// prisma/schema/factory.prisma

model FactoryDeployedKey {
id String @id @default(cuid())
contractAddress String @unique
creatorWallet String
keyId String?
deployedAt DateTime
txHash String
eventIndex Int
createdAt DateTime @default(now())

@@unique([txHash, eventIndex])
@@index([creatorWallet])
}
22 changes: 22 additions & 0 deletions prisma/schema/governance.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,34 @@ model GovernanceProposal {
expiresAt DateTime
closedAt DateTime?
status String @default("active") // active | closed

// Escalation tracking (#935), synced from ProposalExtended contract events.
escalationCount Int @default(0)
originalDeadline DateTime?
extendedDeadline DateTime?
maxEscalations Int @default(3)
escalatedAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([keyId, proposalId])
@@index([keyId, status])
@@index([keyId, expiresAt])
@@index([escalationCount, status])
}

model GovernanceEscalationEventLog {
id String @id @default(cuid())
keyId String
proposalId String
ledger Int?
txHash String
eventIndex Int
createdAt DateTime @default(now())

@@unique([txHash, eventIndex])
@@index([keyId, proposalId])
}

model PauseProposal {
Expand Down
26 changes: 26 additions & 0 deletions prisma/schema/liquidity.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// prisma/schema/liquidity.prisma

model LpPosition {
id String @id @default(cuid())
lpId String @unique @default(cuid())
wallet String
keyId String
sharePercent Decimal @default(0)
accruedRewards Decimal @default(0)
status String @default("active") // active | removed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([wallet])
@@index([keyId])
}

model LpEventLog {
id String @id @default(cuid())
txHash String
eventIndex Int
eventType String
createdAt DateTime @default(now())

@@unique([txHash, eventIndex])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
-- #936: platform activity feed — new ActivityType enum value
ALTER TYPE "ActivityType" ADD VALUE 'SUPPLY_FULLY_FUNDED';

-- #935: governance proposal quorum escalation tracking
ALTER TABLE "GovernanceProposal" ADD COLUMN "escalationCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "originalDeadline" TIMESTAMP(3),
ADD COLUMN "extendedDeadline" TIMESTAMP(3),
ADD COLUMN "maxEscalations" INTEGER NOT NULL DEFAULT 3,
ADD COLUMN "escalatedAt" TIMESTAMP(3);

-- CreateIndex
CREATE INDEX "GovernanceProposal_escalationCount_status_idx" ON "GovernanceProposal"("escalationCount", "status");

-- CreateTable
CREATE TABLE "GovernanceEscalationEventLog" (
"id" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"proposalId" TEXT NOT NULL,
"ledger" INTEGER,
"txHash" TEXT NOT NULL,
"eventIndex" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "GovernanceEscalationEventLog_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "GovernanceEscalationEventLog_txHash_eventIndex_key" ON "GovernanceEscalationEventLog"("txHash", "eventIndex");

-- CreateIndex
CREATE INDEX "GovernanceEscalationEventLog_keyId_proposalId_idx" ON "GovernanceEscalationEventLog"("keyId", "proposalId");

-- #983: key factory deployment event indexing and registry
CREATE TABLE "FactoryDeployedKey" (
"id" TEXT NOT NULL,
"contractAddress" TEXT NOT NULL,
"creatorWallet" TEXT NOT NULL,
"keyId" TEXT,
"deployedAt" TIMESTAMP(3) NOT NULL,
"txHash" TEXT NOT NULL,
"eventIndex" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "FactoryDeployedKey_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "FactoryDeployedKey_contractAddress_key" ON "FactoryDeployedKey"("contractAddress");

-- CreateIndex
CREATE UNIQUE INDEX "FactoryDeployedKey_txHash_eventIndex_key" ON "FactoryDeployedKey"("txHash", "eventIndex");

-- CreateIndex
CREATE INDEX "FactoryDeployedKey_creatorWallet_idx" ON "FactoryDeployedKey"("creatorWallet");

-- #980: LP position indexing and rewards
CREATE TABLE "LpPosition" (
"id" TEXT NOT NULL,
"lpId" TEXT NOT NULL,
"wallet" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"sharePercent" DECIMAL(65,30) NOT NULL DEFAULT 0,
"accruedRewards" DECIMAL(65,30) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "LpPosition_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "LpPosition_lpId_key" ON "LpPosition"("lpId");

-- CreateIndex
CREATE INDEX "LpPosition_wallet_idx" ON "LpPosition"("wallet");

-- CreateIndex
CREATE INDEX "LpPosition_keyId_idx" ON "LpPosition"("keyId");

-- CreateTable
CREATE TABLE "LpEventLog" (
"id" TEXT NOT NULL,
"txHash" TEXT NOT NULL,
"eventIndex" INTEGER NOT NULL,
"eventType" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "LpEventLog_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "LpEventLog_txHash_eventIndex_key" ON "LpEventLog"("txHash", "eventIndex");
32 changes: 32 additions & 0 deletions src/modules/activity/activity-feed.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { AsyncController } from '../../types/auth.types';
import { ActivityFeedQuerySchema } from './activity-feed.schemas';
import { getActivityFeed } from './activity-feed.service';
import {
sendSuccess,
sendValidationError,
} from '../../utils/api-response.utils';

export const httpGetPlatformActivityFeed: AsyncController = async (
req,
res,
next
) => {
try {
const parsed = ActivityFeedQuerySchema.safeParse(req.query);
if (!parsed.success) {
return sendValidationError(
res,
'Invalid query parameters',
parsed.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}))
);
}

const result = await getActivityFeed(parsed.data.cursor);
sendSuccess(res, result);
} catch (error) {
next(error);
}
};
78 changes: 78 additions & 0 deletions src/modules/activity/activity-feed.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import request from 'supertest';
import app from '../../app';
import * as activityFeedService from './activity-feed.service';
import { disconnectRedis } from '../../utils/redis.utils';

jest.mock('../../utils/redis.utils', () => {
const actual = jest.requireActual('../../utils/redis.utils');
return {
...actual,
cacheGetJson: jest.fn().mockResolvedValue(null),
cacheSetJson: jest.fn().mockResolvedValue(undefined),
cacheInvalidate: jest.fn().mockResolvedValue(undefined),
};
});

describe('GET /activity/feed', () => {
afterAll(async () => {
await disconnectRedis();
});

afterEach(() => {
jest.restoreAllMocks();
});

it('returns the platform activity feed, reverse-chronological', async () => {
const mockResult: activityFeedService.ActivityFeedResult = {
items: [
{
type: 'investment',
invoice_id: 'act-1',
amount: '100',
wallet: 'GABC…WXYZ',
timestamp: '2026-09-27T00:00:00.000Z',
},
{
type: 'new_listing',
invoice_id: 'act-2',
amount: null,
wallet: 'GDEF…UVWX',
timestamp: '2026-09-26T00:00:00.000Z',
},
],
next_cursor: null,
has_more: false,
};

jest
.spyOn(activityFeedService, 'getActivityFeed')
.mockResolvedValueOnce(mockResult);

const res = await request(app).get('/api/v1/activity/feed');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.items).toHaveLength(2);
expect(res.body.data.items[0].type).toBe('investment');
expect(res.body.data.has_more).toBe(false);
});

it('paginates with a cursor for older events', async () => {
const spy = jest
.spyOn(activityFeedService, 'getActivityFeed')
.mockResolvedValueOnce({
items: [],
next_cursor: null,
has_more: false,
});

const res = await request(app).get('/api/v1/activity/feed?cursor=act-2');
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledWith('act-2');
});

it('returns 400 for unknown query parameters', async () => {
const res = await request(app).get('/api/v1/activity/feed?bogus=1');
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
27 changes: 27 additions & 0 deletions src/modules/activity/activity-feed.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { z } from 'zod';

export const ActivityFeedQuerySchema = z
.object({
cursor: z.string().optional(),
})
.strict();

export type ActivityFeedQueryType = z.infer<typeof ActivityFeedQuerySchema>;

export const PLATFORM_ACTIVITY_EVENT_TYPES = [
'investment',
'settlement',
'new_listing',
'fully_funded',
] as const;

export type PlatformActivityEventType =
(typeof PLATFORM_ACTIVITY_EVENT_TYPES)[number];

export interface PlatformActivityFeedItem {
type: PlatformActivityEventType;
invoice_id: string;
amount: string | null;
wallet: string;
timestamp: string;
}
Loading
Loading