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 { 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";

View file

@ -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.

View file

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

View file

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

View file

@ -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

View file

@ -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.

View file

@ -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,

View file

@ -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">

View file

@ -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 (

View file

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

View file

@ -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";

View file

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

View file

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

View file

@ -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"
/>

View file

@ -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,

View file

@ -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 {

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(),
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;

View file

@ -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
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
* @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
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() || "?";
}