feat(user): update user profile schema with name and color, extend registration, and integrate across header and kanban cards
This commit is contained in:
parent
bfb54558e5
commit
0e89c44448
24 changed files with 426 additions and 174 deletions
|
|
@ -8,7 +8,7 @@
|
|||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Column from "./column/Column";
|
||||
import { COLUMNS, Task, TaskStatus } from "@/types/tasks";
|
||||
import { COLUMNS, Task, TaskStatus } from "@/types/task";
|
||||
import Header from "./header/Header";
|
||||
import { mutate } from "swr";
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,28 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { CardProps, TaskStatus } from "@/types/tasks";
|
||||
import { Task, TaskStatus } from "@/types/task";
|
||||
import CardActions from "./CardActions";
|
||||
import CardAvatars from "./CardAvatars";
|
||||
import CardDueDate from "./CardDueDate";
|
||||
import CardPriority from "./CardPriority";
|
||||
|
||||
/**
|
||||
* Properties for the Card component.
|
||||
*
|
||||
* @interface CardProps
|
||||
* @property {Task} task - The task data object to render.
|
||||
* @property {boolean} [isUpdating] - Flag indicating whether the card is currently undergoing an asynchronous update operation.
|
||||
* @property {(taskId: string, newStatus: TaskStatus) => void} [onStatusChange] - Callback triggered when the task status changes.
|
||||
* @property {(taskId: string) => void} [onDelete] - Callback triggered when the task is deleted.
|
||||
*/
|
||||
export interface CardProps {
|
||||
task: Task;
|
||||
isUpdating?: boolean;
|
||||
onStatusChange?: (taskId: string, newStatus: TaskStatus) => void;
|
||||
onDelete?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an interactive card container handling drag-and-drop actions, loading states,
|
||||
* and assembling modular sub-components for priorities, due dates, avatars, and actions.
|
||||
|
|
|
|||
|
|
@ -7,16 +7,29 @@
|
|||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { MoreHorizontal, CornerDownRight, Trash2 } from "lucide-react";
|
||||
import { COLUMNS, TaskStatus } from "@/types/tasks";
|
||||
import { COLUMNS, TaskStatus } from "@/types/task";
|
||||
|
||||
/**
|
||||
* Properties for the CardActions component.
|
||||
*
|
||||
* @interface CardActionsProps
|
||||
* @property {TaskStatus} currentStatus - The current status category of the task.
|
||||
* @property {boolean} isCreator - Flag indicating whether the current user is the creator of the task.
|
||||
* @property {(newStatus: TaskStatus) => void} onMove - Callback function triggered when a new status column is selected.
|
||||
* @property {() => void} onDelete - Callback function triggered when the delete action is selected.
|
||||
*/
|
||||
interface CardActionsProps {
|
||||
currentStatus: TaskStatus;
|
||||
isCreator: boolean;
|
||||
onMove: (newStatus: TaskStatus) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a mobile-only action menu component allowing users to move a task
|
||||
* between different columns or delete it entirely via a dropdown interface.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {TaskStatus} props.currentStatus - The current status category of the task.
|
||||
* @param {(newStatus: TaskStatus) => void} props.onMove - Callback function triggered when a new status column is selected.
|
||||
* @param {() => void} props.onDelete - Callback function triggered when the delete action is selected.
|
||||
* @param {CardActionsProps} props - The component props.
|
||||
* @returns {JSX.Element} The rendered mobile card actions component.
|
||||
*/
|
||||
export default function CardActions({
|
||||
|
|
@ -24,12 +37,7 @@ export default function CardActions({
|
|||
isCreator,
|
||||
onMove,
|
||||
onDelete,
|
||||
}: {
|
||||
currentStatus: TaskStatus;
|
||||
isCreator: boolean;
|
||||
onMove: (newStatus: TaskStatus) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
}: CardActionsProps) {
|
||||
const [showMobileActions, setShowMobileActions] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,49 +6,94 @@
|
|||
"use client";
|
||||
|
||||
import { Crown } from "lucide-react";
|
||||
import { Task } from "@/types/tasks";
|
||||
import { Task } from "@/types/task";
|
||||
import { getInitials } from "@/utils/user";
|
||||
|
||||
/**
|
||||
* Properties for the UserAvatar component.
|
||||
*
|
||||
* @interface UserAvatarProps
|
||||
* @property {Task["creator"]} user - The user object to render.
|
||||
* @property {string} title - The role description (e.g., "Creator" or "Assignee").
|
||||
* @property {boolean} [isCreator] - Optional flag indicating if the user is the creator.
|
||||
*/
|
||||
interface UserAvatarProps {
|
||||
user: Task["creator"];
|
||||
title: string;
|
||||
isCreator?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single user avatar circle with optional crown badge.
|
||||
*
|
||||
* @param {UserAvatarProps} props - The component props.
|
||||
* @returns {JSX.Element} The rendered user avatar component.
|
||||
*/
|
||||
function UserAvatar({ user, title, isCreator }: UserAvatarProps) {
|
||||
const fullName = `${user.firstName} ${user.lastName}`;
|
||||
const initials = getInitials(user.firstName, user.lastName);
|
||||
const bgColor = user.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative group/avatar group-hover/avatars:scale-100 hover:scale-110 hover:z-20 transition-all duration-200 cursor-default`}
|
||||
title={`${title}: ${fullName}`}
|
||||
>
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full border-2 border-border flex items-center justify-center text-xs font-black tracking-wider text-white shadow-md ring-2 ring-border/50 hover:ring-primary hover:ring-1 transition-all duration-200 ${bgColor}`}
|
||||
style={{ textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)" }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
{isCreator && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-600 rounded-full border-2 border-border flex items-center justify-center shadow-sm">
|
||||
<Crown
|
||||
size={8}
|
||||
className="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties for the CardAvatars component.
|
||||
*
|
||||
* @interface CardAvatarsProps
|
||||
* @property {Task["creator"]} creator - The creator user object.
|
||||
* @property {Task["assignees"]} assignees - The list of assigned user objects.
|
||||
*/
|
||||
interface CardAvatarsProps {
|
||||
creator: Task["creator"];
|
||||
assignees: Task["assignees"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders overlapping avatar indicators for task assignees and a dedicated, crowned badge for the task creator.
|
||||
* Filters out duplicate entries where the assignee matches the creator.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {Task["creator"]} props.creator - The email or identifier of the task creator.
|
||||
* @param {Task["assignees"]} [props.assignees=[]] - An array of emails or identifiers for users assigned to the task.
|
||||
* @returns {JSX.Element | null} The rendered avatars container, or null if neither creator nor assignees exist.
|
||||
* @param {CardAvatarsProps} props - The component props.
|
||||
* @returns {JSX.Element | null} The rendered card avatars container or null if no users are available.
|
||||
*/
|
||||
export default function CardAvatars({
|
||||
creator,
|
||||
assignees = [],
|
||||
}: {
|
||||
creator: Task["creator"];
|
||||
assignees: Task["assignees"];
|
||||
}) {
|
||||
const filteredAssignees = (assignees || []).filter((a) => a !== creator);
|
||||
}: CardAvatarsProps) {
|
||||
const filteredAssignees = (assignees || []).filter(
|
||||
(assignee) => assignee.id !== creator?.id,
|
||||
);
|
||||
|
||||
if (!creator && filteredAssignees.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center shrink-0 -space-x-2.5 group/avatars hover:space-x-0.5 transition-all duration-300">
|
||||
{filteredAssignees.map((assignee, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-7 h-7 rounded-full bg-primary/25 border-2 border-card flex items-center justify-center text-xs font-bold text-primary shadow-md group-hover/avatars:scale-110 transition-transform duration-200 ring-2 ring-border/50 group-hover/avatars:ring-primary/20 cursor-default"
|
||||
title={`Assignee: ${assignee}`}
|
||||
>
|
||||
{assignee.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
{filteredAssignees.map((assignee) => (
|
||||
<UserAvatar key={assignee.id} user={assignee} title="Assignee" />
|
||||
))}
|
||||
|
||||
{creator && (
|
||||
<div
|
||||
className="relative w-7 h-7 rounded-full bg-amber-500 text-white border-2 border-card flex items-center justify-center text-xs font-bold shadow-lg group-hover/avatars:scale-110 transition-transform duration-200 ring-2 ring-border/50 group-hover/avatars:ring-amber-500/20 cursor-default"
|
||||
title={`Creator: ${creator}`}
|
||||
>
|
||||
{creator.substring(0, 2).toUpperCase()}
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-600 rounded-full border-2 border-card flex items-center justify-center">
|
||||
<Crown size={8} className="text-white" />
|
||||
</span>
|
||||
</div>
|
||||
<UserAvatar user={creator} title="Creator" isCreator={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { AlertCircle, CalendarDays } from "lucide-react";
|
||||
import { Task } from "@/types/tasks";
|
||||
import { Task } from "@/types/task";
|
||||
|
||||
/**
|
||||
* Renders a due date badge for a card, showing an overdue alert animation
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* @description Client component rendering the dynamic priority badge for a card based on configuration.
|
||||
*/
|
||||
|
||||
import { TaskPriority, PRIORITY_CONFIG } from "@/types/tasks";
|
||||
import { TaskPriority, PRIORITY_CONFIG } from "@/types/task";
|
||||
|
||||
/**
|
||||
* Renders a styled priority badge for a card.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,31 @@ import { useState } from "react";
|
|||
import Card from "../card/Card";
|
||||
import ColumnHeader from "./ColumnHeader";
|
||||
import ColumnEmptyState from "./ColumnEmptyState";
|
||||
import { ColumnProps, TaskStatus } from "@/types/tasks";
|
||||
import { Task, TaskStatus } from "@/types/task";
|
||||
|
||||
/**
|
||||
* Properties for the Column component.
|
||||
*
|
||||
* @interface ColumnProps
|
||||
* @property {TaskStatus} id - The unique status identifier for the column.
|
||||
* @property {string} title - The display title of the column.
|
||||
* @property {string} color - The indicator color styling for the column header.
|
||||
* @property {number} count - The total count of tasks within this column.
|
||||
* @property {Task[]} tasks - The array of tasks belonging to this column.
|
||||
* @property {Set<string>} [updatingTaskIds] - A set of task IDs currently undergoing updates.
|
||||
* @property {(taskId: string, targetStatus: TaskStatus) => void} [onTaskMove] - Callback triggered when a task is moved to a new status column.
|
||||
* @property {(taskId: string) => void} [onTaskDelete] - Callback triggered when a task deletion is requested.
|
||||
*/
|
||||
export interface ColumnProps {
|
||||
id: TaskStatus;
|
||||
title: string;
|
||||
color: string;
|
||||
count: number;
|
||||
tasks: Task[];
|
||||
updatingTaskIds?: Set<string>;
|
||||
onTaskMove?: (taskId: string, targetStatus: TaskStatus) => void;
|
||||
onTaskDelete?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an interactive board column supporting drag-over drop target indicators,
|
||||
|
|
|
|||
|
|
@ -7,25 +7,32 @@
|
|||
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Properties for the ColumnHeader component.
|
||||
*
|
||||
* @interface ColumnHeaderProps
|
||||
* @property {string} title - The title of the column.
|
||||
* @property {string} [color] - Tailwind CSS color class for the status indicator dot.
|
||||
* @property {number} count - The number of tasks currently inside this column.
|
||||
*/
|
||||
interface ColumnHeaderProps {
|
||||
title: string;
|
||||
color?: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the header section of a column, displaying a color-coded status indicator,
|
||||
* the column name, the total task count badge, and a button to add new tasks.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.title - The title of the column.
|
||||
* @param {string} [props.color] - Tailwind CSS color class for the status indicator dot.
|
||||
* @param {number} props.count - The number of tasks currently inside this column.
|
||||
* @param {ColumnHeaderProps} props - The component props.
|
||||
* @returns {JSX.Element} The rendered column header component.
|
||||
*/
|
||||
export default function ColumnHeader({
|
||||
title,
|
||||
color = "bg-primary",
|
||||
count,
|
||||
}: {
|
||||
title: string;
|
||||
color?: string;
|
||||
count: number;
|
||||
}) {
|
||||
}: ColumnHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border/40">
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
|
|
|||
|
|
@ -11,24 +11,19 @@ import TrashLink from "./TrashLink";
|
|||
import NewTaskButton from "./NewTaskButton";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Properties for the Header component.
|
||||
*
|
||||
* @interface HeaderProps
|
||||
* @property {(taskId: string) => void} onTaskDelete - Callback function triggered when a task is dropped into the delete zone.
|
||||
*/
|
||||
interface HeaderProps {
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dashboard header section featuring title text, a task deletion drop zone,
|
||||
* and conditionally displays the new task action button and trash link based on the drag state.
|
||||
*
|
||||
* @param {HeaderProps} props - The component props.
|
||||
* @param {Object} props - The component props.
|
||||
* @param {(taskId: string) => void} props.onTaskDelete - Callback function triggered when a task is dropped into the delete zone.
|
||||
* @returns {JSX.Element} The rendered dashboard header component.
|
||||
*/
|
||||
export default function Header({ onTaskDelete }: HeaderProps) {
|
||||
export default function Header({
|
||||
onTaskDelete,
|
||||
}: {
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
}) {
|
||||
const [isDraggingActive, setIsDraggingActive] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import Board from "./Board";
|
||||
import { Task } from "@/types/tasks";
|
||||
import { Task } from "@/types/task";
|
||||
import { DbUser } from "@/types/user";
|
||||
import { TaskService } from "@/services/task.service";
|
||||
|
||||
/**
|
||||
|
|
@ -28,15 +29,15 @@ export default async function Dashboard() {
|
|||
|
||||
const assigneesData = await TaskService.findAssigneesForTasks(allTaskIds);
|
||||
|
||||
const assigneesMap = new Map<string, string[]>();
|
||||
const assigneesMap = new Map<string, DbUser[]>();
|
||||
for (const row of assigneesData) {
|
||||
const existing = assigneesMap.get(row.taskId) || [];
|
||||
assigneesMap.set(row.taskId, [...existing, row.email]);
|
||||
assigneesMap.set(row.taskId, [...existing, row.user]);
|
||||
}
|
||||
|
||||
const tasks: Task[] = rawTasksWithCreator.map(({ task, creatorEmail }) => ({
|
||||
const tasks: Task[] = rawTasksWithCreator.map(({ task, user }) => ({
|
||||
...task,
|
||||
creator: creatorEmail,
|
||||
creator: user,
|
||||
assignees: assigneesMap.get(task.id) || [],
|
||||
isCreator: task.userId === currentUserId,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { Task, PRIORITY_CONFIG } from "@/types/tasks";
|
||||
import { Task, PRIORITY_CONFIG } from "@/types/task";
|
||||
import { Calendar } from "lucide-react";
|
||||
import TaskActionButton from "./components/TaskActionButton";
|
||||
|
||||
|
|
|
|||
|
|
@ -10,22 +10,29 @@ import { useRouter } from "next/navigation";
|
|||
import { RotateCcw, Trash2, Loader2 } from "lucide-react";
|
||||
import { mutate } from "swr";
|
||||
|
||||
/**
|
||||
* Properties for the TaskActionButton component.
|
||||
*
|
||||
* @interface TaskActionButtonProps
|
||||
* @property {string} taskId - The unique identifier of the target task.
|
||||
* @property {"restore" | "delete"} action - The type of action to execute (restore or delete).
|
||||
*/
|
||||
interface TaskActionButtonProps {
|
||||
taskId: string;
|
||||
action: "restore" | "delete";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an action button for either restoring or permanently deleting a task,
|
||||
* managing the request lifecycle and visual transition states.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.taskId - The unique identifier of the target task.
|
||||
* @param {"restore" | "delete"} props.action - The type of action to execute (restore or delete).
|
||||
* @param {TaskActionButtonProps} props - The component props.
|
||||
* @returns {JSX.Element} The rendered task action button component.
|
||||
*/
|
||||
export default function TaskActionButton({
|
||||
taskId,
|
||||
action,
|
||||
}: {
|
||||
taskId: string;
|
||||
action: "restore" | "delete";
|
||||
}) {
|
||||
}: TaskActionButtonProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
* @description Server component rendering the trash management view using the TaskService.
|
||||
*/
|
||||
|
||||
import { Task } from "@/db/schema";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import TrashList from "../trash/TrashList";
|
||||
import TrashHeader from "../trash/components/TrashHeader";
|
||||
import { TaskService } from "@/services/task.service";
|
||||
import { Task } from "@/types/task";
|
||||
|
||||
/**
|
||||
* Renders the trash page verifying user authentication, querying soft-deleted tasks,
|
||||
|
|
@ -26,8 +26,10 @@ export default async function TrashPage() {
|
|||
const rawTrashedTasks =
|
||||
await TaskService.findTrashTasksForUser(currentUserId);
|
||||
|
||||
const tasks: Task[] = rawTrashedTasks.map(({ task }) => ({
|
||||
const tasks: Task[] = rawTrashedTasks.map(({ task, user }) => ({
|
||||
...task,
|
||||
creator: user,
|
||||
assignees: [],
|
||||
isCreator: task.userId === currentUserId,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -34,15 +34,30 @@ export default function RegisterPage() {
|
|||
setLoading(true);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const firstName = formData.get("firstName");
|
||||
const lastName = formData.get("lastName");
|
||||
const email = formData.get("email");
|
||||
const password = formData.get("password");
|
||||
const confirmPassword = formData.get("confirmPassword");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send registration request to the API
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
// Ensure the response is valid JSON before parsing
|
||||
|
|
@ -95,17 +110,40 @@ export default function RegisterPage() {
|
|||
|
||||
{/* Credentials Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
name="firstName"
|
||||
type="text"
|
||||
placeholder="First Name*"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground"
|
||||
/>
|
||||
<input
|
||||
name="lastName"
|
||||
type="text"
|
||||
placeholder="Last Name*"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
placeholder="Email*"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground"
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
placeholder="Password*"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground"
|
||||
/>
|
||||
<input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm Password*"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* @file route.ts
|
||||
* @description API route handler for user registration, managing email normalization, credential validation, secure password hashing, and auto sign-in.
|
||||
* @description API route handler for user registration, managing name validation, email normalization, password matching, hashing, and auto sign-in.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
|
@ -9,19 +9,19 @@ import { usersTable } from "@/db/schema";
|
|||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { signIn } from "@/auth";
|
||||
import { AVAILABLE_COLORS } from "@/types/user";
|
||||
|
||||
/**
|
||||
* Handles POST requests to register a new user.
|
||||
* Parses and validates the request body, normalizes the email address, enforces security rules,
|
||||
* checks for existing records, stores the hashed password in the database, and attempts an automatic sign-in.
|
||||
* Handles POST requests for new user registration.
|
||||
* Validates input fields, checks password confirmation, normalizes email, checks for existing users,
|
||||
* assigns a random profile color, hashes the password, saves the user to the database, and performs an automatic sign-in.
|
||||
*
|
||||
* @async
|
||||
* @param {Request} request - The incoming HTTP request containing the registration data.
|
||||
* @returns {Promise<NextResponse>} A JSON response with status details indicating success or failure.
|
||||
* @param {Request} request - The incoming HTTP request containing the registration payload in JSON format.
|
||||
* @returns {Promise<NextResponse>} A JSON response indicating registration success or an error message with the appropriate HTTP status code.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Parse incoming JSON body safely
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
|
|
@ -31,22 +31,42 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
const { email: rawEmail, password } = body;
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email: rawEmail,
|
||||
password,
|
||||
confirmPassword,
|
||||
} = body;
|
||||
|
||||
// Ensure types and presence of required fields
|
||||
// Validation of all required fields
|
||||
if (
|
||||
!firstName ||
|
||||
typeof firstName !== "string" ||
|
||||
!lastName ||
|
||||
typeof lastName !== "string" ||
|
||||
!rawEmail ||
|
||||
typeof rawEmail !== "string" ||
|
||||
!password ||
|
||||
typeof password !== "string"
|
||||
typeof password !== "string" ||
|
||||
!confirmPassword ||
|
||||
typeof confirmPassword !== "string"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ message: "Email and password are required" },
|
||||
{ message: "All fields are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize email (lowercase and trim)
|
||||
// Check if passwords match
|
||||
if (password !== confirmPassword) {
|
||||
return NextResponse.json(
|
||||
{ message: "Passwords do not match" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const email = rawEmail.toLowerCase().trim();
|
||||
|
||||
|
|
@ -57,7 +77,7 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
// Enforce minimum password length security constraint
|
||||
// Password length check
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ message: "Password must be at least 8 characters long" },
|
||||
|
|
@ -65,7 +85,7 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
// Check if user with this email already exists
|
||||
// Check existing user
|
||||
const existing = await db.query.usersTable.findFirst({
|
||||
where: eq(usersTable.email, email),
|
||||
});
|
||||
|
|
@ -77,11 +97,21 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
// Hash password securely with bcrypt before storing
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
await db.insert(usersTable).values({ email, password: hashedPassword });
|
||||
// Select a random default color from the palette
|
||||
const randomColor =
|
||||
AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)];
|
||||
|
||||
// Automatically authenticate the user after successful registration
|
||||
// Hash password & save user
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
await db.insert(usersTable).values({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
email,
|
||||
password: hashedPassword,
|
||||
color: randomColor,
|
||||
});
|
||||
|
||||
// Auto sign-in
|
||||
const signInResult = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,16 @@ import { NextResponse } from "next/server";
|
|||
import { auth } from "@/auth";
|
||||
import { taskStatusEnum } from "@/db/schema";
|
||||
import { TaskService } from "@/services/task.service";
|
||||
import { RouteContext, TaskStatus } from "@/types/tasks";
|
||||
import { RouteContext, TaskStatus } from "@/types/task";
|
||||
|
||||
/**
|
||||
* Handles PATCH requests to either update a task's status or restore a soft-deleted task.
|
||||
* Handles PATCH requests to either update a task's status or restore a soft-deleted task,
|
||||
* validating user sessions, request payloads, and authorization permissions.
|
||||
*
|
||||
* @async
|
||||
* @param {Request} request - The incoming HTTP request object.
|
||||
* @param {RouteContext} context - The route context containing dynamic route parameters.
|
||||
* @returns {Promise<NextResponse>} A JSON response containing the updated/restored task or an error message.
|
||||
*/
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
|
|
@ -85,7 +91,12 @@ export async function PATCH(request: Request, context: RouteContext) {
|
|||
|
||||
/**
|
||||
* Handles DELETE requests to either move a task to trash (Soft Delete)
|
||||
* or permanently delete it if it is already in the trash.
|
||||
* or permanently delete it if it is already in the trash, verifying session authorization.
|
||||
*
|
||||
* @async
|
||||
* @param {Request} request - The incoming HTTP request object containing query parameters.
|
||||
* @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) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
/**
|
||||
* @file UserBadge.tsx
|
||||
* @description Component rendering a user avatar badge with name and online status indicator.
|
||||
*/
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
/**
|
||||
* Renders a user profile badge including an avatar image, name, and current status.
|
||||
*
|
||||
* @returns {JSX.Element} The rendered user badge component.
|
||||
*/
|
||||
export default function UserBadge() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full relative overflow-hidden border border-border">
|
||||
<Image
|
||||
src="https://randomuser.me/api/portraits/women/1.jpg"
|
||||
alt="User Avatar"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden sm:block text-left">
|
||||
<p className="text-sm">Charlotte W.</p>
|
||||
<p className="text-foreground-muted text-xs">Online</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
app/components/layout/UserBadge.tsx
Normal file
41
app/components/layout/UserBadge.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* @file UserBadge.tsx
|
||||
* @description Server component rendering the authenticated user profile badge with initials and name.
|
||||
*/
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { UserService } from "@/services/user.service";
|
||||
import { getInitials } from "@/utils/user";
|
||||
|
||||
/**
|
||||
* Renders a user profile badge including an avatar initial circle with user color and full name.
|
||||
*
|
||||
* @async
|
||||
* @returns {Promise<JSX.Element | null>} The rendered user badge component, or null if unauthenticated.
|
||||
*/
|
||||
export default async function UserBadge() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
const user = await UserService.findProfileById(session.user.id);
|
||||
if (!user) return null;
|
||||
|
||||
const fullName = `${user.firstName} ${user.lastName}`.trim();
|
||||
const initials = getInitials(user.firstName, user.lastName);
|
||||
const bgColor = user.color || "bg-primary";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg font-black tracking-wider text-white shadow-md border-2 border-card ring-2 ring-border/50 shrink-0 ${bgColor}`}
|
||||
style={{ textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)" }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<div className="hidden sm:block text-left">
|
||||
<p className="text-sm font-medium leading-tight">{fullName}</p>
|
||||
<p className="text-foreground-muted text-xs">Online</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,6 +46,9 @@ export const usersTable = pgTable("users", {
|
|||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
email: text("email").notNull().unique(),
|
||||
password: text("password").notNull(),
|
||||
firstName: text("first_name").notNull(),
|
||||
lastName: text("last_name").notNull(),
|
||||
color: text("color").default("bg-indigo-500").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
|
|
@ -91,3 +94,4 @@ export const taskAssigneesTable = pgTable(
|
|||
// ==========================================
|
||||
|
||||
export type Task = typeof tasksTable.$inferSelect;
|
||||
export type User = typeof usersTable.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,8 @@
|
|||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
tasksTable,
|
||||
taskAssigneesTable,
|
||||
type Task as DbTask,
|
||||
usersTable,
|
||||
} from "@/db/schema";
|
||||
import { TaskStatus } from "@/types/tasks";
|
||||
import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
|
||||
import { DbTask, TaskStatus } from "@/types/task";
|
||||
import { and, eq, or, exists, isNotNull, isNull, inArray } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
|
|
@ -112,7 +107,7 @@ export class TaskService {
|
|||
return await db
|
||||
.select({
|
||||
task: tasksTable,
|
||||
creatorEmail: usersTable.email,
|
||||
user: usersTable,
|
||||
})
|
||||
.from(tasksTable)
|
||||
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
|
||||
|
|
@ -137,7 +132,7 @@ export class TaskService {
|
|||
return await db
|
||||
.select({
|
||||
taskId: taskAssigneesTable.taskId,
|
||||
email: usersTable.email,
|
||||
user: usersTable,
|
||||
})
|
||||
.from(taskAssigneesTable)
|
||||
.innerJoin(usersTable, eq(taskAssigneesTable.userId, usersTable.id))
|
||||
|
|
@ -155,7 +150,7 @@ export class TaskService {
|
|||
return await db
|
||||
.select({
|
||||
task: tasksTable,
|
||||
creatorEmail: usersTable.email,
|
||||
user: usersTable,
|
||||
})
|
||||
.from(tasksTable)
|
||||
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
|
||||
|
|
|
|||
33
services/user.service.ts
Normal file
33
services/user.service.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* @file services/user.service.ts
|
||||
* @description Business logic service handling user-related database queries.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { usersTable } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Service class for handling user operations and database interactions.
|
||||
*/
|
||||
export class UserService {
|
||||
/**
|
||||
* Retrieves specific profile information (firstName, lastName, color) for a user by ID.
|
||||
*
|
||||
* @async
|
||||
* @param {string} userId - The unique identifier of the user.
|
||||
* @returns {Promise<{ firstName: string; lastName: string; color: string } | null>} The user profile data or null.
|
||||
*/
|
||||
static async findProfileById(userId: string) {
|
||||
const [user] = await db
|
||||
.select({
|
||||
firstName: usersTable.firstName,
|
||||
lastName: usersTable.lastName,
|
||||
color: usersTable.color,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId));
|
||||
|
||||
return user || null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,20 @@
|
|||
/**
|
||||
* @file types/tasks.ts
|
||||
* @description Type definitions, interfaces, and UI configuration mappings for task management and views.
|
||||
* @file types/task.ts
|
||||
* @description Central type definitions and global configurations for task management.
|
||||
*/
|
||||
|
||||
import {
|
||||
taskPriorityEnum,
|
||||
taskStatusEnum,
|
||||
type Task as DbTask,
|
||||
type User as DbUser,
|
||||
} from "@/db/schema";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export type { DbTask };
|
||||
export type TaskStatus = (typeof taskStatusEnum.enumValues)[number];
|
||||
export type TaskPriority = (typeof taskPriorityEnum.enumValues)[number];
|
||||
|
||||
|
|
@ -20,14 +22,10 @@ export type TaskPriority = (typeof taskPriorityEnum.enumValues)[number];
|
|||
// Interfaces
|
||||
// ==========================================
|
||||
|
||||
export interface Task extends Omit<DbTask, "dueDate"> {
|
||||
assignees?: string[];
|
||||
creator?: string;
|
||||
isCreator?: boolean;
|
||||
}
|
||||
|
||||
export interface RouteContext {
|
||||
params: Promise<{ id: string }>;
|
||||
export interface Task extends DbTask {
|
||||
creator: DbUser;
|
||||
assignees: DbUser[];
|
||||
isCreator: boolean;
|
||||
}
|
||||
|
||||
export interface TaskPriorityConfig {
|
||||
|
|
@ -35,25 +33,8 @@ export interface TaskPriorityConfig {
|
|||
className: string;
|
||||
}
|
||||
|
||||
export interface ColumnConfig {
|
||||
id: TaskStatus;
|
||||
title: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ColumnProps extends ColumnConfig {
|
||||
count: number;
|
||||
tasks: Task[];
|
||||
updatingTaskIds?: Set<string>;
|
||||
onTaskMove?: (taskId: string, targetStatus: TaskStatus) => void;
|
||||
onTaskDelete?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export interface CardProps {
|
||||
task: Task;
|
||||
isUpdating?: boolean;
|
||||
onStatusChange?: (taskId: string, newStatus: TaskStatus) => void;
|
||||
onDelete?: (taskId: string) => void;
|
||||
export interface RouteContext {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
|
@ -80,4 +61,4 @@ export const COLUMNS = [
|
|||
{ id: "in_progress", title: "In Progress", color: "bg-indigo-500" },
|
||||
{ id: "await_feedback", title: "Await Feedback", color: "bg-amber-500" },
|
||||
{ id: "done", title: "Done", color: "bg-emerald-500" },
|
||||
] as const satisfies readonly ColumnConfig[];
|
||||
] as const;
|
||||
27
types/user.ts
Normal file
27
types/user.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* @file types/user.ts
|
||||
* @description Type definitions and constants related to user entities and UI preferences.
|
||||
*/
|
||||
|
||||
import { type User as DbUser } from "@/db/schema";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export type { DbUser };
|
||||
export type UserColor = (typeof AVAILABLE_COLORS)[number];
|
||||
|
||||
// ==========================================
|
||||
// UI Configurations
|
||||
// ==========================================
|
||||
|
||||
export const AVAILABLE_COLORS = [
|
||||
"bg-indigo-500",
|
||||
"bg-emerald-500",
|
||||
"bg-amber-500",
|
||||
"bg-rose-500",
|
||||
"bg-violet-500",
|
||||
"bg-sky-500",
|
||||
"bg-teal-500",
|
||||
] as const satisfies readonly string[];
|
||||
17
utils/user.ts
Normal file
17
utils/user.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* @file utils/user.ts
|
||||
* @description Utility functions for user formatting and initials generation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates uppercase initials from a user's first and last name.
|
||||
*
|
||||
* @param {string} [firstName] - The user's first name.
|
||||
* @param {string} [lastName] - The user's last name.
|
||||
* @returns {string} The computed initials (e.g., "JD") or a question mark if neither is provided.
|
||||
*/
|
||||
export function getInitials(firstName?: string, lastName?: string): string {
|
||||
const first = firstName?.[0] || "";
|
||||
const last = lastName?.[0] || "";
|
||||
return `${first}${last}`.toUpperCase() || "?";
|
||||
}
|
||||
Loading…
Reference in a new issue