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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ COPY package*.json ./
COPY prisma ./prisma/

# Install production dependencies only
RUN npm ci --only=production && \
RUN npm ci --only=production --ignore-scripts && \
npm cache clean --force

# Copy built application from builder
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { HealthModule } from './modules/health/health.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { ReportingModule } from './modules/reporting/reporting.module';
import { DependencyTrackerModule } from './modules/contracts/dependencies/dependency-tracker.module';
import { GovernanceModule } from './modules/governance/governance.module';

@Module({
imports: [
Expand All @@ -13,6 +14,7 @@ import { DependencyTrackerModule } from './modules/contracts/dependencies/depend
NotificationsModule,
ReportingModule,
DependencyTrackerModule,
GovernanceModule,
],
controllers: [AppController],
})
Expand Down
80 changes: 80 additions & 0 deletions apps/backend/src/modules/governance/governance.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GovernanceController } from './governance.controller';
import { GovernanceService } from './governance.service';
import { GovernanceEventDto, ProposalDto, VoteDto } from './interfaces/governance.interface';

describe('GovernanceController', () => {
let controller: GovernanceController;
let service: GovernanceService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GovernanceController],
providers: [
{
provide: GovernanceService,
useValue: {
trackProposal: jest.fn(),
getProposals: jest.fn(),
trackVote: jest.fn(),
getVotes: jest.fn(),
logEvent: jest.fn(),
getEvents: jest.fn(),
},
},
],
}).compile();

controller = module.get<GovernanceController>(GovernanceController);
service = module.get<GovernanceService>(GovernanceService);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

describe('trackProposal', () => {
it('should call trackProposal on service', async () => {
const dto: ProposalDto = { title: 'T', description: 'D', proposer: 'P' };
await controller.trackProposal(dto);
expect(service.trackProposal).toHaveBeenCalledWith(dto);
});
});

describe('getProposals', () => {
it('should call getProposals on service', async () => {
await controller.getProposals();
expect(service.getProposals).toHaveBeenCalled();
});
});

describe('trackVote', () => {
it('should call trackVote on service', async () => {
const dto: VoteDto = { proposalId: '1', voter: 'V', choice: 'yes', weight: '1' };
await controller.trackVote(dto);
expect(service.trackVote).toHaveBeenCalledWith(dto);
});
});

describe('getVotes', () => {
it('should call getVotes on service', async () => {
await controller.getVotes('1');
expect(service.getVotes).toHaveBeenCalledWith('1');
});
});

describe('logEvent', () => {
it('should call logEvent on service', async () => {
const dto: GovernanceEventDto = { eventType: 'create', transactionHash: '0x' };
await controller.logEvent(dto);
expect(service.logEvent).toHaveBeenCalledWith(dto);
});
});

describe('getEvents', () => {
it('should call getEvents on service', async () => {
await controller.getEvents();
expect(service.getEvents).toHaveBeenCalled();
});
});
});
38 changes: 38 additions & 0 deletions apps/backend/src/modules/governance/governance.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { GovernanceService } from './governance.service';
import { ProposalDto, VoteDto, GovernanceEventDto } from './interfaces/governance.interface';

@Controller('governance')
export class GovernanceController {
constructor(private readonly governanceService: GovernanceService) {}

@Post('proposals')
async trackProposal(@Body() dto: ProposalDto) {
return this.governanceService.trackProposal(dto);
}

@Get('proposals')
async getProposals() {
return this.governanceService.getProposals();
}

@Post('votes')
async trackVote(@Body() dto: VoteDto) {
return this.governanceService.trackVote(dto);
}

@Get('votes/:proposalId')
async getVotes(@Param('proposalId') proposalId: string) {
return this.governanceService.getVotes(proposalId);
}

@Post('events')
async logEvent(@Body() dto: GovernanceEventDto) {
return this.governanceService.logEvent(dto);
}

@Get('events')
async getEvents() {
return this.governanceService.getEvents();
}
}
10 changes: 10 additions & 0 deletions apps/backend/src/modules/governance/governance.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { GovernanceController } from './governance.controller';
import { GovernanceService } from './governance.service';

@Module({
controllers: [GovernanceController],
providers: [GovernanceService],
exports: [GovernanceService],
})
export class GovernanceModule {}
83 changes: 83 additions & 0 deletions apps/backend/src/modules/governance/governance.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GovernanceService } from './governance.service';
import { PrismaClient } from '@prisma/client';

// Mock PrismaClient
jest.mock('@prisma/client', () => {
const mPrismaClient = {
proposal: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
},
vote: {
create: jest.fn(),
findMany: jest.fn(),
},
governanceEvent: {
create: jest.fn(),
findMany: jest.fn(),
},
};
return { PrismaClient: jest.fn(() => mPrismaClient) };
});

describe('GovernanceService', () => {
let service: GovernanceService;
let prisma: PrismaClient;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GovernanceService],
}).compile();

