feat(user): update user profile schema with name and color, extend registration, and integrate across header and kanban cards

This commit is contained in:
Chneemann 2026-08-12 19:36:39 +02:00
parent bfb54558e5
commit 0e89c44448
No known key found for this signature in database
24 changed files with 426 additions and 174 deletions

View file

@ -8,7 +8,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Column from "./column/Column"; 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 Header from "./header/Header";
import { mutate } from "swr"; import { mutate } from "swr";

View file

@ -6,12 +6,28 @@
"use client"; "use client";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { CardProps, TaskStatus } from "@/types/tasks"; import { Task, TaskStatus } from "@/types/task";
import CardActions from "./CardActions"; import CardActions from "./CardActions";
import CardAvatars from "./CardAvatars"; import CardAvatars from "./CardAvatars";
import CardDueDate from "./CardDueDate"; import CardDueDate from "./CardDueDate";
import CardPriority from "./CardPriority"; 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, * Renders an interactive card container handling drag-and-drop actions, loading states,
* and assembling modular sub-components for priorities, due dates, avatars, and actions. * and assembling modular sub-components for priorities, due dates, avatars, and actions.

View file

@ -7,16 +7,29 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { MoreHorizontal, CornerDownRight, Trash2 } from "lucide-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 * Renders a mobile-only action menu component allowing users to move a task
* between different columns or delete it entirely via a dropdown interface. * between different columns or delete it entirely via a dropdown interface.
* *
* @param {Object} props - The component props. * @param {CardActionsProps} 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.
* @returns {JSX.Element} The rendered mobile card actions component. * @returns {JSX.Element} The rendered mobile card actions component.
*/ */
export default function CardActions({ export default function CardActions({
@ -24,12 +37,7 @@ export default function CardActions({
isCreator, isCreator,
onMove, onMove,
onDelete, onDelete,
}: { }: CardActionsProps) {
currentStatus: TaskStatus;
isCreator: boolean;
onMove: (newStatus: TaskStatus) => void;
onDelete: () => void;
}) {
const [showMobileActions, setShowMobileActions] = useState(false); const [showMobileActions, setShowMobileActions] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);

View file

@ -6,49 +6,94 @@
"use client"; "use client";
import { Crown } from "lucide-react"; 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. * 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 {CardAvatarsProps} props - The component props.
* @param {Task["creator"]} props.creator - The email or identifier of the task creator. * @returns {JSX.Element | null} The rendered card avatars container or null if no users are available.
* @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.
*/ */
export default function CardAvatars({ export default function CardAvatars({
creator, creator,
assignees = [], assignees = [],
}: { }: CardAvatarsProps) {
creator: Task["creator"]; const filteredAssignees = (assignees || []).filter(
assignees: Task["assignees"]; (assignee) => assignee.id !== creator?.id,
}) { );
const filteredAssignees = (assignees || []).filter((a) => a !== creator);
if (!creator && filteredAssignees.length === 0) return null; if (!creator && filteredAssignees.length === 0) return null;
return ( return (
<div className="flex items-center shrink-0 -space-x-2.5 group/avatars hover:space-x-0.5 transition-all duration-300"> <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) => ( {filteredAssignees.map((assignee) => (
<div <UserAvatar key={assignee.id} user={assignee} title="Assignee" />
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>
))} ))}
{creator && ( {creator && (
<div <UserAvatar user={creator} title="Creator" isCreator={true} />
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>
)} )}
</div> </div>
); );

View file

@ -4,7 +4,7 @@
*/ */
import { AlertCircle, CalendarDays } from "lucide-react"; 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 * Renders a due date badge for a card, showing an overdue alert animation

View file

@ -3,7 +3,7 @@
* @description Client component rendering the dynamic priority badge for a card based on configuration. * @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. * Renders a styled priority badge for a card.

View file

@ -9,7 +9,31 @@ import { useState } from "react";
import Card from "../card/Card"; import Card from "../card/Card";
import ColumnHeader from "./ColumnHeader"; import ColumnHeader from "./ColumnHeader";
import ColumnEmptyState from "./ColumnEmptyState"; 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, * Renders an interactive board column supporting drag-over drop target indicators,

View file

@ -7,25 +7,32 @@
import { Plus } from "lucide-react"; 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, * 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. * the column name, the total task count badge, and a button to add new tasks.
* *
* @param {Object} props - The component props. * @param {ColumnHeaderProps} 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.
* @returns {JSX.Element} The rendered column header component. * @returns {JSX.Element} The rendered column header component.
*/ */
export default function ColumnHeader({ export default function ColumnHeader({
title, title,
color = "bg-primary", color = "bg-primary",
count, count,
}: { }: ColumnHeaderProps) {
title: string;
color?: string;
count: number;
}) {
return ( return (
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border/40"> <div className="flex items-center justify-between mb-4 pb-2 border-b border-border/40">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">

View file

@ -11,24 +11,19 @@ import TrashLink from "./TrashLink";
import NewTaskButton from "./NewTaskButton"; import NewTaskButton from "./NewTaskButton";
import { useState } from "react"; 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, * 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. * 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. * @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); const [isDraggingActive, setIsDraggingActive] = useState(false);
return ( return (

View file

@ -6,7 +6,8 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Board from "./Board"; 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"; import { TaskService } from "@/services/task.service";
/** /**
@ -28,15 +29,15 @@ export default async function Dashboard() {
const assigneesData = await TaskService.findAssigneesForTasks(allTaskIds); const assigneesData = await TaskService.findAssigneesForTasks(allTaskIds);
const assigneesMap = new Map<string, string[]>(); const assigneesMap = new Map<string, DbUser[]>();
for (const row of assigneesData) { for (const row of assigneesData) {
const existing = assigneesMap.get(row.taskId) || []; 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, ...task,
creator: creatorEmail, creator: user,
assignees: assigneesMap.get(task.id) || [], assignees: assigneesMap.get(task.id) || [],
isCreator: task.userId === currentUserId, isCreator: task.userId === currentUserId,
})); }));

View file

@ -5,7 +5,7 @@
"use client"; "use client";
import { Task, PRIORITY_CONFIG } from "@/types/tasks"; import { Task, PRIORITY_CONFIG } from "@/types/task";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
import TaskActionButton from "./components/TaskActionButton"; import TaskActionButton from "./components/TaskActionButton";

View file

@ -10,22 +10,29 @@ import { useRouter } from "next/navigation";
import { RotateCcw, Trash2, Loader2 } from "lucide-react"; import { RotateCcw, Trash2, Loader2 } from "lucide-react";
import { mutate } from "swr"; 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, * Renders an action button for either restoring or permanently deleting a task,
* managing the request lifecycle and visual transition states. * managing the request lifecycle and visual transition states.
* *
* @param {Object} props - The component props. * @param {TaskActionButtonProps} 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).
* @returns {JSX.Element} The rendered task action button component. * @returns {JSX.Element} The rendered task action button component.
*/ */
export default function TaskActionButton({ export default function TaskActionButton({
taskId, taskId,
action, action,
}: { }: TaskActionButtonProps) {
taskId: string;
action: "restore" | "delete";
}) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();

View file

@ -3,12 +3,12 @@
* @description Server component rendering the trash management view using the TaskService. * @description Server component rendering the trash management view using the TaskService.
*/ */
import { Task } from "@/db/schema";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import TrashList from "../trash/TrashList"; import TrashList from "../trash/TrashList";
import TrashHeader from "../trash/components/TrashHeader"; import TrashHeader from "../trash/components/TrashHeader";
import { TaskService } from "@/services/task.service"; import { TaskService } from "@/services/task.service";
import { Task } from "@/types/task";
/** /**
* Renders the trash page verifying user authentication, querying soft-deleted tasks, * Renders the trash page verifying user authentication, querying soft-deleted tasks,
@ -26,8 +26,10 @@ export default async function TrashPage() {
const rawTrashedTasks = const rawTrashedTasks =
await TaskService.findTrashTasksForUser(currentUserId); await TaskService.findTrashTasksForUser(currentUserId);
const tasks: Task[] = rawTrashedTasks.map(({ task }) => ({ const tasks: Task[] = rawTrashedTasks.map(({ task, user }) => ({
...task, ...task,
creator: user,
assignees: [],
isCreator: task.userId === currentUserId, isCreator: task.userId === currentUserId,
})); }));

View file

@ -34,15 +34,30 @@ export default function RegisterPage() {
setLoading(true); setLoading(true);
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const firstName = formData.get("firstName");
const lastName = formData.get("lastName");
const email = formData.get("email"); const email = formData.get("email");
const password = formData.get("password"); const password = formData.get("password");
const confirmPassword = formData.get("confirmPassword");
if (password !== confirmPassword) {
setError("Passwords do not match");
setLoading(false);
return;
}
try { try {
// Send registration request to the API // Send registration request to the API
const response = await fetch("/api/auth/register", { const response = await fetch("/api/auth/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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 // Ensure the response is valid JSON before parsing
@ -95,17 +110,40 @@ export default function RegisterPage() {
{/* Credentials Form */} {/* Credentials Form */}
<form onSubmit={handleSubmit} className="space-y-4"> <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 <input
name="email" name="email"
type="email" type="email"
placeholder="Email" placeholder="Email*"
required required
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground" className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground"
/> />
<input <input
name="password" name="password"
type="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 required
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground" className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground"
/> />

View file

@ -1,6 +1,6 @@
/** /**
* @file route.ts * @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"; import { NextResponse } from "next/server";
@ -9,19 +9,19 @@ import { usersTable } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { signIn } from "@/auth"; import { signIn } from "@/auth";
import { AVAILABLE_COLORS } from "@/types/user";
/** /**
* Handles POST requests to register a new user. * Handles POST requests for new user registration.
* Parses and validates the request body, normalizes the email address, enforces security rules, * Validates input fields, checks password confirmation, normalizes email, checks for existing users,
* checks for existing records, stores the hashed password in the database, and attempts an automatic sign-in. * assigns a random profile color, hashes the password, saves the user to the database, and performs an automatic sign-in.
* *
* @async * @async
* @param {Request} request - The incoming HTTP request containing the registration data. * @param {Request} request - The incoming HTTP request containing the registration payload in JSON format.
* @returns {Promise<NextResponse>} A JSON response with status details indicating success or failure. * @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) { export async function POST(request: Request) {
try { try {
// Parse incoming JSON body safely
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
if (!body) { 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 ( if (
!firstName ||
typeof firstName !== "string" ||
!lastName ||
typeof lastName !== "string" ||
!rawEmail || !rawEmail ||
typeof rawEmail !== "string" || typeof rawEmail !== "string" ||
!password || !password ||
typeof password !== "string" typeof password !== "string" ||
!confirmPassword ||
typeof confirmPassword !== "string"
) { ) {
return NextResponse.json( return NextResponse.json(
{ message: "Email and password are required" }, { message: "All fields are required" },
{ status: 400 }, { 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 emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const email = rawEmail.toLowerCase().trim(); 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) { if (password.length < 8) {
return NextResponse.json( return NextResponse.json(
{ message: "Password must be at least 8 characters long" }, { 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({ const existing = await db.query.usersTable.findFirst({
where: eq(usersTable.email, email), where: eq(usersTable.email, email),
}); });
@ -77,11 +97,21 @@ export async function POST(request: Request) {
); );
} }
// Hash password securely with bcrypt before storing // Select a random default color from the palette
const hashedPassword = await bcrypt.hash(password, 10); const randomColor =
await db.insert(usersTable).values({ email, password: hashedPassword }); 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", { const signInResult = await signIn("credentials", {
email, email,
password, password,

View file

@ -7,10 +7,16 @@ import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { taskStatusEnum } from "@/db/schema"; import { taskStatusEnum } from "@/db/schema";
import { TaskService } from "@/services/task.service"; 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) { export async function PATCH(request: Request, context: RouteContext) {
try { 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) * 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) { export async function DELETE(request: Request, context: RouteContext) {
try { try {

View file

@ -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>
);
}

View 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>
);
}

View file

@ -46,6 +46,9 @@ export const usersTable = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
password: text("password").notNull(), 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(), createdAt: timestamp("created_at").defaultNow().notNull(),
}); });
@ -91,3 +94,4 @@ export const taskAssigneesTable = pgTable(
// ========================================== // ==========================================
export type Task = typeof tasksTable.$inferSelect; export type Task = typeof tasksTable.$inferSelect;
export type User = typeof usersTable.$inferSelect;

View file

@ -4,13 +4,8 @@
*/ */
import { db } from "@/db"; import { db } from "@/db";
import { import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
tasksTable, import { DbTask, TaskStatus } from "@/types/task";
taskAssigneesTable,
type Task as DbTask,
usersTable,
} from "@/db/schema";
import { TaskStatus } from "@/types/tasks";
import { and, eq, or, exists, isNotNull, isNull, inArray } from "drizzle-orm"; import { and, eq, or, exists, isNotNull, isNull, inArray } from "drizzle-orm";
/** /**
@ -112,7 +107,7 @@ export class TaskService {
return await db return await db
.select({ .select({
task: tasksTable, task: tasksTable,
creatorEmail: usersTable.email, user: usersTable,
}) })
.from(tasksTable) .from(tasksTable)
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id)) .innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
@ -137,7 +132,7 @@ export class TaskService {
return await db return await db
.select({ .select({
taskId: taskAssigneesTable.taskId, taskId: taskAssigneesTable.taskId,
email: usersTable.email, user: usersTable,
}) })
.from(taskAssigneesTable) .from(taskAssigneesTable)
.innerJoin(usersTable, eq(taskAssigneesTable.userId, usersTable.id)) .innerJoin(usersTable, eq(taskAssigneesTable.userId, usersTable.id))
@ -155,7 +150,7 @@ export class TaskService {
return await db return await db
.select({ .select({
task: tasksTable, task: tasksTable,
creatorEmail: usersTable.email, user: usersTable,
}) })
.from(tasksTable) .from(tasksTable)
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id)) .innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))

33
services/user.service.ts Normal file
View 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;
}
}

View file

@ -1,18 +1,20 @@
/** /**
* @file types/tasks.ts * @file types/task.ts
* @description Type definitions, interfaces, and UI configuration mappings for task management and views. * @description Central type definitions and global configurations for task management.
*/ */
import { import {
taskPriorityEnum, taskPriorityEnum,
taskStatusEnum, taskStatusEnum,
type Task as DbTask, type Task as DbTask,
type User as DbUser,
} from "@/db/schema"; } from "@/db/schema";
// ========================================== // ==========================================
// Types // Types
// ========================================== // ==========================================
export type { DbTask };
export type TaskStatus = (typeof taskStatusEnum.enumValues)[number]; export type TaskStatus = (typeof taskStatusEnum.enumValues)[number];
export type TaskPriority = (typeof taskPriorityEnum.enumValues)[number]; export type TaskPriority = (typeof taskPriorityEnum.enumValues)[number];
@ -20,14 +22,10 @@ export type TaskPriority = (typeof taskPriorityEnum.enumValues)[number];
// Interfaces // Interfaces
// ========================================== // ==========================================
export interface Task extends Omit<DbTask, "dueDate"> { export interface Task extends DbTask {
assignees?: string[]; creator: DbUser;
creator?: string; assignees: DbUser[];
isCreator?: boolean; isCreator: boolean;
}
export interface RouteContext {
params: Promise<{ id: string }>;
} }
export interface TaskPriorityConfig { export interface TaskPriorityConfig {
@ -35,25 +33,8 @@ export interface TaskPriorityConfig {
className: string; className: string;
} }
export interface ColumnConfig { export interface RouteContext {
id: TaskStatus; params: Promise<{ id: string }>;
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;
} }
// ========================================== // ==========================================
@ -80,4 +61,4 @@ export const COLUMNS = [
{ id: "in_progress", title: "In Progress", color: "bg-indigo-500" }, { id: "in_progress", title: "In Progress", color: "bg-indigo-500" },
{ id: "await_feedback", title: "Await Feedback", color: "bg-amber-500" }, { id: "await_feedback", title: "Await Feedback", color: "bg-amber-500" },
{ id: "done", title: "Done", color: "bg-emerald-500" }, { id: "done", title: "Done", color: "bg-emerald-500" },
] as const satisfies readonly ColumnConfig[]; ] as const;

27
types/user.ts Normal file
View 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
View 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() || "?";
}