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
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,23 @@ DEBUG_RPC=false
# Enable CORS for development
CORS_ENABLED=true
CORS_ORIGIN=http://localhost:3000

# ============================================
# Decentralized Storage
# ============================================

# Storage provider: pinata or mock
DECENTRALIZED_STORAGE_PROVIDER=pinata

# Pinata JWT used for IPFS pinning
PINATA_JWT=

# Gateway base URL for pinned assets
STORAGE_GATEWAY_BASE_URL=https://gateway.pinata.cloud/ipfs

# Garbage collection and retry settings
STORAGE_GC_RETENTION_DAYS=30
STORAGE_MAX_PIN_ATTEMPTS=5
STORAGE_BACKOFF_DELAY_MS=1000
STORAGE_WORKER_CONCURRENCY=10
STORAGE_JOB_TIMEOUT_MS=30000
3 changes: 3 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
process.env.NODE_ENV = 'test';

export default {
preset: 'ts-jest',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
setupFiles: ['<rootDir>/tests/jest.setup.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
Expand Down
14 changes: 14 additions & 0 deletions backend/logs/combined.log
Original file line number Diff line number Diff line change
Expand Up @@ -3823,3 +3823,17 @@ Unique constraint failed on the fields: (`tokenId`)
2026-05-20 09:55:58 info: Certificate minted on-chain: cert-api-stud-api-cour-1779267358588 -> token 295396
2026-05-20 09:55:58 info: Certificate minted: cert-api-stud-api-cour-1779267358588
2026-05-20 09:55:58 info: GET /api/v1/certificates/verify/295396
2026-05-31 01:55:38 info: Pinned decentralized asset project/project-1/example -> bafyd37873b9cdea335789ab00f55e95233b9090e32bb1162154b65bc6c2
2026-05-31 01:55:38 info: Unpinned stale decentralized asset bafy123
2026-05-31 01:57:57 info: Pinned decentralized asset project/project-1/example -> bafyd37873b9cdea335789ab00f55e95233b9090e32bb1162154b65bc6c2
2026-05-31 01:57:57 info: Unpinned stale decentralized asset bafy123
2026-05-31 02:00:22 info: Pinned decentralized asset project/project-1/example -> bafyd37873b9cdea335789ab00f55e95233b9090e32bb1162154b65bc6c2
2026-05-31 02:00:22 info: Unpinned stale decentralized asset bafy123
2026-05-31 02:03:29 info: Pinned decentralized asset project/project-1/example -> bafyd37873b9cdea335789ab00f55e95233b9090e32bb1162154b65bc6c2
2026-05-31 02:03:29 info: Unpinned stale decentralized asset bafy123
2026-05-31 02:06:51 info: Pinned decentralized asset project/project-1/example -> bafyd37873b9cdea335789ab00f55e95233b9090e32bb1162154b65bc6c2
2026-05-31 02:06:51 info: Unpinned stale decentralized asset bafy123
2026-05-31 02:10:44 info: Pinned decentralized asset project/project-1/example -> bafyd37873b9cdea335789ab00f55e95233b9090e32bb1162154b65bc6c2
2026-05-31 02:10:44 info: Unpinned stale decentralized asset bafy123
2026-05-31 02:13:08 info: Pinned decentralized asset project/project-1/example -> bafyd37873b9cdea335789ab00f55e95233b9090e32bb1162154b65bc6c2
2026-05-31 02:13:08 info: Unpinned stale decentralized asset bafy123
6 changes: 4 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
"start": "node dist/src/index.js",
"start:prod": "node dist/src/index.js",
"dev": "tsx watch src/index.ts",


"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./jest.config.js",
"collaboration": "tsx src/collaborationServer.ts"
},
"keywords": [],
Expand Down Expand Up @@ -47,9 +46,12 @@
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/node": "^25.5.0",
"@types/qrcode": "^1.5.5",
"@types/ws": "^8.18.1",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "decentralized_assets" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL DEFAULT 'default',
"resourceType" TEXT NOT NULL,
"resourceId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'pinata',
"cid" TEXT NOT NULL,
"ipfsUri" TEXT NOT NULL,
"gatewayUrl" TEXT NOT NULL,
"mimeType" TEXT,
"sizeBytes" INTEGER,
"status" TEXT NOT NULL DEFAULT 'queued',
"referenceCount" INTEGER NOT NULL DEFAULT 1,
"metadata" JSONB,
"error" TEXT,
"pinnedAt" TIMESTAMP(3),
"unpinnedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

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

-- CreateIndex
CREATE UNIQUE INDEX "decentralized_assets_cid_key" ON "decentralized_assets"("cid");

-- CreateIndex
CREATE UNIQUE INDEX "decentralized_assets_workspaceId_resourceType_resourceId_name_key" ON "decentralized_assets"("workspaceId", "resourceType", "resourceId", "name");

-- CreateIndex
CREATE INDEX "decentralized_assets_workspaceId_status_idx" ON "decentralized_assets"("workspaceId", "status");

-- CreateIndex
CREATE INDEX "decentralized_assets_workspaceId_resourceType_resourceId_idx" ON "decentralized_assets"("workspaceId", "resourceType", "resourceId");
28 changes: 28 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,31 @@ model Canvas {
@@index([studentId])
@@index([createdAt])
}

model DecentralizedAsset {
id String @id @default(cuid())
workspaceId String @default("default")
resourceType String
resourceId String
name String
kind String
provider String @default("pinata")
cid String @unique
ipfsUri String
gatewayUrl String
mimeType String?
sizeBytes Int?
status String @default("queued")
referenceCount Int @default(1)
metadata Json?
error String?
pinnedAt DateTime?
unpinnedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([workspaceId, resourceType, resourceId, name])
@@index([workspaceId, status])
@@index([workspaceId, resourceType, resourceId])
@@map("decentralized_assets")
}
41 changes: 35 additions & 6 deletions backend/src/certificates/CertificateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
import { MetadataGenerator } from './MetadataGenerator.js';
import { certificateBlockchainService } from '../blockchain/CertificateBlockchainService.js';
import logger from '../utils/logger.js';
import { certificateImageGenerator } from '../utils/certificateImageGenerator.js';
import { storageService } from '../services/storage/index.js';

export class CertificateService {
private metadataGenerator: MetadataGenerator;
Expand Down Expand Up @@ -87,10 +89,36 @@ export class CertificateService {
},
});

