Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions messages/cs-CZ.json
Original file line number Diff line number Diff line change
Expand Up @@ -1647,6 +1647,7 @@
"sdkVRt": "Načítám úkoly…",
"C9zLOs": "Žádné starší nedokončené úkoly",
"MY5T76": "Nic neodpovídá hledání.",
"IlpEFO": "Vybrat všechny úkoly: {name}",
"dUnu9I": "Vybráno: {count, number}",
"b1g2Cx": "Nic nevybráno",
"b8dDI6": "Přidat {count, plural, one {# úkol} few {# úkoly} other {# úkolů}}",
Expand Down
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1647,6 +1647,7 @@
"sdkVRt": "Loading tasks…",
"C9zLOs": "No older unfinished tasks",
"MY5T76": "Nothing matches the search.",
"IlpEFO": "Select all tasks: {name}",
"dUnu9I": "Selected: {count, number}",
"b1g2Cx": "Nothing selected",
"b8dDI6": "Add {count, plural, one {# task} other {# tasks}}",
Expand Down
10 changes: 4 additions & 6 deletions src/app/api/meetings/[id]/tasks/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { z } from "zod";
import { addAgendaTasksInput } from "@/lib/meetings/model";
import { meetingRoute } from "@/lib/meetings/route";
import { addAgendaTask } from "@/lib/meetings/service";
import { addAgendaTasks } from "@/lib/meetings/service";
export const POST = meetingRoute(async ({ userId, params, request }) => {
const input = z
.object({ taskId: z.string().min(1).max(255) })
.parse(await request.json());
await addAgendaTask(userId, params.id, input.taskId);
const input = addAgendaTasksInput.parse(await request.json());
await addAgendaTasks(userId, params.id, input.taskIds ?? [input.taskId]);
});
304 changes: 169 additions & 135 deletions src/components/meetings/older-tasks-dialog.tsx

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/lib/meetings/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { z } from "zod";
import { AppError, ErrorCode } from "@/lib/errors";

const id = z.string().min(1).max(255);
export const agendaTaskIdsInput = z
.array(id)
.min(1)
.max(500)
.transform((ids) => [...new Set(ids)]);
export const addAgendaTasksInput = z.union([
z.object({ taskIds: agendaTaskIdsInput, taskId: z.never().optional() }),
z.object({ taskId: id, taskIds: z.never().optional() }),
]);
const line = z
.string()
.trim()
Expand Down
2 changes: 2 additions & 0 deletions src/lib/meetings/positions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** First agenda position of added older tasks, above every position the last-meeting snapshot assigns. */
export const OLDER_TASK_POSITION_BASE = 100000;
130 changes: 97 additions & 33 deletions src/lib/meetings/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,18 @@ import {
} from "@/lib/meetings/calendar-snapshot";
import { meetingInvitees } from "@/lib/meetings/meeting-roster";
import {
agendaTaskIdsInput,
createMeetingInput,
createPointInput,
groupAgendaTasks,
patchMeetingInput,
patchPointInput,
validatePointTree,
} from "@/lib/meetings/model";
import { OLDER_TASK_POSITION_BASE } from "@/lib/meetings/positions";
import { agendaSuggestionsAvailability } from "@/lib/meetings/suggestions-provider";
import { taskViewerById } from "@/lib/tasks/access";
import { getVisibleTask, getVisibleTasks } from "@/lib/tasks/tasks";
import { getVisibleTasks } from "@/lib/tasks/tasks";

type Agenda = typeof meetingAgendas.$inferSelect;
type Access = Awaited<ReturnType<typeof requireMeetingAccess>>;
Expand Down Expand Up @@ -738,7 +740,7 @@ export async function getMeeting(
eq(agendaTasks.userId, agenda.userId),
),
)
.orderBy(asc(agendaTasks.position)),
.orderBy(asc(agendaTasks.position), asc(agendaTasks.taskId)),
db
.select()
.from(agendaSuggestions)
Expand Down Expand Up @@ -1398,33 +1400,87 @@ export async function addAgendaTask(
id: string,
taskId: string,
) {
return addAgendaTasks(userId, id, [taskId]);
}

/** Add older tasks atomically in selection order under the agenda lock. */
export async function addAgendaTasks(
userId: string,
id: string,
rawTaskIds: readonly string[],
) {
const taskIds = agendaTaskIdsInput.parse(rawTaskIds);
const viewer = { ...(await taskViewerById(userId)), includeReceived: true };
const task = await getVisibleTask(viewer, taskId);
if (
!task ||
task.own ||
!(await olderTasks(userId, id, "all")).some(
(candidate) => candidate?.id === taskId,
)
)
throw new AppError(
ErrorCode.NOT_FOUND,
"Open task in this folder not found",
404,
);
await editMeeting(userId, id, "edit", async (tx, { agenda }) => {
const folders = agenda.meetingItemId
? await meetingFolders(tx, agenda.meetingItemId, agenda.userId)
: [];
const candidates = folders.length
? await tx
.select({ id: recordingTasks.id })
.from(recordingTasks)
.leftJoin(
chatterItems,
eq(chatterItems.id, recordingTasks.itemId),
)
.where(
and(
inArray(recordingTasks.id, taskIds),
olderTaskCondition(
viewer,
folders.map((folder) => folder.id),
),
),
)
: [];
const links = await tx
.select()
.from(agendaTasks)
.where(
and(
eq(agendaTasks.agendaId, agenda.id),
eq(agendaTasks.userId, agenda.userId),
),
);
const eligible = new Set(candidates.map((task) => task.id));
const current = new Set(
links.filter((link) => !link.removedAt).map((link) => link.taskId),
);
const refusedTaskIds = taskIds.filter(
(taskId) => !eligible.has(taskId),
);
if (refusedTaskIds.length)
throw new AppError(
ErrorCode.NOT_FOUND,
"Open task in this folder not found",
404,
{ refusedTaskIds },
);
const addedTaskIds = taskIds.filter((taskId) => !current.has(taskId));
if (!addedTaskIds.length) return false;
const lastPosition = Math.max(
-1,
...links.map((link) => link.position),
);
const firstPosition = Math.max(
lastPosition + 1,
OLDER_TASK_POSITION_BASE,
);
await tx
.insert(agendaTasks)
.values({
agendaId: agenda.id,
userId: agenda.userId,
taskId,
origin: "older",
position: 100000,
})
.values(
addedTaskIds.map((taskId, index) => ({
agendaId: agenda.id,
userId: agenda.userId,
taskId,
origin: "older" as const,
position: firstPosition + index,
})),
)
.onConflictDoUpdate({
target: [agendaTasks.agendaId, agendaTasks.taskId],
set: { removedAt: null },
set: { removedAt: null, position: sql`excluded.position` },
setWhere: eq(agendaTasks.userId, agenda.userId),
});
});
}
Expand Down Expand Up @@ -1550,6 +1606,23 @@ export async function decideSuggestion(
});
}

function olderTaskCondition(viewer: TaskViewer, folderIds: string[]) {
return and(
eq(recordingTasks.status, "open"),
ne(recordingTasks.source, "personal"),
isNull(chatterItems.deletedAt),
or(
isNull(chatterItems.id),
inArray(chatterItems.kind, ["audio", "mail"]),
),
or(
sql`exists (select 1 from ${recordingFolderAssignments} where ${recordingFolderAssignments.itemId} = ${recordingTasks.itemId} and ${inArray(recordingFolderAssignments.folderId, folderIds)})`,
sql`exists (select 1 from ${taskMentions} inner join ${recordingFolderAssignments} on ${recordingFolderAssignments.itemId} = ${taskMentions.itemId} where ${taskMentions.taskId} = ${recordingTasks.id} and ${taskMentions.matchDecision} in ('home', 'accepted', 'kept_new') and ${inArray(recordingFolderAssignments.folderId, folderIds)})`,
),
taskVisible(viewer, ANY_TASK_ITEM, true),
);
}

/** Open tasks in the meeting's folder, optionally limited to its invitees; never pre-added. */
export async function olderTasks(
userId: string,
Expand All @@ -1565,16 +1638,7 @@ export async function olderTasks(
.select({ id: recordingTasks.id })
.from(recordingTasks)
.leftJoin(chatterItems, eq(chatterItems.id, recordingTasks.itemId))
.where(
and(
eq(recordingTasks.status, "open"),
or(
sql`exists (select 1 from ${recordingFolderAssignments} where ${recordingFolderAssignments.itemId} = ${recordingTasks.itemId} and ${inArray(recordingFolderAssignments.folderId, folderIds)})`,
sql`exists (select 1 from ${taskMentions} inner join ${recordingFolderAssignments} on ${recordingFolderAssignments.itemId} = ${taskMentions.itemId} where ${taskMentions.taskId} = ${recordingTasks.id} and ${taskMentions.matchDecision} in ('home', 'accepted', 'kept_new') and ${inArray(recordingFolderAssignments.folderId, folderIds)})`,
),
taskVisible(viewer, ANY_TASK_ITEM, true),
),
)
.where(olderTaskCondition(viewer, folderIds))
.limit(500);
const current = new Set(
detail.tasks.filter((task) => !task.removedAt).map((task) => task.id),
Expand Down
Loading
Loading