ai-spec-dev 0.41.0 → 0.42.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.
Files changed (37) hide show
  1. package/.ai-spec-workspace.json +17 -0
  2. package/.ai-spec.json +7 -0
  3. package/cli/pipeline/single-repo.ts +19 -10
  4. package/core/cli-ui.ts +136 -0
  5. package/core/code-generator.ts +4 -2
  6. package/core/error-feedback.ts +4 -2
  7. package/core/provider-utils.ts +8 -7
  8. package/demo-backend/.ai-spec-constitution.md +65 -0
  9. package/demo-backend/package.json +21 -0
  10. package/demo-backend/prisma/schema.prisma +22 -0
  11. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +186 -0
  12. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +211 -0
  13. package/demo-backend/src/controllers/bookmark.controller.test.ts +255 -0
  14. package/demo-backend/src/controllers/bookmark.controller.ts +187 -0
  15. package/demo-backend/src/index.ts +17 -0
  16. package/demo-backend/src/routes/bookmark.routes.test.ts +264 -0
  17. package/demo-backend/src/routes/bookmark.routes.ts +11 -0
  18. package/demo-backend/src/routes/index.ts +8 -0
  19. package/demo-backend/src/services/bookmark.service.test.ts +433 -0
  20. package/demo-backend/src/services/bookmark.service.ts +261 -0
  21. package/demo-backend/tsconfig.json +12 -0
  22. package/demo-frontend/.ai-spec-constitution.md +95 -0
  23. package/demo-frontend/package.json +23 -0
  24. package/demo-frontend/src/App.tsx +12 -0
  25. package/demo-frontend/src/main.tsx +9 -0
  26. package/demo-frontend/tsconfig.json +13 -0
  27. package/dist/cli/index.js +130 -21
  28. package/dist/cli/index.js.map +1 -1
  29. package/dist/cli/index.mjs +130 -21
  30. package/dist/cli/index.mjs.map +1 -1
  31. package/dist/index.js +80 -8
  32. package/dist/index.js.map +1 -1
  33. package/dist/index.mjs +80 -8
  34. package/dist/index.mjs.map +1 -1
  35. package/package.json +1 -1
  36. package/RELEASE_LOG.md +0 -2962
  37. package/purpose.md +0 -1434
@@ -0,0 +1,211 @@
1
+ # Feature Spec: Bookmark 管理系统
2
+
3
+ ## 1. 功能概述 (Overview)
4
+ 本功能旨在实现一个基础的个人书签管理系统。它允许用户创建、查看、编辑和删除网页书签,并支持通过标签进行简单分类和分页浏览,解决了个人或团队对常用网页链接进行集中存储和快速检索的需求。
5
+
6
+ ## 2. 背景与动机 (Background)
7
+ - **当前问题**:用户或团队成员可能通过浏览器书签栏、笔记软件或聊天记录零散地保存网页链接,缺乏统一的管理界面,难以共享、检索和分类。
8
+ - **构建理由**:作为系统的基础数据管理功能,为后续可能的搜索、分享、高级分类等功能奠定数据基础。
9
+ - **用户价值**:提供一个集中、结构化的界面来管理个人或项目相关的网页资源,提升信息获取效率。
10
+
11
+ ## 3. 用户故事 (User Stories)
12
+ - 作为 **用户**,我希望能 **创建一个新的书签(包含标题、URL和标签)**,以便 **保存我感兴趣的网页链接**。
13
+ - 作为 **用户**,我希望能 **以分页列表的形式查看所有书签**,以便 **高效地浏览和找到目标书签**。
14
+ - 作为 **用户**,我希望能 **编辑已保存书签的信息**,以便 **修正错误或更新信息**。
15
+ - 作为 **用户**,我希望能 **删除不再需要的书签**,以便 **保持列表的整洁和相关性**。
16
+
17
+ ## 4. 功能需求 (Functional Requirements)
18
+
19
+ ### 4.1 核心功能
20
+ - [ ] **Bookmark 数据模型**:实现包含 `id` (UUID), `title` (必填字符串), `url` (必填,需为有效URL格式), `tags` (字符串数组), `createdAt`, `updatedAt` 字段的模型。
21
+ - [ ] **分页列表接口**:提供 `GET /api/v1/bookmarks` 接口,支持 `limit` 和 `offset` 查询参数进行分页,返回书签数组及总数。
22
+ - [ ] **创建书签接口**:提供 `POST /api/v1/bookmarks` 接口,接收 `{ title, url, tags? }` 请求体,创建并返回新书签。
23
+ - [ ] **更新书签接口**:提供 `PUT /api/v1/bookmarks/:id` 接口,接收完整书签信息,更新指定ID的书签。
24
+ - [ ] **删除书签接口**:提供 `DELETE /api/v1/bookmarks/:id` 接口,删除指定ID的书签。
25
+ - [ ] **排序**:所有列表查询结果默认按 `createdAt` 字段倒序排列。
26
+
27
+ ### 4.2 边界条件与错误处理
28
+ - **输入验证**:
29
+ - `title`: 必填,非空字符串。
30
+ - `url`: 必填,需符合标准URL格式(可通过正则或URL构造函数验证)。
31
+ - `tags`: 可选,若提供则必须为字符串数组。
32
+ - `id` (路径参数): 必须为有效的UUID格式。
33
+ - `limit` (查询参数): 可选,正整数,默认20。
34
+ - `offset` (查询参数): 可选,非负整数,默认0。
35
+ - **错误场景**:
36
+ - 请求体验证失败:返回 `400` 状态码,并在响应 `message` 中说明具体错误。
37
+ - 根据 `id` 查找不到资源(更新、删除、后续可能的获取详情):返回 `404` 状态码。
38
+ - 服务器内部错误:返回 `500` 状态码。
39
+ - **权限控制**:本次实现为简化版本,暂不涉及用户认证与鉴权,所有接口均为公开访问。未来可集成 `auth.middleware.ts` 进行保护。
40
+
41
+ ## 5. API 设计 (API Design)
42
+
43
+ ### 接口列表
44
+ | Method | Endpoint | Auth Required | Description |
45
+ |--------|----------|:-------------:|-------------|
46
+ | GET | /api/v1/bookmarks | ❌ | 获取书签分页列表 |
47
+ | POST | /api/v1/bookmarks | ❌ | 创建新书签 |
48
+ | PUT | /api/v1/bookmarks/:id | ❌ | 全量更新指定书签 |
49
+ | DELETE | /api/v1/bookmarks/:id | ❌ | 删除指定书签 |
50
+
51
+ ### 请求/响应示例
52
+
53
+ **获取书签列表**
54
+
55
+ ```
56
+ GET /api/v1/bookmarks?limit=2&offset=0
57
+ ```
58
+
59
+ 成功响应 (200):
60
+ ```json
61
+ {
62
+ "code": 200,
63
+ "message": "success",
64
+ "data": {
65
+ "bookmarks": [
66
+ {
67
+ "id": "550e8400-e29b-41d4-a716-446655440000",
68
+ "title": "Example Domain",
69
+ "url": "https://example.com",
70
+ "tags": ["example", "test"],
71
+ "createdAt": "2023-10-27T10:00:00.000Z",
72
+ "updatedAt": "2023-10-27T10:00:00.000Z"
73
+ }
74
+ ],
75
+ "total": 10
76
+ }
77
+ }
78
+ ```
79
+
80
+ **创建书签**
81
+
82
+ ```
83
+ POST /api/v1/bookmarks
84
+ Content-Type: application/json
85
+ ```
86
+
87
+ 请求体:
88
+ ```json
89
+ {
90
+ "title": "GitHub",
91
+ "url": "https://github.com",
92
+ "tags": ["code", "repository"]
93
+ }
94
+ ```
95
+
96
+ 成功响应 (201):
97
+ ```json
98
+ {
99
+ "code": 201,
100
+ "message": "success",
101
+ "data": {
102
+ "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
103
+ "title": "GitHub",
104
+ "url": "https://github.com",
105
+ "tags": ["code", "repository"],
106
+ "createdAt": "2023-10-27T11:00:00.000Z",
107
+ "updatedAt": "2023-10-27T11:00:00.000Z"
108
+ }
109
+ }
110
+ ```
111
+
112
+ 错误响应 (400 - URL格式无效):
113
+ ```json
114
+ {
115
+ "code": 400,
116
+ "message": "Validation failed: \"url\" must be a valid URL"
117
+ }
118
+ ```
119
+
120
+ **更新书签**
121
+
122
+ ```
123
+ PUT /api/v1/bookmarks/6ba7b810-9dad-11d1-80b4-00c04fd430c8
124
+ Content-Type: application/json
125
+ ```
126
+
127
+ 请求体:
128
+ ```json
129
+ {
130
+ "title": "GitHub: Let’s build from here",
131
+ "url": "https://github.com",
132
+ "tags": ["code", "repository", "dev"]
133
+ }
134
+ ```
135
+
136
+ 成功响应 (200):
137
+ ```json
138
+ {
139
+ "code": 200,
140
+ "message": "success",
141
+ "data": {
142
+ "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
143
+ "title": "GitHub: Let’s build from here",
144
+ "url": "https://github.com",
145
+ "tags": ["code", "repository", "dev"],
146
+ "createdAt": "2023-10-27T11:00:00.000Z",
147
+ "updatedAt": "2023-10-27T11:05:00.000Z"
148
+ }
149
+ }
150
+ ```
151
+
152
+ 错误响应 (404):
153
+ ```json
154
+ {
155
+ "code": 404,
156
+ "message": "Bookmark not found"
157
+ }
158
+ ```
159
+
160
+ **删除书签**
161
+
162
+ ```
163
+ DELETE /api/v1/bookmarks/6ba7b810-9dad-11d1-80b4-00c04fd430c8
164
+ ```
165
+
166
+ 成功响应 (204 No Content):无响应体。
167
+
168
+ 错误响应 (404):
169
+ ```json
170
+ {
171
+ "code": 404,
172
+ "message": "Bookmark not found"
173
+ }
174
+ ```
175
+
176
+ ## 6. 数据模型 (Data Model)
177
+ ```prisma
178
+ model Bookmark {
179
+ id String @id @default(uuid())
180
+ title String
181
+ url String
182
+ tags String[]
183
+ createdAt DateTime @default(now())
184
+ updatedAt DateTime @updatedAt
185
+
186
+ @@map("bookmarks")
187
+ }
188
+ ```
189
+
190
+ ## 7. 非功能性需求 (Non-functional Requirements)
191
+ - **性能**: 单次列表查询(带分页)响应时间应 < 200ms。预期支持数百条书签的并发读写。
192
+ - **安全**: 对用户输入的 `url` 和 `title` 进行基本的清理,防止 XSS 等注入攻击(可在 service 层或通过中间件处理)。未来集成认证后,需确保用户只能操作自己的书签。
193
+ - **可靠性**: `POST` 和 `PUT` 操作应保证幂等性(通过业务逻辑或数据库约束)。对于 `PUT` 操作,需先检查资源是否存在。
194
+ - **可维护性**: 在关键业务路径(如创建、更新、删除)记录 `info` 级别日志。记录 `error` 级别日志并关联请求上下文。监控 API 的请求量、错误率和响应时间。
195
+
196
+ ## 8. 实施要点 (Implementation Notes)
197
+ - **集成点**: 完全基于现有 Express + TypeScript + Prisma 技术栈。需与 `error.middleware.ts` 集成以处理 `AppError`。
198
+ - **实施顺序**:
199
+ 1. **数据模型**: 在 `prisma/schema.prisma` 中添加 `Bookmark` 模型,并运行迁移。
200
+ 2. **服务层 (`service.ts`)**: 实现核心业务逻辑(CRUD、分页查询、输入验证)。
201
+ 3. **控制器层 (`controller.ts`)**: 处理 HTTP 请求,调用 service,构造响应。
202
+ 4. **路由层 (`routes.ts`)**: 定义 API 端点,将请求映射到控制器方法。
203
+ 5. **测试**: 为 service 和 controller 编写单元测试,为 API 编写集成测试。
204
+ - **技术注意事项**:
205
+ - 使用 Zod 或类似库在 service 层进行严格的输入验证。
206
+ - 分页查询使用 Prisma 的 `skip` 和 `take`,计数使用 `count` 方法。
207
+ - 严格遵循项目的三层架构 (`routes → controllers → services → database`),禁止跨层调用。
208
+ - **测试要点**:
209
+ - **Service 单测**: 验证 `createBookmark` 对无效 URL 的拒绝,`getBookmarks` 分页逻辑的正确性。
210
+ - **Controller 单测**: 验证对 service 的调用和对 HTTP 响应(状态码、格式)的正确构造。
211
+ - **API 集成测试**: 使用 `supertest` 模拟完整的 HTTP 请求流程,覆盖成功和错误场景(400, 404)。
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
2
+ import { Request, Response, NextFunction } from 'express';
3
+ import {
4
+ getBookmarks,
5
+ createBookmark,
6
+ updateBookmark,
7
+ deleteBookmark,
8
+ } from '../bookmark.controller';
9
+ import * as bookmarkService from '../../services/bookmark.service';
10
+ import { AppError } from '../../middleware/error.middleware';
11
+
12
+ vi.mock('../../services/bookmark.service');
13
+
14
+ const mockedBookmarkService = vi.mocked(bookmarkService);
15
+
16
+ describe('Bookmark Controller', () => {
17
+ let mockRequest: Partial<Request>;
18
+ let mockResponse: Partial<Response>;
19
+ let mockNext: NextFunction;
20
+
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ mockResponse = {
24
+ status: vi.fn().mockReturnThis(),
25
+ json: vi.fn().mockReturnThis(),
26
+ send: vi.fn().mockReturnThis(),
27
+ };
28
+ mockNext = vi.fn();
29
+ });
30
+
31
+ describe('getBookmarks', () => {
32
+ it('should return 200 with paginated bookmarks', async () => {
33
+ const mockBookmarks = [
34
+ {
35
+ id: '1',
36
+ title: 'Test',
37
+ url: 'https://test.com',
38
+ tags: ['test'],
39
+ createdAt: new Date(),
40
+ updatedAt: new Date(),
41
+ },
42
+ ];
43
+ const mockTotal = 1;
44
+
45
+ mockRequest = {
46
+ query: { limit: '10', offset: '0' },
47
+ };
48
+
49
+ mockedBookmarkService.getBookmarks.mockResolvedValue({
50
+ bookmarks: mockBookmarks,
51
+ total: mockTotal,
52
+ });
53
+
54
+ await getBookmarks(
55
+ mockRequest as Request,
56
+ mockResponse as Response,
57
+ mockNext
58
+ );
59
+
60
+ expect(mockedBookmarkService.getBookmarks).toHaveBeenCalledWith(10, 0);
61
+ expect(mockResponse.status).toHaveBeenCalledWith(200);
62
+ expect(mockResponse.json).toHaveBeenCalledWith({
63
+ code: 200,
64
+ message: 'success',
65
+ data: {
66
+ bookmarks: mockBookmarks,
67
+ total: mockTotal,
68
+ },
69
+ });
70
+ });
71
+
72
+ it('should call next with error when service throws', async () => {
73
+ const mockError = new AppError('Database error', 500, 'INTERNAL_ERROR');
74
+ mockRequest = {
75
+ query: {},
76
+ };
77
+
78
+ mockedBookmarkService.getBookmarks.mockRejectedValue(mockError);
79
+
80
+ await getBookmarks(
81
+ mockRequest as Request,
82
+ mockResponse as Response,
83
+ mockNext
84
+ );
85
+
86
+ expect(mockNext).toHaveBeenCalledWith(mockError);
87
+ });
88
+ });
89
+
90
+ describe('createBookmark', () => {
91
+ it('should return 201 with created bookmark', async () => {
92
+ const mockBookmark = {
93
+ id: '1',
94
+ title: 'Test',
95
+ url: 'https://test.com',
96
+ tags: ['test'],
97
+ createdAt: new Date(),
98
+ updatedAt: new Date(),
99
+ };
100
+
101
+ mockRequest = {
102
+ body: {
103
+ title: 'Test',
104
+ url: 'https://test.com',
105
+ tags: ['test'],
106
+ },
107
+ };
108
+
109
+ mockedBookmarkService.createBookmark.mockResolvedValue(mockBookmark);
110
+
111
+ await createBookmark(
112
+ mockRequest as Request,
113
+ mockResponse as Response,
114
+ mockNext
115
+ );
116
+
117
+ expect(mockedBookmarkService.createBookmark).toHaveBeenCalledWith({
118
+ title: 'Test',
119
+ url: 'https://test.com',
120
+ tags: ['test'],
121
+ });
122
+ expect(mockResponse.status).toHaveBeenCalledWith(201);
123
+ expect(mockResponse.json).toHaveBeenCalledWith({
124
+ code: 201,
125
+ message: 'success',
126
+ data: mockBookmark,
127
+ });
128
+ });
129
+
130
+ it('should call next with validation error for invalid URL', async () => {
131
+ const mockError = new AppError(
132
+ 'Validation failed: "url" must be a valid URL',
133
+ 400,
134
+ 'VALIDATION_ERROR'
135
+ );
136
+ mockRequest = {
137
+ body: {
138
+ title: 'Test',
139
+ url: 'invalid-url',
140
+ },
141
+ };
142
+
143
+ mockedBookmarkService.createBookmark.mockRejectedValue(mockError);
144
+
145
+ await createBookmark(
146
+ mockRequest as Request,
147
+ mockResponse as Response,
148
+ mockNext
149
+ );
150
+
151
+ expect(mockNext).toHaveBeenCalledWith(mockError);
152
+ });
153
+ });
154
+
155
+ describe('updateBookmark', () => {
156
+ it('should return 200 with updated bookmark', async () => {
157
+ const mockBookmark = {
158
+ id: '1',
159
+ title: 'Updated',
160
+ url: 'https://updated.com',
161
+ tags: ['updated'],
162
+ createdAt: new Date(),
163
+ updatedAt: new Date(),
164
+ };
165
+
166
+ mockRequest = {
167
+ params: { id: '1' },
168
+ body: {
169
+ title: 'Updated',
170
+ url: 'https://updated.com',
171
+ tags: ['updated'],
172
+ },
173
+ };
174
+
175
+ mockedBookmarkService.updateBookmark.mockResolvedValue(mockBookmark);
176
+
177
+ await updateBookmark(
178
+ mockRequest as Request,
179
+ mockResponse as Response,
180
+ mockNext
181
+ );
182
+
183
+ expect(mockedBookmarkService.updateBookmark).toHaveBeenCalledWith('1', {
184
+ title: 'Updated',
185
+ url: 'https://updated.com',
186
+ tags: ['updated'],
187
+ });
188
+ expect(mockResponse.status).toHaveBeenCalledWith(200);
189
+ expect(mockResponse.json).toHaveBeenCalledWith({
190
+ code: 200,
191
+ message: 'success',
192
+ data: mockBookmark,
193
+ });
194
+ });
195
+
196
+ it('should call next with 404 when bookmark not found', async () => {
197
+ const mockError = new AppError('Bookmark not found', 404, 'NOT_FOUND');
198
+ mockRequest = {
199
+ params: { id: 'non-existent-id' },
200
+ body: {
201
+ title: 'Updated',
202
+ url: 'https://updated.com',
203
+ tags: [],
204
+ },
205
+ };
206
+
207
+ mockedBookmarkService.updateBookmark.mockRejectedValue(mockError);
208
+
209
+ await updateBookmark(
210
+ mockRequest as Request,
211
+ mockResponse as Response,
212
+ mockNext
213
+ );
214
+
215
+ expect(mockNext).toHaveBeenCalledWith(mockError);
216
+ });
217
+ });
218
+
219
+ describe('deleteBookmark', () => {
220
+ it('should return 204 with no content', async () => {
221
+ mockRequest = {
222
+ params: { id: '1' },
223
+ };
224
+
225
+ mockedBookmarkService.deleteBookmark.mockResolvedValue(undefined);
226
+
227
+ await deleteBookmark(
228
+ mockRequest as Request,
229
+ mockResponse as Response,
230
+ mockNext
231
+ );
232
+
233
+ expect(mockedBookmarkService.deleteBookmark).toHaveBeenCalledWith('1');
234
+ expect(mockResponse.status).toHaveBeenCalledWith(204);
235
+ expect(mockResponse.send).toHaveBeenCalled();
236
+ });
237
+
238
+ it('should call next with 404 when bookmark not found', async () => {
239
+ const mockError = new AppError('Bookmark not found', 404, 'NOT_FOUND');
240
+ mockRequest = {
241
+ params: { id: 'non-existent-id' },
242
+ };
243
+
244
+ mockedBookmarkService.deleteBookmark.mockRejectedValue(mockError);
245
+
246
+ await deleteBookmark(
247
+ mockRequest as Request,
248
+ mockResponse as Response,
249
+ mockNext
250
+ );
251
+
252
+ expect(mockNext).toHaveBeenCalledWith(mockError);
253
+ });
254
+ });
255
+ });
@@ -0,0 +1,187 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { AppError } from '../middleware/error.middleware';
3
+ import * as bookmarkService from '../services/bookmark.service';
4
+
5
+ interface BookmarkQueryParams {
6
+ limit?: string;
7
+ offset?: string;
8
+ }
9
+
10
+ interface CreateBookmarkBody {
11
+ title?: string;
12
+ url?: string;
13
+ tags?: string[];
14
+ }
15
+
16
+ interface UpdateBookmarkBody {
17
+ title?: string;
18
+ url?: string;
19
+ tags?: string[];
20
+ }
21
+
22
+ interface BookmarkParams {
23
+ id?: string;
24
+ }
25
+
26
+ export class BookmarkController {
27
+ static async getBookmarks(
28
+ req: Request<{}, {}, {}, BookmarkQueryParams>,
29
+ res: Response,
30
+ next: NextFunction
31
+ ) {
32
+ try {
33
+ const { limit: limitStr, offset: offsetStr } = req.query;
34
+
35
+ const limit = limitStr ? parseInt(limitStr, 10) : 20;
36
+ const offset = offsetStr ? parseInt(offsetStr, 10) : 0;
37
+
38
+ if (isNaN(limit) || limit <= 0) {
39
+ throw new AppError(400, '"limit" must be a positive integer');
40
+ }
41
+
42
+ if (isNaN(offset) || offset < 0) {
43
+ throw new AppError(400, '"offset" must be a non-negative integer');
44
+ }
45
+
46
+ const result = await bookmarkService.getBookmarks(limit, offset);
47
+
48
+ res.status(200).json({
49
+ code: 200,
50
+ message: 'success',
51
+ data: {
52
+ bookmarks: result.bookmarks,
53
+ total: result.total
54
+ }
55
+ });
56
+ } catch (error) {
57
+ next(error);
58
+ }
59
+ }
60
+
61
+ static async createBookmark(
62
+ req: Request<{}, {}, CreateBookmarkBody>,
63
+ res: Response,
64
+ next: NextFunction
65
+ ) {
66
+ try {
67
+ const { title, url, tags } = req.body;
68
+
69
+ if (!title || typeof title !== 'string' || title.trim() === '') {
70
+ throw new AppError(400, '"title" is required and must be a non-empty string');
71
+ }
72
+
73
+ if (!url || typeof url !== 'string') {
74
+ throw new AppError(400, '"url" is required and must be a string');
75
+ }
76
+
77
+ try {
78
+ new URL(url);
79
+ } catch {
80
+ throw new AppError(400, '"url" must be a valid URL');
81
+ }
82
+
83
+ if (tags !== undefined && !Array.isArray(tags)) {
84
+ throw new AppError(400, '"tags" must be an array of strings');
85
+ }
86
+
87
+ if (tags && !tags.every(tag => typeof tag === 'string')) {
88
+ throw new AppError(400, '"tags" must be an array of strings');
89
+ }
90
+
91
+ const bookmark = await bookmarkService.createBookmark({
92
+ title: title.trim(),
93
+ url: url.trim(),
94
+ tags: tags || []
95
+ });
96
+
97
+ res.status(201).json({
98
+ code: 201,
99
+ message: 'success',
100
+ data: bookmark
101
+ });
102
+ } catch (error) {
103
+ next(error);
104
+ }
105
+ }
106
+
107
+ static async updateBookmark(
108
+ req: Request<BookmarkParams, {}, UpdateBookmarkBody>,
109
+ res: Response,
110
+ next: NextFunction
111
+ ) {
112
+ try {
113
+ const { id } = req.params;
114
+ const { title, url, tags } = req.body;
115
+
116
+ if (!id) {
117
+ throw new AppError(400, '"id" parameter is required');
118
+ }
119
+
120
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
121
+ if (!uuidRegex.test(id)) {
122
+ throw new AppError(400, '"id" must be a valid UUID');
123
+ }
124
+
125
+ if (!title || typeof title !== 'string' || title.trim() === '') {
126
+ throw new AppError(400, '"title" is required and must be a non-empty string');
127
+ }
128
+
129
+ if (!url || typeof url !== 'string') {
130
+ throw new AppError(400, '"url" is required and must be a string');
131
+ }
132
+
133
+ try {
134
+ new URL(url);
135
+ } catch {
136
+ throw new AppError(400, '"url" must be a valid URL');
137
+ }
138
+
139
+ if (!tags || !Array.isArray(tags)) {
140
+ throw new AppError(400, '"tags" is required and must be an array of strings');
141
+ }
142
+
143
+ if (!tags.every(tag => typeof tag === 'string')) {
144
+ throw new AppError(400, '"tags" must be an array of strings');
145
+ }
146
+
147
+ const bookmark = await bookmarkService.updateBookmark(id, {
148
+ title: title.trim(),
149
+ url: url.trim(),
150
+ tags
151
+ });
152
+
153
+ res.status(200).json({
154
+ code: 200,
155
+ message: 'success',
156
+ data: bookmark
157
+ });
158
+ } catch (error) {
159
+ next(error);
160
+ }
161
+ }
162
+
163
+ static async deleteBookmark(
164
+ req: Request<BookmarkParams>,
165
+ res: Response,
166
+ next: NextFunction
167
+ ) {
168
+ try {
169
+ const { id } = req.params;
170
+
171
+ if (!id) {
172
+ throw new AppError(400, '"id" parameter is required');
173
+ }
174
+
175
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
176
+ if (!uuidRegex.test(id)) {
177
+ throw new AppError(400, '"id" must be a valid UUID');
178
+ }
179
+
180
+ await bookmarkService.deleteBookmark(id);
181
+
182
+ res.status(204).send();
183
+ } catch (error) {
184
+ next(error);
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,17 @@
1
+ import express from "express";
2
+ import cors from "cors";
3
+
4
+ const app = express();
5
+ app.use(cors());
6
+ app.use(express.json());
7
+
8
+ app.get("/health", (_req, res) => {
9
+ res.json({ status: "ok" });
10
+ });
11
+
12
+ const PORT = process.env.PORT || 3001;
13
+ app.listen(PORT, () => {
14
+ console.log(`Server running on http://localhost:${PORT}`);
15
+ });
16
+
17
+ export default app;