From d28226d505fb80bb3f770fd039ea24a1e89b854a Mon Sep 17 00:00:00 2001 From: Annakaee Date: Thu, 18 Jun 2026 11:15:07 +0100 Subject: [PATCH 1/3] feat: implement governance monitoring framework models and module --- apps/backend/src/app.module.ts | 2 + .../governance/governance.controller.ts | 38 +++++++++ .../modules/governance/governance.module.ts | 10 +++ .../modules/governance/governance.service.ts | 77 +++++++++++++++++++ .../interfaces/governance.interface.ts | 20 +++++ prisma/schema.prisma | 42 ++++++++++ 6 files changed, 189 insertions(+) create mode 100644 apps/backend/src/modules/governance/governance.controller.ts create mode 100644 apps/backend/src/modules/governance/governance.module.ts create mode 100644 apps/backend/src/modules/governance/governance.service.ts create mode 100644 apps/backend/src/modules/governance/interfaces/governance.interface.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 88bae4d..d323e8e 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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: [ @@ -13,6 +14,7 @@ import { DependencyTrackerModule } from './modules/contracts/dependencies/depend NotificationsModule, ReportingModule, DependencyTrackerModule, + GovernanceModule, ], controllers: [AppController], }) diff --git a/apps/backend/src/modules/governance/governance.controller.ts b/apps/backend/src/modules/governance/governance.controller.ts new file mode 100644 index 0000000..597ea10 --- /dev/null +++ b/apps/backend/src/modules/governance/governance.controller.ts @@ -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(); + } +} diff --git a/apps/backend/src/modules/governance/governance.module.ts b/apps/backend/src/modules/governance/governance.module.ts new file mode 100644 index 0000000..7e1ea6a --- /dev/null +++ b/apps/backend/src/modules/governance/governance.module.ts @@ -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 {} diff --git a/apps/backend/src/modules/governance/governance.service.ts b/apps/backend/src/modules/governance/governance.service.ts new file mode 100644 index 0000000..a71512f --- /dev/null +++ b/apps/backend/src/modules/governance/governance.service.ts @@ -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, + }); + } +} diff --git a/apps/backend/src/modules/governance/interfaces/governance.interface.ts b/apps/backend/src/modules/governance/interfaces/governance.interface.ts new file mode 100644 index 0000000..28fc696 --- /dev/null +++ b/apps/backend/src/modules/governance/interfaces/governance.interface.ts @@ -0,0 +1,20 @@ +export interface GovernanceEventDto { + eventType: string; + proposalId?: string; + voter?: string; + transactionHash: string; + metadata?: Record; +} + +export interface ProposalDto { + title: string; + description: string; + proposer: string; +} + +export interface VoteDto { + proposalId: string; + voter: string; + choice: string; + weight: string; +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5b1289..88f7cc2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } \ No newline at end of file From 6cd7968edc8c4294d966162d7cdfc12cbc7ae211 Mon Sep 17 00:00:00 2001 From: Annakaee Date: Thu, 18 Jun 2026 11:17:54 +0100 Subject: [PATCH 2/3] test: add governance tests --- .../governance/governance.controller.spec.ts | 80 ++++++++++++++++++ .../governance/governance.service.spec.ts | 83 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 apps/backend/src/modules/governance/governance.controller.spec.ts create mode 100644 apps/backend/src/modules/governance/governance.service.spec.ts diff --git a/apps/backend/src/modules/governance/governance.controller.spec.ts b/apps/backend/src/modules/governance/governance.controller.spec.ts new file mode 100644 index 0000000..daf995a --- /dev/null +++ b/apps/backend/src/modules/governance/governance.controller.spec.ts @@ -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); + service = module.get(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(); + }); + }); +}); diff --git a/apps/backend/src/modules/governance/governance.service.spec.ts b/apps/backend/src/modules/governance/governance.service.spec.ts new file mode 100644 index 0000000..3fcabc4 --- /dev/null +++ b/apps/backend/src/modules/governance/governance.service.spec.ts @@ -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 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'); + }); + }); +}); From bba57e5f6edeb0b3c67caf505fe9adf0b4132f22 Mon Sep 17 00:00:00 2001 From: Annakaee Date: Thu, 18 Jun 2026 11:29:05 +0100 Subject: [PATCH 3/3] fix: ignore scripts in docker production install to fix husky error --- Dockerfile | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 75550fc..444b9f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/package.json b/package.json index afe7670..cf6dd5b 100644 --- a/package.json +++ b/package.json @@ -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": {