service = module.get<GovernanceService>(GovernanceService);
// GovernanceService instantiates its own PrismaClient, so we can grab the mock instance
prisma = new PrismaClient();
});

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

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('trackProposal', () => {
it('should create a proposal', async () => {
const dto = { title: 'Test', description: 'Desc', proposer: 'User1' };
const expected = { id: '1', ...dto };

(prisma.proposal.create as jest.Mock).mockResolvedValue(expected);

const result = await service.trackProposal(dto);

expect(prisma.proposal.create).toHaveBeenCalledWith({ data: dto });
expect(result).toEqual(expected);
});
});

describe('trackVote', () => {
it('should create a vote if proposal exists', async () => {
const dto = { proposalId: '1', voter: 'User1', choice: 'yes', weight: '10' };
const expected = { id: '1', ...dto };

(prisma.proposal.findUnique as jest.Mock).mockResolvedValue({ id: '1' });
(prisma.vote.create as jest.Mock).mockResolvedValue(expected);

const result = await service.trackVote(dto);

expect(prisma.vote.create).toHaveBeenCalledWith({ data: dto });
expect(result).toEqual(expected);
});

it('should throw NotFoundException if proposal does not exist', async () => {
const dto = { proposalId: '99', voter: 'User1', choice: 'yes', weight: '10' };

(prisma.proposal.findUnique as jest.Mock).mockResolvedValue(null);

await expect(service.trackVote(dto)).rejects.toThrow('Proposal 99 not found');
});
});
});
77 changes: 77 additions & 0 deletions apps/backend/src/modules/governance/governance.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ProposalDto, VoteDto, GovernanceEventDto } from './interfaces/governance.interface';

@Injectable()
export class GovernanceService {
private prisma: PrismaClient;

constructor() {
this.prisma = new PrismaClient();
}

async trackProposal(dto: ProposalDto) {
const proposal = await this.prisma.proposal.create({
data: {
title: dto.title,
description: dto.description,
proposer: dto.proposer,
},
});
return proposal;
}

async getProposals() {
return this.prisma.proposal.findMany({
orderBy: { createdAt: 'desc' },
});
}

async trackVote(dto: VoteDto) {
const proposal = await this.prisma.proposal.findUnique({
where: { id: dto.proposalId },
});

if (!proposal) {
throw new NotFoundException(`Proposal ${dto.proposalId} not found`);
}

const vote = await this.prisma.vote.create({
data: {
proposalId: dto.proposalId,
voter: dto.voter,
choice: dto.choice,
weight: dto.weight,
},
});

return vote;
}

async getVotes(proposalId: string) {
return this.prisma.vote.findMany({
where: { proposalId },
orderBy: { createdAt: 'desc' },
});
}

async logEvent(dto: GovernanceEventDto) {
const event = await this.prisma.governanceEvent.create({
data: {
eventType: dto.eventType,
proposalId: dto.proposalId,
voter: dto.voter,
transactionHash: dto.transactionHash,
metadata: dto.metadata || {},
},
});
return event;
}

async getEvents() {
return this.prisma.governanceEvent.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface GovernanceEventDto {
eventType: string;
proposalId?: string;
voter?: string;
transactionHash: string;
metadata?: Record<string, any>;

Check warning on line 6 in apps/backend/src/modules/governance/interfaces/governance.interface.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Unexpected any. Specify a different type

Check warning on line 6 in apps/backend/src/modules/governance/interfaces/governance.interface.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Unexpected any. Specify a different type
}

export interface ProposalDto {
title: string;
description: string;
proposer: string;
}

export interface VoteDto {
proposalId: string;
voter: string;
choice: string;
weight: string;
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"docker:db:reset": "docker-compose exec backend npx prisma migrate reset",
"docker:db:studio": "docker-compose exec backend npx prisma studio",
"docker:ps": "docker-compose ps",
"prepare": "husky install",
"prepare": "husky || true",
"lint-staged": "lint-staged"
},
"repository": {
Expand Down
42 changes: 42 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,46 @@ model AuditLog {
user User @relation(fields: [userId], references: [id])

@@map("audit_logs")
}

model Proposal {
id String @id @default(cuid())
title String
description String
status String @default("active") // "active", "passed", "rejected"
proposer String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

votes Vote[]
events GovernanceEvent[]

@@map("proposals")
}

model Vote {
id String @id @default(cuid())
proposalId String
voter String
choice String // "yes", "no", "abstain"
weight String
createdAt DateTime @default(now())

proposal Proposal @relation(fields: [proposalId], references: [id])

@@map("votes")
}

model GovernanceEvent {
id String @id @default(cuid())
eventType String // "proposal_created", "vote_cast"
proposalId String?
voter String?
transactionHash String @unique
metadata Json?
createdAt DateTime @default(now())

proposal Proposal? @relation(fields: [proposalId], references: [id])

@@map("governance_events")
}
Loading