// Generate the metadata
const metadata = this.metadataGenerator.generate(certificate, course, student);

try {
// Generate and pin the certificate image and metadata to decentralized storage
const imageBuffer = await certificateImageGenerator.generateCertificateImage({
studentName: `${student.firstName || ''} ${student.lastName || ''}`.trim() || 'Student',
courseTitle: course.title,
instructor: course.instructor,
completionDate: certificate.issuedAt.toISOString(),
grade: certificate.grade || undefined,
credentialId: certificate.tokenId || tokenIdValue,
issuerName: process.env.ISSUER_NAME || 'Web3 Student Lab',
});

const imageAsset = await storageService.pinCertificateImage({
certificateId: certificateId,
content: imageBuffer,
mimeType: 'image/svg+xml',
});

const metadata = this.metadataGenerator.generate(certificate, course, student, {
imageUri: imageAsset.ipfsUri,
externalUrl: `${process.env.API_BASE_URL || 'http://localhost:8080'}/api/v1/certificates/${
certificate.tokenId || tokenIdValue
}/metadata`,
});

const metadataAsset = await storageService.pinCertificateMetadata({
certificateId: certificateId,
content: metadata,
});

// Call blockchain service to mint actual NFT
const mintResult = await certificateBlockchainService.mintCertificate(metadata);

Expand All @@ -101,7 +129,7 @@ export class CertificateService {
certificateHash: mintResult.transactionHash,
contractAddress: mintResult.contractAddress,
status: 'ACTIVE',
metadataUri: metadata.image,
metadataUri: metadataAsset.ipfsUri,
tokenId: mintResult.tokenId || tokenIdValue,
},
});
Expand All @@ -118,15 +146,16 @@ export class CertificateService {
txHash: mintResult.transactionHash,
});
} catch (error) {
logger.error(`Blockchain mint failed for ${certificateId}:`, error);
logger.error(`Certificate issuance failed for ${certificateId}:`, error);
await prisma.certificate.update({
where: { id: certificateId },
data: {
status: 'FAILED',
},
});
await storageService.releaseResource('certificate', certificateId);
throw new Error(
`Failed to mint certificate on blockchain: ${error instanceof Error ? error.message : 'Unknown error'}`
`Failed to mint certificate: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}

Expand Down
8 changes: 5 additions & 3 deletions backend/src/certificates/MetadataGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export class MetadataGenerator {
generate(
certificate: Certificate & { student: any; course: any },
course: any,
student: any
student: any,
options: { imageUri?: string; externalUrl?: string } = {}
): CertificateMetadata {
// Build verification info
const verification = this.buildVerificationInfo(certificate);
Expand All @@ -43,10 +44,11 @@ export class MetadataGenerator {
const attributes = this.buildAttributes(certificate, course, student);

// Build external URL (deep link to certificate viewer)
const externalUrl = `${this.baseUrl}/certificates/${certificate.tokenId}/view`;
const externalUrl =
options.externalUrl || `${this.baseUrl}/certificates/${certificate.tokenId}/view`;

// Build image URL
const imageUrl = this.buildImageUrl(certificate);
const imageUrl = options.imageUri || this.buildImageUrl(certificate);

return {
name,
Expand Down
12 changes: 12 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { requestLogger } from './middleware/requestLogger.js';
import { requireWorkspaceMiddleware } from './middleware/WorkspaceContext.js';
import freelanceRoute from './routes/freelance.js';
import routes from './routes/index.js';
import { scheduleStorageGc, startStorageWorkers, stopStorageWorkers } from './services/storage/index.js';
import { startWebhookWorker, stopWebhookWorker } from './services/webhooks/index.js';
import { validateEnvironment } from './utils/checkEnv.js';
import logger from './utils/logger.js';
import { pubClient, redisConnection, subClient } from './utils/redis.js';
Expand Down Expand Up @@ -45,6 +47,12 @@ if (process.env.NODE_ENV !== 'test') {
logger.warn('CacheWarmer failed to start:', err);
});

startStorageWorkers();
startWebhookWorker();
scheduleStorageGc().catch((err) => {
logger.warn('Storage GC schedule failed to start:', err);
});

logger.info('Distributed caching layer initialized');
}

Expand Down Expand Up @@ -116,6 +124,8 @@ if (process.env.NODE_ENV !== 'test') {
// Stop cache components
blockHeaderListener.stop();
cacheWarmer.stop();
await stopStorageWorkers();
await stopWebhookWorker();
await distributedCacheManager.gracefulShutdown();

// Clean up connections
Expand All @@ -135,6 +145,8 @@ if (process.env.NODE_ENV !== 'test') {
// Stop cache components
blockHeaderListener.stop();
cacheWarmer.stop();
await stopStorageWorkers();
await stopWebhookWorker();
await distributedCacheManager.gracefulShutdown();

// Clean up connections
Expand Down
51 changes: 50 additions & 1 deletion backend/src/routes/generator/generator.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import { Request, Response, Router } from 'express';
import { GeneratorService } from '../../generator/generator.service.js';
import logger from '../../utils/logger.js';
import { getRandomProjectIdea, mockProjectIdeas } from '../../generator/mockData.js';
import { randomUUID } from 'crypto';
import { storageService } from '../../services/storage/index.js';

const router = Router();
const generatorService = new GeneratorService();
const slugify = (value: string): string =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');

/**
* @route POST /api/generator/generate
Expand All @@ -13,7 +20,7 @@ const generatorService = new GeneratorService();
*/
router.post('/generate', async (req: Request, res: Response) => {
try {
const { theme, techStack, difficulty } = req.body;
const { theme, techStack, difficulty, persistToStorage, queuedPersist } = req.body;

if (!theme || !techStack || !difficulty) {
res.status(400).json({ error: 'Theme, techStack, and difficulty are required' });
Expand All @@ -23,11 +30,53 @@ router.post('/generate', async (req: Request, res: Response) => {
// Try AI generation first, fallback to mock data if it fails
try {
const projectIdea = await generatorService.generateProjectIdea(theme, techStack, difficulty);

if (persistToStorage) {
const projectId = `${slugify(theme)}-${Date.now()}-${randomUUID().slice(0, 8)}`;
const storageResult = queuedPersist
? await storageService.pinProjectIdea({
projectId,
content: projectIdea,
queued: true,
})
: await storageService.pinProjectIdea({
projectId,
content: projectIdea,
});

res.json({
projectIdea,
storage: storageResult,
});
return;
}

res.json({ projectIdea });
} catch (aiError) {
logger.warn(`AI generation failed, using mock data: ${aiError}`);
// Return a random mock project idea as fallback
const projectIdea = getRandomProjectIdea();
if (persistToStorage) {
const projectId = `mock-${Date.now()}-${randomUUID().slice(0, 8)}`;
const storageResult = queuedPersist
? await storageService.pinProjectIdea({
projectId,
content: projectIdea,
queued: true,
})
: await storageService.pinProjectIdea({
projectId,
content: projectIdea,
});

res.json({
projectIdea,
fromMock: true,
storage: storageResult,
});
return;
}

res.json({ projectIdea, fromMock: true });
}
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import exportRouter from './export.routes.js';
import generatorRouter from './generator/generator.routes.js';
import learningRoutes from './learning/learning.routes.js';
import healthRouter from './health.routes.js';
import storageRouter from './storage.routes.js';
import securityRouter from './security.routes.js';
import studentsRouter from './students.js';

Expand All @@ -29,6 +30,7 @@ router.use('/learning', learningRoutes);
router.use('/security', securityRouter);
router.use('/generator', generatorRouter);
router.use('/export', exportRouter);
router.use('/storage', storageRouter);
router.use('/user', userRouter);

export default router;
Loading