Skip to content

feat: share todo with email #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
EMAIL_SERVICE_SECRET_TOKEN=a470af17-4371-4362-8b51-335c243ee5eb
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Deno v2 Todo API
# Deno v2 Todo API :beers:

Battle testing Deno v2 with a simple REST API for managing todos.

Expand Down Expand Up @@ -27,6 +27,7 @@ deno run test
- GET /api/todos - Retrieves all todos.
- GET /api/todos/:id - Retrieves a specific todo by ID.
- POST /api/todos - Creates a new todo
- POST /api/todos/:id/shar - Shares an existing todo
Copy link
Preview

Copilot AI Apr 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The share endpoint path appears to be misspelled as '/shar'. Please correct it to '/share' to match the implementation.

Suggested change
- POST /api/todos/:id/shar - Shares an existing todo
- POST /api/todos/:id/share - Shares an existing todo

Copilot uses AI. Check for mistakes.

- PUT /api/todos - Creates a new todo.
- PATCH /api/todos/:id - Updates a specific todo by ID.
- DELETE /api/todos/:id - Archives a specific todo by ID.
67 changes: 67 additions & 0 deletions src/controllers/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,78 @@ import z from "https://deno.land/x/[email protected]/index.ts";
import { MemoDatabase, TodoSchema } from "../database.ts";
import { respond } from "../utilities.ts";

const sendEmails = (todo: z.infer<typeof TodoSchema>, sharedWith: string[]) => {
// await emailService.send({
// to: sharedWith,
// subject: `Todo shared with you: ${todo.text}`,
// body: `You have been shared a todo: ${todo.text}.`,
// });

const token = Deno.env.get("EMAIL_SERVICE_SECRET_TOKEN");

if (!token) {
throw new Error("Email service token is not set");
}

console.log(`Sending email with token: ${token}`);
console.log(`Sending emails to: ${sharedWith.join(", ")}`);
console.log(`Todo details: ${JSON.stringify(todo)}`);
return Promise.resolve();
};

const handleShare = async (
req: Request,
dbClient: MemoDatabase,
validatedBody: z.infer<typeof TodoSchema>
): Promise<Response> => {
const pattern = new URLPattern({ pathname: "/api/todos/:id/share" });
const match = pattern.exec(req.url);
const id = Number(match?.pathname.groups.id);

if (Number.isNaN(id)) {
return respond({ error: "Invalid id" }, 400);
}

try {
const existingTodo = dbClient.todos.getById(id);
if (!existingTodo) {
return respond({ error: "Todo not found" }, 404);
}

// Important: Check that todo hasn't been shared with the same user

Comment on lines +43 to +44
Copy link
Preview

Copilot AI Apr 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deduplication check for shared recipients mentioned in the comment is not implemented. Consider adding logic to prevent sharing the same todo with duplicate email addresses.

Suggested change
// Important: Check that todo hasn't been shared with the same user
// Important: Check that todo hasn't been shared with the same user
const deduplicatedSharedWith = Array.from(new Set(validatedBody.sharedWith));

Copilot uses AI. Check for mistakes.

const updatedTodo = dbClient.todos.update(id, {
...existingTodo,
sharedWith: validatedBody.sharedWith,
});

if (!updatedTodo) {
return respond({ error: "Todo not found or cannot be updated" }, 404);
}

await sendEmails(updatedTodo, updatedTodo.sharedWith!);
return respond(updatedTodo);
} catch (error) {
if (error instanceof z.ZodError) {
return respond({ error: "Invalid data", issues: error.errors }, 400);
}
return respond({ error: "Unexpected error", issues: error }, 400);
}
};

