From 7485545e8765d17fb5026ed50eb5501214af1a03 Mon Sep 17 00:00:00 2001
From: JNO
Date: Mon, 2 Nov 2026 08:48:53 +0000
Subject: [PATCH 1/2] fix(meetings): select a person's older tasks at once and
add them in one step
The earlier-task chooser in meeting preparation added the selected tasks one
request at a time. Every request refreshed the agenda behind the dialog, so
adding ten tasks took long and the page jumped once per task.
- Each person group (and "No assignee") has a three-state checkbox that selects
or clears the group's tasks visible under the current search.
- POST /api/meetings/[id]/tasks accepts `taskIds` (up to 500, deduplicated)
next to the single `taskId`. All ids are checked and added in one
transaction under the agenda lock: one refused id refuses the batch and the
error names the refused ids. Tasks already active in the agenda are skipped,
a removed task is restored, and positions follow the selection order after
the current last task.
- The dialog sends one request with one optimistic update and closes; on
failure it keeps the selection and shows one error.
- The listing and the add share one eligibility condition, matching the item
rule of getVisibleTask; personal tasks no longer appear in the chooser
(they could never be added).
---
messages/cs-CZ.json | 1 +
messages/en.json | 1 +
src/app/api/meetings/[id]/tasks/route.ts | 10 +-
.../meetings/older-tasks-dialog.tsx | 299 +++++++------
src/lib/meetings/model.ts | 9 +
src/lib/meetings/service.ts | 123 ++++--
.../older-tasks-batch.integration.test.ts | 418 ++++++++++++++++++
.../meetings/older-tasks-dialog.test.tsx | 180 ++++++--
8 files changed, 835 insertions(+), 206 deletions(-)
create mode 100644 src/tests/meetings/older-tasks-batch.integration.test.ts
diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json
index 16ce0d46..0df886be 100644
--- a/messages/cs-CZ.json
+++ b/messages/cs-CZ.json
@@ -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ů}}",
diff --git a/messages/en.json b/messages/en.json
index d18f435a..10e44ee5 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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}}",
diff --git a/src/app/api/meetings/[id]/tasks/route.ts b/src/app/api/meetings/[id]/tasks/route.ts
index b932e45f..6535a4ea 100644
--- a/src/app/api/meetings/[id]/tasks/route.ts
+++ b/src/app/api/meetings/[id]/tasks/route.ts
@@ -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]);
});
diff --git a/src/components/meetings/older-tasks-dialog.tsx b/src/components/meetings/older-tasks-dialog.tsx
index 96177dcf..874431e3 100644
--- a/src/components/meetings/older-tasks-dialog.tsx
+++ b/src/components/meetings/older-tasks-dialog.tsx
@@ -23,6 +23,114 @@ function searchText(value: string) {
return value.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase();
}
+function OlderTaskGroup({
+ group,
+ selected,
+ adding,
+ onToggleTask,
+ onToggleGroup,
+ ids,
+}: {
+ group: ReturnType>[number];
+ selected: ReadonlyMap;
+ adding: boolean;
+ onToggleTask: (task: OlderTask, checked: boolean) => void;
+ onToggleGroup: (tasks: OlderTask[], checked: boolean) => void;
+ ids: string;
+}) {
+ const i18n = useExtracted();
+ const format = useFormatter();
+ const checked = group.tasks.every((task) => selected.has(task.id));
+ const mixed = !checked && group.tasks.some((task) => selected.has(task.id));
+ return (
+
+ );
+}
+
/** Picks unfinished tasks from the agenda's folders and adds the selected ones to the agenda. */
export function OlderTasksDialog({
detail,
@@ -36,7 +144,6 @@ export function OlderTasksDialog({
mutate: AgendaController["mutate"];
}) {
const i18n = useExtracted();
- const format = useFormatter();
const [scope, setScope] = useState<"invited" | "all">("invited");
const [older, setOlder] = useState(null);
const [error, setError] = useState(null);
@@ -82,42 +189,61 @@ export function OlderTasksDialog({
const selection = [...selected.values()].filter(
(task) => !inAgenda.has(task.id),
);
+ const toggleTask = (task: OlderTask, checked: boolean) => {
+ setSelected((current) => {
+ const next = new Map(current);
+ if (checked) next.set(task.id, task);
+ else next.delete(task.id);
+ return next;
+ });
+ };
+ const toggleGroup = (tasks: OlderTask[], checked: boolean) => {
+ setSelected((current) => {
+ const next = new Map(current);
+ for (const task of tasks) {
+ if (checked) next.set(task.id, task);
+ else next.delete(task.id);
+ }
+ return next;
+ });
+ };
const add = async () => {
if (adding || !selection.length) return;
setAdding(true);
- let failed = false;
- for (const task of selection) {
- // mutate reports its own failure; stop there so an outage shows one error and the rest stay selected.
- const saved = await mutate(
- "/tasks",
- "POST",
- { taskId: task.id },
- (value) => ({
+ const saved = await mutate(
+ "/tasks",
+ "POST",
+ { taskIds: selection.map((task) => task.id) },
+ (value) => {
+ const ids = new Set(selection.map((task) => task.id));
+ const lastPosition = Math.max(
+ -1,
+ ...value.tasks.map((task) => task.position),
+ );
+ return {
...value,
tasks: [
- ...value.tasks.filter((entry) => entry.id !== task.id),
- {
- ...task,
- removedAt: null,
- agendaNote: null,
- agendaOrigin: "older",
- position: 100000,
- },
+ ...value.tasks.filter((entry) => !ids.has(entry.id)),
+ ...selection.map((task, index) => {
+ const previous = value.tasks.find(
+ (entry) => entry.id === task.id,
+ );
+ return {
+ ...task,
+ removedAt: null,
+ agendaNote: previous?.agendaNote ?? null,
+ agendaOrigin:
+ previous?.agendaOrigin ??
+ ("older" as const),
+ position: lastPosition + index + 1,
+ };
+ }),
],
- }),
- ).catch(() => false);
- if (!saved) {
- failed = true;
- break;
- }
- setSelected((current) => {
- const next = new Map(current);
- next.delete(task.id);
- return next;
- });
- }
+ };
+ },
+ ).catch(() => false);
setAdding(false);
- if (!failed) {
+ if (saved) {
setSelected(new Map());
onOpenChange(false);
}
@@ -194,112 +320,15 @@ export function OlderTasksDialog({
) : (
groups.map((group, groupIndex) => (
-
-
- {group.name ?? i18n("No assignee")}
- {!group.invited && group.name && (
-
- {i18n("Not invited")}
-
- )}
-
-
- {group.tasks.map((task, taskIndex) => {
- const source =
- task.evidence ?? task.recording;
- return (
-
-
- {
- const checked =
- event.target
- .checked;
- setSelected(
- (current) => {
- const next =
- new Map(
- current,
- );
- if (checked)
- next.set(
- task.id,
- task,
- );
- else
- next.delete(
- task.id,
- );
- return next;
- },
- );
- }}
- />
-
-
- {task.text}
-
-
- {source?.title ??
- i18n(
- "Own task",
- )}{" "}
- ·{" "}
- {format.dateTime(
- new Date(
- source?.startTime ??
- task.createdAt,
- ),
- {
- dateStyle:
- "medium",
- },
- )}{" "}
- ·{" "}
- {task.dueDate
- ? format.dateTime(
- new Date(
- `${task.dueDate}T12:00:00`,
- ),
- {
- dateStyle:
- "medium",
- },
- )
- : i18n(
- "No due date",
- )}
-
-
-
-
- );
- })}
-
-
+ group={group}
+ selected={selected}
+ adding={adding}
+ onToggleTask={toggleTask}
+ onToggleGroup={toggleGroup}
+ ids={`${rowId}-${groupIndex}`}
+ />
))
)}
diff --git a/src/lib/meetings/model.ts b/src/lib/meetings/model.ts
index 4106e1b3..1c0cb133 100644
--- a/src/lib/meetings/model.ts
+++ b/src/lib/meetings/model.ts
@@ -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()
diff --git a/src/lib/meetings/service.ts b/src/lib/meetings/service.ts
index fa8de4c7..453f2a6f 100644
--- a/src/lib/meetings/service.ts
+++ b/src/lib/meetings/service.ts
@@ -69,6 +69,7 @@ import {
} from "@/lib/meetings/calendar-snapshot";
import { meetingInvitees } from "@/lib/meetings/meeting-roster";
import {
+ agendaTaskIdsInput,
createMeetingInput,
createPointInput,
groupAgendaTasks,
@@ -78,7 +79,7 @@ import {
} from "@/lib/meetings/model";
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>;
@@ -1398,33 +1399,83 @@ 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),
+ );
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: lastPosition + index + 1,
+ })),
+ )
.onConflictDoUpdate({
target: [agendaTasks.agendaId, agendaTasks.taskId],
- set: { removedAt: null },
+ set: { removedAt: null, position: sql`excluded.position` },
+ setWhere: eq(agendaTasks.userId, agenda.userId),
});
});
}
@@ -1550,6 +1601,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,
@@ -1565,16 +1633,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),
diff --git a/src/tests/meetings/older-tasks-batch.integration.test.ts b/src/tests/meetings/older-tasks-batch.integration.test.ts
new file mode 100644
index 00000000..2cadbead
--- /dev/null
+++ b/src/tests/meetings/older-tasks-batch.integration.test.ts
@@ -0,0 +1,418 @@
+import { asc, eq } from "drizzle-orm";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import {
+ agendaTasks,
+ chatterItems,
+ meetingAgendas,
+ recordingFolderAssignments,
+ recordingFolders,
+ recordingTasks,
+ taskMentions,
+ users,
+} from "@/db/schema";
+import { decryptText, encryptText } from "@/lib/encryption/fields";
+import { lookupHash } from "@/lib/knowledge/lookup-hash";
+import {
+ createMigratedTestDatabase,
+ getTestDatabaseUrl,
+ type TestPostgresDatabase,
+} from "@/tests/integration/postgres";
+
+const holder = vi.hoisted(() => ({
+ database: null as Record | null,
+ caller: "owner",
+}));
+vi.mock("@/db", () => ({
+ db: new Proxy(
+ {},
+ {
+ get(_target, key) {
+ const value = holder.database?.[key];
+ return typeof value === "function"
+ ? value.bind(holder.database)
+ : value;
+ },
+ },
+ ),
+}));
+vi.mock("@/lib/env", () => ({
+ env: {
+ ENCRYPTION_KEY:
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ AUTHZ_MODE: "local",
+ IS_HOSTED: false,
+ BETTER_AUTH_SECRET: "meeting-batch-test",
+ APP_URL: "https://example.com",
+ },
+}));
+vi.mock("@/lib/org/config", () => ({
+ getOrgUserId: async () => null,
+ isOrgScopeVisible: () => true,
+ assertOrgScopeWritable: () => {},
+}));
+vi.mock("@/lib/authz/curator", () => ({
+ actsForOrganization: async () => false,
+ userCuratesOrganizationNow: async () => false,
+}));
+vi.mock("@/lib/auth-server", () => ({
+ requireApiSession: async () => ({ user: { id: holder.caller } }),
+}));
+vi.mock("@/lib/posthog-server", () => ({ captureServerException: vi.fn() }));
+
+import { POST } from "@/app/api/meetings/[id]/tasks/route";
+import { addAgendaTask, addAgendaTasks } from "@/lib/meetings/service";
+
+const url = getTestDatabaseUrl();
+describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => {
+ let database: TestPostgresDatabase;
+ beforeAll(async () => {
+ database = await createMigratedTestDatabase(
+ url ?? "",
+ "older_task_batch",
+ );
+ holder.database = database.db as unknown as Record<
+ PropertyKey,
+ unknown
+ >;
+ }, 120000);
+ afterAll(async () => {
+ holder.database = null;
+ await database?.dispose();
+ }, 60000);
+ beforeEach(async () => {
+ holder.caller = "owner";
+ const db = database.db;
+ await db.delete(users);
+ await db.insert(users).values(
+ ["owner", "stranger"].map((id) => ({
+ id,
+ email: `${id}@example.com`,
+ })),
+ );
+ await db.insert(recordingFolders).values(
+ ["first", "second", "outside"].map((id) => ({
+ id,
+ userId: "owner",
+ name: encryptText(id),
+ nameHash: lookupHash(id),
+ })),
+ );
+ await db.insert(chatterItems).values(
+ [
+ { id: "meeting", userId: "owner", kind: "meeting" as const },
+ { id: "source", userId: "owner", kind: "audio" as const },
+ { id: "other-source", userId: "owner", kind: "audio" as const },
+ {
+ id: "private-source",
+ userId: "stranger",
+ kind: "audio" as const,
+ },
+ {
+ id: "outside-source",
+ userId: "owner",
+ kind: "audio" as const,
+ },
+ ].map((row) => ({
+ ...row,
+ title: encryptText("Planning"),
+ occurredAt: new Date(),
+ })),
+ );
+ await db.insert(meetingAgendas).values({
+ id: "agenda",
+ userId: "owner",
+ meetingItemId: "meeting",
+ });
+ await db.insert(recordingFolderAssignments).values([
+ { itemId: "meeting", folderId: "first", userId: "owner" },
+ { itemId: "meeting", folderId: "second", userId: "owner" },
+ { itemId: "source", folderId: "first", userId: "owner" },
+ { itemId: "other-source", folderId: "second", userId: "owner" },
+ { itemId: "outside-source", folderId: "outside", userId: "owner" },
+ ]);
+ await db.insert(recordingTasks).values(
+ [
+ { id: "existing", itemId: "source", userId: "owner" },
+ { id: "one", itemId: "source", userId: "owner" },
+ { id: "two", itemId: "other-source", userId: "owner" },
+ { id: "three", itemId: "source", userId: "owner" },
+ {
+ id: "invisible",
+ itemId: "private-source",
+ userId: "stranger",
+ },
+ { id: "outside", itemId: "outside-source", userId: "owner" },
+ ].map((row) => ({
+ ...row,
+ status: "open" as const,
+ source: "manual" as const,
+ text: encryptText("Follow up"),
+ })),
+ );
+ await db.insert(agendaTasks).values({
+ agendaId: "agenda",
+ userId: "owner",
+ taskId: "existing",
+ origin: "last_meeting",
+ position: 7,
+ });
+ });
+ const post = (body: unknown) =>
+ POST(
+ new Request("https://example.com/api/meetings/agenda/tasks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ id: "agenda" }) },
+ );
+ const links = () =>
+ database.db
+ .select()
+ .from(agendaTasks)
+ .orderBy(asc(agendaTasks.position));
+ async function version() {
+ const [row] = await database.db
+ .select()
+ .from(meetingAgendas)
+ .where(eq(meetingAgendas.id, "agenda"));
+ return row.version;
+ }
+ it("adds tasks from every agenda folder in selection order and bumps once", async () => {
+ expect((await post({ taskIds: ["two", "one", "three"] })).status).toBe(
+ 200,
+ );
+ expect(
+ (await links()).map((row) => [row.taskId, row.position]),
+ ).toEqual([
+ ["existing", 7],
+ ["two", 8],
+ ["one", 9],
+ ["three", 10],
+ ]);
+ expect(await version()).toBe(1);
+ });
+ it("refuses all invisible or missing ids without adding valid tasks or bumping the agenda", async () => {
+ await database.db.insert(taskMentions).values({
+ taskId: "invisible",
+ itemId: "source",
+ userId: "owner",
+ view: "private",
+ statementAt: new Date(),
+ matchDecision: "accepted",
+ });
+ const response = await post({
+ taskIds: ["one", "invisible", "two", "missing"],
+ });
+ expect(response.status).toBe(404);
+ expect(await response.json()).toMatchObject({
+ details: { refusedTaskIds: ["invisible", "missing"] },
+ });
+ expect((await links()).map((row) => row.taskId)).toEqual(["existing"]);
+ expect(await version()).toBe(0);
+ });
+ it("restores removed membership at the end and preserves its note and origin", async () => {
+ await database.db.insert(agendaTasks).values({
+ agendaId: "agenda",
+ userId: "owner",
+ taskId: "one",
+ origin: "last_meeting",
+ position: 2,
+ removedAt: new Date(),
+ note: encryptText("Review next time"),
+ });
+ expect((await post({ taskIds: ["two", "one"] })).status).toBe(200);
+ const rows = await links();
+ expect(rows.map((row) => [row.taskId, row.position])).toEqual([
+ ["existing", 7],
+ ["two", 8],
+ ["one", 9],
+ ]);
+ expect(rows[2].removedAt).toBeNull();
+ expect(rows[2].origin).toBe("last_meeting");
+ expect(decryptText(rows[2].note ?? "")).toBe("Review next time");
+ });
+ it("deduplicates ids while preserving their first selection order", async () => {
+ expect(
+ (await post({ taskIds: ["two", "one", "two", "one"] })).status,
+ ).toBe(200);
+ expect(
+ (await links()).map((row) => [row.taskId, row.position]),
+ ).toEqual([
+ ["existing", 7],
+ ["two", 8],
+ ["one", 9],
+ ]);
+ expect(await version()).toBe(1);
+ });
+ it("skips active members while adding the remaining tasks in selection order", async () => {
+ await database.db
+ .update(agendaTasks)
+ .set({ note: encryptText("Keep this note") })
+ .where(eq(agendaTasks.taskId, "existing"));
+ const [existing] = await links();
+ expect(
+ (await post({ taskIds: ["two", "existing", "one"] })).status,
+ ).toBe(200);
+ const rows = await links();
+ expect(rows[0]).toEqual(existing);
+ expect(rows.map((row) => [row.taskId, row.position])).toEqual([
+ ["existing", 7],
+ ["two", 8],
+ ["one", 9],
+ ]);
+ expect(await version()).toBe(1);
+ });
+ it("succeeds without changing links or the agenda when all ids are active", async () => {
+ await database.db
+ .update(agendaTasks)
+ .set({ note: encryptText("Keep this note") })
+ .where(eq(agendaTasks.taskId, "existing"));
+ const beforeLinks = await links();
+ const beforeAgendas = await database.db.select().from(meetingAgendas);
+ expect((await post({ taskIds: ["existing"] })).status).toBe(200);
+ await addAgendaTask("owner", "agenda", "existing");
+ expect(await links()).toEqual(beforeLinks);
+ expect(await database.db.select().from(meetingAgendas)).toEqual(
+ beforeAgendas,
+ );
+ });
+ it("retains the single-task route and MCP service entry point", async () => {
+ expect((await post({ taskId: "one" })).status).toBe(200);
+ await addAgendaTask("owner", "agenda", "two");
+ expect((await links()).map((row) => row.taskId)).toEqual([
+ "existing",
+ "one",
+ "two",
+ ]);
+ });
+ it("rejects tasks outside all agenda folders and rolls back the batch", async () => {
+ const response = await post({ taskIds: ["one", "outside"] });
+ expect(response.status).toBe(404);
+ expect(await response.json()).toMatchObject({
+ details: { refusedTaskIds: ["outside"] },
+ });
+ expect(await links()).toHaveLength(1);
+ });
+ it("accepts folder membership through an accepted mention", async () => {
+ await database.db.insert(taskMentions).values({
+ taskId: "outside",
+ itemId: "source",
+ userId: "owner",
+ view: "private",
+ statementAt: new Date(),
+ matchDecision: "accepted",
+ });
+ expect((await post({ taskIds: ["outside"] })).status).toBe(200);
+ expect((await links()).map((row) => row.taskId)).toEqual([
+ "existing",
+ "outside",
+ ]);
+ });
+ it.each([
+ "done",
+ "dropped",
+ "proposed",
+ ] as const)("refuses %s tasks atomically", async (status) => {
+ await database.db
+ .update(recordingTasks)
+ .set({ status })
+ .where(eq(recordingTasks.id, "two"));
+ expect((await post({ taskIds: ["one", "two"] })).status).toBe(404);
+ expect(await links()).toHaveLength(1);
+ });
+ it("refuses deleted sources and personal tasks while allowing current members", async () => {
+ await database.db
+ .update(chatterItems)
+ .set({ deletedAt: new Date() })
+ .where(eq(chatterItems.id, "other-source"));
+ await database.db.insert(recordingTasks).values({
+ id: "personal",
+ userId: "owner",
+ folderId: "first",
+ source: "personal",
+ status: "open",
+ text: encryptText("Own task"),
+ });
+ const response = await post({
+ taskIds: ["one", "two", "personal", "existing"],
+ });
+ expect(response.status).toBe(404);
+ expect(await response.json()).toMatchObject({
+ details: { refusedTaskIds: ["two", "personal"] },
+ });
+ expect(await links()).toHaveLength(1);
+ expect(await version()).toBe(0);
+ });
+ it("still refuses an active member that is no longer eligible", async () => {
+ await database.db
+ .update(recordingTasks)
+ .set({ status: "done" })
+ .where(eq(recordingTasks.id, "existing"));
+ const response = await post({ taskIds: ["one", "existing"] });
+ expect(response.status).toBe(404);
+ expect(await response.json()).toMatchObject({
+ details: { refusedTaskIds: ["existing"] },
+ });
+ expect(await links()).toHaveLength(1);
+ expect(await version()).toBe(0);
+ });
+ it("requires agenda edit access and keeps absorbed agendas read-only", async () => {
+ holder.caller = "stranger";
+ expect((await post({ taskIds: ["one"] })).status).toBe(404);
+ holder.caller = "owner";
+ await database.db
+ .update(meetingAgendas)
+ .set({ recordingItemId: "source", absorbedAt: new Date() })
+ .where(eq(meetingAgendas.id, "agenda"));
+ expect((await post({ taskIds: ["one"] })).status).toBe(409);
+ expect(await links()).toHaveLength(1);
+ });
+ it("serializes concurrent batches so each appends contiguous positions", async () => {
+ await Promise.all([
+ addAgendaTasks("owner", "agenda", ["two", "one"]),
+ addAgendaTasks("owner", "agenda", ["three"]),
+ ]);
+ const rows = await links();
+ expect(rows.map((row) => row.position)).toEqual([7, 8, 9, 10]);
+ const ids = rows.map((row) => row.taskId);
+ expect(ids.indexOf("one")).toBe(ids.indexOf("two") + 1);
+ expect(await version()).toBe(2);
+ });
+ it("accepts overlapping concurrent batches without moving existing links", async () => {
+ await Promise.all([
+ addAgendaTasks("owner", "agenda", ["one", "two"]),
+ addAgendaTasks("owner", "agenda", ["two", "three"]),
+ ]);
+ const rows = await links();
+ expect(rows.map((row) => row.position)).toEqual([7, 8, 9, 10]);
+ expect(rows.map((row) => row.taskId).sort()).toEqual([
+ "existing",
+ "one",
+ "three",
+ "two",
+ ]);
+ expect(await version()).toBe(2);
+ });
+ it.each([
+ {},
+ { taskIds: [] },
+ { taskIds: [""] },
+ { taskIds: [42] },
+ { taskIds: ["x".repeat(256)] },
+ { taskIds: Array.from({ length: 501 }, () => "one") },
+ { taskIds: ["one"], taskId: "two" },
+ ])("rejects malformed or oversized input: %j", async (body) => {
+ expect((await post(body)).status).toBe(400);
+ expect(await links()).toHaveLength(1);
+ expect(await version()).toBe(0);
+ });
+});
diff --git a/src/tests/meetings/older-tasks-dialog.test.tsx b/src/tests/meetings/older-tasks-dialog.test.tsx
index a47284ee..24e5ba7e 100644
--- a/src/tests/meetings/older-tasks-dialog.test.tsx
+++ b/src/tests/meetings/older-tasks-dialog.test.tsx
@@ -18,10 +18,14 @@ import { useAgenda } from "@/components/meetings/use-agenda";
import { translatedSourceMessage } from "@/lib/i18n/messages";
import { at, detail, task } from "./ui-fixtures";
-const { toastError, onOpenChange } = vi.hoisted(() => ({
- toastError: vi.fn(),
- onOpenChange: vi.fn(),
-}));
+const { toastError, onOpenChange, mutateCall, optimisticUpdate } = vi.hoisted(
+ () => ({
+ toastError: vi.fn(),
+ mutateCall: vi.fn(),
+ optimisticUpdate: vi.fn(),
+ onOpenChange: vi.fn(),
+ }),
+);
vi.mock("sonner", () => ({ toast: { error: toastError } }));
vi.mock("next-intl", () => ({
useExtracted: () => (text: string, values?: TranslationValues) =>
@@ -61,7 +65,14 @@ function Harness() {
onOpenChange(value);
setOpen(value);
}}
- mutate={agenda.mutate}
+ mutate={(path, method, body, optimistic) => {
+ mutateCall(path, method, body);
+ return agenda.mutate(path, method, body, (value) => {
+ const next = optimistic(value);
+ optimisticUpdate(next);
+ return next;
+ });
+ }}
/>
)}
>
@@ -94,25 +105,33 @@ beforeEach(() => {
if (url.includes("/older-tasks"))
return Response.json({ tasks: older });
if (init?.method === "POST") {
- const { taskId } = JSON.parse(String(init.body)) as {
- taskId: string;
+ const { taskIds } = JSON.parse(String(init.body)) as {
+ taskIds: string[];
};
- const entry = older.find(
- (candidate) => candidate.id === taskId,
+ const lastPosition = Math.max(
+ -1,
+ ...server.tasks.map((entry) => entry.position),
);
- if (!entry) throw new Error("Unknown test task");
server = {
...server,
version: server.version + 1,
tasks: [
- ...server.tasks,
- {
- ...entry,
- agendaOrigin: "older",
- agendaNote: null,
- removedAt: null,
- position: 100000,
- },
+ ...server.tasks.filter(
+ (entry) => !taskIds.includes(entry.id),
+ ),
+ ...taskIds.map((id, index) => {
+ const entry = older.find(
+ (candidate) => candidate.id === id,
+ );
+ if (!entry) throw new Error("Unknown test task");
+ return {
+ ...entry,
+ agendaOrigin: "older" as const,
+ agendaNote: null,
+ removedAt: null,
+ position: lastPosition + index + 1,
+ };
+ }),
],
};
return Response.json({ success: true });
@@ -161,7 +180,7 @@ it("searches task text without case or diacritics and preserves selections while
fireEvent.click(screen.getByRole("checkbox", { name: "Review dates" }));
const search = screen.getByRole("searchbox", { name: "Search tasks" });
fireEvent.change(search, { target: { value: "ZLUTOUCKY" } });
- expect(screen.getAllByRole("checkbox")).toHaveLength(1);
+ expect(screen.getAllByRole("checkbox")).toHaveLength(2);
expect(
screen.getByRole("checkbox", { name: "Prepare Žluťoučký report" }),
).toBeTruthy();
@@ -202,7 +221,7 @@ it("switches scope through the segmented control and refetches", async () => {
);
});
-it("selects real checkboxes, updates the footer and adds each task in selection order", async () => {
+it("adds the selection in one request, one optimistic update and one refresh", async () => {
await mount();
expect(screen.getByText("Nothing selected")).toBeTruthy();
expect(screen.getByRole("button", { name: "Add tasks" })).toHaveProperty(
@@ -230,20 +249,43 @@ it("selects real checkboxes, updates the footer and adds each task in selection
expect(
posts.map(([url, init]) => [url, JSON.parse(String(init.body))]),
).toEqual([
- ["/api/meetings/agenda/tasks", { taskId: "Review dates" }],
- ["/api/meetings/agenda/tasks", { taskId: "Prepare Žluťoučký report" }],
+ [
+ "/api/meetings/agenda/tasks",
+ { taskIds: ["Review dates", "Prepare Žluťoučký report"] },
+ ],
]);
+ expect(mutateCall).toHaveBeenCalledExactlyOnceWith("/tasks", "POST", {
+ taskIds: ["Review dates", "Prepare Žluťoučký report"],
+ });
+ expect(optimisticUpdate).toHaveBeenCalledTimes(1);
+ expect(
+ optimisticUpdate.mock.calls[0][0].tasks.map(
+ (entry: { id: string; position: number }) => [
+ entry.id,
+ entry.position,
+ ],
+ ),
+ ).toEqual([
+ ["already-added", 0],
+ ["Review dates", 1],
+ ["Prepare Žluťoučký report", 2],
+ ]);
+ expect(
+ fetchMock.mock.calls.filter(([url]) =>
+ url.startsWith("/api/meetings/agenda?since="),
+ ),
+ ).toHaveLength(1);
expect(screen.queryByRole("dialog")).toBeNull();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(screen.getByTestId("agenda-tasks").textContent).toContain(
- "Review dates:older:100000",
+ "Review dates:older:1",
);
fireEvent.click(screen.getByRole("button", { name: "Open picker" }));
await screen.findByRole("checkbox", { name: "Check agenda" });
expect(screen.getByText("Nothing selected")).toBeTruthy();
});
-it("stops the batch at a failure, keeps the rest selected and the dialog open, and retries them", async () => {
+it("keeps the entire selection and dialog open on failure, rolls back and retries once", async () => {
const base = fetchMock.getMockImplementation();
let fail = true;
fetchMock.mockImplementation((url: string, init?: RequestInit) => {
@@ -279,29 +321,40 @@ it("stops the batch at a failure, keeps the rest selected and the dialog open, a
screen.getByRole("checkbox", { name: "Check agenda" }),
).toHaveProperty("checked", true);
expect(
- screen.queryByRole("checkbox", { name: "Prepare Žluťoučký report" }),
- ).toBeNull();
- expect(screen.getByText("Selected: 2")).toBeTruthy();
+ screen.getByRole("checkbox", { name: "Prepare Žluťoučký report" }),
+ ).toHaveProperty("checked", true);
+ expect(screen.getByText("Selected: 3")).toBeTruthy();
+ expect(screen.getByTestId("agenda-tasks").textContent).toBe(
+ "already-added:last_meeting:0",
+ );
expect(toastError).toHaveBeenCalledExactlyOnceWith(
"Could not save the agenda",
);
expect(
fetchMock.mock.calls.filter(([, init]) => init.method === "POST"),
- ).toHaveLength(2);
+ ).toHaveLength(1);
fail = false;
fetchMock.mockClear();
await act(async () =>
- fireEvent.click(screen.getByRole("button", { name: "Add 2 tasks" })),
+ fireEvent.click(screen.getByRole("button", { name: "Add 3 tasks" })),
);
expect(
fetchMock.mock.calls
.filter(([, init]) => init.method === "POST")
.map(([, init]) => JSON.parse(String(init.body))),
- ).toEqual([{ taskId: "Review dates" }, { taskId: "Check agenda" }]);
+ ).toEqual([
+ {
+ taskIds: [
+ "Prepare Žluťoučký report",
+ "Review dates",
+ "Check agenda",
+ ],
+ },
+ ]);
expect(screen.queryByRole("dialog")).toBeNull();
});
-it("optimistically adds a task before its POST completes", async () => {
+it("optimistically adds the whole batch before its POST completes", async () => {
const base = fetchMock.getMockImplementation();
let finish = () => {};
fetchMock.mockImplementation((url: string, init?: RequestInit) =>
@@ -315,12 +368,18 @@ it("optimistically adds a task before its POST completes", async () => {
);
await mount();
fireEvent.click(screen.getByRole("checkbox", { name: "Review dates" }));
- fireEvent.click(screen.getByRole("button", { name: "Add 1 task" }));
+ fireEvent.click(screen.getByRole("checkbox", { name: "Check agenda" }));
+ fireEvent.click(screen.getByRole("button", { name: "Add 2 tasks" }));
await waitFor(() =>
expect(screen.getByTestId("agenda-tasks").textContent).toContain(
- "Review dates:older:100000",
+ "Review dates:older:1",
),
);
+ expect(screen.getByTestId("agenda-tasks").textContent).toContain(
+ "Check agenda:older:2",
+ );
+ expect(mutateCall).toHaveBeenCalledTimes(1);
+ expect(optimisticUpdate).toHaveBeenCalledTimes(1);
await act(async () => finish());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
@@ -417,3 +476,58 @@ it("traps keyboard focus inside the dialog", async () => {
true,
);
});
+
+it("selects and clears visible group tasks with mixed state while preserving hidden selections", async () => {
+ older.push(task("Review budget", other));
+ older.push(task("already-added", other));
+ await mount();
+ const group = () =>
+ screen.getByRole("checkbox", { name: `Select all tasks: ${other}` });
+ expect(group()).toHaveProperty("checked", false);
+ expect(group()).toHaveProperty("indeterminate", false);
+ fireEvent.click(screen.getByRole("checkbox", { name: "Review dates" }));
+ expect(group()).toHaveProperty("indeterminate", true);
+ expect(group().getAttribute("aria-checked")).toBe("mixed");
+ fireEvent.click(group());
+ expect(group()).toHaveProperty("checked", true);
+ expect(group()).toHaveProperty("indeterminate", false);
+ expect(screen.getByText("Selected: 2")).toBeTruthy();
+ expect(
+ screen.getByRole("checkbox", { name: "Prepare Žluťoučký report" }),
+ ).toHaveProperty("checked", false);
+ expect(
+ screen.getByRole("checkbox", { name: "Check agenda" }),
+ ).toHaveProperty("checked", false);
+ const search = screen.getByRole("searchbox", { name: "Search tasks" });
+ fireEvent.change(search, { target: { value: "dates" } });
+ fireEvent.click(group());
+ expect(screen.getByText("Selected: 1")).toBeTruthy();
+ fireEvent.change(search, { target: { value: "" } });
+ expect(
+ screen.getByRole("checkbox", { name: "Review budget" }),
+ ).toHaveProperty("checked", true);
+ expect(group()).toHaveProperty("indeterminate", true);
+ fireEvent.click(group());
+ fireEvent.click(group());
+ expect(screen.getByText("Nothing selected")).toBeTruthy();
+ fireEvent.change(search, { target: { value: "dates" } });
+ fireEvent.click(group());
+ fireEvent.change(search, { target: { value: "" } });
+ expect(
+ screen.getByRole("checkbox", { name: "Review budget" }),
+ ).toHaveProperty("checked", false);
+ expect(
+ screen.getByRole("checkbox", { name: "Review dates" }),
+ ).toHaveProperty("checked", true);
+});
+
+it("selects unassigned tasks as a group", async () => {
+ await mount();
+ fireEvent.click(
+ screen.getByRole("checkbox", { name: "Select all tasks: No assignee" }),
+ );
+ expect(
+ screen.getByRole("checkbox", { name: "Check agenda" }),
+ ).toHaveProperty("checked", true);
+ expect(screen.getByText("Selected: 1")).toBeTruthy();
+});
From 873a35e3e0d3d71753f8c0c29a3d0b028bd11ece Mon Sep 17 00:00:00 2001
From: JNO
Date: Mon, 2 Nov 2026 09:44:22 +0000
Subject: [PATCH 2/2] fix(meetings): keep added older tasks after the
last-meeting snapshot
The last-meeting snapshot re-inserts its tasks at positions 0..N-1 when the task source changes or the agenda is filed elsewhere. Added older tasks had moved to max+1, so they could tie or interleave with a larger re-snapshot. They start at OLDER_TASK_POSITION_BASE (100000) again, sequential in selection order, in the service and the optimistic update; agenda tasks order by position, then task id.
---
.../meetings/older-tasks-dialog.tsx | 7 +-
src/lib/meetings/positions.ts | 2 +
src/lib/meetings/service.ts | 9 +-
.../older-tasks-batch.integration.test.ts | 95 ++++++++++++++++---
.../meetings/older-tasks-dialog.test.tsx | 24 +++--
5 files changed, 114 insertions(+), 23 deletions(-)
create mode 100644 src/lib/meetings/positions.ts
diff --git a/src/components/meetings/older-tasks-dialog.tsx b/src/components/meetings/older-tasks-dialog.tsx
index 874431e3..89e668b9 100644
--- a/src/components/meetings/older-tasks-dialog.tsx
+++ b/src/components/meetings/older-tasks-dialog.tsx
@@ -13,6 +13,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
+import { OLDER_TASK_POSITION_BASE } from "@/lib/meetings/positions";
import { groupAgendaTasks } from "@/lib/meetings/task-groups";
import { cn } from "@/lib/utils";
import { meetingRequest, meetingUrl } from "./api";
@@ -220,6 +221,10 @@ export function OlderTasksDialog({
-1,
...value.tasks.map((task) => task.position),
);
+ const firstPosition = Math.max(
+ lastPosition + 1,
+ OLDER_TASK_POSITION_BASE,
+ );
return {
...value,
tasks: [
@@ -235,7 +240,7 @@ export function OlderTasksDialog({
agendaOrigin:
previous?.agendaOrigin ??
("older" as const),
- position: lastPosition + index + 1,
+ position: firstPosition + index,
};
}),
],
diff --git a/src/lib/meetings/positions.ts b/src/lib/meetings/positions.ts
new file mode 100644
index 00000000..6eb4b5d5
--- /dev/null
+++ b/src/lib/meetings/positions.ts
@@ -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;
diff --git a/src/lib/meetings/service.ts b/src/lib/meetings/service.ts
index 453f2a6f..dec1bfa2 100644
--- a/src/lib/meetings/service.ts
+++ b/src/lib/meetings/service.ts
@@ -77,6 +77,7 @@ import {
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 { getVisibleTasks } from "@/lib/tasks/tasks";
@@ -739,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)
@@ -1461,6 +1462,10 @@ export async function addAgendaTasks(
-1,
...links.map((link) => link.position),
);
+ const firstPosition = Math.max(
+ lastPosition + 1,
+ OLDER_TASK_POSITION_BASE,
+ );
await tx
.insert(agendaTasks)
.values(
@@ -1469,7 +1474,7 @@ export async function addAgendaTasks(
userId: agenda.userId,
taskId,
origin: "older" as const,
- position: lastPosition + index + 1,
+ position: firstPosition + index,
})),
)
.onConflictDoUpdate({
diff --git a/src/tests/meetings/older-tasks-batch.integration.test.ts b/src/tests/meetings/older-tasks-batch.integration.test.ts
index 2cadbead..fc20aa6b 100644
--- a/src/tests/meetings/older-tasks-batch.integration.test.ts
+++ b/src/tests/meetings/older-tasks-batch.integration.test.ts
@@ -68,7 +68,12 @@ vi.mock("@/lib/auth-server", () => ({
vi.mock("@/lib/posthog-server", () => ({ captureServerException: vi.fn() }));
import { POST } from "@/app/api/meetings/[id]/tasks/route";
-import { addAgendaTask, addAgendaTasks } from "@/lib/meetings/service";
+import {
+ addAgendaTask,
+ addAgendaTasks,
+ getMeeting,
+ patchMeeting,
+} from "@/lib/meetings/service";
const url = getTestDatabaseUrl();
describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => {
@@ -194,12 +199,74 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => {
(await links()).map((row) => [row.taskId, row.position]),
).toEqual([
["existing", 7],
- ["two", 8],
- ["one", 9],
- ["three", 10],
+ ["two", 100000],
+ ["one", 100001],
+ ["three", 100002],
]);
expect(await version()).toBe(1);
});
+ it("keeps a larger replacement snapshot before older tasks in selection order", async () => {
+ await addAgendaTasks("owner", "agenda", ["two", "one"]);
+ await database.db.insert(chatterItems).values({
+ id: "replacement-source",
+ userId: "owner",
+ kind: "audio",
+ title: encryptText("Next planning session"),
+ occurredAt: new Date(),
+ });
+ const snapshotIds = Array.from(
+ { length: 10 },
+ (_, position) => `snapshot-${position}`,
+ );
+ await database.db.insert(recordingTasks).values(
+ snapshotIds.map((id, position) => ({
+ id,
+ itemId: "replacement-source",
+ userId: "owner",
+ status: "open" as const,
+ source: "manual" as const,
+ text: encryptText("Review milestone"),
+ position,
+ })),
+ );
+
+ const detail = await patchMeeting("owner", "agenda", {
+ taskSourceItemId: "replacement-source",
+ });
+ if (detail.unchanged) throw new Error("Expected meeting detail");
+ expect(detail.snapshotSourceItemId).toBe("replacement-source");
+ expect(detail.tasks.map((task) => task.agendaOrigin)).toEqual([
+ ...snapshotIds.map(() => "last_meeting"),
+ "older",
+ "older",
+ ]);
+ expect(detail.tasks.map((task) => task.id)).toEqual([
+ ...snapshotIds,
+ "two",
+ "one",
+ ]);
+ expect(detail.tasks.slice(-2).map((task) => task.position)).toEqual([
+ 100000, 100001,
+ ]);
+ });
+ it("orders equal agenda positions by task id", async () => {
+ await database.db.insert(agendaTasks).values(
+ ["two", "one"].map((taskId) => ({
+ agendaId: "agenda",
+ userId: "owner",
+ taskId,
+ origin: "older" as const,
+ position: 7,
+ })),
+ );
+ const detail = await getMeeting("owner", "agenda");
+ if (detail.unchanged) throw new Error("Expected meeting detail");
+ expect(detail.tasks.map((task) => task.id)).toEqual([
+ "existing",
+ "one",
+ "two",
+ ]);
+ });
it("refuses all invisible or missing ids without adding valid tasks or bumping the agenda", async () => {
await database.db.insert(taskMentions).values({
taskId: "invisible",
@@ -233,8 +300,8 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => {
const rows = await links();
expect(rows.map((row) => [row.taskId, row.position])).toEqual([
["existing", 7],
- ["two", 8],
- ["one", 9],
+ ["two", 100000],
+ ["one", 100001],
]);
expect(rows[2].removedAt).toBeNull();
expect(rows[2].origin).toBe("last_meeting");
@@ -248,8 +315,8 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => {
(await links()).map((row) => [row.taskId, row.position]),
).toEqual([
["existing", 7],
- ["two", 8],
- ["one", 9],
+ ["two", 100000],
+ ["one", 100001],
]);
expect(await version()).toBe(1);
});
@@ -266,8 +333,8 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => {
expect(rows[0]).toEqual(existing);
expect(rows.map((row) => [row.taskId, row.position])).toEqual([
["existing", 7],
- ["two", 8],
- ["one", 9],
+ ["two", 100000],
+ ["one", 100001],
]);
expect(await version()).toBe(1);
});
@@ -382,7 +449,9 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => {
addAgendaTasks("owner", "agenda", ["three"]),
]);
const rows = await links();
- expect(rows.map((row) => row.position)).toEqual([7, 8, 9, 10]);
+ expect(rows.map((row) => row.position)).toEqual([
+ 7, 100000, 100001, 100002,
+ ]);
const ids = rows.map((row) => row.taskId);
expect(ids.indexOf("one")).toBe(ids.indexOf("two") + 1);
expect(await version()).toBe(2);
@@ -393,7 +462,9 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => {
addAgendaTasks("owner", "agenda", ["two", "three"]),
]);
const rows = await links();
- expect(rows.map((row) => row.position)).toEqual([7, 8, 9, 10]);
+ expect(rows.map((row) => row.position)).toEqual([
+ 7, 100000, 100001, 100002,
+ ]);
expect(rows.map((row) => row.taskId).sort()).toEqual([
"existing",
"one",
diff --git a/src/tests/meetings/older-tasks-dialog.test.tsx b/src/tests/meetings/older-tasks-dialog.test.tsx
index 24e5ba7e..39e9a49f 100644
--- a/src/tests/meetings/older-tasks-dialog.test.tsx
+++ b/src/tests/meetings/older-tasks-dialog.test.tsx
@@ -112,6 +112,7 @@ beforeEach(() => {
-1,
...server.tasks.map((entry) => entry.position),
);
+ const firstPosition = Math.max(lastPosition + 1, 100000);
server = {
...server,
version: server.version + 1,
@@ -129,7 +130,7 @@ beforeEach(() => {
agendaOrigin: "older" as const,
agendaNote: null,
removedAt: null,
- position: lastPosition + index + 1,
+ position: firstPosition + index,
};
}),
],
@@ -221,7 +222,14 @@ it("switches scope through the segmented control and refetches", async () => {
);
});
-it("adds the selection in one request, one optimistic update and one refresh", async () => {
+it.each([
+ { lastPosition: 0, firstPosition: 100000 },
+ { lastPosition: 100005, firstPosition: 100006 },
+])("adds the selection after $lastPosition in one request, one optimistic update and one refresh", async ({
+ lastPosition,
+ firstPosition,
+}) => {
+ server.tasks[0].position = lastPosition;
await mount();
expect(screen.getByText("Nothing selected")).toBeTruthy();
expect(screen.getByRole("button", { name: "Add tasks" })).toHaveProperty(
@@ -266,9 +274,9 @@ it("adds the selection in one request, one optimistic update and one refresh", a
],
),
).toEqual([
- ["already-added", 0],
- ["Review dates", 1],
- ["Prepare Žluťoučký report", 2],
+ ["already-added", lastPosition],
+ ["Review dates", firstPosition],
+ ["Prepare Žluťoučký report", firstPosition + 1],
]);
expect(
fetchMock.mock.calls.filter(([url]) =>
@@ -278,7 +286,7 @@ it("adds the selection in one request, one optimistic update and one refresh", a
expect(screen.queryByRole("dialog")).toBeNull();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(screen.getByTestId("agenda-tasks").textContent).toContain(
- "Review dates:older:1",
+ `Review dates:older:${firstPosition}`,
);
fireEvent.click(screen.getByRole("button", { name: "Open picker" }));
await screen.findByRole("checkbox", { name: "Check agenda" });
@@ -372,11 +380,11 @@ it("optimistically adds the whole batch before its POST completes", async () =>
fireEvent.click(screen.getByRole("button", { name: "Add 2 tasks" }));
await waitFor(() =>
expect(screen.getByTestId("agenda-tasks").textContent).toContain(
- "Review dates:older:1",
+ "Review dates:older:100000",
),
);
expect(screen.getByTestId("agenda-tasks").textContent).toContain(
- "Check agenda:older:2",
+ "Check agenda:older:100001",
);
expect(mutateCall).toHaveBeenCalledTimes(1);
expect(optimisticUpdate).toHaveBeenCalledTimes(1);