tangled
alpha
login
or
join now
thecoded.prof
/
CMU
0
fork
atom
CMU Coding Bootcamp
0
fork
atom
overview
issues
pulls
pipelines
feat: Dec 11
thecoded.prof
2 months ago
be13960b
6d383ad5
verified
This commit was signed with the committer's
known signature
.
thecoded.prof
SSH Key Fingerprint:
SHA256:ePn0u8NlJyz3J4Zl9MHOYW3f4XKoi5K1I4j53bwpG0U=
+419
-7
4 changed files
expand all
collapse all
unified
split
react
src
App.tsx
server
books.ts
index.ts
posts.ts
+2
-2
react/src/App.tsx
···
9
9
<>
10
10
<title>Posts</title>
11
11
<div className="w-screen p-5 flex flex-col items-center gap-10">
12
12
-
<nav className="flex justify-between items-center w-full sticky top-0">
12
12
+
<nav className="flex justify-between items-center w-full sticky top-0 max-w-3xl">
13
13
<h1 className="text-3xl font-bold text-left">Blog App</h1>
14
14
{searchBarDisplay ? (
15
15
<>
···
17
17
type="text"
18
18
placeholder="Search..."
19
19
className="border border-gray-300 rounded px-2 py-1"
20
20
-
onChange={(e) => {
20
20
+
onChange={(_e) => {
21
21
// Implement search functionality here
22
22
}}
23
23
/>
+5
-5
server/books.ts
···
128
128
author: "string",
129
129
};
130
130
131
131
-
const validateBook = (task: { [key: string]: any }): Book => {
131
131
+
const validateBook = (book: { [key: string]: any }): Book => {
132
132
let missingKeys = ["id", "title", "author"].filter(
133
133
-
(key) => !Object.keys(task).includes(key),
133
133
+
(key) => !Object.keys(book).includes(key),
134
134
);
135
135
-
let extraKeys = Object.keys(task).filter(
135
135
+
let extraKeys = Object.keys(book).filter(
136
136
(key) => !["id", "title", "author"].includes(key),
137
137
);
138
138
-
let badValues = Object.entries(task)
138
138
+
let badValues = Object.entries(book)
139
139
.filter(([key, value]) => {
140
140
if (key === "id") return typeof value !== "string" || !ISBN13.test(value);
141
141
if (key === "title") return typeof value !== "string";
···
149
149
if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) {
150
150
throw new BadDataIssues(missingKeys, extraKeys, badValues);
151
151
}
152
152
-
return task as Book;
152
152
+
return book as Book;
153
153
};
154
154
155
155
const auth = async (req: Request, res: Response, next: NextFunction) => {
+2
server/index.ts
···
1
1
import express from "express";
2
2
import tasks from "./tasks.ts";
3
3
import books from "./books.ts";
4
4
+
import posts from "./posts.ts";
4
5
5
6
const app = express();
6
7
const port = process.env["NODE_PORT"] ?? 5173;
7
8
8
9
app.use("/tasks", tasks);
9
10
app.use("/books", books);
11
11
+
app.use("/posts", posts);
10
12
11
13
app.listen(port, () => {
12
14
console.log(`Server listening on port ${port}`);
+410
server/posts.ts
···
1
1
+
import express, {
2
2
+
type NextFunction,
3
3
+
type Request,
4
4
+
type Response,
5
5
+
} from "express";
6
6
+
import { writeFile, exists, readFile } from "fs/promises";
7
7
+
8
8
+
const uuidv7 = /^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
9
9
+
10
10
+
interface Comment {
11
11
+
id: string;
12
12
+
author: string;
13
13
+
content: string;
14
14
+
}
15
15
+
16
16
+
interface Post {
17
17
+
id: string;
18
18
+
title: string;
19
19
+
author: string;
20
20
+
publicationDate: number;
21
21
+
readTime: number;
22
22
+
content: string;
23
23
+
likes: number;
24
24
+
tags: string[];
25
25
+
comments: Comment[];
26
26
+
}
27
27
+
28
28
+
const getPosts: () => Promise<Post[]> = async () => {
29
29
+
if (!(await exists("posts.json"))) {
30
30
+
await writeFile("posts.json", JSON.stringify([]));
31
31
+
}
32
32
+
const file = await readFile("posts.json", "utf-8");
33
33
+
if (file.length === 0) {
34
34
+
return [];
35
35
+
}
36
36
+
return JSON.parse(file);
37
37
+
};
38
38
+
39
39
+
const updatePost = async (post: Post): Promise<void> => {
40
40
+
const posts = await getPosts();
41
41
+
const index = posts.findIndex((t) => t.id === post.id);
42
42
+
if (index !== -1) {
43
43
+
posts[index] = post;
44
44
+
} else {
45
45
+
posts.push(post);
46
46
+
}
47
47
+
await writeFile("posts.json", JSON.stringify(posts));
48
48
+
};
49
49
+
50
50
+
const deletePost = async (id: string): Promise<void> => {
51
51
+
const posts = await getPosts();
52
52
+
const index = posts.findIndex((t) => t.id === id);
53
53
+
if (index !== -1) {
54
54
+
posts.splice(index, 1);
55
55
+
await writeFile("posts.json", JSON.stringify(posts));
56
56
+
}
57
57
+
};
58
58
+
59
59
+
const keyTypes = {
60
60
+
title: "string",
61
61
+
author: "string",
62
62
+
content: "string",
63
63
+
};
64
64
+
65
65
+
class BadDataIssues extends Error {
66
66
+
constructor(
67
67
+
public missingKeys: string[],
68
68
+
public extraKeys: string[],
69
69
+
public badValues: [string, string][],
70
70
+
) {
71
71
+
super("Bad data issues");
72
72
+
}
73
73
+
}
74
74
+
75
75
+
const validateUpdate = (post: Record<string, any>) => {
76
76
+
let missingKeys = ["title", "author", "content", "tags"].filter(
77
77
+
(key) => !Object.keys(post).includes(key),
78
78
+
);
79
79
+
let extraKeys = Object.keys(post).filter(
80
80
+
(key) => !["title", "author", "content", "tags"].includes(key),
81
81
+
);
82
82
+
let badValues = Object.entries(post)
83
83
+
.filter(([key, value]) => {
84
84
+
if (key === "title") return typeof value !== "string";
85
85
+
if (key === "author") return typeof value !== "string";
86
86
+
if (key === "content") return typeof value !== "string";
87
87
+
if (key === "tags")
88
88
+
return (
89
89
+
!Array.isArray(value) ||
90
90
+
!value.every((tag) => typeof tag === "string")
91
91
+
);
92
92
+
return false;
93
93
+
})
94
94
+
.map(
95
95
+
([key, _value]) =>
96
96
+
[key, keyTypes[key as keyof typeof keyTypes]] as [string, string],
97
97
+
);
98
98
+
if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) {
99
99
+
throw new BadDataIssues(missingKeys, extraKeys, badValues);
100
100
+
}
101
101
+
return post as Pick<Post, "title" | "author" | "content" | "tags">;
102
102
+
};
103
103
+
104
104
+
const validateComment = (comment: Record<string, any>) => {
105
105
+
let missingKeys = ["author", "content"].filter(
106
106
+
(key) => !Object.keys(comment).includes(key),
107
107
+
);
108
108
+
let extraKeys = Object.keys(comment).filter(
109
109
+
(key) => !["author", "content"].includes(key),
110
110
+
);
111
111
+
let badValues = Object.entries(comment)
112
112
+
.filter(([key, value]) => {
113
113
+
if (key === "author") return typeof value !== "string";
114
114
+
if (key === "content") return typeof value !== "string";
115
115
+
return false;
116
116
+
})
117
117
+
.map(
118
118
+
([key, _value]) =>
119
119
+
[key, keyTypes[key as keyof typeof keyTypes]] as [string, string],
120
120
+
);
121
121
+
if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) {
122
122
+
throw new BadDataIssues(missingKeys, extraKeys, badValues);
123
123
+
}
124
124
+
return comment as Pick<Comment, "author" | "content">;
125
125
+
};
126
126
+
127
127
+
enum ErrorType {
128
128
+
BadData,
129
129
+
MalformedID,
130
130
+
NotFound,
131
131
+
CNotFound,
132
132
+
InvalidQuery,
133
133
+
}
134
134
+
135
135
+
class PostError extends Error {
136
136
+
status: number;
137
137
+
constructor(type: ErrorType) {
138
138
+
let msg: string;
139
139
+
let st: number;
140
140
+
switch (type) {
141
141
+
case ErrorType.BadData:
142
142
+
msg = "Bad data";
143
143
+
st = 400;
144
144
+
break;
145
145
+
case ErrorType.NotFound:
146
146
+
msg = "Post not found";
147
147
+
st = 404;
148
148
+
break;
149
149
+
case ErrorType.MalformedID:
150
150
+
msg = "Malformed ID (should be a UUIDv7)";
151
151
+
st = 400;
152
152
+
break;
153
153
+
case ErrorType.CNotFound:
154
154
+
msg = "Comment not found";
155
155
+
st = 404;
156
156
+
break;
157
157
+
case ErrorType.InvalidQuery:
158
158
+
msg = "Invalid query";
159
159
+
st = 400;
160
160
+
break;
161
161
+
default:
162
162
+
msg = "Unknown error";
163
163
+
st = 500;
164
164
+
}
165
165
+
super(msg);
166
166
+
this.name = "PostError";
167
167
+
this.status = st;
168
168
+
}
169
169
+
}
170
170
+
171
171
+
const errorHandler = (
172
172
+
err: Error,
173
173
+
_req: Request,
174
174
+
res: Response,
175
175
+
_next: NextFunction,
176
176
+
) => {
177
177
+
if (err instanceof PostError) {
178
178
+
let msg = err.message.replace("{{id}}", res.locals.id ?? "");
179
179
+
180
180
+
let obj: Map<string, any> = new Map<string, any>([
181
181
+
["error", `${err.name}: ${msg}`],
182
182
+
]);
183
183
+
184
184
+
if (res.locals.bdi) {
185
185
+
if (res.locals.bdi.missingKeys.length > 0) {
186
186
+
obj.set("missingKeys", res.locals.bdi.missingKeys);
187
187
+
}
188
188
+
if (res.locals.bdi.extraKeys.length > 0) {
189
189
+
obj.set("extraKeys", res.locals.bdi.extraKeys);
190
190
+
}
191
191
+
if (res.locals.bdi.badValues.length > 0) {
192
192
+
obj.set("badValues", res.locals.bdi.badValues);
193
193
+
}
194
194
+
}
195
195
+
196
196
+
res.status(err.status).json(Object.fromEntries(obj.entries()));
197
197
+
} else {
198
198
+
console.error(err.stack);
199
199
+
res.status(500).json({ error: "Internal Server Error" });
200
200
+
}
201
201
+
};
202
202
+
203
203
+
const router = express.Router();
204
204
+
205
205
+
router.use((req, _res, next) => {
206
206
+
console.log(`Recieved ${req.method} request to ${req.url}`);
207
207
+
next();
208
208
+
});
209
209
+
210
210
+
router.get("/", async (_req, res) => {
211
211
+
const posts = await getPosts();
212
212
+
res.json(posts);
213
213
+
});
214
214
+
215
215
+
router.post("/", async (req, res) => {
216
216
+
try {
217
217
+
const details = validateUpdate(req.body);
218
218
+
const post: Post = {
219
219
+
id: Bun.randomUUIDv7(),
220
220
+
publicationDate: Date.now(),
221
221
+
likes: 0,
222
222
+
comments: [],
223
223
+
readTime: Math.ceil(details.content.length / 200),
224
224
+
...details,
225
225
+
};
226
226
+
await updatePost(post);
227
227
+
res.json(post);
228
228
+
} catch (err) {
229
229
+
if (err instanceof BadDataIssues) {
230
230
+
res.locals.bdi = err;
231
231
+
throw new PostError(ErrorType.BadData);
232
232
+
} else {
233
233
+
throw err;
234
234
+
}
235
235
+
}
236
236
+
});
237
237
+
238
238
+
router.put("/:id", async (req, res) => {
239
239
+
res.locals.id = req.params.id;
240
240
+
if (!uuidv7.test(res.locals.id)) {
241
241
+
throw new PostError(ErrorType.MalformedID);
242
242
+
}
243
243
+
const posts = await getPosts();
244
244
+
const post = posts.find((p) => p.id === res.locals.id);
245
245
+
if (!post) throw new PostError(ErrorType.NotFound);
246
246
+
try {
247
247
+
const details = validateUpdate(req.body);
248
248
+
post.title = details.title;
249
249
+
post.author = details.author;
250
250
+
post.content = details.content;
251
251
+
post.readTime = Math.ceil(details.content.length / 200);
252
252
+
await updatePost(post);
253
253
+
res.json(post);
254
254
+
} catch (err) {
255
255
+
if (err instanceof BadDataIssues) {
256
256
+
res.locals.bdi = err;
257
257
+
throw new PostError(ErrorType.BadData);
258
258
+
} else {
259
259
+
throw err;
260
260
+
}
261
261
+
}
262
262
+
});
263
263
+
264
264
+
router.delete("/:id", async (req, res) => {
265
265
+
res.locals.id = req.params.id;
266
266
+
if (!uuidv7.test(res.locals.id)) {
267
267
+
throw new PostError(ErrorType.MalformedID);
268
268
+
}
269
269
+
const posts = await getPosts();
270
270
+
const post = posts.find((p) => p.id === res.locals.id);
271
271
+
if (!post) throw new PostError(ErrorType.NotFound);
272
272
+
await deletePost(res.locals.id);
273
273
+
res.json(post);
274
274
+
});
275
275
+
276
276
+
router.post("/:id/like", async (req, res) => {
277
277
+
res.locals.id = req.params.id;
278
278
+
if (!uuidv7.test(res.locals.id)) {
279
279
+
throw new PostError(ErrorType.MalformedID);
280
280
+
}
281
281
+
const posts = await getPosts();
282
282
+
const post = posts.find((p) => p.id === res.locals.id);
283
283
+
if (!post) throw new PostError(ErrorType.NotFound);
284
284
+
post.likes++;
285
285
+
await updatePost(post);
286
286
+
res.json({ likes: post.likes });
287
287
+
});
288
288
+
289
289
+
router.post("/:id/comment", async (req, res) => {
290
290
+
res.locals.id = req.params.id;
291
291
+
if (!uuidv7.test(res.locals.id)) {
292
292
+
throw new PostError(ErrorType.MalformedID);
293
293
+
}
294
294
+
const posts = await getPosts();
295
295
+
const post = posts.find((p) => p.id === res.locals.id);
296
296
+
if (!post) throw new PostError(ErrorType.NotFound);
297
297
+
try {
298
298
+
const details = validateComment(req.body);
299
299
+
const comment: Comment = {
300
300
+
id: Bun.randomUUIDv7(),
301
301
+
...details,
302
302
+
};
303
303
+
post.comments.push(comment);
304
304
+
await updatePost(post);
305
305
+
res.json(post);
306
306
+
} catch (err) {
307
307
+
if (err instanceof BadDataIssues) {
308
308
+
res.locals.bdi = err;
309
309
+
throw new PostError(ErrorType.BadData);
310
310
+
} else {
311
311
+
throw err;
312
312
+
}
313
313
+
}
314
314
+
});
315
315
+
316
316
+
router.get("/:id/comments", async (req, res) => {
317
317
+
res.locals.id = req.params.id;
318
318
+
if (!uuidv7.test(res.locals.id)) {
319
319
+
throw new PostError(ErrorType.MalformedID);
320
320
+
}
321
321
+
const posts = await getPosts();
322
322
+
const post = posts.find((p) => p.id === res.locals.id);
323
323
+
if (!post) throw new PostError(ErrorType.NotFound);
324
324
+
res.json(post.comments);
325
325
+
});
326
326
+
327
327
+
router.put("/comments/:id", async (req, res) => {
328
328
+
res.locals.cid = req.params.id;
329
329
+
if (!uuidv7.test(res.locals.id)) {
330
330
+
throw new PostError(ErrorType.MalformedID);
331
331
+
}
332
332
+
const posts = await getPosts();
333
333
+
const post = posts.find((p) =>
334
334
+
p.comments.find((c) => c.id === res.locals.id),
335
335
+
);
336
336
+
if (!post) throw new PostError(ErrorType.CNotFound);
337
337
+
const comment = post.comments.find((c) => c.id === res.locals.id);
338
338
+
if (!comment) throw new PostError(ErrorType.CNotFound);
339
339
+
try {
340
340
+
const details = validateComment(req.body);
341
341
+
Object.assign(comment, details);
342
342
+
await updatePost(post);
343
343
+
res.json(comment);
344
344
+
} catch (err) {
345
345
+
if (err instanceof BadDataIssues) {
346
346
+
res.locals.bdi = err;
347
347
+
throw new PostError(ErrorType.BadData);
348
348
+
} else {
349
349
+
throw err;
350
350
+
}
351
351
+
}
352
352
+
});
353
353
+
354
354
+
router.delete("/comments/:id", async (req, res) => {
355
355
+
res.locals.cid = req.params.id;
356
356
+
if (!uuidv7.test(res.locals.id)) {
357
357
+
throw new PostError(ErrorType.MalformedID);
358
358
+
}
359
359
+
const posts = await getPosts();
360
360
+
const post = posts.find((p) =>
361
361
+
p.comments.find((c) => c.id === res.locals.id),
362
362
+
);
363
363
+
if (!post) throw new PostError(ErrorType.CNotFound);
364
364
+
const comment = post.comments.find((c) => c.id === res.locals.id);
365
365
+
if (!comment) throw new PostError(ErrorType.CNotFound);
366
366
+
post.comments = post.comments.filter((c) => c.id !== res.locals.id);
367
367
+
await updatePost(post);
368
368
+
res.json(comment);
369
369
+
});
370
370
+
371
371
+
router.get("/search", async (req, res) => {
372
372
+
const query = req.query.q?.toString();
373
373
+
if (!query) {
374
374
+
throw new PostError(ErrorType.InvalidQuery);
375
375
+
}
376
376
+
const posts = await getPosts();
377
377
+
378
378
+
const filteredPosts = posts.filter((post) => {
379
379
+
const titleMatch = post.title.includes(query);
380
380
+
const contentMatch = post.content.includes(query);
381
381
+
return titleMatch && contentMatch;
382
382
+
});
383
383
+
384
384
+
res.json(filteredPosts);
385
385
+
});
386
386
+
387
387
+
router.get("/filter", async (req, res) => {
388
388
+
const filter = req.query as Partial<{ author: string; tag: string }>;
389
389
+
if (!filter) {
390
390
+
throw new PostError(ErrorType.InvalidQuery);
391
391
+
}
392
392
+
const posts = await getPosts();
393
393
+
394
394
+
const filteredPosts = posts.filter((post) => {
395
395
+
const authorMatch = filter.author
396
396
+
? post.author.includes(filter.author)
397
397
+
: true;
398
398
+
const tagsMatch = filter.tag
399
399
+
? post.tags.some((tag) => tag === filter.tag)
400
400
+
: true;
401
401
+
402
402
+
return authorMatch && tagsMatch;
403
403
+
});
404
404
+
405
405
+
res.json(filteredPosts);
406
406
+
});
407
407
+
408
408
+
router.use(errorHandler);
409
409
+
410
410
+
export default router;