Skip to content
GitLab
Projects
Groups
Topics
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Register
Sign in
Toggle navigation
Menu
Paulo Avila
petruz-tasks
Commits
24d624aa
Commit
24d624aa
authored
Jul 11, 2026
by
Paulo Avila
Browse files
Tarefas compartilhadas entre colaboradores
parent
ca3426d3
Changes
10
Hide whitespace changes
Inline
Side-by-side
src/app/(app)/workspaces/[id]/membro/[memberId]/page.tsx
View file @
24d624aa
...
...
@@ -53,7 +53,10 @@ export default async function MemberFolderPage({
if
(
memberId
!==
0
&&
!
member
)
notFound
();
const
tasks
=
allTasks
.
filter
((
t
)
=>
member
?
t
.
AssigneeId
===
member
.
UserId
:
t
.
AssigneeId
===
null
member
?
t
.
AssigneeId
===
member
.
UserId
||
t
.
Collaborators
.
some
((
c
)
=>
c
.
UserId
===
member
.
UserId
)
:
t
.
AssigneeId
===
null
);
const
taskIds
=
new
Set
(
tasks
.
map
((
t
)
=>
t
.
Id
));
const
subtasksByTask
=
allSubtasks
...
...
src/components/tasks/task-board.tsx
View file @
24d624aa
...
...
@@ -2,7 +2,7 @@ import { CalendarCheck, CalendarClock, CalendarPlus, Pencil } from "lucide-react
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
type
{
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
formatDateShort
}
from
"
@/lib/dates
"
;
import
{
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
canDeleteTask
,
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Card
,
CardContent
}
from
"
@/components/ui/card
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
...
...
@@ -15,7 +15,7 @@ import { TaskDeleteButton } from "./task-delete-button";
import
{
TaskDialog
}
from
"
./task-dialog
"
;
import
{
taskPercent
}
from
"
./task-progress
"
;
import
{
TaskStatusSelect
}
from
"
./task-status-select
"
;
import
{
toFormValues
}
from
"
./task-utils
"
;
import
{
initials
,
toFormValues
}
from
"
./task-utils
"
;
export
function
TaskBoard
({
tasks
,
...
...
@@ -47,6 +47,7 @@ export function TaskBoard({
<
div
className
=
"flex flex-col gap-2"
>
{
columnTasks
.
map
((
task
)
=>
{
const
editable
=
canEditTask
(
task
,
currentUserId
,
isAdmin
);
const
deletable
=
canDeleteTask
(
task
,
currentUserId
,
isAdmin
);
const
percent
=
taskPercent
(
task
.
Status
,
task
.
SubtaskCount
,
...
...
@@ -80,10 +81,12 @@ export function TaskBoard({
</
Button
>
}
/>
<
TaskDeleteButton
taskId
=
{
task
.
Id
}
taskTitle
=
{
task
.
Title
}
/>
{
deletable
&&
(
<
TaskDeleteButton
taskId
=
{
task
.
Id
}
taskTitle
=
{
task
.
Title
}
/>
)
}
</
div
>
)
}
</
div
>
...
...
@@ -110,7 +113,31 @@ export function TaskBoard({
</
span
>
</
div
>
<
div
className
=
"space-y-1 text-xs text-muted-foreground"
>
<
div
>
{
task
.
AssigneeName
??
"
Sem responsável
"
}
</
div
>
<
div
className
=
"flex items-center gap-1.5"
>
<
span
>
{
task
.
AssigneeName
??
"
Sem responsável
"
}
</
span
>
{
task
.
Collaborators
.
length
>
0
&&
(
<
span
className
=
"flex -space-x-1.5"
title
=
{
task
.
Collaborators
.
map
((
c
)
=>
c
.
Name
)
.
join
(
"
,
"
)
}
>
{
task
.
Collaborators
.
slice
(
0
,
3
).
map
((
c
)
=>
(
<
span
key
=
{
c
.
UserId
}
className
=
"flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[9px] font-semibold text-foreground ring-1 ring-background"
>
{
initials
(
c
.
Name
)
}
</
span
>
))
}
{
task
.
Collaborators
.
length
>
3
&&
(
<
span
className
=
"flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[9px] font-semibold text-foreground ring-1 ring-background"
>
+
{
task
.
Collaborators
.
length
-
3
}
</
span
>
)
}
</
span
>
)
}
</
div
>
<
div
className
=
"flex flex-wrap items-center gap-x-3 gap-y-1"
>
{
task
.
StartDate
&&
(
<
span
...
...
src/components/tasks/task-dialog.tsx
View file @
24d624aa
...
...
@@ -30,6 +30,7 @@ import {
SelectValue
,
}
from
"
@/components/ui/select
"
;
import
{
Alert
,
AlertDescription
}
from
"
@/components/ui/alert
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
export
interface
TaskFormValues
{
id
:
number
;
...
...
@@ -45,6 +46,7 @@ export interface TaskFormValues {
entry
:
number
|
null
;
metaType
:
string
|
null
;
// 'teto' | 'piso' | null
metaValue
:
number
|
null
;
collaboratorIds
:
number
[];
}
function
SubmitButton
({
...
...
@@ -94,8 +96,8 @@ export function TaskDialog({
trigger
:
ReactNode
;
})
{
const
isSubtask
=
entryId
!==
undefined
&&
!
task
;
// Meta só existe em tarefa-mãe
: some ao criar/editar subtarefa
.
const
showMeta
=
!
isSubtask
&&
!
(
task
&&
task
.
entry
!=
null
);
// Meta
e compartilhamento
só existe
m
em tarefa-mãe.
const
isMotherTask
=
!
isSubtask
&&
!
(
task
&&
task
.
entry
!=
null
);
// Membros comuns só podem atribuir tarefas a si mesmos; mantém o
// responsável atual visível em modo edição.
const
selectableMembers
=
isAdmin
...
...
@@ -105,6 +107,9 @@ export function TaskDialog({
);
const
[
open
,
setOpen
]
=
useState
(
false
);
const
[
metaType
,
setMetaType
]
=
useState
<
string
>
(
task
?.
metaType
??
"
none
"
);
const
[
collabs
,
setCollabs
]
=
useState
<
number
[]
>
(
task
?.
collaboratorIds
??
[]
);
const
action
=
task
?
updateTaskAction
.
bind
(
null
,
task
.
id
)
:
createTaskAction
;
...
...
@@ -223,6 +228,49 @@ export function TaskDialog({
</
SelectContent
>
</
Select
>
</
div
>
{
isMotherTask
&&
members
.
length
>
1
&&
(
<
div
className
=
"space-y-2"
>
<
Label
>
Compartilhar com
</
Label
>
<
div
className
=
"flex flex-wrap gap-1.5"
>
{
members
.
map
((
m
)
=>
{
const
selected
=
collabs
.
includes
(
m
.
UserId
);
return
(
<
button
key
=
{
m
.
UserId
}
type
=
"button"
onClick
=
{
()
=>
setCollabs
((
prev
)
=>
selected
?
prev
.
filter
((
id
)
=>
id
!==
m
.
UserId
)
:
[...
prev
,
m
.
UserId
]
)
}
className
=
{
cn
(
"
rounded-full border px-3 py-1 text-xs font-medium transition-colors
"
,
selected
?
"
border-primary bg-primary text-primary-foreground
"
:
"
border-border bg-card text-muted-foreground hover:bg-muted
"
)
}
>
{
m
.
Name
}
</
button
>
);
})
}
</
div
>
{
collabs
.
map
((
id
)
=>
(
<
input
key
=
{
id
}
type
=
"hidden"
name
=
"collaborators"
value
=
{
id
}
/>
))
}
<
p
className
=
"text-xs text-muted-foreground"
>
Quem for marcado também vê e edita a tarefa, e ela aparece na
pasta dele. O responsável não precisa ser marcado.
</
p
>
</
div
>
)
}
<
div
className
=
"grid grid-cols-2 gap-4"
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"task-start"
>
Início
</
Label
>
...
...
@@ -282,7 +330,7 @@ export function TaskDialog({
:
"
Digite quanto da tarefa já foi concluído (0 a 100).
"
}
</
p
>
</
div
>
{
showMeta
&&
(
{
isMotherTask
&&
(
<
div
className
=
"space-y-2 rounded-lg border bg-muted/30 p-3"
>
<
div
className
=
"grid grid-cols-2 gap-4"
>
<
div
className
=
"space-y-2"
>
...
...
src/components/tasks/task-table.tsx
View file @
24d624aa
...
...
@@ -6,7 +6,7 @@ import type { Task, WorkspaceMember } from "@/lib/data";
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
{
formatDateShort
,
getDueInfo
}
from
"
@/lib/dates
"
;
import
{
getMetaInfo
}
from
"
@/lib/meta
"
;
import
{
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
canDeleteTask
,
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
initials
,
toFormValues
}
from
"
./task-utils
"
;
import
{
Avatar
,
AvatarFallback
}
from
"
@/components/ui/avatar
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
...
...
@@ -196,6 +196,7 @@ export function TaskTable({
<
TableBody
>
{
filteredTasks
.
map
((
task
)
=>
{
const
editable
=
canEditTask
(
task
,
currentUserId
,
isAdmin
);
const
deletable
=
canDeleteTask
(
task
,
currentUserId
,
isAdmin
);
const
subtasks
=
subtasksByTask
[
task
.
Id
]
??
[];
const
hasSubtasks
=
subtasks
.
length
>
0
;
const
isOpen
=
expanded
.
includes
(
task
.
Id
);
...
...
@@ -284,16 +285,46 @@ export function TaskTable({
)
}
</
TableCell
>
<
TableCell
className
=
"py-3"
>
{
task
.
AssigneeName
?
(
{
task
.
AssigneeName
||
task
.
Collaborators
.
length
>
0
?
(
<
div
className
=
"flex items-center gap-2"
>
<
Avatar
className
=
"h-7 w-7"
>
<
AvatarFallback
className
=
"bg-primary/15 text-[10px] font-semibold text-primary"
>
{
initials
(
task
.
AssigneeName
)
}
</
AvatarFallback
>
</
Avatar
>
<
span
className
=
"hidden text-sm lg:inline"
>
{
task
.
AssigneeName
}
</
span
>
<
div
className
=
"flex -space-x-2"
>
{
task
.
AssigneeName
&&
(
<
Avatar
className
=
"h-7 w-7 ring-2 ring-background"
title
=
{
`
${
task
.
AssigneeName
}
(responsável)`
}
>
<
AvatarFallback
className
=
"bg-primary/15 text-[10px] font-semibold text-primary"
>
{
initials
(
task
.
AssigneeName
)
}
</
AvatarFallback
>
</
Avatar
>
)
}
{
task
.
Collaborators
.
slice
(
0
,
3
).
map
((
c
)
=>
(
<
Avatar
key
=
{
c
.
UserId
}
className
=
"h-7 w-7 ring-2 ring-background"
title
=
{
`
${
c
.
Name
}
(compartilhada)`
}
>
<
AvatarFallback
className
=
"bg-muted text-[10px] font-semibold text-foreground"
>
{
initials
(
c
.
Name
)
}
</
AvatarFallback
>
</
Avatar
>
))
}
{
task
.
Collaborators
.
length
>
3
&&
(
<
span
className
=
"flex h-7 w-7 items-center justify-center rounded-full bg-muted text-[10px] font-semibold ring-2 ring-background"
title
=
{
task
.
Collaborators
.
map
((
c
)
=>
c
.
Name
)
.
join
(
"
,
"
)
}
>
+
{
task
.
Collaborators
.
length
-
3
}
</
span
>
)
}
</
div
>
{
task
.
AssigneeName
&&
(
<
span
className
=
"hidden text-sm lg:inline"
>
{
task
.
AssigneeName
}
</
span
>
)
}
</
div
>
)
:
(
<
span
className
=
"text-sm text-muted-foreground"
>
—
</
span
>
...
...
@@ -344,10 +375,12 @@ export function TaskTable({
</
Button
>
}
/>
<
TaskDeleteButton
taskId
=
{
task
.
Id
}
taskTitle
=
{
task
.
Title
}
/>
{
deletable
&&
(
<
TaskDeleteButton
taskId
=
{
task
.
Id
}
taskTitle
=
{
task
.
Title
}
/>
)
}
</
div
>
)
}
</
TableCell
>
...
...
src/components/tasks/task-utils.ts
View file @
24d624aa
...
...
@@ -27,6 +27,7 @@ export function toFormValues(task: Task): TaskFormValues {
entry
:
task
.
Entry
??
null
,
metaType
:
task
.
MetaType
,
metaValue
:
task
.
MetaValue
,
collaboratorIds
:
task
.
Collaborators
.
map
((
c
)
=>
c
.
UserId
),
};
}
...
...
src/lib/actions.ts
View file @
24d624aa
...
...
@@ -10,6 +10,7 @@ import {
verifyPassword
,
}
from
"
./auth
"
;
import
{
assertCanDeleteTask
,
assertCanEditTask
,
assertSuperAdmin
,
assertWorkspaceAdmin
,
...
...
@@ -315,6 +316,8 @@ const taskSchema = z
.
int
()
.
positive
(
"
A meta deve ser um número maior que zero.
"
)
.
optional
(),
// Usuários vinculados (compartilhamento) — só na tarefa-mãe.
collaborators
:
z
.
array
(
z
.
coerce
.
number
().
int
().
positive
()).
optional
(),
})
.
refine
(
(
t
)
=>
!
t
.
startDate
||
!
t
.
dueDate
||
t
.
startDate
<=
t
.
dueDate
,
...
...
@@ -345,9 +348,28 @@ function parseTaskForm(formData: FormData) {
entry
:
formData
.
get
(
"
entry
"
)
||
undefined
,
metaType
:
parseMetaType
(
formData
.
get
(
"
metaType
"
)),
metaValue
:
formData
.
get
(
"
metaValue
"
)
||
undefined
,
collaborators
:
formData
.
getAll
(
"
collaborators
"
),
});
}
/**
* Restringe os colaboradores aos membros do espaço, remove o próprio
* responsável (para não duplicar) e deduplica.
*/
async
function
resolveCollaborators
(
workspaceId
:
number
,
assigneeId
:
number
|
null
,
collaborators
:
number
[]
|
undefined
):
Promise
<
number
[]
>
{
if
(
!
collaborators
||
collaborators
.
length
===
0
)
return
[];
const
memberIds
=
new
Set
(
(
await
data
.
listWorkspaceMembers
(
workspaceId
)).
map
((
m
)
=>
m
.
UserId
)
);
return
Array
.
from
(
new
Set
(
collaborators
)).
filter
(
(
id
)
=>
memberIds
.
has
(
id
)
&&
id
!==
assigneeId
);
}
export
async
function
createTaskAction
(
_prev
:
ActionState
,
formData
:
FormData
...
...
@@ -371,7 +393,7 @@ export async function createTaskAction(
error
:
"
Apenas admins podem criar tarefas para outra pessoa.
"
,
};
}
await
data
.
createTask
(
const
newTaskId
=
await
data
.
createTask
(
{
workspaceId
:
parsed
.
data
.
workspaceId
,
title
:
parsed
.
data
.
title
,
...
...
@@ -389,6 +411,15 @@ export async function createTaskAction(
},
user
.
id
);
// Compartilhamento só em tarefa-mãe.
if
(
!
parsed
.
data
.
entry
)
{
const
collaborators
=
await
resolveCollaborators
(
parsed
.
data
.
workspaceId
,
parsed
.
data
.
assigneeId
??
null
,
parsed
.
data
.
collaborators
);
await
data
.
setTaskCollaborators
(
newTaskId
,
collaborators
);
}
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao criar a tarefa.
"
,
...
...
@@ -438,6 +469,15 @@ export async function updateTaskAction(
metaType
:
task
.
Entry
!=
null
?
null
:
(
parsed
.
data
.
metaType
??
null
),
metaValue
:
task
.
Entry
!=
null
?
null
:
(
parsed
.
data
.
metaValue
??
null
),
});
// Compartilhamento só em tarefa-mãe.
if
(
task
.
Entry
==
null
)
{
const
collaborators
=
await
resolveCollaborators
(
task
.
WorkspaceId
,
parsed
.
data
.
assigneeId
??
null
,
parsed
.
data
.
collaborators
);
await
data
.
setTaskCollaborators
(
taskId
,
collaborators
);
}
revalidatePath
(
`/workspaces/
${
task
.
WorkspaceId
}
`
);
}
catch
(
err
)
{
return
{
...
...
@@ -498,7 +538,7 @@ export async function deleteTaskAction(taskId: number): Promise<ActionState> {
try
{
const
task
=
await
data
.
getTask
(
taskId
);
if
(
!
task
)
return
{
error
:
"
Tarefa não encontrada.
"
};
await
assertCan
Edit
Task
(
task
,
user
);
await
assertCan
Delete
Task
(
task
,
user
);
await
data
.
deleteTask
(
taskId
);
revalidatePath
(
`/workspaces/
${
task
.
WorkspaceId
}
`
);
}
catch
(
err
)
{
...
...
src/lib/authz.ts
View file @
24d624aa
...
...
@@ -2,7 +2,7 @@ import "server-only";
import
{
redirect
}
from
"
next/navigation
"
;
import
{
getPool
,
sql
}
from
"
./db
"
;
import
{
getSession
,
type
SessionUser
}
from
"
./auth
"
;
import
{
canEditTask
}
from
"
./permissions
"
;
import
{
canDeleteTask
,
canEditTask
}
from
"
./permissions
"
;
/**
* Políticas de autorização:
...
...
@@ -101,7 +101,12 @@ export async function assertWorkspaceMember(
* a tarefa não tem responsável).
*/
export
async
function
assertCanEditTask
(
task
:
{
WorkspaceId
:
number
;
AssigneeId
:
number
|
null
;
CreatedById
:
number
},
task
:
{
WorkspaceId
:
number
;
AssigneeId
:
number
|
null
;
CreatedById
:
number
;
Collaborators
?:
{
UserId
:
number
}[];
},
user
:
SessionUser
):
Promise
<
void
>
{
const
role
=
await
getWorkspaceRole
(
task
.
WorkspaceId
,
user
.
id
);
...
...
@@ -112,3 +117,17 @@ export async function assertCanEditTask(
throw
new
Error
(
"
Apenas o responsável pela tarefa pode alterá-la.
"
);
}
}
/** Como assertCanEditTask, mas para exclusão (colaboradores não excluem). */
export
async
function
assertCanDeleteTask
(
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
(
!
canDeleteTask
(
task
,
user
.
id
,
role
===
"
admin
"
))
{
throw
new
Error
(
"
Apenas o responsável pode excluir esta tarefa.
"
);
}
}
src/lib/data.ts
View file @
24d624aa
...
...
@@ -48,6 +48,18 @@ export interface Task {
CreatedAt
:
Date
;
SubtaskCount
:
number
;
SubtaskDone
:
number
;
/** Usuários vinculados além do responsável (compartilhamento). */
Collaborators
:
{
UserId
:
number
;
Name
:
string
}[];
}
/** Converte a coluna CollaboratorsJson (FOR JSON) no array Collaborators. */
function
mapTask
(
row
:
Record
<
string
,
unknown
>
):
Task
{
const
{
CollaboratorsJson
,
...
rest
}
=
row
;
return
{
...(
rest
as
unknown
as
Task
),
Collaborators
:
typeof
CollaboratorsJson
===
"
string
"
?
JSON
.
parse
(
CollaboratorsJson
)
:
[],
};
}
// ---------- Usuários ----------
...
...
@@ -262,7 +274,11 @@ const TASK_SELECT = `
t.AssigneeId, a.Name AS AssigneeName, t.CreatedById,
t.StartDate, t.DueDate, t.CompletedDate, t.Progress, t.MetaType, t.MetaValue, t.CreatedAt, w.Name AS WorkspaceName,
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id) AS SubtaskCount,
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id AND s.Status = 'done') AS SubtaskDone
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id AND s.Status = 'done') AS SubtaskDone,
(SELECT u.Id AS UserId, u.Name
FROM dbo.TaskCollaborators tc JOIN dbo.Users u ON u.Id = tc.UserId
WHERE tc.TaskId = t.Id
FOR JSON PATH) AS CollaboratorsJson
FROM dbo.Tasks t
LEFT JOIN dbo.Users a ON a.Id = t.AssigneeId
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId`
;
...
...
@@ -279,7 +295,7 @@ export async function listTasksByWorkspace(
WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NULL
ORDER BY CASE WHEN t.DueDate IS NULL THEN 1 ELSE 0 END, t.DueDate, t.Id DESC`
);
return
result
.
recordset
;
return
result
.
recordset
.
map
(
mapTask
)
;
}
export
async
function
getTask
(
id
:
number
):
Promise
<
Task
|
null
>
{
...
...
@@ -288,7 +304,8 @@ export async function getTask(id: number): Promise<Task | null> {
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
id
)
.
query
(
`
${
TASK_SELECT
}
WHERE t.Id = @id`
);
return
result
.
recordset
[
0
]
??
null
;
const
row
=
result
.
recordset
[
0
];
return
row
?
mapTask
(
row
)
:
null
;
}
export
interface
TaskInput
{
...
...
@@ -308,9 +325,9 @@ export interface TaskInput {
export
async
function
createTask
(
input
:
TaskInput
,
createdById
:
number
):
Promise
<
void
>
{
):
Promise
<
number
>
{
const
pool
=
await
getPool
();
await
pool
const
result
=
await
pool
.
request
()
.
input
(
"
workspaceId
"
,
sql
.
Int
,
input
.
workspaceId
)
.
input
(
"
title
"
,
sql
.
NVarChar
(
200
),
input
.
title
.
trim
())
...
...
@@ -327,10 +344,41 @@ export async function createTask(
.
input
(
"
createdById
"
,
sql
.
Int
,
createdById
)
.
query
(
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, Entry, AssigneeId, StartDate, DueDate, CompletedDate, Progress, MetaType, MetaValue, CreatedById)
OUTPUT INSERTED.Id
VALUES (@workspaceId, @title, @description, @status, @priority, @entry, @assigneeId, @startDate, @dueDate,
CASE WHEN @status = 'done' THEN CAST(GETDATE() AS DATE) ELSE NULL END,
@progress, @metaType, @metaValue, @createdById)`
);
return
result
.
recordset
[
0
].
Id
;
}
/** Substitui a lista de colaboradores (usuários vinculados) de uma tarefa. */
export
async
function
setTaskCollaborators
(
taskId
:
number
,
userIds
:
number
[]
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
const
tx
=
new
sql
.
Transaction
(
pool
);
await
tx
.
begin
();
try
{
await
tx
.
request
()
.
input
(
"
taskId
"
,
sql
.
Int
,
taskId
)
.
query
(
"
DELETE FROM dbo.TaskCollaborators WHERE TaskId = @taskId
"
);
for
(
const
userId
of
userIds
)
{
await
tx
.
request
()
.
input
(
"
taskId
"
,
sql
.
Int
,
taskId
)
.
input
(
"
userId
"
,
sql
.
Int
,
userId
)
.
query
(
"
INSERT INTO dbo.TaskCollaborators (TaskId, UserId) VALUES (@taskId, @userId)
"
);
}
await
tx
.
commit
();
}
catch
(
err
)
{
await
tx
.
rollback
();
throw
err
;
}
}
export
async
function
updateTask
(
id
:
number
,
input
:
TaskInput
):
Promise
<
void
>
{
...
...
@@ -419,7 +467,7 @@ export async function listSubtasksByWorkspace(
WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NOT NULL
ORDER BY t.Id DESC`
);
return
result
.
recordset
;
return
result
.
recordset
.
map
(
mapTask
)
;
}
// ---------- Painel (dashboard) ----------
...
...
@@ -466,5 +514,5 @@ export async function getDueAlertsForUser(
AND t.DueDate <= DATEADD(day, @days, CAST(GETDATE() AS DATE))
ORDER BY t.DueDate`
);
return
result
.
recordset
;
return
result
.
recordset
.
map
(
mapTask
)
;
}
src/lib/db-init.ts
View file @
24d624aa
...
...
@@ -75,6 +75,17 @@ const DDL_STATEMENTS: string[] = [
`IF COL_LENGTH('dbo.Tasks', 'MetaValue') IS NULL
ALTER TABLE dbo.Tasks ADD MetaValue INT NULL`
,
// Tarefas compartilhadas: usuários vinculados além do responsável
`IF OBJECT_ID('dbo.TaskCollaborators', 'U') IS NULL
CREATE TABLE dbo.TaskCollaborators (
TaskId INT NOT NULL REFERENCES dbo.Tasks(Id) ON DELETE CASCADE,
UserId INT NOT NULL REFERENCES dbo.Users(Id),
CONSTRAINT PK_TaskCollaborators PRIMARY KEY (TaskId, UserId)
)`
,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TaskCollaborators_UserId')
CREATE INDEX IX_TaskCollaborators_UserId ON dbo.TaskCollaborators (UserId)`
,
`IF OBJECT_ID('dbo.Subtasks', 'U') IS NULL
CREATE TABLE dbo.Subtasks (
Id INT IDENTITY(1,1) PRIMARY KEY,
...
...
src/lib/permissions.ts
View file @
24d624aa
...
...
@@ -2,9 +2,30 @@
* 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;
* - colaboradores vinculados também podem editar/atualizar a tarefa;
* - tarefas sem responsável podem ser editadas por quem as criou.
*/
export
function
canEditTask
(
task
:
{
AssigneeId
:
number
|
null
;
CreatedById
:
number
;
Collaborators
?:
{
UserId
:
number
}[];
},
userId
:
number
,
isAdmin
:
boolean
):
boolean
{
if
(
isAdmin
)
return
true
;
if
(
task
.
AssigneeId
===
userId
)
return
true
;
if
(
task
.
Collaborators
?.
some
((
c
)
=>
c
.
UserId
===
userId
))
return
true
;
return
task
.
AssigneeId
===
null
&&
task
.
CreatedById
===
userId
;
}
/**
* Exclusão é mais restrita que edição: colaboradores podem atualizar, mas não
* excluir a tarefa compartilhada — só admin, responsável ou (sem responsável)
* o criador.
*/
export
function
canDeleteTask
(
task
:
{
AssigneeId
:
number
|
null
;
CreatedById
:
number
},
userId
:
number
,
isAdmin
:
boolean
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment