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
53 changes: 0 additions & 53 deletions db/migrations/20260226014238_create_milestones_table.cjs

This file was deleted.

9 changes: 5 additions & 4 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@ const config: Config = {
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@prisma/client$': '<rootDir>/src/tests/__mocks__/prisma-client.ts',
},
transform: {
'^.+\\.ts$': ['<rootDir>/node_modules/ts-jest', {
useESM: true,
'^.+\\.ts$': ['<rootDir>/node_modules/ts-jest', {
useESM: true,
tsconfig: {
module: 'NodeNext',
moduleResolution: 'NodeNext',
target: 'ES2022'
},
diagnostics: { ignoreCodes: [151002] }
diagnostics: { ignoreCodes: [151002] }
}],
},
testMatch: ['**/tests/**/*.test.ts'],
clearMocks: true,
}

export default config
export default config
59 changes: 21 additions & 38 deletions src/repositories/milestoneRepository.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,40 @@
import { Knex } from 'knex';
import { Milestone, MilestoneStatus } from '../types/milestone.js';
import { Knex } from 'knex'
import { Milestone, MilestoneStatus } from '../types/milestone.js'

export class MilestoneRepository {
constructor(private db: Knex) {}

/**
* Create a new milestone
*/
async create(milestone: Milestone): Promise<Milestone> {
async create(milestone: Omit<Milestone, 'created_at' | 'updated_at'>): Promise<Milestone> {
const [created] = await this.db('milestones')
.insert({
...milestone,
// Ensure criteria is properly stringified for JSONB insertion if needed by the driver
criteria: JSON.stringify(milestone.criteria)
})
.returning('*');
return created;
.insert(milestone)
.returning('*')
return created
}

async getById(id: string): Promise<Milestone | undefined> {
return this.db('milestones').where({ id }).first()
}

/**
* List all milestones for a specific vault
*/
async listByVault(vaultId: string): Promise<Milestone[]> {
return this.db('milestones')
.where({ vault_id: vaultId })
.orderBy('created_at', 'asc');
.orderBy('created_at', 'asc')
}

/**
* Update the status of a specific milestone
*/
async updateStatus(id: string, status: MilestoneStatus): Promise<Milestone | undefined> {
const [updated] = await this.db('milestones')
.where({ id })
.update({
status,
updated_at: this.db.fn.now()
.update({
status,
updated_at: this.db.fn.now(),
})
.returning('*');
return updated;
.returning('*')
return updated
}

/**
* Update the criteria of a specific milestone
*/
async updateCriteria(id: string, criteria: Record<string, any>): Promise<Milestone | undefined> {
const [updated] = await this.db('milestones')
.where({ id })
.update({
criteria: JSON.stringify(criteria),
updated_at: this.db.fn.now()
})
.returning('*');
return updated;
async allCompletedByVault(vaultId: string): Promise<boolean> {
const milestones = await this.listByVault(vaultId)
if (milestones.length === 0) return false
return milestones.every((m) => m.status === 'completed')
}
}
}
164 changes: 130 additions & 34 deletions src/routes/milestones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,24 @@ import {
createMilestone,
getMilestonesByVaultId,
getMilestoneById,
verifyMilestone,
allMilestonesVerified,
transitionMilestone,
allMilestonesCompleted,
} from '../services/milestones.js'
import { completeVault } from '../services/vaultTransitions.js'
import { vaults } from './vaults.js'
import type { MilestoneStatus } from '../types/milestone.js'

export const milestonesRouter = Router({ mergeParams: true })

const VALID_STATUSES: ReadonlySet<string> = new Set([
'pending',
'in_progress',
'completed',
'failed',
])

// POST /api/vaults/:vaultId/milestones
milestonesRouter.post('/', authenticate, requireUser, (req: Request, res: Response) => {
milestonesRouter.post('/', authenticate, requireUser, async (req: Request, res: Response) => {
const { vaultId } = req.params
const vault = vaults.find((v) => v.id === vaultId)

Expand All @@ -28,18 +36,37 @@ milestonesRouter.post('/', authenticate, requireUser, (req: Request, res: Respon
return
}

const { description } = req.body as { description?: string }
if (!description?.trim()) {
res.status(400).json({ error: 'description is required' })
const { title, description, target_amount, deadline } = req.body
if (!title?.trim()) {
res.status(400).json({ error: 'title is required' })
return
}
if (!target_amount) {
res.status(400).json({ error: 'target_amount is required' })
return
}
if (!deadline) {
res.status(400).json({ error: 'deadline is required' })
return
}

const id = `ms-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
const milestone = await createMilestone({
id,
vault_id: vaultId,
title: title.trim(),
description: description?.trim() || null,
target_amount,
current_amount: '0',
deadline,
status: 'pending',
})

const milestone = createMilestone(vaultId, description.trim())
res.status(201).json(milestone)
})

// GET /api/vaults/:vaultId/milestones
milestonesRouter.get('/', (req: Request, res: Response) => {
milestonesRouter.get('/', async (req: Request, res: Response) => {
const { vaultId } = req.params
const vault = vaults.find((v) => v.id === vaultId)

Expand All @@ -48,37 +75,106 @@ milestonesRouter.get('/', (req: Request, res: Response) => {
return
}

const milestones = getMilestonesByVaultId(vaultId)
const milestones = await getMilestonesByVaultId(vaultId)
res.json({ milestones })
})

// PATCH /api/vaults/:vaultId/milestones/:id/verify
milestonesRouter.patch('/:id/verify', authenticate, requireVerifier, (req: Request, res: Response) => {
const { vaultId, id } = req.params
// PATCH /api/vaults/:vaultId/milestones/:id/transition
milestonesRouter.patch(
'/:id/transition',
authenticate,
async (req: Request, res: Response) => {
const { vaultId, id } = req.params
const { status: targetStatus } = req.body as { status?: string }

const vault = vaults.find((v) => v.id === vaultId)
if (!vault) {
res.status(404).json({ error: 'Vault not found' })
return
}
if (!targetStatus || !VALID_STATUSES.has(targetStatus)) {
res.status(400).json({ error: `Invalid status. Must be one of: ${[...VALID_STATUSES].join(', ')}` })
return
}

const milestone = getMilestoneById(id)
if (!milestone || milestone.vaultId !== vaultId) {
res.status(404).json({ error: 'Milestone not found' })
return
}
const vault = vaults.find((v) => v.id === vaultId)
if (!vault) {
res.status(404).json({ error: 'Vault not found' })
return
}

const verified = verifyMilestone(id)
if (!verified) {
res.status(404).json({ error: 'Milestone not found' })
return
}
const milestone = await getMilestoneById(id)
if (!milestone || milestone.vault_id !== vaultId) {
res.status(404).json({ error: 'Milestone not found' })
return
}

let vaultCompleted = false
if (allMilestonesVerified(vaultId) && vault.status === 'active') {
const result = completeVault(vaultId)
vaultCompleted = result.success
}
// RBAC: VERIFIER required for completed/failed, USER for in_progress/pending
const role = req.user?.role
if (
(targetStatus === 'completed' || targetStatus === 'failed') &&
role !== 'VERIFIER' && role !== 'ADMIN'
) {
res.status(403).json({ error: 'Only verifiers can transition to completed or failed' })
return
}

res.json({ milestone: verified, vaultCompleted })
})
const result = await transitionMilestone(id, targetStatus as MilestoneStatus)
if (!result.success) {
res.status(409).json({ error: result.error })
return
}

// Auto-complete vault when all milestones are completed
let vaultCompleted = false
if (targetStatus === 'completed' && vault.status === 'active') {
const allDone = await allMilestonesCompleted(vaultId)
if (allDone) {
const vaultResult = await completeVault(vaultId)
vaultCompleted = vaultResult.success
if (vaultCompleted) {
console.info(`[Milestones] Auto-completed vault=${vaultId} after all milestones completed`)
}
}
}

res.json({ milestone: result.milestone, vaultCompleted })
},
)

// PATCH /api/vaults/:vaultId/milestones/:id/verify (legacy alias → transitions to 'completed')
milestonesRouter.patch(
'/:id/verify',
authenticate,
requireVerifier,
async (req: Request, res: Response) => {
const { vaultId, id } = req.params

const vault = vaults.find((v) => v.id === vaultId)
if (!vault) {
res.status(404).json({ error: 'Vault not found' })
return
}

const milestone = await getMilestoneById(id)
if (!milestone || milestone.vault_id !== vaultId) {
res.status(404).json({ error: 'Milestone not found' })
return
}

const result = await transitionMilestone(id, 'completed')
if (!result.success) {
res.status(409).json({ error: result.error })
return
}

let vaultCompleted = false
if (vault.status === 'active') {
const allDone = await allMilestonesCompleted(vaultId)
if (allDone) {
const vaultResult = await completeVault(vaultId)
vaultCompleted = vaultResult.success
if (vaultCompleted) {
console.info(`[Milestones] Auto-completed vault=${vaultId} after all milestones verified (legacy)`)
}
}
}

res.json({ milestone: result.milestone, vaultCompleted })
},
)
Loading