mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: add Sheet component for bottom sheet UI with drag-to-dismiss and snap points
test: implement DialogStyleContext tests for preference management and style recommendations test: create tests for useAutoSelection hook, including countdown and pause functionality feat: implement useAutoSelection hook for enhanced auto-selection with sound notifications feat: create Zustand store for managing issue submission wizard state feat: add Zod validation schemas for issue-related API requests feat: implement issue service for CRUD operations and validation handling feat: define TypeScript types for issue submission and management
This commit is contained in:
@@ -7,13 +7,16 @@
|
||||
* ├── queues/ # Queue history directory
|
||||
* │ ├── index.json # Queue index (active + history)
|
||||
* │ └── {queue-id}.json # Individual queue files
|
||||
* └── solutions/
|
||||
* ├── {issue-id}.jsonl # Solutions for issue (one per line)
|
||||
* └── ...
|
||||
* ├── solutions/
|
||||
* │ ├── {issue-id}.jsonl # Solutions for issue (one per line)
|
||||
* │ └── ...
|
||||
* └── attachments/
|
||||
* └── {issue-id}/ # Attachments for issue
|
||||
* └── {filename} # Uploaded files
|
||||
*
|
||||
* API Endpoints (8 total):
|
||||
* API Endpoints:
|
||||
* - GET /api/issues - List all issues
|
||||
* - POST /api/issues - Create new issue
|
||||
* - POST /api/issues - Create new issue (with Zod validation)
|
||||
* - GET /api/issues/:id - Get issue detail
|
||||
* - PATCH /api/issues/:id - Update issue (includes binding logic)
|
||||
* - DELETE /api/issues/:id - Delete issue
|
||||
@@ -21,10 +24,21 @@
|
||||
* - PATCH /api/issues/:id/tasks/:taskId - Update task
|
||||
* - GET /api/queue - Get execution queue
|
||||
* - POST /api/queue/reorder - Reorder queue items
|
||||
* - POST /api/issues/:id/attachments - Upload attachment (multipart/form-data)
|
||||
* - GET /api/issues/:id/attachments - List attachments
|
||||
* - DELETE /api/issues/:id/attachments/:attachmentId - Delete attachment
|
||||
* - GET /api/issues/files/:issueId/:filename - Download file
|
||||
*/
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { join, resolve, normalize } from 'path';
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync, createReadStream, statSync } from 'fs';
|
||||
import { join, resolve, normalize, basename } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { RouteContext } from './types.js';
|
||||
import {
|
||||
processCreateIssueRequest,
|
||||
generateIssueId,
|
||||
type CreateIssueResult,
|
||||
} from '../services/issue-service.js';
|
||||
import type { Issue, Attachment } from '../types/issue.js';
|
||||
|
||||
// ========== JSONL Helper Functions ==========
|
||||
|
||||
@@ -85,6 +99,117 @@ function generateQueueFileId(): string {
|
||||
return `QUE-${ts}`;
|
||||
}
|
||||
|
||||
// ========== Attachment Helper Functions ==========
|
||||
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
// Images
|
||||
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||
// Documents
|
||||
'application/pdf',
|
||||
'text/plain', 'text/markdown', 'text/csv',
|
||||
// Code files
|
||||
'application/json', 'text/javascript', 'text/typescript', 'text/html', 'text/css',
|
||||
'application/xml', 'text/xml',
|
||||
// Archives
|
||||
'application/zip', 'application/x-gzip',
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
function getAttachmentsDir(issuesDir: string, issueId: string): string {
|
||||
return join(issuesDir, 'attachments', issueId);
|
||||
}
|
||||
|
||||
function sanitizeFilename(filename: string): string {
|
||||
// Remove path traversal attempts and invalid characters
|
||||
const sanitized = basename(filename)
|
||||
.replace(/[<>:"|?*\x00-\x1f]/g, '')
|
||||
.replace(/\.\./g, '');
|
||||
// Add timestamp prefix to prevent collisions
|
||||
const ext = sanitized.includes('.') ? `.${sanitized.split('.').pop()}` : '';
|
||||
const base = ext ? sanitized.slice(0, -(ext.length)) : sanitized;
|
||||
return `${Date.now()}-${base}${ext}`;
|
||||
}
|
||||
|
||||
function isValidMimeType(mimeType: string): boolean {
|
||||
// Allow common code file types that might not have standard MIME types
|
||||
const additionalTypes = [
|
||||
'application/octet-stream', // Generic binary, often used for various file types
|
||||
];
|
||||
return ALLOWED_MIME_TYPES.includes(mimeType) || additionalTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
function parseMultipartFormData(req: any): Promise<{ fields: Record<string, string>; files: Array<{ name: string; data: Buffer; filename: string; type: string }> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const boundary = req.headers['content-type']?.match(/boundary=(.+)/)?.[1];
|
||||
if (!boundary) {
|
||||
reject(new Error('No boundary in content-type'));
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const boundaryBuffer = Buffer.from(`--${boundary}`);
|
||||
const fields: Record<string, string> = {};
|
||||
const files: Array<{ name: string; data: Buffer; filename: string; type: string }> = [];
|
||||
|
||||
// Split by boundary
|
||||
let start = 0;
|
||||
while (start < buffer.length) {
|
||||
const boundaryIndex = buffer.indexOf(boundaryBuffer, start);
|
||||
if (boundaryIndex === -1) break;
|
||||
|
||||
const nextBoundary = buffer.indexOf(boundaryBuffer, boundaryIndex + boundaryBuffer.length);
|
||||
if (nextBoundary === -1) break;
|
||||
|
||||
const part = buffer.slice(boundaryIndex + boundaryBuffer.length + 2, nextBoundary - 2); // +2 for \r\n, -2 for \r\n before boundary
|
||||
|
||||
// Parse headers
|
||||
const headerEnd = part.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) {
|
||||
start = nextBoundary;
|
||||
continue;
|
||||
}
|
||||
|
||||
const headers = part.slice(0, headerEnd).toString();
|
||||
const content = part.slice(headerEnd + 4);
|
||||
|
||||
// Extract content-disposition
|
||||
const nameMatch = headers.match(/name="([^"]+)"/);
|
||||
const filenameMatch = headers.match(/filename="([^"]+)"/);
|
||||
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1];
|
||||
if (filenameMatch) {
|
||||
// It's a file
|
||||
files.push({
|
||||
name,
|
||||
data: content,
|
||||
filename: filenameMatch[1],
|
||||
type: contentTypeMatch?.[1] || 'application/octet-stream',
|
||||
});
|
||||
} else {
|
||||
// It's a field
|
||||
fields[name] = content.toString().replace(/\r\n$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
start = nextBoundary;
|
||||
}
|
||||
|
||||
resolve({ fields, files });
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function readQueue(issuesDir: string) {
|
||||
// Try new multi-queue structure first
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
@@ -1140,30 +1265,31 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/issues - Create issue
|
||||
// POST /api/issues - Create issue (with Zod validation)
|
||||
if (pathname === '/api/issues' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
if (!body.id || !body.title) return { error: 'id and title required' };
|
||||
// Use new validation service
|
||||
const result: CreateIssueResult = processCreateIssueRequest(body);
|
||||
|
||||
if (!result.success) {
|
||||
return { error: result.error.error.message, status: result.status, details: result.error.error };
|
||||
}
|
||||
|
||||
// TypeScript narrowing: result is now { success: true; issue: Issue; status: 201 }
|
||||
const { issue } = result;
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
if (issues.find(i => i.id === body.id)) return { error: `Issue ${body.id} exists` };
|
||||
|
||||
const newIssue = {
|
||||
id: body.id,
|
||||
title: body.title,
|
||||
status: body.status || 'registered',
|
||||
priority: body.priority || 3,
|
||||
context: body.context || '',
|
||||
source: body.source || 'text',
|
||||
source_url: body.source_url || null,
|
||||
tags: body.tags || [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
// Check for duplicate ID (auto-generated IDs should be unique)
|
||||
if (issues.find((i: any) => i.id === issue.id)) {
|
||||
return { error: `Issue ${issue.id} already exists`, status: 409 };
|
||||
}
|
||||
|
||||
issues.push(newIssue);
|
||||
// Store issue
|
||||
issues.push(issue);
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
return { success: true, issue: newIssue };
|
||||
|
||||
// Return 201 Created response
|
||||
return { success: true, data: { issue }, status: 201 };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1626,5 +1752,233 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== Attachment Routes =====
|
||||
|
||||
// POST /api/issues/:id/attachments - Upload attachment
|
||||
const uploadAttachmentMatch = pathname.match(/^\/api\/issues\/([^/]+)\/attachments$/);
|
||||
if (uploadAttachmentMatch && req.method === 'POST') {
|
||||
const issueId = decodeURIComponent(uploadAttachmentMatch[1]);
|
||||
|
||||
// Check if issue exists
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issueIndex = issues.findIndex(i => i.id === issueId);
|
||||
if (issueIndex === -1) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Content-Type must be multipart/form-data' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const { files } = await parseMultipartFormData(req);
|
||||
|
||||
if (files.length === 0) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'No files uploaded' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const uploadedAttachments: Attachment[] = [];
|
||||
const attachmentsDir = getAttachmentsDir(issuesDir, issueId);
|
||||
if (!existsSync(attachmentsDir)) {
|
||||
mkdirSync(attachmentsDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
// Validate file size
|
||||
if (file.data.length > MAX_FILE_SIZE) {
|
||||
continue; // Skip files that are too large
|
||||
}
|
||||
|
||||
// Validate MIME type (allow common types)
|
||||
if (!isValidMimeType(file.type)) {
|
||||
continue; // Skip invalid file types
|
||||
}
|
||||
|
||||
// Generate safe filename
|
||||
const safeFilename = sanitizeFilename(file.filename);
|
||||
const filePath = join(attachmentsDir, safeFilename);
|
||||
|
||||
// Save file
|
||||
writeFileSync(filePath, file.data);
|
||||
|
||||
// Create attachment record
|
||||
const attachment: Attachment = {
|
||||
id: randomUUID(),
|
||||
filename: file.filename,
|
||||
path: `attachments/${issueId}/${safeFilename}`,
|
||||
type: file.type,
|
||||
size: file.data.length,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
uploadedAttachments.push(attachment);
|
||||
}
|
||||
|
||||
if (uploadedAttachments.length === 0) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'No valid files uploaded. Check file size (max 10MB) and type.' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update issue with attachments
|
||||
if (!issues[issueIndex].attachments) {
|
||||
issues[issueIndex].attachments = [];
|
||||
}
|
||||
issues[issueIndex].attachments!.push(...uploadedAttachments);
|
||||
issues[issueIndex].updated_at = new Date().toISOString();
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
|
||||
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
issueId,
|
||||
attachments: uploadedAttachments,
|
||||
count: uploadedAttachments.length,
|
||||
}));
|
||||
} catch (err: any) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message || 'Failed to upload attachments' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/issues/:id/attachments - List attachments
|
||||
const listAttachmentsMatch = pathname.match(/^\/api\/issues\/([^/]+)\/attachments$/);
|
||||
if (listAttachmentsMatch && req.method === 'GET') {
|
||||
const issueId = decodeURIComponent(listAttachmentsMatch[1]);
|
||||
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
|
||||
if (!issue) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
issueId,
|
||||
attachments: issue.attachments || [],
|
||||
count: (issue.attachments || []).length,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// DELETE /api/issues/:id/attachments/:attachmentId - Delete attachment
|
||||
const deleteAttachmentMatch = pathname.match(/^\/api\/issues\/([^/]+)\/attachments\/([^/]+)$/);
|
||||
if (deleteAttachmentMatch && req.method === 'DELETE') {
|
||||
const issueId = decodeURIComponent(deleteAttachmentMatch[1]);
|
||||
const attachmentId = decodeURIComponent(deleteAttachmentMatch[2]);
|
||||
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issueIndex = issues.findIndex(i => i.id === issueId);
|
||||
|
||||
if (issueIndex === -1) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const issue = issues[issueIndex];
|
||||
if (!issue.attachments || issue.attachments.length === 0) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'No attachments found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const attachmentIndex = issue.attachments.findIndex((a: Attachment) => a.id === attachmentId);
|
||||
if (attachmentIndex === -1) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Attachment not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const attachment = issue.attachments[attachmentIndex];
|
||||
|
||||
// Delete file from disk
|
||||
const filePath = join(issuesDir, attachment.path);
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
} catch {
|
||||
// Ignore file deletion errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from issue
|
||||
issue.attachments.splice(attachmentIndex, 1);
|
||||
issue.updated_at = new Date().toISOString();
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
issueId,
|
||||
deletedAttachmentId: attachmentId,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/issues/files/:issueId/:filename - Get/download file
|
||||
const fileMatch = pathname.match(/^\/api\/issues\/files\/([^/]+)\/(.+)$/);
|
||||
if (fileMatch && req.method === 'GET') {
|
||||
const issueId = decodeURIComponent(fileMatch[1]);
|
||||
const filename = decodeURIComponent(fileMatch[2]);
|
||||
|
||||
// Verify the file belongs to this issue
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
|
||||
if (!issue) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find attachment by filename (check both original and sanitized name)
|
||||
const attachment = (issue.attachments || []).find((a: Attachment) =>
|
||||
a.path.endsWith(filename) ||
|
||||
a.filename === filename
|
||||
);
|
||||
|
||||
if (!attachment) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'File not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = join(issuesDir, attachment.path);
|
||||
if (!existsSync(filePath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'File not found on disk' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': attachment.type,
|
||||
'Content-Length': stat.size,
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(attachment.filename)}"`,
|
||||
});
|
||||
const fileStream = createReadStream(filePath);
|
||||
fileStream.pipe(res);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to read file' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
156
ccw/src/core/schemas/issue-schema.ts
Normal file
156
ccw/src/core/schemas/issue-schema.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Issue Zod Validation Schemas
|
||||
* Provides runtime validation for Issue-related API requests
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Issue type enum schema
|
||||
*/
|
||||
export const IssueTypeSchema = z.enum(['bug', 'feature', 'improvement', 'other']);
|
||||
|
||||
/**
|
||||
* Issue priority enum schema
|
||||
*/
|
||||
export const IssuePrioritySchema = z.enum(['low', 'medium', 'high', 'urgent']);
|
||||
|
||||
/**
|
||||
* Issue source enum schema
|
||||
*/
|
||||
export const IssueSourceSchema = z.enum(['text', 'github', 'file']);
|
||||
|
||||
/**
|
||||
* Attachment schema for file uploads
|
||||
*/
|
||||
export const AttachmentSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
filename: z.string().max(255),
|
||||
path: z.string(),
|
||||
type: z.string().max(100),
|
||||
size: z.number().int().nonnegative(),
|
||||
uploaded_at: z.string().datetime(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create Issue request schema with validation rules
|
||||
* - title: required, max 200 characters
|
||||
* - description: required, max 10000 characters
|
||||
* - type: optional enum
|
||||
* - priority: optional enum
|
||||
* - attachments: optional array of attachments
|
||||
*/
|
||||
export const CreateIssueRequestSchema = z.object({
|
||||
title: z.string()
|
||||
.min(1, 'Title is required')
|
||||
.max(200, 'Title must be at most 200 characters')
|
||||
.trim(),
|
||||
|
||||
description: z.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(10000, 'Description must be at most 10000 characters'),
|
||||
|
||||
type: IssueTypeSchema.optional()
|
||||
.default('other'),
|
||||
|
||||
priority: IssuePrioritySchema.optional()
|
||||
.default('medium'),
|
||||
|
||||
context: z.string()
|
||||
.max(5000, 'Context must be at most 5000 characters')
|
||||
.optional()
|
||||
.default(''),
|
||||
|
||||
source: IssueSourceSchema.optional()
|
||||
.default('text'),
|
||||
|
||||
source_url: z.string()
|
||||
.url('Source URL must be a valid URL')
|
||||
.nullable()
|
||||
.optional()
|
||||
.default(null),
|
||||
|
||||
tags: z.array(z.string().max(50))
|
||||
.max(10, 'Maximum 10 tags allowed')
|
||||
.optional()
|
||||
.default([]),
|
||||
|
||||
attachments: z.array(AttachmentSchema)
|
||||
.max(10, 'Maximum 10 attachments allowed')
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type inference from Zod schema
|
||||
*/
|
||||
export type CreateIssueRequestInput = z.infer<typeof CreateIssueRequestSchema>;
|
||||
|
||||
/**
|
||||
* Validation result type
|
||||
*/
|
||||
export interface ValidationResult<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
errors?: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
code: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate create issue request
|
||||
* Returns validated data or error details
|
||||
*/
|
||||
export function validateCreateIssueRequest(data: unknown): ValidationResult<CreateIssueRequestInput> {
|
||||
const result = CreateIssueRequestSchema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
const errors = result.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.') || 'root',
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation errors for API response
|
||||
*/
|
||||
export function formatValidationErrors(
|
||||
errors: Array<{ field: string; message: string; code: string }>
|
||||
): { message: string; suggestions: string[] } {
|
||||
const messages = errors.map((e) => `${e.field}: ${e.message}`);
|
||||
const suggestions: string[] = [];
|
||||
|
||||
// Add contextual suggestions based on error types
|
||||
for (const error of errors) {
|
||||
if (error.field === 'title' && error.code === 'too_big') {
|
||||
suggestions.push('Consider using a shorter, more descriptive title');
|
||||
}
|
||||
if (error.field === 'description' && error.code === 'too_big') {
|
||||
suggestions.push('Move detailed information to context field or attachments');
|
||||
}
|
||||
if (error.field === 'type' && error.code === 'invalid_enum_value') {
|
||||
suggestions.push('Valid types are: bug, feature, improvement, other');
|
||||
}
|
||||
if (error.field === 'priority' && error.code === 'invalid_enum_value') {
|
||||
suggestions.push('Valid priorities are: low, medium, high, urgent');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Validation failed: ${messages.join('; ')}`,
|
||||
suggestions: suggestions.length > 0 ? suggestions : ['Check your request body format'],
|
||||
};
|
||||
}
|
||||
@@ -93,7 +93,11 @@ function handlePostRequest(req: http.IncomingMessage, res: http.ServerResponse,
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
// Support custom success status codes (e.g., 201 Created)
|
||||
const successStatus = typeof statusValue === 'number' && statusValue >= 200 && statusValue < 300
|
||||
? statusValue
|
||||
: 200;
|
||||
res.writeHead(successStatus, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
147
ccw/src/core/services/issue-service.ts
Normal file
147
ccw/src/core/services/issue-service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Issue Service
|
||||
* Business logic for Issue CRUD operations
|
||||
*/
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
Issue,
|
||||
CreateIssueRequest,
|
||||
ApiResponse,
|
||||
ApiErrorResponse,
|
||||
ValidationErrorResponse,
|
||||
IssueType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
} from '../types/issue.js';
|
||||
import {
|
||||
validateCreateIssueRequest,
|
||||
formatValidationErrors,
|
||||
type CreateIssueRequestInput,
|
||||
} from '../schemas/issue-schema.js';
|
||||
|
||||
/**
|
||||
* Generate a unique Issue ID
|
||||
* Format: ISS-{timestamp}-{random}
|
||||
*/
|
||||
export function generateIssueId(): string {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = randomUUID().split('-')[0];
|
||||
return `ISS-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map validated input to Issue entity
|
||||
*/
|
||||
export function createIssueEntity(input: CreateIssueRequestInput, customId?: string): Issue {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: customId || generateIssueId(),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
type: input.type as IssueType,
|
||||
priority: input.priority as IssuePriority,
|
||||
status: 'registered' as IssueStatus,
|
||||
context: input.context || '',
|
||||
source: input.source || 'text',
|
||||
source_url: input.source_url || null,
|
||||
tags: input.tags || [],
|
||||
attachments: input.attachments || [],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create success response
|
||||
*/
|
||||
export function createSuccessResponse<T>(data: T): ApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error response
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
code: string,
|
||||
message: string,
|
||||
suggestions?: string[]
|
||||
): ApiErrorResponse {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
suggestions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create validation error response with field details
|
||||
*/
|
||||
export function createValidationErrorResponse(
|
||||
errors: Array<{ field: string; message: string; code: string }>
|
||||
): ValidationErrorResponse {
|
||||
const formatted = formatValidationErrors(errors);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: formatted.message,
|
||||
details: errors,
|
||||
suggestions: formatted.suggestions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service result type for create operation
|
||||
*/
|
||||
export type CreateIssueResult =
|
||||
| { success: true; issue: Issue; status: 201 }
|
||||
| { success: false; error: ValidationErrorResponse | ApiErrorResponse; status: 400 };
|
||||
|
||||
/**
|
||||
* Process create issue request with validation
|
||||
*/
|
||||
export function processCreateIssueRequest(
|
||||
body: unknown,
|
||||
options?: { customId?: string }
|
||||
): CreateIssueResult {
|
||||
// Validate request body
|
||||
const validation = validateCreateIssueRequest(body);
|
||||
|
||||
if (!validation.success || !validation.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: createValidationErrorResponse(validation.errors || []),
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
// Create issue entity
|
||||
const issue = createIssueEntity(validation.data, options?.customId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
issue,
|
||||
status: 201,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an issue ID already exists
|
||||
*/
|
||||
export function isIssueIdExists(issues: Issue[], id: string): boolean {
|
||||
return issues.some((issue) => issue.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate issue ID format
|
||||
*/
|
||||
export function isValidIssueId(id: string): boolean {
|
||||
return /^ISS-[a-z0-9]+-[a-z0-9]+$/i.test(id);
|
||||
}
|
||||
113
ccw/src/core/types/issue.ts
Normal file
113
ccw/src/core/types/issue.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Issue Type Definitions
|
||||
* TypeScript types for Issue submission and management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Issue type enum values
|
||||
*/
|
||||
export type IssueType = 'bug' | 'feature' | 'improvement' | 'other';
|
||||
|
||||
/**
|
||||
* Issue priority enum values
|
||||
*/
|
||||
export type IssuePriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
/**
|
||||
* Issue status enum values
|
||||
*/
|
||||
export type IssueStatus = 'registered' | 'analyzing' | 'planned' | 'executing' | 'completed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Attachment entity for file uploads
|
||||
*/
|
||||
export interface Attachment {
|
||||
id: string; // UUID
|
||||
filename: string; // 原始文件名
|
||||
path: string; // 相对存储路径
|
||||
type: string; // MIME类型
|
||||
size: number; // 文件大小(bytes)
|
||||
uploaded_at: string; // ISO时间戳
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Issue Request DTO
|
||||
* Required fields: title, description
|
||||
*/
|
||||
export interface CreateIssueRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
type?: IssueType;
|
||||
priority?: IssuePriority;
|
||||
context?: string;
|
||||
source?: 'text' | 'github' | 'file';
|
||||
source_url?: string | null;
|
||||
tags?: string[];
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue entity stored in persistence layer
|
||||
*/
|
||||
export interface Issue {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: IssueType;
|
||||
priority: IssuePriority;
|
||||
status: IssueStatus;
|
||||
context: string;
|
||||
source: 'text' | 'github' | 'file';
|
||||
source_url: string | null;
|
||||
tags: string[];
|
||||
attachments?: Attachment[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API success response wrapper
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* API error response wrapper
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
suggestions?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all API responses
|
||||
*/
|
||||
export type ApiResult<T = unknown> = ApiResponse<T> | ApiErrorResponse;
|
||||
|
||||
/**
|
||||
* Validation error detail
|
||||
*/
|
||||
export interface ValidationErrorDetail {
|
||||
field: string;
|
||||
message: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error response with detailed field errors
|
||||
*/
|
||||
export interface ValidationErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR';
|
||||
message: string;
|
||||
details: ValidationErrorDetail[];
|
||||
suggestions: string[];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user