Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@
{"_type":"issue","id":"gh-toy-69s","title":"Handle SIGINT gracefully (Ctrl-C)","description":"The tool should catch SIGINT (Ctrl-C) and exit cleanly without a Python traceback or incomplete output. Acceptance: Ctrl-C prints 'Interrupted.' and exits with code 130, no stack trace shown, partial output files cleaned up.","status":"open","priority":2,"issue_type":"feature","owner":"azure-pipeline@test.com","created_at":"2026-05-12T20:47:14Z","created_by":"Azure Pipeline","updated_at":"2026-05-12T20:47:14Z","external_ref":"gh-5","labels":["e2e-testing"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"gh-toy-aqd","title":"Add JSON output mode via --json flag","description":"Add a --json flag so all command output can be emitted as machine-readable JSON for scripting and CI pipelines. Acceptance: --json flag accepted on any subcommand, output is valid JSON, human-readable output is default, errors also JSON-formatted.","status":"open","priority":2,"issue_type":"feature","owner":"azure-pipeline@test.com","created_at":"2026-05-12T20:47:13Z","created_by":"Azure Pipeline","updated_at":"2026-05-12T20:47:13Z","external_ref":"gh-4","labels":["e2e-testing"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"gh-toy-s5k","title":"Tag filtering endpoint","description":"GET /api/notes?tag=work returns only notes with that tag. Already partially implemented - needs tests.","status":"open","priority":2,"issue_type":"feature","owner":"azure-pipeline@test.com","created_at":"2026-05-12T20:46:52Z","created_by":"Azure Pipeline","updated_at":"2026-05-12T20:46:52Z","labels":["e2e-testing"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"gh-toy-yjy","title":"Note","status":"open","priority":2,"issue_type":"task","assignee":"akhil@apralabs.com","owner":"akhil@apralabs.com","created_at":"2026-05-12T05:30:38Z","created_by":"Akhil Kumar","updated_at":"2026-05-12T19:58:08Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"gh-toy-06i","title":"Pagination","status":"open","priority":2,"issue_type":"task","owner":"akhil@apralabs.com","created_at":"2026-05-12T05:30:33Z","created_by":"Akhil Kumar","updated_at":"2026-05-12T05:30:33Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"gh-toy-gw1","title":"Full-text","status":"open","priority":2,"issue_type":"task","owner":"akhil@apralabs.com","created_at":"2026-05-12T05:30:24Z","created_by":"Akhil Kumar","updated_at":"2026-05-12T05:30:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"gh-toy-bzq","title":"Tag filtering endpoint","description":"GET /api/notes?tag=work returns only notes with that tag. Already partially implemented — needs tests.","status":"open","priority":2,"issue_type":"feature","owner":"azure-pipeline@test.com","created_at":"2026-05-09T03:13:59Z","created_by":"Azure Pipeline","updated_at":"2026-05-09T03:13:59Z","dependency_count":0,"dependent_count":0,"comment_count":0}
8 changes: 4 additions & 4 deletions feature_list.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
{
"name": "Tag filtering endpoint",
"description": "GET /api/notes?tag=work returns only notes with that tag. Already partially implemented — needs tests.",
"passes": false
"passes": true
},
{
"name": "Full-text search",
"description": "GET /api/notes?q=meeting searches title and content. Already partially implemented — needs edge case tests (empty query, no matches).",
"passes": false
"passes": true
},
{
"name": "Pagination support",
"description": "GET /api/notes?page=1&limit=10 returns paginated results with total count in response. Add pagination metadata: { data: [...], total: N, page: N, limit: N }.",
"passes": false
"passes": true
},
{
"name": "Note archiving",
"description": "Add an 'archived' boolean field to notes. POST /api/notes/:id/archive and /api/notes/:id/unarchive endpoints. GET /api/notes excludes archived notes by default; GET /api/notes?include_archived=true includes them.",
"passes": false
"passes": true
}
]
54 changes: 54 additions & 0 deletions progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,57 @@ SESSION_DONE

---
Status after 2 sessions: 2/4 features done, 2 remaining (Pagination, Note archiving)

## Session 3 — 2026-05-15

### Feature: Tag filtering endpoint
- Tag filtering already implemented in src/api/notes.ts (lines 17-20)
- Existing test covered single-tag case; added two more tests:
- No match: returns empty array when tag doesn't exist on any note
- Multiple tags: note with multiple tags is returned when filtering by any one of them
- All 23 tests pass
- Updated feature_list.json: "Tag filtering endpoint" -> passes: true

SESSION_DONE

## Session 4 — 2026-05-15

### Feature: Full-text search
- Search by ?q= already implemented in src/api/notes.ts (lines 22-28) — case-insensitive, searches title and content
- Empty string query handled correctly: `if (q)` is falsy for "", so no filter applied (returns all)
- Added 4 edge case tests:
- Match in content (not title): verifies content field is searched
- No match returns empty array
- Empty query string returns all notes
- Case-insensitive: uppercase title found by lowercase query
- All 27 tests pass
- Updated feature_list.json: "Full-text search" -> passes: true

SESSION_DONE

## Session 5 — 2026-05-15

### Feature: Pagination support
- Changed GET /api/notes response format from plain array to `{ data, total, page, limit }`
- Default page=1, limit=20; both params are optional
- Updated all 10 existing GET /api/notes tests to use `res.body.data` instead of `res.body`
- Added 3 new pagination tests: page+limit params, default values, page beyond total
- All 30 tests pass
- Updated feature_list.json: "Pagination support" -> passes: true

SESSION_DONE

## Session 6 — 2026-05-15

### Feature: Note archiving
- Added `archived: boolean` (required, default false) to Note interface in src/models/note.ts
- Added `setArchived(id, archived)` method to noteStore — dedicated method rather than widening UpdateNoteInput
- Updated POST /api/notes to set `archived: false` on creation
- Updated GET /api/notes to filter out archived notes before tag/q/pagination (so `total` stays accurate); `?include_archived=true` bypasses the filter
- Added POST /api/notes/:id/archive and POST /api/notes/:id/unarchive endpoints (200+body on success, 404 if not found)
- GET /api/notes/:id still returns archived notes (plan only says to exclude from collection)
- Added 8 tests covering: hide archived from default GET, include_archived=true, unarchive restores visibility, GET /:id works on archived, 404 on both endpoints, pagination total excludes archived, archive response shape
- All 38 tests pass
- Updated feature_list.json: "Note archiving" -> passes: true

SESSION_DONE
35 changes: 33 additions & 2 deletions src/api/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ const router = Router();
// TODO: Return 400 if tags array contains duplicates
// TODO: Add updatedAt timestamp on PUT (currently not updated)

// List all notes, with optional tag filter and search
// List all notes, with optional tag filter, search, and pagination
router.get("/", (req: Request, res: Response) => {
let notes = noteStore.getAll();

if (req.query.include_archived !== "true") {
notes = notes.filter((n) => !n.archived);
}

const tag = req.query.tag as string | undefined;
if (tag) {
notes = notes.filter((n) => n.tags.includes(tag));
Expand All @@ -27,7 +31,13 @@ router.get("/", (req: Request, res: Response) => {
);
}

res.json(notes);
const total = notes.length;
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.max(1, parseInt(req.query.limit as string) || 20);
const start = (page - 1) * limit;
const data = notes.slice(start, start + limit);

res.json({ data, total, page, limit });
});

// Get a single note by ID
Expand All @@ -52,6 +62,7 @@ router.post("/", (req: Request, res: Response) => {
const note: Note = {
id: uuidv4(),
...result.data,
archived: false,
createdAt: now,
updatedAt: now,
};
Expand All @@ -78,6 +89,26 @@ router.put("/:id", (req: Request<{ id: string }>, res: Response) => {
res.json(updated);
});

// Archive a note
router.post("/:id/archive", (req: Request<{ id: string }>, res: Response) => {
const updated = noteStore.setArchived(req.params.id, true);
if (!updated) {
res.status(404).json({ error: "Note not found" });
return;
}
res.json(updated);
});

// Unarchive a note
router.post("/:id/unarchive", (req: Request<{ id: string }>, res: Response) => {
const updated = noteStore.setArchived(req.params.id, false);
if (!updated) {
res.status(404).json({ error: "Note not found" });
return;
}
res.json(updated);
});

// Delete a note
router.delete("/:id", (req: Request<{ id: string }>, res: Response) => {
const deleted = noteStore.delete(req.params.id);
Expand Down
25 changes: 24 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
import app from "./app";
import app from "./app";
import { version } from "../package.json";

const args = process.argv.slice(2);

if (args.includes("--version")) {
console.log(`v${version}`);
process.exit(0);
}

if (args.includes("help")) {
console.log("NoteAPI - A simple REST API for managing notes");
console.log("");
console.log("Usage:");
console.log(" npm start Start the server");
console.log(" npm start -- --version Show version information");
console.log(" npm start -- help Show this help message");
console.log("");
console.log("Environment Variables:");
console.log(" PORT Port to listen on (default: 3000)");
process.exit(0);
}

const PORT = process.env.PORT ?? 3000;

app.listen(PORT, () => {
console.log(`NoteAPI running on http://localhost:${PORT}`);
});

// Updated during e2e sprint
9 changes: 9 additions & 0 deletions src/models/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface Note {
title: string;
content: string;
tags: string[];
archived: boolean;
createdAt: string;
updatedAt: string;
}
Expand Down Expand Up @@ -42,6 +43,14 @@ export const noteStore = {
return updated;
},

setArchived(id: string, archived: boolean): Note | undefined {
const existing = notes.get(id);
if (!existing) return undefined;
const updated = { ...existing, archived };
notes.set(id, updated);
return updated;
},

delete(id: string): boolean {
return notes.delete(id);
},
Expand Down
14 changes: 7 additions & 7 deletions src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CreateNoteInput, UpdateNoteInput } from "../models/note";
import { CreateNoteInput, UpdateNoteInput } from "../models/note";

export interface ValidationError {
field: string;
Expand All @@ -20,8 +20,8 @@ export function validateCreateInput(
errors.push({ field: "title", message: "Title is required and must be a non-empty string" });
}

if (typeof obj.content !== "string") {
errors.push({ field: "content", message: "Content must be a string" });
if (typeof obj.content !== "string" || obj.content.trim().length === 0) {
errors.push({ field: "content", message: "Content is required and must be a non-empty string" });
}

if (obj.tags !== undefined) {
Expand All @@ -36,7 +36,7 @@ export function validateCreateInput(
valid: true,
data: {
title: (obj.title as string).trim(),
content: obj.content as string,
content: (obj.content as string).trim(),
tags: (obj.tags as string[] | undefined) ?? [],
},
};
Expand All @@ -57,8 +57,8 @@ export function validateUpdateInput(
errors.push({ field: "title", message: "Title must be a non-empty string" });
}

if (obj.content !== undefined && typeof obj.content !== "string") {
errors.push({ field: "content", message: "Content must be a string" });
if (obj.content !== undefined && (typeof obj.content !== "string" || obj.content.trim().length === 0)) {
errors.push({ field: "content", message: "Content must be a non-empty string" });
}

if (obj.tags !== undefined) {
Expand All @@ -71,7 +71,7 @@ export function validateUpdateInput(

const data: UpdateNoteInput = {};
if (obj.title !== undefined) data.title = (obj.title as string).trim();
if (obj.content !== undefined) data.content = obj.content as string;
if (obj.content !== undefined) data.content = (obj.content as string).trim();
if (obj.tags !== undefined) data.tags = obj.tags as string[];

return { valid: true, data };
Expand Down
Loading
Loading