feat(api): implement soft-delete, restore and permanent task deletion
This commit is contained in:
parent
2d2a4388e2
commit
de64b94869
3 changed files with 107 additions and 47 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* @file app/api/tasks/[id]/route.ts
|
* @file app/api/tasks/[id]/route.ts
|
||||||
* @description API route handlers for updating a task's status or deleting a task, enforcing user authentication, schema validations, and authorization checks.
|
* @description API route handlers for task mutations (status update, soft delete, restore, and permanent deletion), enforcing authentication and authorization.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
@ -10,14 +10,7 @@ import { TaskService } from "@/services/task.service";
|
||||||
import { RouteContext, TaskStatus } from "@/types/tasks";
|
import { RouteContext, TaskStatus } from "@/types/tasks";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles PATCH requests to update a specific task's status.
|
* Handles PATCH requests to either update a task's status or restore a soft-deleted task.
|
||||||
* Verifies user authentication, validates the incoming status against allowed schema values,
|
|
||||||
* checks permissions via the task service, and performs the update.
|
|
||||||
*
|
|
||||||
* @async
|
|
||||||
* @param {Request} request - The incoming HTTP request containing the status update payload.
|
|
||||||
* @param {RouteContext} context - The route context containing dynamic route parameters.
|
|
||||||
* @returns {Promise<NextResponse>} A JSON response indicating success with the updated task or an error message.
|
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: Request, context: RouteContext) {
|
export async function PATCH(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -27,10 +20,30 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id: taskId } = await context.params;
|
const { id: taskId } = await context.params;
|
||||||
const body = await request.json();
|
const body = await request.json().catch(() => ({}));
|
||||||
const { status } = body as { status: unknown };
|
const { status, restore } = body as { status?: unknown; restore?: boolean };
|
||||||
|
|
||||||
// Validate status against enum values using TaskStatus type guard/check
|
// Restore a task from the Trash
|
||||||
|
if (restore === true) {
|
||||||
|
const restoredTask = await TaskService.restoreIfAuthorized(
|
||||||
|
taskId,
|
||||||
|
session.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!restoredTask) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Task not found or access denied" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, task: restoredTask },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Task Status
|
||||||
const validStatuses = taskStatusEnum.enumValues;
|
const validStatuses = taskStatusEnum.enumValues;
|
||||||
if (
|
if (
|
||||||
!status ||
|
!status ||
|
||||||
|
|
@ -44,8 +57,6 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const typedStatus = status as TaskStatus;
|
const typedStatus = status as TaskStatus;
|
||||||
|
|
||||||
// Execute update & verification via Service in a single step
|
|
||||||
const updatedTask = await TaskService.updateStatusIfAuthorized(
|
const updatedTask = await TaskService.updateStatusIfAuthorized(
|
||||||
taskId,
|
taskId,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
|
|
@ -64,7 +75,7 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating task status:", error);
|
console.error("Error during PATCH task operation:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal Server Error" },
|
{ error: "Internal Server Error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|
@ -73,13 +84,8 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles DELETE requests to remove a specific task by its ID.
|
* Handles DELETE requests to either move a task to trash (Soft Delete)
|
||||||
* Verifies user authentication and checks authorization permissions before deletion.
|
* or permanently delete it if it is already in the trash.
|
||||||
*
|
|
||||||
* @async
|
|
||||||
* @param {Request} request - The incoming HTTP request.
|
|
||||||
* @param {RouteContext} context - The route context containing dynamic route parameters.
|
|
||||||
* @returns {Promise<NextResponse>} A JSON response confirming deletion or an error message.
|
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: Request, context: RouteContext) {
|
export async function DELETE(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -89,15 +95,19 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id: taskId } = await context.params;
|
const { id: taskId } = await context.params;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const permanent = url.searchParams.get("permanent") === "true";
|
||||||
|
|
||||||
const deletedTask = await TaskService.deleteIfAuthorized(
|
// Delete Permanently (only if already in the Recycle Bin)
|
||||||
|
if (permanent) {
|
||||||
|
const deletedTask = await TaskService.permanentlyDeleteIfAuthorized(
|
||||||
taskId,
|
taskId,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!deletedTask) {
|
if (!deletedTask) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Task not found or access denied" },
|
{ error: "Task not found, access denied, or not in trash" },
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -106,8 +116,27 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||||
{ success: true, task: deletedTask },
|
{ success: true, task: deletedTask },
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to the Recycle Bin (Soft Delete)
|
||||||
|
const softDeletedTask = await TaskService.softDeleteIfAuthorized(
|
||||||
|
taskId,
|
||||||
|
session.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!softDeletedTask) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Task not found or access denied" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, task: softDeletedTask },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting task:", error);
|
console.error("Error during DELETE task operation:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal Server Error" },
|
{ error: "Internal Server Error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,10 @@ export const tasksTable = pgTable("tasks", {
|
||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
status: taskStatusEnum("status").default("todo").notNull(),
|
status: taskStatusEnum("status").default("todo").notNull(),
|
||||||
priority: taskPriorityEnum("priority").default("medium").notNull(),
|
priority: taskPriorityEnum("priority").default("medium").notNull(),
|
||||||
dueDate: timestamp("due_date"),
|
dueDate: timestamp("due_date").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp("deleted_at"),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
type Task as DbTask,
|
type Task as DbTask,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { TaskStatus } from "@/types/tasks";
|
import { TaskStatus } from "@/types/tasks";
|
||||||
import { and, eq, or, exists } from "drizzle-orm";
|
import { and, eq, or, exists, isNotNull } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service class for handling task-related operations and database interactions.
|
* Service class for handling task-related operations and database interactions.
|
||||||
|
|
@ -99,37 +99,67 @@ export class TaskService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a task if the user is authorized as either the owner or an assignee.
|
* Soft-deletes a task by setting its deletion timestamp if the user is the creator.
|
||||||
*
|
*
|
||||||
* @async
|
* @async
|
||||||
* @param {string} taskId - The unique identifier of the task to delete.
|
* @param {string} taskId - The unique identifier of the task to soft-delete.
|
||||||
* @param {string} userId - The unique identifier of the user performing the deletion.
|
* @param {string} userId - The unique identifier of the user performing the operation.
|
||||||
* @returns {Promise<DbTask | null>} The deleted task object, or null if the deletion failed or user is unauthorized.
|
* @returns {Promise<DbTask | null>} The updated task object with a deletion timestamp, or null if unauthorized.
|
||||||
*/
|
*/
|
||||||
static async deleteIfAuthorized(taskId: string, userId: string) {
|
static async softDeleteIfAuthorized(taskId: string, userId: string) {
|
||||||
|
const [updatedTask] = await db
|
||||||
|
.update(tasksTable)
|
||||||
|
.set({
|
||||||
|
deletedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(tasksTable.id, taskId), eq(tasksTable.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return (updatedTask as DbTask | undefined) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently deletes a task from the database if it is already soft-deleted and the user is the creator.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {string} taskId - The unique identifier of the task to permanently delete.
|
||||||
|
* @param {string} userId - The unique identifier of the user performing the operation.
|
||||||
|
* @returns {Promise<DbTask | null>} The permanently deleted task object, or null if unauthorized.
|
||||||
|
*/
|
||||||
|
static async permanentlyDeleteIfAuthorized(taskId: string, userId: string) {
|
||||||
const [deletedTask] = await db
|
const [deletedTask] = await db
|
||||||
.delete(tasksTable)
|
.delete(tasksTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(tasksTable.id, taskId),
|
eq(tasksTable.id, taskId),
|
||||||
or(
|
isNotNull(tasksTable.deletedAt),
|
||||||
eq(tasksTable.userId, userId),
|
eq(tasksTable.userId, userId),
|
||||||
exists(
|
|
||||||
db
|
|
||||||
.select({ taskId: taskAssigneesTable.taskId })
|
|
||||||
.from(taskAssigneesTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(taskAssigneesTable.taskId, taskId),
|
|
||||||
eq(taskAssigneesTable.userId, userId),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return (deletedTask as DbTask | undefined) || null;
|
return (deletedTask as DbTask | undefined) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores a soft-deleted task by clearing its deletion timestamp if the user is the creator.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {string} taskId - The unique identifier of the task to restore.
|
||||||
|
* @param {string} userId - The unique identifier of the user performing the operation.
|
||||||
|
* @returns {Promise<DbTask | null>} The restored task object, or null if unauthorized.
|
||||||
|
*/
|
||||||
|
static async restoreIfAuthorized(taskId: string, userId: string) {
|
||||||
|
const [restoredTask] = await db
|
||||||
|
.update(tasksTable)
|
||||||
|
.set({
|
||||||
|
deletedAt: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(tasksTable.id, taskId), eq(tasksTable.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return (restoredTask as DbTask | undefined) || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue