Commit f5fa1ac0 authored by Paulo Avila's avatar Paulo Avila
Browse files

Versão 1.0 Petruz Task

parent 95bad46e
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { useFormState, useFormStatus } from "react-dom";
import { createWorkspaceAction, type ActionState } from "@/lib/actions";
import { WORKSPACE_COLORS } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@/lib/utils";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Criando..." : "Criar espaço"}
</Button>
);
}
const initialState: ActionState = {};
export function WorkspaceCreateDialog({
defaultOpen = false,
}: {
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const [color, setColor] = useState<string>(WORKSPACE_COLORS[0]);
const [state, formAction] = useFormState(createWorkspaceAction, initialState);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Novo espaço
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Novo espaço de trabalho</DialogTitle>
<DialogDescription>
Organize as tarefas da sua equipe em um espaço dedicado.
</DialogDescription>
</DialogHeader>
<form action={formAction} className="space-y-4">
{state.error && (
<Alert variant="destructive">
<AlertDescription>{state.error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="ws-name">Nome</Label>
<Input
id="ws-name"
name="name"
placeholder="Ex.: Marketing, Produção..."
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="ws-description">Descrição (opcional)</Label>
<Textarea
id="ws-description"
name="description"
rows={3}
placeholder="Para que serve este espaço?"
/>
</div>
<div className="space-y-2">
<Label>Cor</Label>
<input type="hidden" name="color" value={color} />
<div className="flex gap-2">
{WORKSPACE_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Cor ${c}`}
onClick={() => setColor(c)}
className={cn(
"h-7 w-7 rounded-full border-2 transition",
color === c
? "border-foreground scale-110"
: "border-transparent"
)}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<SubmitButton />
</form>
</DialogContent>
</Dialog>
);
}
"use client";
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { deleteWorkspaceAction } from "@/lib/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function WorkspaceDeleteButton({
workspaceId,
workspaceName,
}: {
workspaceId: number;
workspaceName: string;
}) {
const [pending, setPending] = useState(false);
async function handleDelete() {
setPending(true);
const result = await deleteWorkspaceAction(workspaceId);
// Em caso de sucesso a action redireciona; só chega aqui com erro.
if (result?.error) {
toast.error(result.error);
setPending(false);
}
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir espaço
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Excluir “{workspaceName}”?</DialogTitle>
<DialogDescription>
Todas as tarefas deste espaço serão excluídas permanentemente. Esta
ação não pode ser desfeita.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="destructive"
onClick={handleDelete}
disabled={pending}
className="w-full"
>
{pending ? "Excluindo..." : "Excluir definitivamente"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import {
createSession,
destroySession,
hashPassword,
verifyPassword,
} from "./auth";
import {
assertCanEditTask,
assertWorkspaceAdmin,
assertWorkspaceMember,
requireUser,
} from "./authz";
import * as data from "./data";
import { TASK_PRIORITIES, TASK_STATUSES } from "./constants";
export interface ActionState {
error?: string;
success?: boolean;
}
function firstError(error: z.ZodError): string {
return error.issues[0]?.message ?? "Dados inválidos.";
}
// ---------- Autenticação ----------
const registerSchema = z.object({
name: z.string().min(2, "Informe seu nome completo."),
email: z.string().email("E-mail inválido."),
password: z.string().min(8, "A senha deve ter no mínimo 8 caracteres."),
});
export async function registerAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = registerSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) return { error: firstError(parsed.error) };
try {
const existing = await data.findUserByEmail(parsed.data.email);
if (existing) return { error: "Este e-mail já está cadastrado." };
const hash = await hashPassword(parsed.data.password);
const user = await data.createUser(parsed.data.name, parsed.data.email, hash);
await createSession({
id: user.Id,
name: user.Name,
email: user.Email,
role: user.Role,
});
} catch (err) {
console.error("registerAction:", err);
return { error: "Erro ao conectar ao banco de dados. Tente novamente." };
}
redirect("/dashboard");
}
const loginSchema = z.object({
email: z.string().email("E-mail inválido."),
password: z.string().min(1, "Informe a senha."),
});
export async function loginAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = loginSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) return { error: firstError(parsed.error) };
try {
const user = await data.findUserByEmail(parsed.data.email);
if (!user || !(await verifyPassword(parsed.data.password, user.PasswordHash))) {
return { error: "E-mail ou senha incorretos." };
}
await createSession({
id: user.Id,
name: user.Name,
email: user.Email,
role: user.Role,
});
} catch (err) {
console.error("loginAction:", err);
return { error: "Erro ao conectar ao banco de dados. Tente novamente." };
}
redirect("/dashboard");
}
export async function logoutAction(): Promise<void> {
destroySession();
redirect("/login");
}
// ---------- Workspaces ----------
const workspaceSchema = z.object({
name: z.string().min(2, "O nome do espaço deve ter pelo menos 2 caracteres."),
description: z.string().max(500).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Cor inválida."),
});
export async function createWorkspaceAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const user = await requireUser();
const parsed = workspaceSchema.safeParse({
name: formData.get("name"),
description: formData.get("description") || undefined,
color: formData.get("color"),
});
if (!parsed.success) return { error: firstError(parsed.error) };
let workspaceId: number;
try {
workspaceId = await data.createWorkspace(
parsed.data.name,
parsed.data.description ?? null,
parsed.data.color,
user.id
);
} catch (err) {
console.error("createWorkspaceAction:", err);
return { error: "Erro ao criar o espaço de trabalho." };
}
revalidatePath("/workspaces");
redirect(`/workspaces/${workspaceId}`);
}
export async function deleteWorkspaceAction(
workspaceId: number
): Promise<ActionState> {
const user = await requireUser();
try {
await assertWorkspaceAdmin(workspaceId, user);
await data.deleteWorkspace(workspaceId);
} catch (err) {
return { error: err instanceof Error ? err.message : "Erro ao excluir." };
}
revalidatePath("/workspaces");
redirect("/workspaces");
}
const addMemberSchema = z.object({
workspaceId: z.coerce.number().int().positive(),
email: z.string().email("E-mail inválido."),
role: z.enum(["admin", "member"]),
});
export async function addMemberAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const user = await requireUser();
const parsed = addMemberSchema.safeParse({
workspaceId: formData.get("workspaceId"),
email: formData.get("email"),
role: formData.get("role"),
});
if (!parsed.success) return { error: firstError(parsed.error) };
try {
await assertWorkspaceAdmin(parsed.data.workspaceId, user);
const member = await data.findUserByEmail(parsed.data.email);
if (!member) {
return { error: "Nenhum usuário cadastrado com este e-mail." };
}
await data.addWorkspaceMember(
parsed.data.workspaceId,
member.Id,
parsed.data.role
);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao adicionar membro.",
};
}
revalidatePath(`/workspaces/${parsed.data.workspaceId}`);
return { success: true };
}
export async function removeMemberAction(
workspaceId: number,
userId: number
): Promise<ActionState> {
const user = await requireUser();
try {
await assertWorkspaceAdmin(workspaceId, user);
const workspace = await data.getWorkspace(workspaceId);
if (workspace?.OwnerId === userId) {
return { error: "O dono do espaço não pode ser removido." };
}
await data.removeWorkspaceMember(workspaceId, userId);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao remover membro.",
};
}
revalidatePath(`/workspaces/${workspaceId}`);
return { success: true };
}
// ---------- Tarefas ----------
const statusValues = TASK_STATUSES.map((s) => s.value) as [string, ...string[]];
const priorityValues = TASK_PRIORITIES.map((p) => p.value) as [
string,
...string[],
];
const taskSchema = z
.object({
workspaceId: z.coerce.number().int().positive(),
title: z.string().min(2, "O título deve ter pelo menos 2 caracteres."),
description: z.string().max(2000).optional(),
status: z.enum(statusValues),
priority: z.enum(priorityValues),
assigneeId: z.coerce.number().int().positive().optional(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
progress: z.coerce
.number()
.int()
.min(0, "O progresso deve ser entre 0 e 100.")
.max(100, "O progresso deve ser entre 0 e 100.")
.optional(),
})
.refine(
(t) => !t.startDate || !t.dueDate || t.startDate <= t.dueDate,
{ message: "A data de início não pode ser depois do vencimento." }
);
function parseTaskForm(formData: FormData) {
return taskSchema.safeParse({
workspaceId: formData.get("workspaceId"),
title: formData.get("title"),
description: formData.get("description") || undefined,
status: formData.get("status"),
priority: formData.get("priority"),
assigneeId: formData.get("assigneeId") || undefined,
startDate: formData.get("startDate") || undefined,
dueDate: formData.get("dueDate") || undefined,
progress: formData.get("progress") || undefined,
});
}
export async function createTaskAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const user = await requireUser();
const parsed = parseTaskForm(formData);
if (!parsed.success) return { error: firstError(parsed.error) };
try {
await assertWorkspaceMember(parsed.data.workspaceId, user);
await data.createTask(
{
workspaceId: parsed.data.workspaceId,
title: parsed.data.title,
description: parsed.data.description ?? null,
status: parsed.data.status,
priority: parsed.data.priority,
assigneeId: parsed.data.assigneeId ?? null,
startDate: parsed.data.startDate ?? null,
dueDate: parsed.data.dueDate ?? null,
progress: parsed.data.progress ?? 0,
},
user.id
);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao criar a tarefa.",
};
}
revalidatePath(`/workspaces/${parsed.data.workspaceId}`);
revalidatePath("/dashboard");
return { success: true };
}
export async function updateTaskAction(
taskId: number,
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const user = await requireUser();
const parsed = parseTaskForm(formData);
if (!parsed.success) return { error: firstError(parsed.error) };
try {
const task = await data.getTask(taskId);
if (!task) return { error: "Tarefa não encontrada." };
await assertCanEditTask(task, user);
await data.updateTask(taskId, {
workspaceId: task.WorkspaceId,
title: parsed.data.title,
description: parsed.data.description ?? null,
status: parsed.data.status,
priority: parsed.data.priority,
assigneeId: parsed.data.assigneeId ?? null,
startDate: parsed.data.startDate ?? null,
dueDate: parsed.data.dueDate ?? null,
progress: parsed.data.progress ?? task.Progress,
});
revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao salvar a tarefa.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
export async function updateTaskStatusAction(
taskId: number,
status: string
): Promise<ActionState> {
const user = await requireUser();
if (!statusValues.includes(status)) return { error: "Status inválido." };
try {
const task = await data.getTask(taskId);
if (!task) return { error: "Tarefa não encontrada." };
await assertCanEditTask(task, user);
await data.updateTaskStatus(taskId, status);
revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao atualizar status.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
export async function updateTaskProgressAction(
taskId: number,
progress: number
): Promise<ActionState> {
const user = await requireUser();
if (!Number.isInteger(progress) || progress < 0 || progress > 100) {
return { error: "O progresso deve ser um número entre 0 e 100." };
}
try {
const task = await data.getTask(taskId);
if (!task) return { error: "Tarefa não encontrada." };
await assertCanEditTask(task, user);
await data.updateTaskProgress(taskId, progress);
revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) {
return {
error:
err instanceof Error ? err.message : "Erro ao atualizar o progresso.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
// ---------- Subtarefas ----------
const subtaskTitleSchema = z
.string()
.min(1, "Informe o título da subtarefa.")
.max(200, "Título muito longo.");
export async function createSubtaskAction(
taskId: number,
title: string
): Promise<ActionState> {
const user = await requireUser();
const parsed = subtaskTitleSchema.safeParse(title?.trim());
if (!parsed.success) return { error: firstError(parsed.error) };
try {
const task = await data.getTask(taskId);
if (!task) return { error: "Tarefa não encontrada." };
await assertCanEditTask(task, user);
await data.createSubtask(taskId, parsed.data);
revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao criar a subtarefa.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
export async function toggleSubtaskAction(
subtaskId: number,
isDone: boolean
): Promise<ActionState> {
const user = await requireUser();
try {
const subtask = await data.getSubtaskWithWorkspace(subtaskId);
if (!subtask) return { error: "Subtarefa não encontrada." };
await assertCanEditTask(subtask, user);
await data.setSubtaskDone(subtaskId, isDone);
revalidatePath(`/workspaces/${subtask.WorkspaceId}`);
} catch (err) {
return {
error:
err instanceof Error ? err.message : "Erro ao atualizar a subtarefa.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
export async function deleteSubtaskAction(
subtaskId: number
): Promise<ActionState> {
const user = await requireUser();
try {
const subtask = await data.getSubtaskWithWorkspace(subtaskId);
if (!subtask) return { error: "Subtarefa não encontrada." };
await assertCanEditTask(subtask, user);
await data.deleteSubtask(subtaskId);
revalidatePath(`/workspaces/${subtask.WorkspaceId}`);
} catch (err) {
return {
error:
err instanceof Error ? err.message : "Erro ao excluir a subtarefa.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
export async function deleteTaskAction(taskId: number): Promise<ActionState> {
const user = await requireUser();
try {
const task = await data.getTask(taskId);
if (!task) return { error: "Tarefa não encontrada." };
await assertCanEditTask(task, user);
await data.deleteTask(taskId);
revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao excluir a tarefa.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
import "server-only";
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
import bcrypt from "bcryptjs";
const SESSION_COOKIE = "petruz_session";
const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 7; // 7 dias
export interface SessionUser {
id: number;
name: string;
email: string;
role: string;
}
function getSecret(): Uint8Array {
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error("AUTH_SECRET não configurado nas variáveis de ambiente");
}
return new TextEncoder().encode(secret);
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export async function createSession(user: SessionUser): Promise<void> {
const token = await new SignJWT({
sub: String(user.id),
name: user.name,
email: user.email,
role: user.role,
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${SESSION_DURATION_SECONDS}s`)
.sign(getSecret());
cookies().set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SESSION_DURATION_SECONDS,
path: "/",
});
}
export async function getSession(): Promise<SessionUser | null> {
const token = cookies().get(SESSION_COOKIE)?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, getSecret());
return {
id: Number(payload.sub),
name: String(payload.name),
email: String(payload.email),
role: String(payload.role),
};
} catch {
return null;
}
}
export function destroySession(): void {
cookies().delete(SESSION_COOKIE);
}
export { SESSION_COOKIE };
import "server-only";
import { redirect } from "next/navigation";
import { getPool, sql } from "./db";
import { getSession, type SessionUser } from "./auth";
import { canEditTask } from "./permissions";
/**
* Políticas de autorização:
* - Toda rota do app exige sessão válida (middleware + requireUser).
* - Workspaces só são visíveis para seus membros.
* - Gestão do workspace (membros, exclusão, edição) exige papel 'admin'
* no workspace — o criador entra como admin automaticamente.
* - Tarefas podem ser criadas/editadas por qualquer membro do workspace.
*/
export async function requireUser(): Promise<SessionUser> {
const session = await getSession();
if (!session) redirect("/login");
return session;
}
export type WorkspaceRole = "admin" | "member";
export async function getWorkspaceRole(
workspaceId: number,
userId: number
): Promise<WorkspaceRole | null> {
const pool = await getPool();
const result = await pool
.request()
.input("workspaceId", sql.Int, workspaceId)
.input("userId", sql.Int, userId)
.query(
"SELECT Role FROM dbo.WorkspaceMembers WHERE WorkspaceId = @workspaceId AND UserId = @userId"
);
const role = result.recordset[0]?.Role;
return role === "admin" || role === "member" ? role : null;
}
/** Garante que o usuário é membro do workspace; senão, redireciona. */
export async function requireWorkspaceMember(
workspaceId: number,
user: SessionUser
): Promise<WorkspaceRole> {
const role = await getWorkspaceRole(workspaceId, user.id);
if (!role) redirect("/workspaces");
return role;
}
/** Garante papel de admin no workspace (ou lança erro para actions). */
export async function assertWorkspaceAdmin(
workspaceId: number,
user: SessionUser
): Promise<void> {
const role = await getWorkspaceRole(workspaceId, user.id);
if (role !== "admin") {
throw new Error("Você não tem permissão para gerenciar este espaço.");
}
}
/** Garante que é membro (ou lança erro para actions). */
export async function assertWorkspaceMember(
workspaceId: number,
user: SessionUser
): Promise<void> {
const role = await getWorkspaceRole(workspaceId, user.id);
if (!role) {
throw new Error("Você não tem acesso a este espaço de trabalho.");
}
}
/**
* Garante que o usuário pode editar/excluir a tarefa: precisa ser membro do
* workspace e (admin do espaço, responsável pela tarefa, ou criador quando
* a tarefa não tem responsável).
*/
export async function assertCanEditTask(
task: { WorkspaceId: number; AssigneeId: number | null; CreatedById: number },
user: SessionUser
): Promise<void> {
const role = await getWorkspaceRole(task.WorkspaceId, user.id);
if (!role) {
throw new Error("Você não tem acesso a este espaço de trabalho.");
}
if (!canEditTask(task, user.id, role === "admin")) {
throw new Error("Apenas o responsável pela tarefa pode alterá-la.");
}
}
export const TASK_STATUSES = [
{ value: "todo", label: "A Fazer" },
{ value: "in_progress", label: "Em Andamento" },
{ value: "review", label: "Em Revisão" },
{ value: "done", label: "Concluída" },
] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number]["value"];
export const TASK_PRIORITIES = [
{ value: "low", label: "Baixa" },
{ value: "medium", label: "Média" },
{ value: "high", label: "Alta" },
{ value: "urgent", label: "Urgente" },
] as const;
export type TaskPriority = (typeof TASK_PRIORITIES)[number]["value"];
export function statusLabel(status: string): string {
return TASK_STATUSES.find((s) => s.value === status)?.label ?? status;
}
export function priorityLabel(priority: string): string {
return TASK_PRIORITIES.find((p) => p.value === priority)?.label ?? priority;
}
export const WORKSPACE_COLORS = [
"#7C3AED",
"#9333EA",
"#6D28D9",
"#A855F7",
"#C026D3",
"#581C87",
] as const;
import "server-only";
import { getPool, sql } from "./db";
export interface UserRecord {
Id: number;
Name: string;
Email: string;
PasswordHash: string;
Role: string;
}
export interface Workspace {
Id: number;
Name: string;
Description: string | null;
Color: string;
OwnerId: number;
MemberRole: string;
TaskCount: number;
DoneCount: number;
}
export interface WorkspaceMember {
UserId: number;
Name: string;
Email: string;
Role: string;
}
export interface Task {
Id: number;
WorkspaceId: number;
WorkspaceName?: string;
Title: string;
Description: string | null;
Status: string;
Priority: string;
AssigneeId: number | null;
AssigneeName: string | null;
CreatedById: number;
StartDate: Date | null;
DueDate: Date | null;
Progress: number;
CreatedAt: Date;
SubtaskCount: number;
SubtaskDone: number;
}
export interface Subtask {
Id: number;
TaskId: number;
Title: string;
IsDone: boolean;
}
// ---------- Usuários ----------
export async function findUserByEmail(
email: string
): Promise<UserRecord | null> {
const pool = await getPool();
const result = await pool
.request()
.input("email", sql.NVarChar(255), email.toLowerCase().trim())
.query("SELECT * FROM dbo.Users WHERE Email = @email");
return result.recordset[0] ?? null;
}
export async function createUser(
name: string,
email: string,
passwordHash: string
): Promise<UserRecord> {
const pool = await getPool();
const result = await pool
.request()
.input("name", sql.NVarChar(120), name.trim())
.input("email", sql.NVarChar(255), email.toLowerCase().trim())
.input("hash", sql.NVarChar(255), passwordHash)
.query(
`INSERT INTO dbo.Users (Name, Email, PasswordHash)
OUTPUT INSERTED.*
VALUES (@name, @email, @hash)`
);
return result.recordset[0];
}
// ---------- Workspaces ----------
export async function listWorkspacesForUser(
userId: number
): Promise<Workspace[]> {
const pool = await getPool();
const result = await pool.request().input("userId", sql.Int, userId).query(
`SELECT w.Id, w.Name, w.Description, w.Color, w.OwnerId,
wm.Role AS MemberRole,
(SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id) AS TaskCount,
(SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id AND t.Status = 'done') AS DoneCount
FROM dbo.Workspaces w
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = w.Id
WHERE wm.UserId = @userId
ORDER BY w.Name`
);
return result.recordset;
}
export async function getWorkspace(id: number): Promise<Workspace | null> {
const pool = await getPool();
const result = await pool
.request()
.input("id", sql.Int, id)
.query(
`SELECT Id, Name, Description, Color, OwnerId, '' AS MemberRole, 0 AS TaskCount, 0 AS DoneCount
FROM dbo.Workspaces WHERE Id = @id`
);
return result.recordset[0] ?? null;
}
export async function createWorkspace(
name: string,
description: string | null,
color: string,
ownerId: number
): Promise<number> {
const pool = await getPool();
const result = await pool
.request()
.input("name", sql.NVarChar(120), name.trim())
.input("description", sql.NVarChar(500), description)
.input("color", sql.NVarChar(20), color)
.input("ownerId", sql.Int, ownerId)
.query(
`INSERT INTO dbo.Workspaces (Name, Description, Color, OwnerId)
OUTPUT INSERTED.Id
VALUES (@name, @description, @color, @ownerId);`
);
const workspaceId: number = result.recordset[0].Id;
await pool
.request()
.input("workspaceId", sql.Int, workspaceId)
.input("userId", sql.Int, ownerId)
.query(
`INSERT INTO dbo.WorkspaceMembers (WorkspaceId, UserId, Role)
VALUES (@workspaceId, @userId, 'admin')`
);
return workspaceId;
}
export async function deleteWorkspace(id: number): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.query("DELETE FROM dbo.Workspaces WHERE Id = @id");
}
export async function listWorkspaceMembers(
workspaceId: number
): Promise<WorkspaceMember[]> {
const pool = await getPool();
const result = await pool
.request()
.input("workspaceId", sql.Int, workspaceId)
.query(
`SELECT u.Id AS UserId, u.Name, u.Email, wm.Role
FROM dbo.WorkspaceMembers wm
JOIN dbo.Users u ON u.Id = wm.UserId
WHERE wm.WorkspaceId = @workspaceId
ORDER BY u.Name`
);
return result.recordset;
}
export async function addWorkspaceMember(
workspaceId: number,
userId: number,
role: string
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("workspaceId", sql.Int, workspaceId)
.input("userId", sql.Int, userId)
.input("role", sql.NVarChar(20), role)
.query(
`IF NOT EXISTS (SELECT 1 FROM dbo.WorkspaceMembers WHERE WorkspaceId = @workspaceId AND UserId = @userId)
INSERT INTO dbo.WorkspaceMembers (WorkspaceId, UserId, Role)
VALUES (@workspaceId, @userId, @role)`
);
}
export async function removeWorkspaceMember(
workspaceId: number,
userId: number
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("workspaceId", sql.Int, workspaceId)
.input("userId", sql.Int, userId)
.query(
`DELETE FROM dbo.WorkspaceMembers
WHERE WorkspaceId = @workspaceId AND UserId = @userId`
);
}
// ---------- Tarefas ----------
const TASK_SELECT = `
SELECT t.Id, t.WorkspaceId, t.Title, t.Description, t.Status, t.Priority,
t.AssigneeId, a.Name AS AssigneeName, t.CreatedById,
t.StartDate, t.DueDate, t.Progress, t.CreatedAt, w.Name AS WorkspaceName,
(SELECT COUNT(*) FROM dbo.Subtasks s WHERE s.TaskId = t.Id) AS SubtaskCount,
(SELECT COUNT(*) FROM dbo.Subtasks s WHERE s.TaskId = t.Id AND s.IsDone = 1) AS SubtaskDone
FROM dbo.Tasks t
LEFT JOIN dbo.Users a ON a.Id = t.AssigneeId
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId`;
export async function listTasksByWorkspace(
workspaceId: number
): Promise<Task[]> {
const pool = await getPool();
const result = await pool
.request()
.input("workspaceId", sql.Int, workspaceId)
.query(
`${TASK_SELECT}
WHERE t.WorkspaceId = @workspaceId
ORDER BY CASE WHEN t.DueDate IS NULL THEN 1 ELSE 0 END, t.DueDate, t.Id DESC`
);
return result.recordset;
}
export async function getTask(id: number): Promise<Task | null> {
const pool = await getPool();
const result = await pool
.request()
.input("id", sql.Int, id)
.query(`${TASK_SELECT} WHERE t.Id = @id`);
return result.recordset[0] ?? null;
}
export interface TaskInput {
workspaceId: number;
title: string;
description: string | null;
status: string;
priority: string;
assigneeId: number | null;
startDate: string | null;
dueDate: string | null;
progress: number;
}
export async function createTask(
input: TaskInput,
createdById: number
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("workspaceId", sql.Int, input.workspaceId)
.input("title", sql.NVarChar(200), input.title.trim())
.input("description", sql.NVarChar(2000), input.description)
.input("status", sql.NVarChar(20), input.status)
.input("priority", sql.NVarChar(10), input.priority)
.input("assigneeId", sql.Int, input.assigneeId)
.input("startDate", sql.Date, input.startDate)
.input("dueDate", sql.Date, input.dueDate)
.input("progress", sql.Int, input.progress)
.input("createdById", sql.Int, createdById)
.query(
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, AssigneeId, StartDate, DueDate, Progress, CreatedById)
VALUES (@workspaceId, @title, @description, @status, @priority, @assigneeId, @startDate, @dueDate, @progress, @createdById)`
);
}
export async function updateTask(id: number, input: TaskInput): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.input("title", sql.NVarChar(200), input.title.trim())
.input("description", sql.NVarChar(2000), input.description)
.input("status", sql.NVarChar(20), input.status)
.input("priority", sql.NVarChar(10), input.priority)
.input("assigneeId", sql.Int, input.assigneeId)
.input("startDate", sql.Date, input.startDate)
.input("dueDate", sql.Date, input.dueDate)
.input("progress", sql.Int, input.progress)
.query(
`UPDATE dbo.Tasks
SET Title = @title, Description = @description, Status = @status,
Priority = @priority, AssigneeId = @assigneeId,
StartDate = @startDate, DueDate = @dueDate, Progress = @progress,
UpdatedAt = SYSUTCDATETIME()
WHERE Id = @id`
);
}
export async function updateTaskStatus(
id: number,
status: string
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.input("status", sql.NVarChar(20), status)
.query(
`UPDATE dbo.Tasks SET Status = @status, UpdatedAt = SYSUTCDATETIME() WHERE Id = @id`
);
}
export async function updateTaskProgress(
id: number,
progress: number
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.input("progress", sql.Int, progress)
.query(
`UPDATE dbo.Tasks SET Progress = @progress, UpdatedAt = SYSUTCDATETIME() WHERE Id = @id`
);
}
export async function deleteTask(id: number): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.query("DELETE FROM dbo.Tasks WHERE Id = @id");
}
// ---------- Subtarefas ----------
export async function listSubtasksByWorkspace(
workspaceId: number
): Promise<Subtask[]> {
const pool = await getPool();
const result = await pool
.request()
.input("workspaceId", sql.Int, workspaceId)
.query(
`SELECT s.Id, s.TaskId, s.Title, s.IsDone
FROM dbo.Subtasks s
JOIN dbo.Tasks t ON t.Id = s.TaskId
WHERE t.WorkspaceId = @workspaceId
ORDER BY s.Id`
);
return result.recordset;
}
/** Retorna a subtarefa com dados da tarefa pai (para autorização). */
export async function getSubtaskWithWorkspace(
id: number
): Promise<
| (Subtask & {
WorkspaceId: number;
AssigneeId: number | null;
CreatedById: number;
})
| null
> {
const pool = await getPool();
const result = await pool
.request()
.input("id", sql.Int, id)
.query(
`SELECT s.Id, s.TaskId, s.Title, s.IsDone,
t.WorkspaceId, t.AssigneeId, t.CreatedById
FROM dbo.Subtasks s
JOIN dbo.Tasks t ON t.Id = s.TaskId
WHERE s.Id = @id`
);
return result.recordset[0] ?? null;
}
export async function createSubtask(
taskId: number,
title: string
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("taskId", sql.Int, taskId)
.input("title", sql.NVarChar(200), title.trim())
.query("INSERT INTO dbo.Subtasks (TaskId, Title) VALUES (@taskId, @title)");
}
export async function setSubtaskDone(
id: number,
isDone: boolean
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.input("isDone", sql.Bit, isDone)
.query("UPDATE dbo.Subtasks SET IsDone = @isDone WHERE Id = @id");
}
export async function deleteSubtask(id: number): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.query("DELETE FROM dbo.Subtasks WHERE Id = @id");
}
// ---------- Painel (dashboard) ----------
export interface StatusCount {
WorkspaceId: number;
WorkspaceName: string;
Color: string;
Status: string;
Count: number;
}
export async function getStatusCountsForUser(
userId: number
): Promise<StatusCount[]> {
const pool = await getPool();
const result = await pool.request().input("userId", sql.Int, userId).query(
`SELECT w.Id AS WorkspaceId, w.Name AS WorkspaceName, w.Color, t.Status, COUNT(*) AS Count
FROM dbo.Tasks t
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = w.Id AND wm.UserId = @userId
GROUP BY w.Id, w.Name, w.Color, t.Status`
);
return result.recordset;
}
/** Tarefas não concluídas vencidas ou que vencem nos próximos @days dias. */
export async function getDueAlertsForUser(
userId: number,
days = 3
): Promise<Task[]> {
const pool = await getPool();
const result = await pool
.request()
.input("userId", sql.Int, userId)
.input("days", sql.Int, days)
.query(
`${TASK_SELECT}
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = t.WorkspaceId AND wm.UserId = @userId
WHERE t.Status <> 'done'
AND t.DueDate IS NOT NULL
AND t.DueDate <= DATEADD(day, @days, CAST(GETDATE() AS DATE))
ORDER BY t.DueDate`
);
return result.recordset;
}
import { differenceInCalendarDays } from "date-fns";
/**
* Colunas DATE chegam do driver como Date em meia-noite UTC; usar métodos
* locais deslocaria um dia em fusos negativos (ex.: Brasil). Normalizamos
* tudo para a string ISO "yyyy-MM-dd" antes de comparar/exibir.
*/
export function toISODate(date: Date | string | null): string | null {
if (!date) return null;
if (typeof date === "string") return date.slice(0, 10);
return date.toISOString().slice(0, 10);
}
export function formatDate(date: Date | string | null): string {
const iso = toISODate(date);
if (!iso) return "";
const [year, month, day] = iso.split("-");
return `${day}/${month}/${year}`;
}
function isoToLocalDate(iso: string): Date {
const [year, month, day] = iso.split("-").map(Number);
return new Date(year, month - 1, day);
}
export type DueState = "overdue" | "today" | "soon" | "ok";
export interface DueInfo {
state: DueState;
label: string;
}
/** Classifica o vencimento de uma tarefa para exibir alertas. */
export function getDueInfo(
dueDate: Date | string | null,
status: string
): DueInfo | null {
const iso = toISODate(dueDate);
if (!iso || status === "done") return null;
const days = differenceInCalendarDays(isoToLocalDate(iso), new Date());
if (days < 0) {
return {
state: "overdue",
label: days === -1 ? "Venceu ontem" : `Vencida há ${-days} dias`,
};
}
if (days === 0) return { state: "today", label: "Vence hoje" };
if (days <= 3) {
return {
state: "soon",
label: days === 1 ? "Vence amanhã" : `Vence em ${days} dias`,
};
}
return { state: "ok", label: `Vence em ${days} dias` };
}
import type { ConnectionPool } from "mssql";
/**
* DDL idempotente: cria as tabelas do sistema caso ainda não existam.
* Executado automaticamente na primeira conexão com o banco.
*/
const DDL_STATEMENTS: string[] = [
`IF OBJECT_ID('dbo.Users', 'U') IS NULL
CREATE TABLE dbo.Users (
Id INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(120) NOT NULL,
Email NVARCHAR(255) NOT NULL UNIQUE,
PasswordHash NVARCHAR(255) NOT NULL,
Role NVARCHAR(20) NOT NULL DEFAULT 'user',
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`,
`IF OBJECT_ID('dbo.Workspaces', 'U') IS NULL
CREATE TABLE dbo.Workspaces (
Id INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(120) NOT NULL,
Description NVARCHAR(500) NULL,
Color NVARCHAR(20) NOT NULL DEFAULT '#7C3AED',
OwnerId INT NOT NULL REFERENCES dbo.Users(Id),
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`,
`IF OBJECT_ID('dbo.WorkspaceMembers', 'U') IS NULL
CREATE TABLE dbo.WorkspaceMembers (
WorkspaceId INT NOT NULL REFERENCES dbo.Workspaces(Id) ON DELETE CASCADE,
UserId INT NOT NULL REFERENCES dbo.Users(Id),
Role NVARCHAR(20) NOT NULL DEFAULT 'member',
JoinedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
CONSTRAINT PK_WorkspaceMembers PRIMARY KEY (WorkspaceId, UserId)
)`,
`IF OBJECT_ID('dbo.Tasks', 'U') IS NULL
CREATE TABLE dbo.Tasks (
Id INT IDENTITY(1,1) PRIMARY KEY,
WorkspaceId INT NOT NULL REFERENCES dbo.Workspaces(Id) ON DELETE CASCADE,
Title NVARCHAR(200) NOT NULL,
Description NVARCHAR(2000) NULL,
Status NVARCHAR(20) NOT NULL DEFAULT 'todo',
Priority NVARCHAR(10) NOT NULL DEFAULT 'medium',
AssigneeId INT NULL REFERENCES dbo.Users(Id),
CreatedById INT NOT NULL REFERENCES dbo.Users(Id),
StartDate DATE NULL,
DueDate DATE NULL,
Progress INT NOT NULL DEFAULT 0,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`,
// Migração para bancos criados antes da coluna de progresso manual
`IF COL_LENGTH('dbo.Tasks', 'Progress') IS NULL
ALTER TABLE dbo.Tasks ADD Progress INT NOT NULL DEFAULT 0`,
`IF OBJECT_ID('dbo.Subtasks', 'U') IS NULL
CREATE TABLE dbo.Subtasks (
Id INT IDENTITY(1,1) PRIMARY KEY,
TaskId INT NOT NULL REFERENCES dbo.Tasks(Id) ON DELETE CASCADE,
Title NVARCHAR(200) NOT NULL,
IsDone BIT NOT NULL DEFAULT 0,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Subtasks_TaskId')
CREATE INDEX IX_Subtasks_TaskId ON dbo.Subtasks (TaskId)`,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Tasks_WorkspaceId')
CREATE INDEX IX_Tasks_WorkspaceId ON dbo.Tasks (WorkspaceId)`,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Tasks_AssigneeId')
CREATE INDEX IX_Tasks_AssigneeId ON dbo.Tasks (AssigneeId)`,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Tasks_DueDate')
CREATE INDEX IX_Tasks_DueDate ON dbo.Tasks (DueDate)`,
];
export async function initDb(pool: ConnectionPool): Promise<void> {
for (const statement of DDL_STATEMENTS) {
await pool.request().batch(statement);
}
}
import sql from "mssql";
import { initDb } from "./db-init";
const config: sql.config = {
server: process.env.DB_SERVER ?? "localhost",
port: Number(process.env.DB_PORT ?? 1433),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME ?? "PetruzTasks",
options: {
encrypt: process.env.DB_ENCRYPT === "true",
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE !== "false",
},
pool: { max: 10, min: 0, idleTimeoutMillis: 30_000 },
};
// Em dev o Next recarrega módulos a cada alteração; o pool fica no escopo
// global para não abrir uma conexão nova por reload.
declare global {
// eslint-disable-next-line no-var
var _mssqlPool: Promise<sql.ConnectionPool> | undefined;
}
export async function getPool(): Promise<sql.ConnectionPool> {
if (!global._mssqlPool) {
global._mssqlPool = new sql.ConnectionPool(config)
.connect()
.then(async (pool) => {
await initDb(pool);
return pool;
})
.catch((err) => {
global._mssqlPool = undefined;
throw err;
});
}
return global._mssqlPool;
}
export { sql };
/**
* Política de edição de tarefas (compartilhada entre servidor e UI):
* - admins do workspace podem editar/excluir qualquer tarefa;
* - o responsável (assignee) edita as tarefas dele;
* - tarefas sem responsável podem ser editadas por quem as criou.
*/
export function canEditTask(
task: { AssigneeId: number | null; CreatedById: number },
userId: number,
isAdmin: boolean
): boolean {
if (isAdmin) return true;
if (task.AssigneeId === userId) return true;
return task.AssigneeId === null && task.CreatedById === userId;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
import { NextResponse, type NextRequest } from "next/server";
import { jwtVerify } from "jose";
const SESSION_COOKIE = "petruz_session";
const PUBLIC_PATHS = ["/login", "/register"];
async function hasValidSession(request: NextRequest): Promise<boolean> {
const token = request.cookies.get(SESSION_COOKIE)?.value;
if (!token || !process.env.AUTH_SECRET) return false;
try {
await jwtVerify(token, new TextEncoder().encode(process.env.AUTH_SECRET));
return true;
} catch {
return false;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
const authenticated = await hasValidSession(request);
if (!authenticated && !isPublic) {
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
}
if (authenticated && (isPublic || pathname === "/")) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$|api).*)"],
};
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
const config: Config = { const config: Config = {
content: [ darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
], ],
theme: { theme: {
extend: { extend: {
colors: { colors: {
background: "var(--background)", background: 'hsl(var(--background))',
foreground: "var(--foreground)", foreground: 'hsl(var(--foreground))',
}, card: {
}, DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
}, },
plugins: [], plugins: [require("tailwindcss-animate")],
}; };
export default config; export default config;
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment