@open-skills-hub/api 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controllers/audit.d.ts +33 -0
- package/dist/controllers/audit.d.ts.map +1 -0
- package/dist/controllers/audit.js +122 -0
- package/dist/controllers/audit.js.map +1 -0
- package/dist/controllers/cache.d.ts +42 -0
- package/dist/controllers/cache.d.ts.map +1 -0
- package/dist/controllers/cache.js +247 -0
- package/dist/controllers/cache.js.map +1 -0
- package/dist/controllers/feedback.d.ts +44 -0
- package/dist/controllers/feedback.d.ts.map +1 -0
- package/dist/controllers/feedback.js +216 -0
- package/dist/controllers/feedback.js.map +1 -0
- package/dist/controllers/index.d.ts +9 -0
- package/dist/controllers/index.d.ts.map +1 -0
- package/dist/controllers/index.js +9 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/skills.d.ts +66 -0
- package/dist/controllers/skills.d.ts.map +1 -0
- package/dist/controllers/skills.js +355 -0
- package/dist/controllers/skills.js.map +1 -0
- package/dist/controllers/versions.d.ts +43 -0
- package/dist/controllers/versions.d.ts.map +1 -0
- package/dist/controllers/versions.js +298 -0
- package/dist/controllers/versions.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +78 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +34 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +148 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/error.d.ts +26 -0
- package/dist/middleware/error.d.ts.map +1 -0
- package/dist/middleware/error.js +102 -0
- package/dist/middleware/error.js.map +1 -0
- package/dist/middleware/index.d.ts +8 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +8 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/logger.d.ts +19 -0
- package/dist/middleware/logger.d.ts.map +1 -0
- package/dist/middleware/logger.js +54 -0
- package/dist/middleware/logger.js.map +1 -0
- package/dist/middleware/validation.d.ts +671 -0
- package/dist/middleware/validation.d.ts.map +1 -0
- package/dist/middleware/validation.js +225 -0
- package/dist/middleware/validation.js.map +1 -0
- package/dist/routes/audit.d.ts +6 -0
- package/dist/routes/audit.d.ts.map +1 -0
- package/dist/routes/audit.js +54 -0
- package/dist/routes/audit.js.map +1 -0
- package/dist/routes/cache.d.ts +6 -0
- package/dist/routes/cache.d.ts.map +1 -0
- package/dist/routes/cache.js +70 -0
- package/dist/routes/cache.js.map +1 -0
- package/dist/routes/feedback.d.ts +6 -0
- package/dist/routes/feedback.d.ts.map +1 -0
- package/dist/routes/feedback.js +68 -0
- package/dist/routes/feedback.js.map +1 -0
- package/dist/routes/health.d.ts +6 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +122 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.d.ts.map +1 -0
- package/dist/routes/index.js +12 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/scan.d.ts +8 -0
- package/dist/routes/scan.d.ts.map +1 -0
- package/dist/routes/scan.js +315 -0
- package/dist/routes/scan.js.map +1 -0
- package/dist/routes/search.d.ts +6 -0
- package/dist/routes/search.d.ts.map +1 -0
- package/dist/routes/search.js +44 -0
- package/dist/routes/search.js.map +1 -0
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/skills.d.ts.map +1 -0
- package/dist/routes/skills.js +74 -0
- package/dist/routes/skills.js.map +1 -0
- package/dist/routes/versions.d.ts +6 -0
- package/dist/routes/versions.d.ts.map +1 -0
- package/dist/routes/versions.js +66 -0
- package/dist/routes/versions.js.map +1 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +166 -0
- package/dist/server.js.map +1 -0
- package/package.json +42 -0
- package/src/controllers/audit.ts +175 -0
- package/src/controllers/cache.ts +344 -0
- package/src/controllers/feedback.ts +309 -0
- package/src/controllers/index.ts +9 -0
- package/src/controllers/skills.ts +489 -0
- package/src/controllers/versions.ts +427 -0
- package/src/index.ts +87 -0
- package/src/middleware/auth.ts +219 -0
- package/src/middleware/error.ts +180 -0
- package/src/middleware/index.ts +8 -0
- package/src/middleware/logger.ts +71 -0
- package/src/middleware/validation.ts +270 -0
- package/src/routes/audit.ts +74 -0
- package/src/routes/cache.ts +93 -0
- package/src/routes/feedback.ts +93 -0
- package/src/routes/health.ts +151 -0
- package/src/routes/index.ts +12 -0
- package/src/routes/scan.ts +428 -0
- package/src/routes/search.ts +51 -0
- package/src/routes/skills.ts +102 -0
- package/src/routes/versions.ts +91 -0
- package/src/server.ts +205 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Skills Hub - Cache Controller
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
6
|
+
import {
|
|
7
|
+
getStorage,
|
|
8
|
+
getConfig,
|
|
9
|
+
generateUUID,
|
|
10
|
+
now,
|
|
11
|
+
calculateExpiry,
|
|
12
|
+
buildSkillFullName,
|
|
13
|
+
parseSkillFullName,
|
|
14
|
+
AppError,
|
|
15
|
+
ErrorCodes,
|
|
16
|
+
} from '@open-skills-hub/core';
|
|
17
|
+
import type { CacheMetadata, AuditLog } from '@open-skills-hub/core';
|
|
18
|
+
import { sendSuccess } from '../middleware/error.js';
|
|
19
|
+
import { logger } from '../middleware/logger.js';
|
|
20
|
+
import type {
|
|
21
|
+
SkillNameParam,
|
|
22
|
+
SkillVersionParam,
|
|
23
|
+
} from '../middleware/validation.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get all cached skills
|
|
27
|
+
*/
|
|
28
|
+
export async function getCachedSkills(
|
|
29
|
+
request: FastifyRequest,
|
|
30
|
+
reply: FastifyReply
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const storage = await getStorage();
|
|
33
|
+
const cacheEntries = await storage.getAllCacheMetadata();
|
|
34
|
+
|
|
35
|
+
// Group by skill name
|
|
36
|
+
const bySkill: Record<string, {
|
|
37
|
+
skillName: string;
|
|
38
|
+
versions: {
|
|
39
|
+
version: string;
|
|
40
|
+
cachedAt: string;
|
|
41
|
+
expiresAt?: string;
|
|
42
|
+
size?: number;
|
|
43
|
+
hitCount: number;
|
|
44
|
+
lastHitAt?: string;
|
|
45
|
+
}[];
|
|
46
|
+
totalHits: number;
|
|
47
|
+
totalSize: number;
|
|
48
|
+
}> = {};
|
|
49
|
+
|
|
50
|
+
for (const entry of cacheEntries) {
|
|
51
|
+
if (!bySkill[entry.skillName]) {
|
|
52
|
+
bySkill[entry.skillName] = {
|
|
53
|
+
skillName: entry.skillName,
|
|
54
|
+
versions: [],
|
|
55
|
+
totalHits: 0,
|
|
56
|
+
totalSize: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
bySkill[entry.skillName]!.versions.push({
|
|
61
|
+
version: entry.version,
|
|
62
|
+
cachedAt: entry.cachedAt,
|
|
63
|
+
expiresAt: entry.expiresAt,
|
|
64
|
+
size: entry.size,
|
|
65
|
+
hitCount: entry.hitCount,
|
|
66
|
+
lastHitAt: entry.lastHitAt,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
bySkill[entry.skillName]!.totalHits += entry.hitCount;
|
|
70
|
+
bySkill[entry.skillName]!.totalSize += entry.size ?? 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const skills = Object.values(bySkill).sort((a, b) => b.totalHits - a.totalHits);
|
|
74
|
+
|
|
75
|
+
sendSuccess(request, reply, {
|
|
76
|
+
count: cacheEntries.length,
|
|
77
|
+
skills,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get cache info for a specific skill
|
|
83
|
+
*/
|
|
84
|
+
export async function getSkillCacheInfo(
|
|
85
|
+
request: FastifyRequest<{ Params: SkillNameParam }>,
|
|
86
|
+
reply: FastifyReply
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const storage = await getStorage();
|
|
89
|
+
const { name } = request.params;
|
|
90
|
+
|
|
91
|
+
// Parse name
|
|
92
|
+
const { scope, name: skillName } = parseSkillFullName(name);
|
|
93
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
94
|
+
|
|
95
|
+
const allCache = await storage.getAllCacheMetadata();
|
|
96
|
+
const skillCache = allCache.filter(c => c.skillName === fullName);
|
|
97
|
+
|
|
98
|
+
if (skillCache.length === 0) {
|
|
99
|
+
throw new AppError(
|
|
100
|
+
ErrorCodes.CACHE_MISS,
|
|
101
|
+
`No cache entries found for skill '${fullName}'`,
|
|
102
|
+
404
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const totalHits = skillCache.reduce((sum, c) => sum + c.hitCount, 0);
|
|
107
|
+
const totalSize = skillCache.reduce((sum, c) => sum + (c.size ?? 0), 0);
|
|
108
|
+
|
|
109
|
+
sendSuccess(request, reply, {
|
|
110
|
+
skillName: fullName,
|
|
111
|
+
versions: skillCache.map(c => ({
|
|
112
|
+
version: c.version,
|
|
113
|
+
cachedAt: c.cachedAt,
|
|
114
|
+
expiresAt: c.expiresAt,
|
|
115
|
+
size: c.size,
|
|
116
|
+
hitCount: c.hitCount,
|
|
117
|
+
lastHitAt: c.lastHitAt,
|
|
118
|
+
integrityHash: c.integrityHash,
|
|
119
|
+
})),
|
|
120
|
+
totalVersions: skillCache.length,
|
|
121
|
+
totalHits,
|
|
122
|
+
totalSize,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get cache info for a specific version
|
|
128
|
+
*/
|
|
129
|
+
export async function getVersionCacheInfo(
|
|
130
|
+
request: FastifyRequest<{ Params: SkillVersionParam }>,
|
|
131
|
+
reply: FastifyReply
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const storage = await getStorage();
|
|
134
|
+
const { name, version } = request.params;
|
|
135
|
+
|
|
136
|
+
// Parse name
|
|
137
|
+
const { scope, name: skillName } = parseSkillFullName(name);
|
|
138
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
139
|
+
|
|
140
|
+
const cacheEntry = await storage.getCacheMetadata(fullName, version);
|
|
141
|
+
if (!cacheEntry) {
|
|
142
|
+
throw new AppError(
|
|
143
|
+
ErrorCodes.CACHE_MISS,
|
|
144
|
+
`No cache entry found for '${fullName}@${version}'`,
|
|
145
|
+
404
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
sendSuccess(request, reply, cacheEntry);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Invalidate cache for a skill
|
|
154
|
+
*/
|
|
155
|
+
export async function invalidateSkillCache(
|
|
156
|
+
request: FastifyRequest<{ Params: SkillNameParam }>,
|
|
157
|
+
reply: FastifyReply
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const storage = await getStorage();
|
|
160
|
+
const { name } = request.params;
|
|
161
|
+
const userId = request.user?.id;
|
|
162
|
+
|
|
163
|
+
// Parse name
|
|
164
|
+
const { scope, name: skillName } = parseSkillFullName(name);
|
|
165
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
166
|
+
|
|
167
|
+
const deleted = await storage.deleteCacheMetadata(fullName);
|
|
168
|
+
|
|
169
|
+
// Create audit log
|
|
170
|
+
const auditLog: AuditLog = {
|
|
171
|
+
id: generateUUID(),
|
|
172
|
+
timestamp: now(),
|
|
173
|
+
eventType: 'cache.invalidated',
|
|
174
|
+
actor: {
|
|
175
|
+
type: request.user?.type ?? 'user',
|
|
176
|
+
id: userId,
|
|
177
|
+
username: request.user?.username,
|
|
178
|
+
ip: request.ip,
|
|
179
|
+
userAgent: request.headers['user-agent'],
|
|
180
|
+
},
|
|
181
|
+
resource: {
|
|
182
|
+
type: 'skill',
|
|
183
|
+
name: fullName,
|
|
184
|
+
},
|
|
185
|
+
action: 'invalidate_cache',
|
|
186
|
+
result: deleted ? 'success' : 'failure',
|
|
187
|
+
};
|
|
188
|
+
await storage.createAuditLog(auditLog);
|
|
189
|
+
|
|
190
|
+
logger.info('Cache invalidated', { skillName: fullName, deleted });
|
|
191
|
+
|
|
192
|
+
sendSuccess(request, reply, { invalidated: deleted });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Invalidate cache for a specific version
|
|
197
|
+
*/
|
|
198
|
+
export async function invalidateVersionCache(
|
|
199
|
+
request: FastifyRequest<{ Params: SkillVersionParam }>,
|
|
200
|
+
reply: FastifyReply
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
const storage = await getStorage();
|
|
203
|
+
const { name, version } = request.params;
|
|
204
|
+
const userId = request.user?.id;
|
|
205
|
+
|
|
206
|
+
// Parse name
|
|
207
|
+
const { scope, name: skillName } = parseSkillFullName(name);
|
|
208
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
209
|
+
|
|
210
|
+
const deleted = await storage.deleteCacheMetadata(fullName, version);
|
|
211
|
+
|
|
212
|
+
// Create audit log
|
|
213
|
+
const auditLog: AuditLog = {
|
|
214
|
+
id: generateUUID(),
|
|
215
|
+
timestamp: now(),
|
|
216
|
+
eventType: 'cache.invalidated',
|
|
217
|
+
actor: {
|
|
218
|
+
type: request.user?.type ?? 'user',
|
|
219
|
+
id: userId,
|
|
220
|
+
username: request.user?.username,
|
|
221
|
+
ip: request.ip,
|
|
222
|
+
userAgent: request.headers['user-agent'],
|
|
223
|
+
},
|
|
224
|
+
resource: {
|
|
225
|
+
type: 'version',
|
|
226
|
+
name: fullName,
|
|
227
|
+
version,
|
|
228
|
+
},
|
|
229
|
+
action: 'invalidate_cache',
|
|
230
|
+
result: deleted ? 'success' : 'failure',
|
|
231
|
+
};
|
|
232
|
+
await storage.createAuditLog(auditLog);
|
|
233
|
+
|
|
234
|
+
logger.info('Version cache invalidated', { skillName: fullName, version, deleted });
|
|
235
|
+
|
|
236
|
+
sendSuccess(request, reply, { invalidated: deleted });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Clean expired cache entries
|
|
241
|
+
*/
|
|
242
|
+
export async function cleanExpiredCache(
|
|
243
|
+
request: FastifyRequest,
|
|
244
|
+
reply: FastifyReply
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
const storage = await getStorage();
|
|
247
|
+
const userId = request.user?.id;
|
|
248
|
+
|
|
249
|
+
const expiredEntries = await storage.getExpiredCacheEntries();
|
|
250
|
+
let cleaned = 0;
|
|
251
|
+
|
|
252
|
+
for (const entry of expiredEntries) {
|
|
253
|
+
const deleted = await storage.deleteCacheMetadata(entry.skillName, entry.version);
|
|
254
|
+
if (deleted) cleaned++;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Create audit log
|
|
258
|
+
const auditLog: AuditLog = {
|
|
259
|
+
id: generateUUID(),
|
|
260
|
+
timestamp: now(),
|
|
261
|
+
eventType: 'cache.invalidated',
|
|
262
|
+
actor: {
|
|
263
|
+
type: request.user?.type ?? 'system',
|
|
264
|
+
id: userId,
|
|
265
|
+
username: request.user?.username,
|
|
266
|
+
ip: request.ip,
|
|
267
|
+
userAgent: request.headers['user-agent'],
|
|
268
|
+
},
|
|
269
|
+
resource: {
|
|
270
|
+
type: 'config',
|
|
271
|
+
name: 'cache',
|
|
272
|
+
},
|
|
273
|
+
action: 'clean_expired',
|
|
274
|
+
result: 'success',
|
|
275
|
+
details: {
|
|
276
|
+
expired: expiredEntries.length,
|
|
277
|
+
cleaned,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
await storage.createAuditLog(auditLog);
|
|
281
|
+
|
|
282
|
+
logger.info('Expired cache cleaned', { expired: expiredEntries.length, cleaned });
|
|
283
|
+
|
|
284
|
+
sendSuccess(request, reply, {
|
|
285
|
+
expired: expiredEntries.length,
|
|
286
|
+
cleaned,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Get cache statistics
|
|
292
|
+
*/
|
|
293
|
+
export async function getCacheStats(
|
|
294
|
+
request: FastifyRequest,
|
|
295
|
+
reply: FastifyReply
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
const storage = await getStorage();
|
|
298
|
+
const config = getConfig().get();
|
|
299
|
+
|
|
300
|
+
const cacheEntries = await storage.getAllCacheMetadata();
|
|
301
|
+
const expiredEntries = await storage.getExpiredCacheEntries();
|
|
302
|
+
|
|
303
|
+
const totalSize = cacheEntries.reduce((sum, c) => sum + (c.size ?? 0), 0);
|
|
304
|
+
const totalHits = cacheEntries.reduce((sum, c) => sum + c.hitCount, 0);
|
|
305
|
+
const uniqueSkills = new Set(cacheEntries.map(c => c.skillName)).size;
|
|
306
|
+
|
|
307
|
+
// Most accessed
|
|
308
|
+
const mostAccessed = [...cacheEntries]
|
|
309
|
+
.sort((a, b) => b.hitCount - a.hitCount)
|
|
310
|
+
.slice(0, 10)
|
|
311
|
+
.map(c => ({
|
|
312
|
+
skillName: c.skillName,
|
|
313
|
+
version: c.version,
|
|
314
|
+
hitCount: c.hitCount,
|
|
315
|
+
}));
|
|
316
|
+
|
|
317
|
+
// Recently cached
|
|
318
|
+
const recentlyCached = [...cacheEntries]
|
|
319
|
+
.sort((a, b) => b.cachedAt.localeCompare(a.cachedAt))
|
|
320
|
+
.slice(0, 10)
|
|
321
|
+
.map(c => ({
|
|
322
|
+
skillName: c.skillName,
|
|
323
|
+
version: c.version,
|
|
324
|
+
cachedAt: c.cachedAt,
|
|
325
|
+
}));
|
|
326
|
+
|
|
327
|
+
sendSuccess(request, reply, {
|
|
328
|
+
totalEntries: cacheEntries.length,
|
|
329
|
+
uniqueSkills,
|
|
330
|
+
totalSize,
|
|
331
|
+
totalHits,
|
|
332
|
+
expiredCount: expiredEntries.length,
|
|
333
|
+
config: {
|
|
334
|
+
ttl: config.cache.ttl,
|
|
335
|
+
maxSize: config.cache.maxSize,
|
|
336
|
+
strategy: config.cache.strategy,
|
|
337
|
+
},
|
|
338
|
+
utilization: config.cache.maxSize > 0
|
|
339
|
+
? Math.round((totalSize / config.cache.maxSize) * 100)
|
|
340
|
+
: 0,
|
|
341
|
+
mostAccessed,
|
|
342
|
+
recentlyCached,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Skills Hub - Feedback Controller
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
6
|
+
import {
|
|
7
|
+
getStorage,
|
|
8
|
+
generateUUID,
|
|
9
|
+
now,
|
|
10
|
+
hashIP,
|
|
11
|
+
buildSkillFullName,
|
|
12
|
+
parseSkillFullName,
|
|
13
|
+
AppError,
|
|
14
|
+
ErrorCodes,
|
|
15
|
+
} from '@open-skills-hub/core';
|
|
16
|
+
import type { Feedback, AuditLog } from '@open-skills-hub/core';
|
|
17
|
+
import { sendSuccess } from '../middleware/error.js';
|
|
18
|
+
import { logger } from '../middleware/logger.js';
|
|
19
|
+
import type {
|
|
20
|
+
SkillNameParam,
|
|
21
|
+
IdParam,
|
|
22
|
+
SubmitFeedbackBody,
|
|
23
|
+
PaginationQuery,
|
|
24
|
+
} from '../middleware/validation.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Submit feedback for a skill
|
|
28
|
+
*/
|
|
29
|
+
export async function submitFeedback(
|
|
30
|
+
request: FastifyRequest<{ Body: SubmitFeedbackBody }>,
|
|
31
|
+
reply: FastifyReply
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const storage = await getStorage();
|
|
34
|
+
const body = request.body;
|
|
35
|
+
|
|
36
|
+
// Parse name
|
|
37
|
+
const { scope, name: skillName } = parseSkillFullName(body.skillName);
|
|
38
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
39
|
+
|
|
40
|
+
const skill = await storage.getSkillByName(fullName);
|
|
41
|
+
if (!skill) {
|
|
42
|
+
throw new AppError(
|
|
43
|
+
ErrorCodes.SKILL_NOT_FOUND,
|
|
44
|
+
`Skill '${fullName}' not found`,
|
|
45
|
+
404
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Verify version exists
|
|
50
|
+
const version = await storage.getVersion(skill.id, body.skillVersion);
|
|
51
|
+
if (!version) {
|
|
52
|
+
throw new AppError(
|
|
53
|
+
ErrorCodes.VERSION_NOT_FOUND,
|
|
54
|
+
`Version '${body.skillVersion}' not found for skill '${fullName}'`,
|
|
55
|
+
404
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const feedbackId = generateUUID();
|
|
60
|
+
const timestamp = now();
|
|
61
|
+
|
|
62
|
+
const feedback: Feedback = {
|
|
63
|
+
id: feedbackId,
|
|
64
|
+
skillId: skill.id,
|
|
65
|
+
skillVersion: body.skillVersion,
|
|
66
|
+
feedbackType: body.feedbackType,
|
|
67
|
+
rating: body.rating,
|
|
68
|
+
comment: body.comment,
|
|
69
|
+
context: body.context ?? {},
|
|
70
|
+
status: 'pending',
|
|
71
|
+
sourceIpHash: hashIP(request.ip),
|
|
72
|
+
createdAt: timestamp,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await storage.createFeedback(feedback);
|
|
76
|
+
|
|
77
|
+
// Update skill rating if this is a rating feedback
|
|
78
|
+
if (body.rating) {
|
|
79
|
+
const feedbacks = await storage.getFeedbacks(skill.id, { limit: 1000 });
|
|
80
|
+
const ratings = feedbacks.items.map(f => f.rating).filter((r): r is number => r !== undefined);
|
|
81
|
+
const averageRating = ratings.length > 0
|
|
82
|
+
? ratings.reduce((sum, r) => sum + r, 0) / ratings.length
|
|
83
|
+
: 0;
|
|
84
|
+
|
|
85
|
+
await storage.updateSkill(skill.id, {
|
|
86
|
+
rating: {
|
|
87
|
+
average: Math.round(averageRating * 10) / 10,
|
|
88
|
+
count: ratings.length,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Create audit log
|
|
94
|
+
const auditLog: AuditLog = {
|
|
95
|
+
id: generateUUID(),
|
|
96
|
+
timestamp,
|
|
97
|
+
eventType: 'feedback.submitted',
|
|
98
|
+
actor: {
|
|
99
|
+
type: request.user?.type ?? 'user',
|
|
100
|
+
id: request.user?.id,
|
|
101
|
+
username: request.user?.username,
|
|
102
|
+
ip: request.ip,
|
|
103
|
+
userAgent: request.headers['user-agent'],
|
|
104
|
+
},
|
|
105
|
+
resource: {
|
|
106
|
+
type: 'feedback',
|
|
107
|
+
id: feedbackId,
|
|
108
|
+
name: fullName,
|
|
109
|
+
version: body.skillVersion,
|
|
110
|
+
},
|
|
111
|
+
action: 'create',
|
|
112
|
+
result: 'success',
|
|
113
|
+
details: {
|
|
114
|
+
feedbackType: body.feedbackType,
|
|
115
|
+
rating: body.rating,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
await storage.createAuditLog(auditLog);
|
|
119
|
+
|
|
120
|
+
logger.info('Feedback submitted', {
|
|
121
|
+
feedbackId,
|
|
122
|
+
skillName: fullName,
|
|
123
|
+
feedbackType: body.feedbackType,
|
|
124
|
+
rating: body.rating,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
sendSuccess(request, reply, {
|
|
128
|
+
id: feedbackId,
|
|
129
|
+
status: 'pending',
|
|
130
|
+
createdAt: timestamp,
|
|
131
|
+
}, 201);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get feedbacks for a skill
|
|
136
|
+
*/
|
|
137
|
+
export async function getSkillFeedbacks(
|
|
138
|
+
request: FastifyRequest<{
|
|
139
|
+
Params: SkillNameParam;
|
|
140
|
+
Querystring: PaginationQuery & { feedbackType?: string; status?: string }
|
|
141
|
+
}>,
|
|
142
|
+
reply: FastifyReply
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const storage = await getStorage();
|
|
145
|
+
const { name } = request.params;
|
|
146
|
+
const { cursor, limit, feedbackType, status } = request.query;
|
|
147
|
+
|
|
148
|
+
// Parse name
|
|
149
|
+
const { scope, name: skillName } = parseSkillFullName(name);
|
|
150
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
151
|
+
|
|
152
|
+
const skill = await storage.getSkillByName(fullName);
|
|
153
|
+
if (!skill) {
|
|
154
|
+
throw new AppError(
|
|
155
|
+
ErrorCodes.SKILL_NOT_FOUND,
|
|
156
|
+
`Skill '${fullName}' not found`,
|
|
157
|
+
404
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const result = await storage.getFeedbacks(skill.id, {
|
|
162
|
+
cursor,
|
|
163
|
+
limit,
|
|
164
|
+
feedbackType,
|
|
165
|
+
status,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Remove sensitive data
|
|
169
|
+
const feedbacks = result.items.map(f => ({
|
|
170
|
+
id: f.id,
|
|
171
|
+
skillVersion: f.skillVersion,
|
|
172
|
+
feedbackType: f.feedbackType,
|
|
173
|
+
rating: f.rating,
|
|
174
|
+
comment: f.comment,
|
|
175
|
+
context: f.context,
|
|
176
|
+
status: f.status,
|
|
177
|
+
createdAt: f.createdAt,
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
sendSuccess(request, reply, feedbacks, 200, result.pagination);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get feedback by ID
|
|
185
|
+
*/
|
|
186
|
+
export async function getFeedbackById(
|
|
187
|
+
request: FastifyRequest<{ Params: IdParam }>,
|
|
188
|
+
reply: FastifyReply
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const storage = await getStorage();
|
|
191
|
+
const { id } = request.params;
|
|
192
|
+
|
|
193
|
+
const feedback = await storage.getFeedbackById(id);
|
|
194
|
+
if (!feedback) {
|
|
195
|
+
throw new AppError(
|
|
196
|
+
ErrorCodes.SKILL_NOT_FOUND, // Using generic not found
|
|
197
|
+
`Feedback '${id}' not found`,
|
|
198
|
+
404
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
sendSuccess(request, reply, {
|
|
203
|
+
id: feedback.id,
|
|
204
|
+
skillId: feedback.skillId,
|
|
205
|
+
skillVersion: feedback.skillVersion,
|
|
206
|
+
feedbackType: feedback.feedbackType,
|
|
207
|
+
rating: feedback.rating,
|
|
208
|
+
comment: feedback.comment,
|
|
209
|
+
context: feedback.context,
|
|
210
|
+
status: feedback.status,
|
|
211
|
+
createdAt: feedback.createdAt,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Review feedback (admin)
|
|
217
|
+
*/
|
|
218
|
+
export async function reviewFeedback(
|
|
219
|
+
request: FastifyRequest<{
|
|
220
|
+
Params: IdParam;
|
|
221
|
+
Body: { status: 'reviewed' | 'accepted' | 'rejected'; notes?: string }
|
|
222
|
+
}>,
|
|
223
|
+
reply: FastifyReply
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const storage = await getStorage();
|
|
226
|
+
const { id } = request.params;
|
|
227
|
+
const { status, notes } = request.body;
|
|
228
|
+
const userId = request.user?.id;
|
|
229
|
+
|
|
230
|
+
const feedback = await storage.getFeedbackById(id);
|
|
231
|
+
if (!feedback) {
|
|
232
|
+
throw new AppError(
|
|
233
|
+
ErrorCodes.SKILL_NOT_FOUND,
|
|
234
|
+
`Feedback '${id}' not found`,
|
|
235
|
+
404
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const updated = await storage.updateFeedback(id, {
|
|
240
|
+
status,
|
|
241
|
+
reviewerNotes: notes,
|
|
242
|
+
reviewedAt: now(),
|
|
243
|
+
reviewedBy: userId,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
logger.info('Feedback reviewed', {
|
|
247
|
+
feedbackId: id,
|
|
248
|
+
status,
|
|
249
|
+
reviewedBy: userId,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
sendSuccess(request, reply, {
|
|
253
|
+
id: updated?.id,
|
|
254
|
+
status: updated?.status,
|
|
255
|
+
reviewerNotes: updated?.reviewerNotes,
|
|
256
|
+
reviewedAt: updated?.reviewedAt,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get feedback statistics for a skill
|
|
262
|
+
*/
|
|
263
|
+
export async function getFeedbackStats(
|
|
264
|
+
request: FastifyRequest<{ Params: SkillNameParam }>,
|
|
265
|
+
reply: FastifyReply
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
const storage = await getStorage();
|
|
268
|
+
const { name } = request.params;
|
|
269
|
+
|
|
270
|
+
// Parse name
|
|
271
|
+
const { scope, name: skillName } = parseSkillFullName(name);
|
|
272
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
273
|
+
|
|
274
|
+
const skill = await storage.getSkillByName(fullName);
|
|
275
|
+
if (!skill) {
|
|
276
|
+
throw new AppError(
|
|
277
|
+
ErrorCodes.SKILL_NOT_FOUND,
|
|
278
|
+
`Skill '${fullName}' not found`,
|
|
279
|
+
404
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Get all feedbacks
|
|
284
|
+
const result = await storage.getFeedbacks(skill.id, { limit: 1000 });
|
|
285
|
+
const feedbacks = result.items;
|
|
286
|
+
|
|
287
|
+
// Calculate stats
|
|
288
|
+
const stats = {
|
|
289
|
+
total: feedbacks.length,
|
|
290
|
+
byType: {
|
|
291
|
+
success: feedbacks.filter(f => f.feedbackType === 'success').length,
|
|
292
|
+
failure: feedbacks.filter(f => f.feedbackType === 'failure').length,
|
|
293
|
+
suggestion: feedbacks.filter(f => f.feedbackType === 'suggestion').length,
|
|
294
|
+
bug: feedbacks.filter(f => f.feedbackType === 'bug').length,
|
|
295
|
+
},
|
|
296
|
+
byStatus: {
|
|
297
|
+
pending: feedbacks.filter(f => f.status === 'pending').length,
|
|
298
|
+
reviewed: feedbacks.filter(f => f.status === 'reviewed').length,
|
|
299
|
+
accepted: feedbacks.filter(f => f.status === 'accepted').length,
|
|
300
|
+
rejected: feedbacks.filter(f => f.status === 'rejected').length,
|
|
301
|
+
},
|
|
302
|
+
rating: skill.rating,
|
|
303
|
+
successRate: feedbacks.length > 0
|
|
304
|
+
? feedbacks.filter(f => f.feedbackType === 'success').length / feedbacks.length
|
|
305
|
+
: 0,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
sendSuccess(request, reply, stats);
|
|
309
|
+
}
|