export const handlePost = async (req: Request, dbClient: MemoDatabase): Promise<Response> => {
try {
const body = await req.json();
const validatedBody = TodoSchema.parse(body);

const url = new URL(req.url);
const { pathname } = url;

if (pathname.endsWith("/share")) {
const result = await handleShare(req, dbClient, validatedBody);
return result;
}

const newTodo = dbClient.todos.add(validatedBody);
return respond(newTodo);
} catch (error) {
Expand Down
21 changes: 13 additions & 8 deletions src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const TodoSchema = z.object({
createdAt: z.string().nullish(),
updatedAt: z.string().nullish(),
archivedAt: z.string().nullish(),
sharedWith: z.array(z.string()).nullish(),
});

export type Todo = z.infer<typeof TodoSchema>;
Expand Down Expand Up @@ -44,10 +45,10 @@ class Todos {

getById(id: number): Todo | undefined {
if (!this.isSelectableAndMutable(id)) {
return undefined
return undefined;
}

return { id, ...this.todos[id] };
return this.todos[id];
}

add(todo: Todo): Todo {
Expand All @@ -57,14 +58,18 @@ class Todos {
return newTodo;
}

update(id: number, todo: Todo): Todo | undefined {
if (!this.isSelectableAndMutable(id)) {
return undefined
update(inputId: number, inputTodo: Todo): Todo | undefined {
if (!this.isSelectableAndMutable(inputId)) {
return undefined;
}

if (this.todos[id]) {
this.todos[id] = { ...this.todos[id], id, ...todo, updatedAt: new Date().toISOString() };
return this.todos[id];
if (this.todos[inputId]) {
this.todos[inputId] = {
...this.todos[inputId],
updatedAt: new Date().toISOString(),
sharedWith: inputTodo.sharedWith ?? ["[email protected]"],
};
Comment on lines +69 to +71
Copy link
Preview

Copilot AI Apr 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A hardcoded default email value is used when sharedWith is not provided. Consider removing or parameterizing this default to avoid unexpected behavior in production.

Suggested change
updatedAt: new Date().toISOString(),
sharedWith: inputTodo.sharedWith ?? ["[email protected]"],
};
updatedAt: new Date().toISOString()
};
if (inputTodo.sharedWith) {
this.todos[inputId].sharedWith = inputTodo.sharedWith;
}

Copilot uses AI. Check for mistakes.

return this.todos[inputId];
}
}

Expand Down
11 changes: 7 additions & 4 deletions src/main_test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { assertEquals, assertExists } from "@std/assert";
import { router } from "./routes/router.ts";
import { MemoDatabase } from "./database.ts";

const dbClient = new MemoDatabase();

const mockRequest = async (method: string, url: string, body: object = {}): Promise<Response> => {
const init: RequestInit = {
Expand All @@ -11,7 +8,7 @@ const mockRequest = async (method: string, url: string, body: object = {}): Prom
};
if (method !== "GET") init.body = JSON.stringify(body);
const req = new Request(url, init);
return await router(req, dbClient);
return await router(req);
};

// Happy case tests
Expand Down Expand Up @@ -47,6 +44,12 @@ methods.forEach((method) => {
});
});

Deno.test("POST /api/todos/share shares a todo with a user", async () => {
const shareData = { todoId: 1, userId: 2 };
const response = await mockRequest("POST", "http://localhost:8080/api/todos/share", shareData);
Comment on lines +47 to +49
Copy link
Preview

Copilot AI Apr 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test URL for sharing a todo is missing the required todo id parameter as per the endpoint '/api/todos/:id/share'. Please update the URL to include an id, for example, '/api/todos/1/share'.

Suggested change
Deno.test("POST /api/todos/share shares a todo with a user", async () => {
const shareData = { todoId: 1, userId: 2 };
const response = await mockRequest("POST", "http://localhost:8080/api/todos/share", shareData);
Deno.test("POST /api/todos/:id/share shares a todo with a user", async () => {
const shareData = { todoId: 1, userId: 2 };
const response = await mockRequest("POST", "http://localhost:8080/api/todos/1/share", shareData);

Copilot uses AI. Check for mistakes.

await response.json();
});

Deno.test("DELETE /api/todos/:id archives a specific todo", async () => {
const response = await mockRequest("DELETE", "http://localhost:8080/api/todos/1");
assertEquals(response.status, 200);
Expand Down
7 changes: 4 additions & 3 deletions src/routes/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { MemoDatabase } from "../database.ts";
import { handleDelete, handleGet, handlePatch, handlePost } from "../controllers/index.ts";
import { respond } from "../utilities.ts";

const dbClient = new MemoDatabase();

export async function router(req: Request, dbClient: MemoDatabase): Promise<Response> {
const { method, url } = req
export async function router(req: Request): Promise<Response> {
const { method, url } = req;
const { pathname } = new URL(url);

if (pathname.startsWith("/api/todos") && method === "GET") {
Expand All @@ -16,7 +17,7 @@ export async function router(req: Request, dbClient: MemoDatabase): Promise<Resp
} else if (pathname.startsWith("/api/todos") && method === "DELETE") {
return handleDelete(req, dbClient);
} else if (pathname === "/health") {
return respond({ status: "ok" })
return respond({ status: "ok" });
}

return new Response("Not found", { status: 404 });
Expand Down
3 changes: 1 addition & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { MemoDatabase } from "./database.ts";
import { router } from "./routes/router.ts";

Deno.serve({ port: 8080, hostname: "127.0.0.1" }, (req) => router(req, new MemoDatabase()));
Deno.serve({ port: 8080, hostname: "127.0.0.1" }, (req) => router(req));